diff --git a/.github/autocomplete.png b/.github/autocomplete.png new file mode 100644 index 0000000..7857187 Binary files /dev/null and b/.github/autocomplete.png differ diff --git a/.github/banner.png b/.github/banner.png new file mode 100644 index 0000000..1f92716 Binary files /dev/null and b/.github/banner.png differ diff --git a/.github/hover.png b/.github/hover.png new file mode 100644 index 0000000..e46a8ef Binary files /dev/null and b/.github/hover.png differ diff --git a/.github/linting.png b/.github/linting.png new file mode 100644 index 0000000..0c11882 Binary files /dev/null and b/.github/linting.png differ diff --git a/.vscodeignore b/.vscodeignore index 23a6082..591602a 100755 --- a/.vscodeignore +++ b/.vscodeignore @@ -8,4 +8,4 @@ contributing.md node_modules/** src/** tests/** -img/** +.github/** diff --git a/CHANGELOG.md b/CHANGELOG.md index f8bf3f3..4b3f32a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.4.0 + +- Added linting and quick fixes for both CSS and markup +- Updated module resolution for compatibility with pnpm (#128) +- The extension now ignores the `purge` option when extracting class names (#131) +- Fixed hover offsets for class names which appear after interpolations + +## 0.3.1 + +- Fixed class attribute completions not showing when using the following Pug syntax (#125): + ``` + div(class="") + ``` +- Fixed hover previews not showing when using a computed class attribute in Vue templates +- Restore missing readme images +- Update settings descriptions to use markdown + ## 0.3.0 ### General diff --git a/README.md b/README.md index 27cc6e8..da341e6 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,36 @@ -# Tailwind CSS IntelliSense + -> [Tailwind CSS](https://tailwindcss.com/) class name completion for VS Code +Tailwind CSS IntelliSense enhances the Tailwind development experience by providing Visual Studio Code users with advanced features such as autocomplete, syntax highlighting, and linting. -**[Get it from the VS Code Marketplace →](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss)** +## Installation -HTML autocompletion +**[Install via the Visual Studio Code Marketplace →](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss)** -## Requirements - -This extension requires: -- a `tailwind.config.js` file to be [present in your project folder](https://github.com/bradlc/vscode-tailwindcss/blob/master/package.json#L38). You can create it with `npx tailwind init`. -- `tailwindcss` to be installed (present in project `node_modules/`) +In order for the extension to activate you must have [`tailwindcss` installed](https://tailwindcss.com/docs/installation/#1-install-tailwind-via-npm) and a [Tailwind config file](https://tailwindcss.com/docs/installation/#3-create-your-tailwind-config-file-optional) named `tailwind.config.js` or `tailwind.js` in your workspace. ## Features -Tailwind CSS IntelliSense uses your projects Tailwind installation and configuration to provide suggestions as you type. +### Autocomplete -It also includes features that improve the overall Tailwind experience, including improved syntax highlighting, and CSS previews. +Intelligent suggestions for class names, as well as [CSS functions and directives](https://tailwindcss.com/docs/functions-and-directives/). -### HTML (including Vue, JSX, PHP etc.) + -- [Class name suggestions, including support for Emmet syntax](#class-name-suggestions-including-support-for-emmet-syntax) - - Suggestions include color previews where applicable, for example for text and background colors - - They also include a preview of the actual CSS for that class name -- [CSS preview when hovering over class names](#css-preview-when-hovering-over-class-names) +### Linting -### CSS +Highlights errors and potential bugs in both your CSS and your markup. -- [Suggestions when using `@apply` and config helpers](#suggestions-when-using-apply-and-config) -- Suggestions when using the `@screen` directive -- Suggestions when using the `@variants` directive -- [Improves syntax highlighting when using `@apply` and config helpers](#improves-syntax-highlighting-when-using-apply-and-config-helpers) + -## Examples +### Hover Preview -#### Class name suggestions, including support for Emmet syntax +See the complete CSS for a Tailwind class name by hovering over it. -HTML autocompletion + -#### CSS preview when hovering over class names +### CSS Syntax Highlighting -HTML hover preview - -#### Suggestions when using `@apply` and config helpers - -CSS autocompletion - -#### Improves syntax highlighting when using `@apply` and config helpers - -Before: - -CSS syntax highlighting before - -After: - -CSS syntax highlighting after +Provides syntax definitions so that Tailwind features are highlighted correctly. ## Settings @@ -70,7 +46,7 @@ This setting allows you to add additional language support. The key of each entr } ``` -### `tailwindcss.emmetCompletions` +### `tailwindCSS.emmetCompletions` Enable completions when using [Emmet](https://emmet.io/)-style syntax, for example `div.bg-red-500.uppercase`. **Default: `false`** @@ -79,3 +55,43 @@ Enable completions when using [Emmet](https://emmet.io/)-style syntax, for examp "tailwindCSS.emmetCompletions": true } ``` + +### `tailwindCSS.validate` + +Enable linting. Rules can be configured individually using the `tailwindcss.lint` settings: + +- `ignore`: disable lint rule entirely +- `warning`: rule violations will be considered "warnings," typically represented by a yellow underline +- `error`: rule violations will be considered "errors," typically represented by a red underline + +#### `tailwindCSS.lint.invalidScreen` + +Unknown screen name used with the [`@screen` directive](https://tailwindcss.com/docs/functions-and-directives/#screen). **Default: `error`** + +#### `tailwindCSS.lint.invalidVariant` + +Unknown variant name used with the [`@variants` directive](https://tailwindcss.com/docs/functions-and-directives/#variants). **Default: `error`** + +#### `tailwindCSS.lint.invalidTailwindDirective` + +Unknown value used with the [`@tailwind` directive](https://tailwindcss.com/docs/functions-and-directives/#tailwind). **Default: `error`** + +#### `tailwindCSS.lint.invalidApply` + +Unsupported use of the [`@apply` directive](https://tailwindcss.com/docs/functions-and-directives/#apply). **Default: `error`** + +#### `tailwindCSS.lint.invalidConfigPath` + +Unknown or invalid path used with the [`theme` helper](https://tailwindcss.com/docs/functions-and-directives/#theme). **Default: `error`** + +#### `tailwindCSS.lint.cssConflict` + +Class names on the same HTML element which apply the same CSS property or properties. **Default: `warning`** + +## Troubleshooting + +If you’re having issues getting the IntelliSense features to activate, there are a few things you can check: + +- Ensure that you have a Tailwind config file in your workspace and that this is named `tailwind.config.js` or `tailwind.js`. Check out the Tailwind documentation for details on [creating a config file](https://tailwindcss.com/docs/installation/#3-create-your-tailwind-config-file-optional). +- Ensure that the `tailwindcss` module is installed in your workspace, via `npm`, `yarn`, or `pnpm`. Tailwind CSS IntelliSense does not currently support Yarn Plug'n'Play. +- If you installed `tailwindcss` or created your config file while your project was already open in Visual Studio Code you may need to reload the editor. You can either restart VS Code entirely, or use the `Developer: Reload Window` command which can be found in the command palette. diff --git a/img/css-highlighting-after.png b/img/css-highlighting-after.png deleted file mode 100755 index 86a12fb..0000000 Binary files a/img/css-highlighting-after.png and /dev/null differ diff --git a/img/css-highlighting-before.png b/img/css-highlighting-before.png deleted file mode 100755 index 2eeffdc..0000000 Binary files a/img/css-highlighting-before.png and /dev/null differ diff --git a/img/css.gif b/img/css.gif deleted file mode 100755 index e81904e..0000000 Binary files a/img/css.gif and /dev/null differ diff --git a/img/html-hover.gif b/img/html-hover.gif deleted file mode 100755 index 94540b8..0000000 Binary files a/img/html-hover.gif and /dev/null differ diff --git a/img/html.gif b/img/html.gif deleted file mode 100755 index 8ffc7ff..0000000 Binary files a/img/html.gif and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 91c1aca..9184c0f 100755 --- a/package-lock.json +++ b/package-lock.json @@ -2026,6 +2026,12 @@ "integrity": "sha1-OjYof1A05pnnV3kBBSwubJQlFjE=", "dev": true }, + "detect-indent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz", + "integrity": "sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==", + "dev": true + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6084,6 +6090,12 @@ "dev": true, "optional": true }, + "sift-string": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/sift-string/-/sift-string-0.0.2.tgz", + "integrity": "sha1-G7ArEhslu4sHRwQr+afh2s+PuJw=", + "dev": true + }, "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", diff --git a/package.json b/package.json index e234600..c209d4b 100755 --- a/package.json +++ b/package.json @@ -1,19 +1,19 @@ { "name": "vscode-tailwindcss", "displayName": "Tailwind CSS IntelliSense", - "description": "Tailwind CSS class name completion", + "description": "Intelligent Tailwind CSS tooling for VS Code", "preview": true, "author": "Brad Cornes ", "license": "MIT", "version": "0.3.1", - "homepage": "https://github.com/bradlc/vscode-tailwindcss", + "homepage": "https://github.com/tailwindcss/intellisense", "bugs": { - "url": "https://github.com/bradlc/vscode-tailwindcss/issues", + "url": "https://github.com/tailwindcss/intellisense/issues", "email": "hello@bradley.dev" }, "repository": { "type": "git", - "url": "https://github.com/bradlc/vscode-tailwindcss.git" + "url": "https://github.com/tailwindcss/intellisense.git" }, "publisher": "bradlc", "keywords": [ @@ -28,10 +28,11 @@ "vscode": "^1.33.0" }, "categories": [ + "Linters", "Other" ], "galleryBanner": { - "color": "#f1f5f8" + "color": "#f9fafb" }, "icon": "media/icon.png", "activationEvents": [ @@ -69,6 +70,78 @@ }, "default": {}, "markdownDescription": "Enable features in languages that are not supported by default. Add a mapping here between the new language and an already supported language.\n E.g.: `{\"plaintext\": \"html\"}`" + }, + "tailwindCSS.validate": { + "type": "boolean", + "default": true, + "markdownDescription": "Enable linting", + "scope": "language-overridable" + }, + "tailwindCSS.lint.cssConflict": { + "type": "string", + "enum": [ + "ignore", + "warning", + "error" + ], + "default": "warning", + "markdownDescription": "Class names on the same HTML element which apply the same CSS property or properties", + "scope": "language-overridable" + }, + "tailwindCSS.lint.invalidApply": { + "type": "string", + "enum": [ + "ignore", + "warning", + "error" + ], + "default": "error", + "markdownDescription": "Unsupported use of the [`@apply` directive](https://tailwindcss.com/docs/functions-and-directives/#apply)", + "scope": "language-overridable" + }, + "tailwindCSS.lint.invalidScreen": { + "type": "string", + "enum": [ + "ignore", + "warning", + "error" + ], + "default": "error", + "markdownDescription": "Unknown screen name used with the [`@screen` directive](https://tailwindcss.com/docs/functions-and-directives/#screen)", + "scope": "language-overridable" + }, + "tailwindCSS.lint.invalidVariant": { + "type": "string", + "enum": [ + "ignore", + "warning", + "error" + ], + "default": "error", + "markdownDescription": "Unknown variant name used with the [`@variants` directive](https://tailwindcss.com/docs/functions-and-directives/#variants)", + "scope": "language-overridable" + }, + "tailwindCSS.lint.invalidConfigPath": { + "type": "string", + "enum": [ + "ignore", + "warning", + "error" + ], + "default": "error", + "markdownDescription": "Unknown or invalid path used with the [`theme` helper](https://tailwindcss.com/docs/functions-and-directives/#theme)", + "scope": "language-overridable" + }, + "tailwindCSS.lint.invalidTailwindDirective": { + "type": "string", + "enum": [ + "ignore", + "warning", + "error" + ], + "default": "error", + "markdownDescription": "Unknown value used with the [`@tailwind` directive](https://tailwindcss.com/docs/functions-and-directives/#tailwind)", + "scope": "language-overridable" } } } @@ -93,6 +166,7 @@ "chokidar": "^3.3.1", "concurrently": "^5.1.0", "css.escape": "^1.5.1", + "detect-indent": "^6.0.0", "dlv": "^1.1.3", "dset": "^2.0.1", "esm": "^3.2.25", @@ -111,6 +185,7 @@ "resolve-from": "^5.0.0", "rimraf": "^3.0.2", "semver": "^7.3.2", + "sift-string": "0.0.2", "stack-trace": "0.0.10", "terser": "^4.6.12", "tiny-invariant": "^1.1.0", diff --git a/src/class-names/index.js b/src/class-names/index.js index ca8ddec..0ddd81c 100644 --- a/src/class-names/index.js +++ b/src/class-names/index.js @@ -141,6 +141,10 @@ export default async function getClassNames( postcss, browserslist, }), + modules: { + tailwindcss, + postcss, + }, } } diff --git a/src/extension.ts b/src/extension.ts index be1fe47..279ec96 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { OutputChannel, WorkspaceFolder, Uri, + ConfigurationScope, } from 'vscode' import { LanguageClient, @@ -22,6 +23,7 @@ import { DEFAULT_LANGUAGES } from './lib/languages' import isObject from './util/isObject' import { dedupe, equal } from './util/array' import { createEmitter } from './lib/emitter' +import { onMessage } from './lsp/notifications' const CLIENT_ID = 'tailwindcss-intellisense' const CLIENT_NAME = 'Tailwind CSS IntelliSense' @@ -150,6 +152,9 @@ export function activate(context: ExtensionContext) { client.onReady().then(() => { let emitter = createEmitter(client) registerConfigErrorHandler(emitter) + onMessage(client, 'getConfiguration', async (scope) => { + return Workspace.getConfiguration('tailwindCSS', scope) + }) }) client.start() diff --git a/src/lib/emitter.ts b/src/lib/emitter.ts index d177c2c..3ad6f05 100644 --- a/src/lib/emitter.ts +++ b/src/lib/emitter.ts @@ -1,6 +1,7 @@ import mitt from 'mitt' import { LanguageClient } from 'vscode-languageclient' import crypto from 'crypto' +import { Connection } from 'vscode-languageserver' export interface NotificationEmitter { on: (name: string, handler: (args: any) => void) => void @@ -8,7 +9,9 @@ export interface NotificationEmitter { emit: (name: string, args: any) => Promise } -export function createEmitter(client: LanguageClient): NotificationEmitter { +export function createEmitter( + client: LanguageClient | Connection +): NotificationEmitter { const emitter = mitt() const registered: string[] = [] @@ -26,7 +29,7 @@ export function createEmitter(client: LanguageClient): NotificationEmitter { emitter.off(name, handler) } - const emit = (name: string, params: any) => { + const emit = (name: string, params: Record = {}) => { return new Promise((resolve, _reject) => { const id = crypto.randomBytes(16).toString('hex') on(`${name}Response`, (result) => { diff --git a/src/lsp/notifications.ts b/src/lsp/notifications.ts index bb4e60d..e8f4ab8 100644 --- a/src/lsp/notifications.ts +++ b/src/lsp/notifications.ts @@ -1,9 +1,10 @@ import { Connection } from 'vscode-languageserver' +import { LanguageClient } from 'vscode-languageclient' export function onMessage( - connection: Connection, + connection: LanguageClient | Connection, name: string, - handler: (params: any) => any + handler: (params: any) => Thenable> ): void { connection.onNotification(`tailwindcss/${name}`, async (params: any) => { const { _id, ...rest } = params diff --git a/src/lsp/providers/codeActions/codeActionProvider.ts b/src/lsp/providers/codeActions/codeActionProvider.ts new file mode 100644 index 0000000..19420f6 --- /dev/null +++ b/src/lsp/providers/codeActions/codeActionProvider.ts @@ -0,0 +1,77 @@ +import { CodeAction, CodeActionParams } from 'vscode-languageserver' +import { State } from '../../util/state' +import { getDiagnostics } from '../diagnostics/diagnosticsProvider' +import { rangesEqual } from '../../util/rangesEqual' +import { + DiagnosticKind, + isInvalidApplyDiagnostic, + AugmentedDiagnostic, + isCssConflictDiagnostic, + isInvalidConfigPathDiagnostic, + isInvalidTailwindDirectiveDiagnostic, + isInvalidScreenDiagnostic, + isInvalidVariantDiagnostic, +} from '../diagnostics/types' +import { flatten, dedupeBy } from '../../../util/array' +import { provideCssConflictCodeActions } from './provideCssConflictCodeActions' +import { provideInvalidApplyCodeActions } from './provideInvalidApplyCodeActions' +import { provideSuggestionCodeActions } from './provideSuggestionCodeActions' + +async function getDiagnosticsFromCodeActionParams( + state: State, + params: CodeActionParams, + only?: DiagnosticKind[] +): Promise { + let document = state.editor.documents.get(params.textDocument.uri) + let diagnostics = await getDiagnostics(state, document, only) + + return params.context.diagnostics + .map((diagnostic) => { + return diagnostics.find((d) => { + return ( + d.code === diagnostic.code && + d.message === diagnostic.message && + rangesEqual(d.range, diagnostic.range) + ) + }) + }) + .filter(Boolean) +} + +export async function provideCodeActions( + state: State, + params: CodeActionParams +): Promise { + let diagnostics = await getDiagnosticsFromCodeActionParams( + state, + params, + params.context.diagnostics + .map((diagnostic) => diagnostic.code) + .filter(Boolean) as DiagnosticKind[] + ) + + return Promise.all( + diagnostics.map((diagnostic) => { + if (isInvalidApplyDiagnostic(diagnostic)) { + return provideInvalidApplyCodeActions(state, params, diagnostic) + } + + if (isCssConflictDiagnostic(diagnostic)) { + return provideCssConflictCodeActions(state, params, diagnostic) + } + + if ( + isInvalidConfigPathDiagnostic(diagnostic) || + isInvalidTailwindDirectiveDiagnostic(diagnostic) || + isInvalidScreenDiagnostic(diagnostic) || + isInvalidVariantDiagnostic(diagnostic) + ) { + return provideSuggestionCodeActions(state, params, diagnostic) + } + + return [] + }) + ) + .then(flatten) + .then((x) => dedupeBy(x, (item) => JSON.stringify(item.edit))) +} diff --git a/src/lsp/providers/codeActions/provideCssConflictCodeActions.ts b/src/lsp/providers/codeActions/provideCssConflictCodeActions.ts new file mode 100644 index 0000000..f6a6edb --- /dev/null +++ b/src/lsp/providers/codeActions/provideCssConflictCodeActions.ts @@ -0,0 +1,42 @@ +import { State } from '../../util/state' +import { + CodeActionParams, + CodeAction, + CodeActionKind, +} from 'vscode-languageserver' +import { CssConflictDiagnostic } from '../diagnostics/types' +import { joinWithAnd } from '../../util/joinWithAnd' +import { removeRangesFromString } from '../../util/removeRangesFromString' + +export async function provideCssConflictCodeActions( + _state: State, + params: CodeActionParams, + diagnostic: CssConflictDiagnostic +): Promise { + return [ + { + title: `Delete ${joinWithAnd( + diagnostic.otherClassNames.map( + (otherClassName) => `'${otherClassName.className}'` + ) + )}`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: [ + { + range: diagnostic.className.classList.range, + newText: removeRangesFromString( + diagnostic.className.classList.classList, + diagnostic.otherClassNames.map( + (otherClassName) => otherClassName.relativeRange + ) + ), + }, + ], + }, + }, + }, + ] +} diff --git a/src/lsp/providers/codeActions/provideInvalidApplyCodeActions.ts b/src/lsp/providers/codeActions/provideInvalidApplyCodeActions.ts new file mode 100644 index 0000000..ec3d483 --- /dev/null +++ b/src/lsp/providers/codeActions/provideInvalidApplyCodeActions.ts @@ -0,0 +1,266 @@ +import { + CodeAction, + CodeActionParams, + CodeActionKind, + TextEdit, + Range, +} from 'vscode-languageserver' +import { State } from '../../util/state' +import { InvalidApplyDiagnostic } from '../diagnostics/types' +import { getClassNameParts } from '../../util/getClassNameAtPosition' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { isCssDoc } from '../../util/css' +import { isWithinRange } from '../../util/isWithinRange' +const dlv = require('dlv') +import type { Root, NodeSource } from 'postcss' +import { absoluteRange } from '../../util/absoluteRange' +import { removeRangesFromString } from '../../util/removeRangesFromString' +import detectIndent from 'detect-indent' +import isObject from '../../../util/isObject' +import { cssObjToAst } from '../../util/cssObjToAst' +import dset from 'dset' +import selectorParser from 'postcss-selector-parser' +import { flatten } from '../../../util/array' +import { getClassNameMeta } from '../../util/getClassNameMeta' +import { validateApply } from '../../util/validateApply' + +export async function provideInvalidApplyCodeActions( + state: State, + params: CodeActionParams, + diagnostic: InvalidApplyDiagnostic +): Promise { + let document = state.editor.documents.get(params.textDocument.uri) + let documentText = document.getText() + let cssRange: Range + let cssText = documentText + const { postcss } = state.modules + let changes: TextEdit[] = [] + + let totalClassNamesInClassList = diagnostic.className.classList.classList.split( + /\s+/ + ).length + + let className = diagnostic.className.className + let classNameParts = getClassNameParts(state, className) + let classNameInfo = dlv(state.classNames.classNames, classNameParts) + + if (Array.isArray(classNameInfo)) { + return [] + } + + if (!isCssDoc(state, document)) { + let languageBoundaries = getLanguageBoundaries(state, document) + if (!languageBoundaries) return [] + cssRange = languageBoundaries.css.find((range) => + isWithinRange(diagnostic.range.start, range) + ) + if (!cssRange) return [] + cssText = document.getText(cssRange) + } + + try { + await postcss([ + postcss.plugin('', (_options = {}) => { + return (root: Root) => { + root.walkRules((rule) => { + if (changes.length) return false + + rule.walkAtRules('apply', (atRule) => { + let atRuleRange = postcssSourceToRange(atRule.source) + if (cssRange) { + atRuleRange = absoluteRange(atRuleRange, cssRange) + } + + if (!isWithinRange(diagnostic.range.start, atRuleRange)) + return true + + let ast = classNameToAst( + state, + classNameParts, + rule.selector, + diagnostic.className.classList.important + ) + + if (!ast) return false + + rule.after(ast.nodes) + let insertedRule = rule.next() + if (!insertedRule) return false + + if (totalClassNamesInClassList === 1) { + atRule.remove() + } else { + changes.push({ + range: diagnostic.className.classList.range, + newText: removeRangesFromString( + diagnostic.className.classList.classList, + diagnostic.className.relativeRange + ), + }) + } + + let ruleRange = postcssSourceToRange(rule.source) + if (cssRange) { + ruleRange = absoluteRange(ruleRange, cssRange) + } + + let outputIndent: string + let documentIndent = detectIndent(cssText) + + changes.push({ + range: ruleRange, + newText: + rule.toString() + + (insertedRule.raws.before || '\n\n') + + insertedRule + .toString() + .replace(/\n\s*\n/g, '\n') + .replace(/(@apply [^;\n]+)$/gm, '$1;') + .replace(/([^\s^]){$/gm, '$1 {') + .replace(/^\s+/gm, (m: string) => { + if (typeof outputIndent === 'undefined') outputIndent = m + return m.replace( + new RegExp(outputIndent, 'g'), + documentIndent.indent + ) + }) + .replace(/^(\s+)(.*?[^{}]\n)([^\s}])/gm, '$1$2$1$3'), + }) + + return false + }) + }) + } + }), + ]).process(cssText, { from: undefined }) + } catch (_) { + return [] + } + + if (!changes.length) { + return [] + } + + return [ + { + title: 'Extract to new rule', + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: changes, + }, + }, + }, + ] +} + +function postcssSourceToRange(source: NodeSource): Range { + return { + start: { + line: source.start.line - 1, + character: source.start.column - 1, + }, + end: { + line: source.end.line - 1, + character: source.end.column, + }, + } +} + +function classNameToAst( + state: State, + classNameParts: string[], + selector: string, + important: boolean = false +) { + const baseClassName = classNameParts[classNameParts.length - 1] + const validatedBaseClassName = validateApply(state, [baseClassName]) + if ( + validatedBaseClassName === null || + validatedBaseClassName.isApplyable === false + ) { + return null + } + const meta = getClassNameMeta(state, classNameParts) + if (Array.isArray(meta)) return null + let context = meta.context + let pseudo = meta.pseudo + const globalContexts = state.classNames.context + let screens = dlv( + state.config, + 'theme.screens', + dlv(state.config, 'screens', {}) + ) + if (!isObject(screens)) screens = {} + screens = Object.keys(screens) + const path = [] + + for (let i = 0; i < classNameParts.length - 1; i++) { + let part = classNameParts[i] + let common = globalContexts[part] + if (!common) return null + if (screens.includes(part)) { + path.push(`@screen ${part}`) + context = context.filter((con) => !common.includes(con)) + } + } + + path.push(...context) + + let obj = {} + for (let i = 1; i <= path.length; i++) { + dset(obj, path.slice(0, i), {}) + } + + selector = appendPseudosToSelector(selector, pseudo) + if (selector === null) return null + + let rule = { + [selector]: { + [`@apply ${baseClassName}${important ? ' !important' : ''}`]: '', + }, + } + if (path.length) { + dset(obj, path, rule) + } else { + obj = rule + } + + return cssObjToAst(obj, state.modules.postcss) +} + +function appendPseudosToSelector( + selector: string, + pseudos: string[] +): string | null { + if (pseudos.length === 0) return selector + + let canTransform = true + + let transformedSelector = selectorParser((selectors) => { + flatten(selectors.split((_) => true)).forEach((sel) => { + // @ts-ignore + for (let i = sel.nodes.length - 1; i >= 0; i--) { + // @ts-ignore + if (sel.nodes[i].type !== 'pseudo') { + break + // @ts-ignore + } else if (pseudos.includes(sel.nodes[i].value)) { + canTransform = false + break + } + } + if (canTransform) { + pseudos.forEach((p) => { + // @ts-ignore + sel.append(selectorParser.pseudo({ value: p })) + }) + } + }) + }).processSync(selector) + + if (!canTransform) return null + + return transformedSelector +} diff --git a/src/lsp/providers/codeActions/provideSuggestionCodeActions.ts b/src/lsp/providers/codeActions/provideSuggestionCodeActions.ts new file mode 100644 index 0000000..9da5fcb --- /dev/null +++ b/src/lsp/providers/codeActions/provideSuggestionCodeActions.ts @@ -0,0 +1,38 @@ +import { State } from '../../util/state' +import { + CodeActionParams, + CodeAction, + CodeActionKind, +} from 'vscode-languageserver' +import { + InvalidConfigPathDiagnostic, + InvalidTailwindDirectiveDiagnostic, + InvalidScreenDiagnostic, + InvalidVariantDiagnostic, +} from '../diagnostics/types' + +export function provideSuggestionCodeActions( + _state: State, + params: CodeActionParams, + diagnostic: + | InvalidConfigPathDiagnostic + | InvalidTailwindDirectiveDiagnostic + | InvalidScreenDiagnostic + | InvalidVariantDiagnostic +): CodeAction[] { + return diagnostic.suggestions.map((suggestion) => ({ + title: `Replace with '${suggestion}'`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: [ + { + range: diagnostic.range, + newText: suggestion, + }, + ], + }, + }, + })) +} diff --git a/src/lsp/providers/completionProvider.ts b/src/lsp/providers/completionProvider.ts index 6dbb1e7..ec93d44 100644 --- a/src/lsp/providers/completionProvider.ts +++ b/src/lsp/providers/completionProvider.ts @@ -618,11 +618,11 @@ async function provideEmmetCompletions( state: State, { position, textDocument }: CompletionParams ): Promise { - let settings = await getDocumentSettings(state, textDocument.uri) - if (settings.emmetCompletions !== true) return null - let doc = state.editor.documents.get(textDocument.uri) + let settings = await getDocumentSettings(state, doc) + if (settings.emmetCompletions !== true) return null + const syntax = isHtmlContext(state, doc, position) ? 'html' : isJsContext(state, doc, position) diff --git a/src/lsp/providers/diagnostics/diagnosticsProvider.ts b/src/lsp/providers/diagnostics/diagnosticsProvider.ts new file mode 100644 index 0000000..a22f846 --- /dev/null +++ b/src/lsp/providers/diagnostics/diagnosticsProvider.ts @@ -0,0 +1,74 @@ +import { TextDocument } from 'vscode-languageserver' +import { State } from '../../util/state' +import { getDocumentSettings } from '../../util/getDocumentSettings' +import { DiagnosticKind, AugmentedDiagnostic } from './types' +import { getCssConflictDiagnostics } from './getCssConflictDiagnostics' +import { getInvalidApplyDiagnostics } from './getInvalidApplyDiagnostics' +import { getInvalidScreenDiagnostics } from './getInvalidScreenDiagnostics' +import { getInvalidVariantDiagnostics } from './getInvalidVariantDiagnostics' +import { getInvalidConfigPathDiagnostics } from './getInvalidConfigPathDiagnostics' +import { getInvalidTailwindDirectiveDiagnostics } from './getInvalidTailwindDirectiveDiagnostics' + +export async function getDiagnostics( + state: State, + document: TextDocument, + only: DiagnosticKind[] = [ + DiagnosticKind.CssConflict, + DiagnosticKind.InvalidApply, + DiagnosticKind.InvalidScreen, + DiagnosticKind.InvalidVariant, + DiagnosticKind.InvalidConfigPath, + DiagnosticKind.InvalidTailwindDirective, + ] +): Promise { + const settings = await getDocumentSettings(state, document) + + return settings.validate + ? [ + ...(only.includes(DiagnosticKind.CssConflict) + ? getCssConflictDiagnostics(state, document, settings) + : []), + ...(only.includes(DiagnosticKind.InvalidApply) + ? getInvalidApplyDiagnostics(state, document, settings) + : []), + ...(only.includes(DiagnosticKind.InvalidScreen) + ? getInvalidScreenDiagnostics(state, document, settings) + : []), + ...(only.includes(DiagnosticKind.InvalidVariant) + ? getInvalidVariantDiagnostics(state, document, settings) + : []), + ...(only.includes(DiagnosticKind.InvalidConfigPath) + ? getInvalidConfigPathDiagnostics(state, document, settings) + : []), + ...(only.includes(DiagnosticKind.InvalidTailwindDirective) + ? getInvalidTailwindDirectiveDiagnostics(state, document, settings) + : []), + ] + : [] +} + +export async function provideDiagnostics(state: State, document: TextDocument) { + state.editor.connection.sendDiagnostics({ + uri: document.uri, + diagnostics: await getDiagnostics(state, document), + }) +} + +export function clearDiagnostics(state: State, document: TextDocument): void { + state.editor.connection.sendDiagnostics({ + uri: document.uri, + diagnostics: [], + }) +} + +export function clearAllDiagnostics(state: State): void { + state.editor.documents.all().forEach((document) => { + clearDiagnostics(state, document) + }) +} + +export function updateAllDiagnostics(state: State): void { + state.editor.documents.all().forEach((document) => { + provideDiagnostics(state, document) + }) +} diff --git a/src/lsp/providers/diagnostics/getCssConflictDiagnostics.ts b/src/lsp/providers/diagnostics/getCssConflictDiagnostics.ts new file mode 100644 index 0000000..73da486 --- /dev/null +++ b/src/lsp/providers/diagnostics/getCssConflictDiagnostics.ts @@ -0,0 +1,85 @@ +import { joinWithAnd } from '../../util/joinWithAnd' +import { State, Settings } from '../../util/state' +import { TextDocument, DiagnosticSeverity } from 'vscode-languageserver' +import { CssConflictDiagnostic, DiagnosticKind } from './types' +import { + findClassListsInDocument, + getClassNamesInClassList, +} from '../../util/find' +import { getClassNameDecls } from '../../util/getClassNameDecls' +import { getClassNameMeta } from '../../util/getClassNameMeta' +import { equal } from '../../../util/array' + +export function getCssConflictDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): CssConflictDiagnostic[] { + let severity = settings.lint.cssConflict + if (severity === 'ignore') return [] + + let diagnostics: CssConflictDiagnostic[] = [] + const classLists = findClassListsInDocument(state, document) + + classLists.forEach((classList) => { + const classNames = getClassNamesInClassList(classList) + + classNames.forEach((className, index) => { + let decls = getClassNameDecls(state, className.className) + if (!decls) return + + let properties = Object.keys(decls) + let meta = getClassNameMeta(state, className.className) + + let otherClassNames = classNames.filter((_className, i) => i !== index) + + let conflictingClassNames = otherClassNames.filter((otherClassName) => { + let otherDecls = getClassNameDecls(state, otherClassName.className) + if (!otherDecls) return false + + let otherMeta = getClassNameMeta(state, otherClassName.className) + + return ( + equal(properties, Object.keys(otherDecls)) && + !Array.isArray(meta) && + !Array.isArray(otherMeta) && + equal(meta.context, otherMeta.context) && + equal(meta.pseudo, otherMeta.pseudo) + ) + }) + + if (conflictingClassNames.length === 0) return + + diagnostics.push({ + code: DiagnosticKind.CssConflict, + className, + otherClassNames: conflictingClassNames, + range: className.range, + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message: `'${className.className}' applies the same CSS ${ + properties.length === 1 ? 'property' : 'properties' + } as ${joinWithAnd( + conflictingClassNames.map( + (conflictingClassName) => `'${conflictingClassName.className}'` + ) + )}.`, + relatedInformation: conflictingClassNames.map( + (conflictingClassName) => { + return { + message: conflictingClassName.className, + location: { + uri: document.uri, + range: conflictingClassName.range, + }, + } + } + ), + }) + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/getInvalidApplyDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidApplyDiagnostics.ts new file mode 100644 index 0000000..e829b57 --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidApplyDiagnostics.ts @@ -0,0 +1,37 @@ +import { findClassNamesInRange } from '../../util/find' +import { InvalidApplyDiagnostic, DiagnosticKind } from './types' +import { Settings, State } from '../../util/state' +import { TextDocument, DiagnosticSeverity } from 'vscode-languageserver' +import { validateApply } from '../../util/validateApply' + +export function getInvalidApplyDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidApplyDiagnostic[] { + let severity = settings.lint.invalidApply + if (severity === 'ignore') return [] + + const classNames = findClassNamesInRange(document, undefined, 'css') + + let diagnostics: InvalidApplyDiagnostic[] = classNames.map((className) => { + let result = validateApply(state, className.className) + + if (result === null || result.isApplyable === true) { + return null + } + + return { + code: DiagnosticKind.InvalidApply, + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + range: className.range, + message: result.reason, + className, + } + }) + + return diagnostics.filter(Boolean) +} diff --git a/src/lsp/providers/diagnostics/getInvalidConfigPathDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidConfigPathDiagnostics.ts new file mode 100644 index 0000000..ccf52fc --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidConfigPathDiagnostics.ts @@ -0,0 +1,230 @@ +import { State, Settings } from '../../util/state' +import { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' +import { InvalidConfigPathDiagnostic, DiagnosticKind } from './types' +import { isCssDoc } from '../../util/css' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { findAll, indexToPosition } from '../../util/find' +import { stringToPath } from '../../util/stringToPath' +import isObject from '../../../util/isObject' +import { closest } from '../../util/closest' +import { absoluteRange } from '../../util/absoluteRange' +import { combinations } from '../../util/combinations' +const dlv = require('dlv') + +function pathToString(path: string | string[]): string { + if (typeof path === 'string') return path + return path.reduce((acc, cur, i) => { + if (i === 0) return cur + if (cur.includes('.')) return `${acc}[${cur}]` + return `${acc}.${cur}` + }, '') +} + +function validateConfigPath( + state: State, + path: string | string[], + base: string[] = [] +): + | { isValid: true; value: any } + | { isValid: false; reason: string; suggestions: string[] } { + let keys = Array.isArray(path) ? path : stringToPath(path) + let value = dlv(state.config, [...base, ...keys]) + let suggestions: string[] = [] + + function findAlternativePath(): string[] { + let points = combinations('123456789'.substr(0, keys.length - 1)).map((x) => + x.split('').map((x) => parseInt(x, 10)) + ) + + let possibilities: string[][] = points + .map((p) => { + let result = [] + let i = 0 + p.forEach((x) => { + result.push(keys.slice(i, x).join('.')) + i = x + }) + result.push(keys.slice(i).join('.')) + return result + }) + .slice(1) // skip original path + + return possibilities.find( + (possibility) => validateConfigPath(state, possibility, base).isValid + ) + } + + if (typeof value === 'undefined') { + let reason = `'${pathToString(path)}' does not exist in your theme config.` + let parentPath = [...base, ...keys.slice(0, keys.length - 1)] + let parentValue = dlv(state.config, parentPath) + + if (isObject(parentValue)) { + let closestValidKey = closest( + keys[keys.length - 1], + Object.keys(parentValue).filter( + (key) => validateConfigPath(state, [...parentPath, key]).isValid + ) + ) + if (closestValidKey) { + suggestions.push( + pathToString([...keys.slice(0, keys.length - 1), closestValidKey]) + ) + reason += ` Did you mean '${suggestions[0]}'?` + } + } else { + let altPath = findAlternativePath() + if (altPath) { + return { + isValid: false, + reason: `${reason} Did you mean '${pathToString(altPath)}'?`, + suggestions: [pathToString(altPath)], + } + } + } + + return { + isValid: false, + reason, + suggestions, + } + } + + if ( + !( + typeof value === 'string' || + typeof value === 'number' || + value instanceof String || + value instanceof Number || + Array.isArray(value) + ) + ) { + let reason = `'${pathToString( + path + )}' was found but does not resolve to a string.` + + if (isObject(value)) { + let validKeys = Object.keys(value).filter( + (key) => validateConfigPath(state, [...keys, key], base).isValid + ) + if (validKeys.length) { + suggestions.push( + ...validKeys.map((validKey) => pathToString([...keys, validKey])) + ) + reason += ` Did you mean something like '${suggestions[0]}'?` + } + } + return { + isValid: false, + reason, + suggestions, + } + } + + // The value resolves successfully, but we need to check that there + // wasn't any funny business. If you have a theme object: + // { msg: 'hello' } and do theme('msg.0') + // this will resolve to 'h', which is probably not intentional, so we + // check that all of the keys are object or array keys (i.e. not string + // indexes) + let isValid = true + for (let i = keys.length - 1; i >= 0; i--) { + let key = keys[i] + let parentValue = dlv(state.config, [...base, ...keys.slice(0, i)]) + if (/^[0-9]+$/.test(key)) { + if (!isObject(parentValue) && !Array.isArray(parentValue)) { + isValid = false + break + } + } else if (!isObject(parentValue)) { + isValid = false + break + } + } + if (!isValid) { + let reason = `'${pathToString(path)}' does not exist in your theme config.` + + let altPath = findAlternativePath() + if (altPath) { + return { + isValid: false, + reason: `${reason} Did you mean '${pathToString(altPath)}'?`, + suggestions: [pathToString(altPath)], + } + } + + return { + isValid: false, + reason, + suggestions: [], + } + } + + return { + isValid: true, + value, + } +} + +export function getInvalidConfigPathDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidConfigPathDiagnostic[] { + let severity = settings.lint.invalidConfigPath + if (severity === 'ignore') return [] + + let diagnostics: InvalidConfigPathDiagnostic[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.css) + } + + ranges.forEach((range) => { + let text = document.getText(range) + let matches = findAll( + /(?\s|^)(?config|theme)\((?['"])(?[^)]+)\k\)/g, + text + ) + + matches.forEach((match) => { + let base = match.groups.helper === 'theme' ? ['theme'] : [] + let result = validateConfigPath(state, match.groups.key, base) + + if (result.isValid === true) { + return null + } + + let startIndex = + match.index + + match.groups.prefix.length + + match.groups.helper.length + + 1 + // open paren + match.groups.quote.length + + diagnostics.push({ + code: DiagnosticKind.InvalidConfigPath, + range: absoluteRange( + { + start: indexToPosition(text, startIndex), + end: indexToPosition(text, startIndex + match.groups.key.length), + }, + range + ), + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message: result.reason, + suggestions: result.suggestions, + }) + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/getInvalidScreenDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidScreenDiagnostics.ts new file mode 100644 index 0000000..b0e4252 --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidScreenDiagnostics.ts @@ -0,0 +1,75 @@ +import { State, Settings } from '../../util/state' +import { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' +import { InvalidScreenDiagnostic, DiagnosticKind } from './types' +import { isCssDoc } from '../../util/css' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { findAll, indexToPosition } from '../../util/find' +import { closest } from '../../util/closest' +import { absoluteRange } from '../../util/absoluteRange' +const dlv = require('dlv') + +export function getInvalidScreenDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidScreenDiagnostic[] { + let severity = settings.lint.invalidScreen + if (severity === 'ignore') return [] + + let diagnostics: InvalidScreenDiagnostic[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.css) + } + + ranges.forEach((range) => { + let text = document.getText(range) + let matches = findAll(/(?:\s|^)@screen\s+(?[^\s{]+)/g, text) + + let screens = Object.keys( + dlv(state.config, 'theme.screens', dlv(state.config, 'screens', {})) + ) + + matches.forEach((match) => { + if (screens.includes(match.groups.screen)) { + return null + } + + let message = `The screen '${match.groups.screen}' does not exist in your theme config.` + let suggestions: string[] = [] + let suggestion = closest(match.groups.screen, screens) + + if (suggestion) { + suggestions.push(suggestion) + message += ` Did you mean '${suggestion}'?` + } + + diagnostics.push({ + code: DiagnosticKind.InvalidScreen, + range: absoluteRange( + { + start: indexToPosition( + text, + match.index + match[0].length - match.groups.screen.length + ), + end: indexToPosition(text, match.index + match[0].length), + }, + range + ), + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message, + suggestions, + }) + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts new file mode 100644 index 0000000..9b88bdb --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts @@ -0,0 +1,83 @@ +import { State, Settings } from '../../util/state' +import { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' +import { InvalidTailwindDirectiveDiagnostic, DiagnosticKind } from './types' +import { isCssDoc } from '../../util/css' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { findAll, indexToPosition } from '../../util/find' +import semver from 'semver' +import { closest } from '../../util/closest' +import { absoluteRange } from '../../util/absoluteRange' + +export function getInvalidTailwindDirectiveDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidTailwindDirectiveDiagnostic[] { + let severity = settings.lint.invalidTailwindDirective + if (severity === 'ignore') return [] + + let diagnostics: InvalidTailwindDirectiveDiagnostic[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.css) + } + + ranges.forEach((range) => { + let text = document.getText(range) + let matches = findAll(/(?:\s|^)@tailwind\s+(?[^;]+)/g, text) + + let valid = [ + 'utilities', + 'components', + 'screens', + semver.gte(state.version, '1.0.0-beta.1') ? 'base' : 'preflight', + ] + + matches.forEach((match) => { + if (valid.includes(match.groups.value)) { + return null + } + + let message = `'${match.groups.value}' is not a valid group.` + let suggestions: string[] = [] + + if (match.groups.value === 'preflight') { + suggestions.push('base') + message += ` Did you mean 'base'?` + } else { + let suggestion = closest(match.groups.value, valid) + if (suggestion) { + suggestions.push(suggestion) + message += ` Did you mean '${suggestion}'?` + } + } + + diagnostics.push({ + code: DiagnosticKind.InvalidTailwindDirective, + range: absoluteRange( + { + start: indexToPosition( + text, + match.index + match[0].length - match.groups.value.length + ), + end: indexToPosition(text, match.index + match[0].length), + }, + range + ), + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message, + suggestions, + }) + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/getInvalidVariantDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidVariantDiagnostics.ts new file mode 100644 index 0000000..0067401 --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidVariantDiagnostics.ts @@ -0,0 +1,77 @@ +import { State, Settings } from '../../util/state' +import { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' +import { InvalidVariantDiagnostic, DiagnosticKind } from './types' +import { isCssDoc } from '../../util/css' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { findAll, indexToPosition } from '../../util/find' +import { closest } from '../../util/closest' +import { absoluteRange } from '../../util/absoluteRange' + +export function getInvalidVariantDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidVariantDiagnostic[] { + let severity = settings.lint.invalidVariant + if (severity === 'ignore') return [] + + let diagnostics: InvalidVariantDiagnostic[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.css) + } + + ranges.forEach((range) => { + let text = document.getText(range) + let matches = findAll(/(?:\s|^)@variants\s+(?[^{]+)/g, text) + + matches.forEach((match) => { + let variants = match.groups.variants.split(/(\s*,\s*)/) + let listStartIndex = + match.index + match[0].length - match.groups.variants.length + + for (let i = 0; i < variants.length; i += 2) { + let variant = variants[i].trim() + if (state.variants.includes(variant)) { + continue + } + + let message = `The variant '${variant}' does not exist.` + let suggestions: string[] = [] + let suggestion = closest(variant, state.variants) + + if (suggestion) { + suggestions.push(suggestion) + message += ` Did you mean '${suggestion}'?` + } + + let variantStartIndex = + listStartIndex + variants.slice(0, i).join('').length + + diagnostics.push({ + code: DiagnosticKind.InvalidVariant, + range: absoluteRange( + { + start: indexToPosition(text, variantStartIndex), + end: indexToPosition(text, variantStartIndex + variant.length), + }, + range + ), + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message, + suggestions, + }) + } + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/types.ts b/src/lsp/providers/diagnostics/types.ts new file mode 100644 index 0000000..1cfd0e2 --- /dev/null +++ b/src/lsp/providers/diagnostics/types.ts @@ -0,0 +1,86 @@ +import { Diagnostic } from 'vscode-languageserver' +import { DocumentClassName, DocumentClassList } from '../../util/state' + +export enum DiagnosticKind { + CssConflict = 'cssConflict', + InvalidApply = 'invalidApply', + InvalidScreen = 'invalidScreen', + InvalidVariant = 'invalidVariant', + InvalidConfigPath = 'invalidConfigPath', + InvalidTailwindDirective = 'invalidTailwindDirective', +} + +export type CssConflictDiagnostic = Diagnostic & { + code: DiagnosticKind.CssConflict + className: DocumentClassName + otherClassNames: DocumentClassName[] +} + +export function isCssConflictDiagnostic( + diagnostic: AugmentedDiagnostic +): diagnostic is CssConflictDiagnostic { + return diagnostic.code === DiagnosticKind.CssConflict +} + +export type InvalidApplyDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidApply + className: DocumentClassName +} + +export function isInvalidApplyDiagnostic( + diagnostic: AugmentedDiagnostic +): diagnostic is InvalidApplyDiagnostic { + return diagnostic.code === DiagnosticKind.InvalidApply +} + +export type InvalidScreenDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidScreen + suggestions: string[] +} + +export function isInvalidScreenDiagnostic( + diagnostic: AugmentedDiagnostic +): diagnostic is InvalidScreenDiagnostic { + return diagnostic.code === DiagnosticKind.InvalidScreen +} + +export type InvalidVariantDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidVariant + suggestions: string[] +} + +export function isInvalidVariantDiagnostic( + diagnostic: AugmentedDiagnostic +): diagnostic is InvalidVariantDiagnostic { + return diagnostic.code === DiagnosticKind.InvalidVariant +} + +export type InvalidConfigPathDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidConfigPath + suggestions: string[] +} + +export function isInvalidConfigPathDiagnostic( + diagnostic: AugmentedDiagnostic +): diagnostic is InvalidConfigPathDiagnostic { + return diagnostic.code === DiagnosticKind.InvalidConfigPath +} + +export type InvalidTailwindDirectiveDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidTailwindDirective + suggestions: string[] +} + +export function isInvalidTailwindDirectiveDiagnostic( + diagnostic: AugmentedDiagnostic +): diagnostic is InvalidTailwindDirectiveDiagnostic { + return diagnostic.code === DiagnosticKind.InvalidTailwindDirective +} + +export type AugmentedDiagnostic = + | CssConflictDiagnostic + | InvalidApplyDiagnostic + | InvalidScreenDiagnostic + | InvalidVariantDiagnostic + | InvalidConfigPathDiagnostic + | InvalidTailwindDirectiveDiagnostic diff --git a/src/lsp/providers/hoverProvider.ts b/src/lsp/providers/hoverProvider.ts index a9010a3..2084ec4 100644 --- a/src/lsp/providers/hoverProvider.ts +++ b/src/lsp/providers/hoverProvider.ts @@ -1,10 +1,11 @@ import { State } from '../util/state' import { Hover, TextDocumentPositionParams } from 'vscode-languageserver' -import { getClassNameParts } from '../util/getClassNameAtPosition' import { stringifyCss, stringifyConfigValue } from '../util/stringify' const dlv = require('dlv') import { isCssContext } from '../util/css' import { findClassNameAtPosition } from '../util/find' +import { validateApply } from '../util/validateApply' +import { getClassNameParts } from '../util/getClassNameAtPosition' export function provideHover( state: State, @@ -81,6 +82,13 @@ function provideClassNameHover( const parts = getClassNameParts(state, className.className) if (!parts) return null + if (isCssContext(state, doc, position)) { + let validated = validateApply(state, parts) + if (validated === null || validated.isApplyable === false) { + return null + } + } + return { contents: { language: 'css', diff --git a/src/lsp/server.ts b/src/lsp/server.ts index b28b1ba..cc85ae4 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -16,6 +16,8 @@ import { Hover, TextDocumentPositionParams, DidChangeConfigurationNotification, + CodeActionParams, + CodeAction, } from 'vscode-languageserver' import getTailwindState from '../class-names/index' import { State, Settings, EditorState } from './util/state' @@ -26,25 +28,44 @@ import { import { provideHover } from './providers/hoverProvider' import { URI } from 'vscode-uri' import { getDocumentSettings } from './util/getDocumentSettings' +import { + provideDiagnostics, + updateAllDiagnostics, + clearAllDiagnostics, +} from './providers/diagnostics/diagnosticsProvider' +import { createEmitter } from '../lib/emitter' +import { provideCodeActions } from './providers/codeActions/codeActionProvider' -let state: State = { enabled: false } let connection = createConnection(ProposedFeatures.all) +let state: State = { enabled: false, emitter: createEmitter(connection) } let documents = new TextDocuments() let workspaceFolder: string | null const defaultSettings: Settings = { emmetCompletions: false, includeLanguages: {}, + validate: true, + lint: { + cssConflict: 'warning', + invalidApply: 'error', + invalidScreen: 'error', + invalidVariant: 'error', + invalidConfigPath: 'error', + invalidTailwindDirective: 'error', + }, } let globalSettings: Settings = defaultSettings let documentSettings: Map = new Map() documents.onDidOpen((event) => { - getDocumentSettings(state, event.document.uri) + getDocumentSettings(state, event.document) }) documents.onDidClose((event) => { documentSettings.delete(event.document.uri) }) +documents.onDidChangeContent((change) => { + provideDiagnostics(state, change.document) +}) documents.listen(connection) connection.onInitialize( @@ -64,6 +85,10 @@ connection.onInitialize( capabilities: { configuration: capabilities.workspace && !!capabilities.workspace.configuration, + diagnosticRelatedInformation: + capabilities.textDocument && + capabilities.textDocument.publishDiagnostics && + capabilities.textDocument.publishDiagnostics.relatedInformation, }, } @@ -73,14 +98,24 @@ connection.onInitialize( // @ts-ignore onChange: (newState: State): void => { if (newState && !newState.error) { - state = { ...newState, enabled: true, editor: editorState } + state = { + ...newState, + enabled: true, + emitter: state.emitter, + editor: editorState, + } connection.sendNotification('tailwindcss/configUpdated', [ state.configPath, state.config, state.plugins, ]) + updateAllDiagnostics(state) } else { - state = { enabled: false, editor: editorState } + state = { + enabled: false, + emitter: state.emitter, + editor: editorState, + } if (newState && newState.error) { const payload: { message: string @@ -95,6 +130,7 @@ connection.onInitialize( } connection.sendNotification('tailwindcss/configError', [payload]) } + clearAllDiagnostics(state) // TODO // connection.sendNotification('tailwindcss/configUpdated', [null]) } @@ -103,9 +139,14 @@ connection.onInitialize( ) if (tailwindState) { - state = { enabled: true, editor: editorState, ...tailwindState } + state = { + enabled: true, + emitter: state.emitter, + editor: editorState, + ...tailwindState, + } } else { - state = { enabled: false, editor: editorState } + state = { enabled: false, emitter: state.emitter, editor: editorState } } return { @@ -133,6 +174,7 @@ connection.onInitialize( ], }, hoverProvider: true, + codeActionProvider: true, }, } } @@ -164,9 +206,7 @@ connection.onDidChangeConfiguration((change) => { ) } - state.editor.documents - .all() - .forEach((doc) => getDocumentSettings(state, doc.uri)) + updateAllDiagnostics(state) }) connection.onCompletion( @@ -190,4 +230,11 @@ connection.onHover( } ) +connection.onCodeAction( + (params: CodeActionParams): Promise => { + if (!state.enabled) return null + return provideCodeActions(state, params) + } +) + connection.listen() diff --git a/src/lsp/util/absoluteRange.ts b/src/lsp/util/absoluteRange.ts new file mode 100644 index 0000000..9250e4f --- /dev/null +++ b/src/lsp/util/absoluteRange.ts @@ -0,0 +1,18 @@ +import { Range } from 'vscode-languageserver' + +export function absoluteRange(range: Range, reference?: Range) { + return { + start: { + line: (reference?.start.line || 0) + range.start.line, + character: + (range.end.line === 0 ? reference?.start.character || 0 : 0) + + range.start.character, + }, + end: { + line: (reference?.start.line || 0) + range.end.line, + character: + (range.end.line === 0 ? reference?.start.character || 0 : 0) + + range.end.character, + }, + } +} diff --git a/src/lsp/util/closest.ts b/src/lsp/util/closest.ts new file mode 100644 index 0000000..ebdfacc --- /dev/null +++ b/src/lsp/util/closest.ts @@ -0,0 +1,5 @@ +import sift from 'sift-string' + +export function closest(input: string, options: string[]): string | undefined { + return options.concat([]).sort((a, b) => sift(input, a) - sift(input, b))[0] +} diff --git a/src/lsp/util/combinations.ts b/src/lsp/util/combinations.ts new file mode 100644 index 0000000..2c9868b --- /dev/null +++ b/src/lsp/util/combinations.ts @@ -0,0 +1,13 @@ +export function combinations(str: string): string[] { + let fn = function (active: string, rest: string, a: string[]) { + if (!active && !rest) return + if (!rest) { + a.push(active) + } else { + fn(active + rest[0], rest.slice(1), a) + fn(active, rest.slice(1), a) + } + return a + } + return fn('', str, []) +} diff --git a/src/lsp/util/cssObjToAst.ts b/src/lsp/util/cssObjToAst.ts new file mode 100644 index 0000000..42826f7 --- /dev/null +++ b/src/lsp/util/cssObjToAst.ts @@ -0,0 +1,127 @@ +/* +This is a modified version of the postcss-js 'parse' function which accepts the +postcss module as an argument. License below: + +The MIT License (MIT) + +Copyright 2015 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +var IMPORTANT = /\s*!important\s*$/i + +var unitless = { + 'box-flex': true, + 'box-flex-group': true, + 'column-count': true, + flex: true, + 'flex-grow': true, + 'flex-positive': true, + 'flex-shrink': true, + 'flex-negative': true, + 'font-weight': true, + 'line-clamp': true, + 'line-height': true, + opacity: true, + order: true, + orphans: true, + 'tab-size': true, + widows: true, + 'z-index': true, + zoom: true, + 'fill-opacity': true, + 'stroke-dashoffset': true, + 'stroke-opacity': true, + 'stroke-width': true, +} + +function dashify(str) { + return str + .replace(/([A-Z])/g, '-$1') + .replace(/^ms-/, '-ms-') + .toLowerCase() +} + +function decl(parent, name, value, postcss) { + if (value === false || value === null) return + + name = dashify(name) + if (typeof value === 'number') { + if (value === 0 || unitless[name]) { + value = value.toString() + } else { + value = value.toString() + 'px' + } + } + + if (name === 'css-float') name = 'float' + + if (IMPORTANT.test(value)) { + value = value.replace(IMPORTANT, '') + parent.push(postcss.decl({ prop: name, value: value, important: true })) + } else { + parent.push(postcss.decl({ prop: name, value: value })) + } +} + +function atRule(parent, parts, value, postcss) { + var node = postcss.atRule({ name: parts[1], params: parts[3] || '' }) + if (typeof value === 'object') { + node.nodes = [] + parse(value, node, postcss) + } + parent.push(node) +} + +function parse(obj, parent, postcss) { + var name, value, node, i + for (name in obj) { + if (obj.hasOwnProperty(name)) { + value = obj[name] + if (value === null || typeof value === 'undefined') { + continue + } else if (name[0] === '@') { + var parts = name.match(/@([^\s]+)(\s+([\w\W]*)\s*)?/) + if (Array.isArray(value)) { + for (i = 0; i < value.length; i++) { + atRule(parent, parts, value[i], postcss) + } + } else { + atRule(parent, parts, value, postcss) + } + } else if (Array.isArray(value)) { + for (i = 0; i < value.length; i++) { + decl(parent, name, value[i], postcss) + } + } else if (typeof value === 'object') { + node = postcss.rule({ selector: name }) + parse(value, node, postcss) + parent.push(node) + } else { + decl(parent, name, value, postcss) + } + } + } +} + +export function cssObjToAst(obj, postcss) { + var root = postcss.root() + parse(obj, root, postcss) + return root +} diff --git a/src/lsp/util/find.ts b/src/lsp/util/find.ts index 5eaef42..b642534 100644 --- a/src/lsp/util/find.ts +++ b/src/lsp/util/find.ts @@ -5,10 +5,12 @@ import { isCssContext, isCssDoc } from './css' import { isHtmlContext, isHtmlDoc, isSvelteDoc, isVueDoc } from './html' import { isWithinRange } from './isWithinRange' import { isJsContext, isJsDoc } from './js' +import { flatten } from '../../util/array' import { getClassAttributeLexer, getComputedClassAttributeLexer, } from './lexers' +import { getLanguageBoundaries } from './getLanguageBoundaries' export function findAll(re: RegExp, str: string): RegExpMatchArray[] { let match: RegExpMatchArray @@ -27,44 +29,63 @@ export function findLast(re: RegExp, str: string): RegExpMatchArray { return matches[matches.length - 1] } +export function getClassNamesInClassList({ + classList, + range, + important, +}: DocumentClassList): DocumentClassName[] { + const parts = classList.split(/(\s+)/) + const names: DocumentClassName[] = [] + let index = 0 + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 0) { + const start = indexToPosition(classList, index) + const end = indexToPosition(classList, index + parts[i].length) + names.push({ + className: parts[i], + classList: { + classList, + range, + important, + }, + relativeRange: { + start, + end, + }, + range: { + start: { + line: range.start.line + start.line, + character: + (end.line === 0 ? range.start.character : 0) + start.character, + }, + end: { + line: range.start.line + end.line, + character: + (end.line === 0 ? range.start.character : 0) + end.character, + }, + }, + }) + } + index += parts[i].length + } + return names +} + export function findClassNamesInRange( doc: TextDocument, range?: Range, mode?: 'html' | 'css' ): DocumentClassName[] { const classLists = findClassListsInRange(doc, range, mode) - return [].concat.apply( - [], - classLists.map(({ classList, range }) => { - const parts = classList.split(/(\s+)/) - const names: DocumentClassName[] = [] - let index = 0 - for (let i = 0; i < parts.length; i++) { - if (i % 2 === 0) { - const start = indexToPosition(classList, index) - const end = indexToPosition(classList, index + parts[i].length) - names.push({ - className: parts[i], - range: { - start: { - line: range.start.line + start.line, - character: - (end.line === 0 ? range.start.character : 0) + - start.character, - }, - end: { - line: range.start.line + end.line, - character: - (end.line === 0 ? range.start.character : 0) + end.character, - }, - }, - }) - } - index += parts[i].length - } - return names - }) - ) + return flatten(classLists.map(getClassNamesInClassList)) +} + +export function findClassNamesInDocument( + state: State, + doc: TextDocument +): DocumentClassName[] { + const classLists = findClassListsInDocument(state, doc) + return flatten(classLists.map(getClassNamesInClassList)) } export function findClassListsInCssRange( @@ -72,7 +93,10 @@ export function findClassListsInCssRange( range?: Range ): DocumentClassList[] { const text = doc.getText(range) - const matches = findAll(/(@apply\s+)(?[^;}]+)[;}]/g, text) + const matches = findAll( + /(@apply\s+)(?[^;}]+?)(?\s*!important)?\s*[;}]/g, + text + ) const globalStart: Position = range ? range.start : { line: 0, character: 0 } return matches.map((match) => { @@ -83,6 +107,7 @@ export function findClassListsInCssRange( ) return { classList: match.groups.classList, + important: Boolean(match.groups.important), range: { start: { line: globalStart.line + start.line, @@ -101,7 +126,7 @@ export function findClassListsInCssRange( export function findClassListsInHtmlRange( doc: TextDocument, - range: Range + range?: Range ): DocumentClassList[] { const text = doc.getText(range) const matches = findAll(/(?:\b|:)class(?:Name)?=['"`{]/g, text) @@ -180,15 +205,16 @@ export function findClassListsInHtmlRange( classList: value.substr(beforeOffset, value.length + afterOffset), range: { start: { - line: range.start.line + start.line, + line: (range?.start.line || 0) + start.line, character: - (end.line === 0 ? range.start.character : 0) + + (end.line === 0 ? range?.start.character || 0 : 0) + start.character, }, end: { - line: range.start.line + end.line, + line: (range?.start.line || 0) + end.line, character: - (end.line === 0 ? range.start.character : 0) + end.character, + (end.line === 0 ? range?.start.character || 0 : 0) + + end.character, }, }, } @@ -202,8 +228,8 @@ export function findClassListsInHtmlRange( export function findClassListsInRange( doc: TextDocument, - range: Range, - mode: 'html' | 'css' + range?: Range, + mode?: 'html' | 'css' ): DocumentClassList[] { if (mode === 'css') { return findClassListsInCssRange(doc, range) @@ -219,73 +245,16 @@ export function findClassListsInDocument( return findClassListsInCssRange(doc) } - if (isVueDoc(doc)) { - let text = doc.getText() - let blocks = findAll( - /<(?template|style|script)\b[^>]*>.*?(<\/\k>|$)/gis, - text - ) - let htmlRanges: Range[] = [] - let cssRanges: Range[] = [] - for (let i = 0; i < blocks.length; i++) { - let range = { - start: indexToPosition(text, blocks[i].index), - end: indexToPosition(text, blocks[i].index + blocks[i][0].length), - } - if (blocks[i].groups.type === 'style') { - cssRanges.push(range) - } else { - htmlRanges.push(range) - } - } - return [].concat.apply( - [], - [ - ...htmlRanges.map((range) => findClassListsInHtmlRange(doc, range)), - ...cssRanges.map((range) => findClassListsInCssRange(doc, range)), - ] - ) - } + let boundaries = getLanguageBoundaries(state, doc) + if (!boundaries) return [] - if (isHtmlDoc(state, doc) || isJsDoc(state, doc) || isSvelteDoc(doc)) { - let text = doc.getText() - let styleBlocks = findAll(/]*>|>).*?(<\/style>|$)/gis, text) - let htmlRanges: Range[] = [] - let cssRanges: Range[] = [] - let currentIndex = 0 - - for (let i = 0; i < styleBlocks.length; i++) { - htmlRanges.push({ - start: indexToPosition(text, currentIndex), - end: indexToPosition(text, styleBlocks[i].index), - }) - cssRanges.push({ - start: indexToPosition(text, styleBlocks[i].index), - end: indexToPosition( - text, - styleBlocks[i].index + styleBlocks[i][0].length - ), - }) - currentIndex = styleBlocks[i].index + styleBlocks[i][0].length - } - htmlRanges.push({ - start: indexToPosition(text, currentIndex), - end: indexToPosition(text, text.length), - }) - - return [].concat.apply( - [], - [ - ...htmlRanges.map((range) => findClassListsInHtmlRange(doc, range)), - ...cssRanges.map((range) => findClassListsInCssRange(doc, range)), - ] - ) - } - - return [] + return flatten([ + ...boundaries.html.map((range) => findClassListsInHtmlRange(doc, range)), + ...boundaries.css.map((range) => findClassListsInCssRange(doc, range)), + ]) } -function indexToPosition(str: string, index: number): Position { +export function indexToPosition(str: string, index: number): Position { const { line, col } = lineColumn(str + '\n', index) return { line: line - 1, character: col - 1 } } diff --git a/src/lsp/util/getClassNameAtPosition.ts b/src/lsp/util/getClassNameAtPosition.ts index 95de79a..7418b2f 100644 --- a/src/lsp/util/getClassNameAtPosition.ts +++ b/src/lsp/util/getClassNameAtPosition.ts @@ -1,48 +1,7 @@ -import { TextDocument, Range, Position } from 'vscode-languageserver' -import { State, DocumentClassName } from './state' +import { State } from './state' +import { combinations } from './combinations' const dlv = require('dlv') -export function getClassNameAtPosition( - document: TextDocument, - position: Position -): DocumentClassName { - const range1: Range = { - start: { line: Math.max(position.line - 5, 0), character: 0 }, - end: position, - } - const text1: string = document.getText(range1) - - if (!/\bclass(Name)?=['"][^'"]*$/.test(text1)) return null - - const range2: Range = { - start: { line: Math.max(position.line - 5, 0), character: 0 }, - end: { line: position.line + 1, character: position.character }, - } - const text2: string = document.getText(range2) - - let str: string = text1 + text2.substr(text1.length).match(/^([^"' ]*)/)[0] - let matches: RegExpMatchArray = str.match(/\bclass(Name)?=["']([^"']+)$/) - - if (!matches) return null - - let className: string = matches[2].split(' ').pop() - if (!className) return null - - let range: Range = { - start: { - line: position.line, - character: - position.character + str.length - text1.length - className.length, - }, - end: { - line: position.line, - character: position.character + str.length - text1.length, - }, - } - - return { className, range } -} - export function getClassNameParts(state: State, className: string): string[] { let separator = state.separator className = className.replace(/^\./, '') @@ -83,17 +42,3 @@ export function getClassNameParts(state: State, className: string): string[] { return false }) } - -function combinations(str: string): string[] { - let fn = function (active: string, rest: string, a: string[]) { - if (!active && !rest) return - if (!rest) { - a.push(active) - } else { - fn(active + rest[0], rest.slice(1), a) - fn(active, rest.slice(1), a) - } - return a - } - return fn('', str, []) -} diff --git a/src/lsp/util/getClassNameDecls.ts b/src/lsp/util/getClassNameDecls.ts new file mode 100644 index 0000000..a04ce94 --- /dev/null +++ b/src/lsp/util/getClassNameDecls.ts @@ -0,0 +1,20 @@ +import { State } from './state' +import { getClassNameParts } from './getClassNameAtPosition' +import removeMeta from './removeMeta' +const dlv = require('dlv') + +export function getClassNameDecls( + state: State, + className: string +): Record | Record[] | null { + const parts = getClassNameParts(state, className) + if (!parts) return null + + const info = dlv(state.classNames.classNames, parts) + + if (Array.isArray(info)) { + return info.map(removeMeta) + } + + return removeMeta(info) +} diff --git a/src/lsp/util/getClassNameMeta.ts b/src/lsp/util/getClassNameMeta.ts new file mode 100644 index 0000000..1099a1a --- /dev/null +++ b/src/lsp/util/getClassNameMeta.ts @@ -0,0 +1,30 @@ +import { State, ClassNameMeta } from './state' +import { getClassNameParts } from './getClassNameAtPosition' +const dlv = require('dlv') + +export function getClassNameMeta( + state: State, + classNameOrParts: string | string[] +): ClassNameMeta | ClassNameMeta[] { + const parts = Array.isArray(classNameOrParts) + ? classNameOrParts + : getClassNameParts(state, classNameOrParts) + if (!parts) return null + const info = dlv(state.classNames.classNames, parts) + + if (Array.isArray(info)) { + return info.map((i) => ({ + source: i.__source, + pseudo: i.__pseudo, + scope: i.__scope, + context: i.__context, + })) + } + + return { + source: info.__source, + pseudo: info.__pseudo, + scope: info.__scope, + context: info.__context, + } +} diff --git a/src/lsp/util/getDocumentSettings.ts b/src/lsp/util/getDocumentSettings.ts index 3ee25bd..2f127de 100644 --- a/src/lsp/util/getDocumentSettings.ts +++ b/src/lsp/util/getDocumentSettings.ts @@ -1,19 +1,19 @@ import { State, Settings } from './state' +import { TextDocument } from 'vscode-languageserver' export async function getDocumentSettings( state: State, - resource: string + document: TextDocument ): Promise { if (!state.editor.capabilities.configuration) { return Promise.resolve(state.editor.globalSettings) } - let result = state.editor.documentSettings.get(resource) + let result = state.editor.documentSettings.get(document.uri) if (!result) { - result = await state.editor.connection.workspace.getConfiguration({ - scopeUri: resource, - section: 'tailwindCSS', + result = await state.emitter.emit('getConfiguration', { + languageId: document.languageId, }) - state.editor.documentSettings.set(resource, result) + state.editor.documentSettings.set(document.uri, result) } return result } diff --git a/src/lsp/util/getLanguageBoundaries.ts b/src/lsp/util/getLanguageBoundaries.ts new file mode 100644 index 0000000..c309aae --- /dev/null +++ b/src/lsp/util/getLanguageBoundaries.ts @@ -0,0 +1,89 @@ +import { TextDocument, Range } from 'vscode-languageserver' +import { isVueDoc, isHtmlDoc, isSvelteDoc } from './html' +import { State } from './state' +import { findAll, indexToPosition } from './find' +import { isJsDoc } from './js' + +export interface LanguageBoundaries { + html: Range[] + css: Range[] +} + +export function getLanguageBoundaries( + state: State, + doc: TextDocument +): LanguageBoundaries | null { + if (isVueDoc(doc)) { + let text = doc.getText() + let blocks = findAll( + /(?<(?template|style|script)\b[^>]*>).*?(?<\/\k>|$)/gis, + text + ) + let htmlRanges: Range[] = [] + let cssRanges: Range[] = [] + for (let i = 0; i < blocks.length; i++) { + let range = { + start: indexToPosition( + text, + blocks[i].index + blocks[i].groups.open.length + ), + end: indexToPosition( + text, + blocks[i].index + blocks[i][0].length - blocks[i].groups.close.length + ), + } + if (blocks[i].groups.type === 'style') { + cssRanges.push(range) + } else { + htmlRanges.push(range) + } + } + + return { + html: htmlRanges, + css: cssRanges, + } + } + + if (isHtmlDoc(state, doc) || isJsDoc(state, doc) || isSvelteDoc(doc)) { + let text = doc.getText() + let styleBlocks = findAll( + /(?]*>|>)).*?(?<\/style>|$)/gis, + text + ) + let htmlRanges: Range[] = [] + let cssRanges: Range[] = [] + let currentIndex = 0 + + for (let i = 0; i < styleBlocks.length; i++) { + htmlRanges.push({ + start: indexToPosition(text, currentIndex), + end: indexToPosition(text, styleBlocks[i].index), + }) + cssRanges.push({ + start: indexToPosition( + text, + styleBlocks[i].index + styleBlocks[i].groups.open.length + ), + end: indexToPosition( + text, + styleBlocks[i].index + + styleBlocks[i][0].length - + styleBlocks[i].groups.close.length + ), + }) + currentIndex = styleBlocks[i].index + styleBlocks[i][0].length + } + htmlRanges.push({ + start: indexToPosition(text, currentIndex), + end: indexToPosition(text, text.length), + }) + + return { + html: htmlRanges, + css: cssRanges, + } + } + + return null +} diff --git a/src/lsp/util/joinWithAnd.ts b/src/lsp/util/joinWithAnd.ts new file mode 100644 index 0000000..2b2efb7 --- /dev/null +++ b/src/lsp/util/joinWithAnd.ts @@ -0,0 +1,11 @@ +export function joinWithAnd(strings: string[]): string { + return strings.reduce((acc, cur, i) => { + if (i === 0) { + return cur + } + if (strings.length > 1 && i === strings.length - 1) { + return `${acc} and ${cur}` + } + return `${acc}, ${cur}` + }, '') +} diff --git a/src/lsp/util/logFull.ts b/src/lsp/util/logFull.ts new file mode 100644 index 0000000..c05fc1b --- /dev/null +++ b/src/lsp/util/logFull.ts @@ -0,0 +1,5 @@ +import * as util from 'util' + +export function logFull(object: any): void { + console.log(util.inspect(object, { showHidden: false, depth: null })) +} diff --git a/src/lsp/util/rangesEqual.ts b/src/lsp/util/rangesEqual.ts new file mode 100644 index 0000000..220cebd --- /dev/null +++ b/src/lsp/util/rangesEqual.ts @@ -0,0 +1,10 @@ +import { Range } from 'vscode-languageserver' + +export function rangesEqual(a: Range, b: Range): boolean { + return ( + a.start.line === b.start.line && + a.start.character === b.start.character && + a.end.line === b.end.line && + a.end.character === b.end.character + ) +} diff --git a/src/lsp/util/removeRangesFromString.ts b/src/lsp/util/removeRangesFromString.ts new file mode 100644 index 0000000..f97d62b --- /dev/null +++ b/src/lsp/util/removeRangesFromString.ts @@ -0,0 +1,39 @@ +import { Range } from 'vscode-languageserver' +import lineColumn from 'line-column' +import { ensureArray } from '../../util/array' + +export function removeRangesFromString( + str: string, + rangeOrRanges: Range | Range[] +): string { + let ranges = ensureArray(rangeOrRanges) + let finder = lineColumn(str + '\n', { origin: 0 }) + let indexRanges: { start: number; end: number }[] = [] + + ranges.forEach((range) => { + let start = finder.toIndex(range.start.line, range.start.character) + let end = finder.toIndex(range.end.line, range.end.character) + for (let i = start - 1; i >= 0; i--) { + if (/\s/.test(str.charAt(i))) { + start = i + } else { + break + } + } + indexRanges.push({ start, end }) + }) + + indexRanges.sort((a, b) => a.start - b.start) + + let result = '' + let i = 0 + + indexRanges.forEach((indexRange) => { + result += str.substring(i, indexRange.start) + i = indexRange.end + }) + + result += str.substring(i) + + return result.trim() +} diff --git a/src/lsp/util/state.ts b/src/lsp/util/state.ts index daaa050..09a0200 100644 --- a/src/lsp/util/state.ts +++ b/src/lsp/util/state.ts @@ -1,4 +1,5 @@ import { TextDocuments, Connection, Range } from 'vscode-languageserver' +import { NotificationEmitter } from '../../lib/emitter' export type ClassNamesTree = { [key: string]: ClassNamesTree @@ -21,19 +22,36 @@ export type EditorState = { userLanguages: Record capabilities: { configuration: boolean + diagnosticRelatedInformation: boolean } } +type DiagnosticSeveritySetting = 'ignore' | 'warning' | 'error' + export type Settings = { emmetCompletions: boolean includeLanguages: Record + validate: boolean + lint: { + cssConflict: DiagnosticSeveritySetting + invalidApply: DiagnosticSeveritySetting + invalidScreen: DiagnosticSeveritySetting + invalidVariant: DiagnosticSeveritySetting + invalidConfigPath: DiagnosticSeveritySetting + invalidTailwindDirective: DiagnosticSeveritySetting + } } export type State = null | { enabled: boolean + emitter: NotificationEmitter version?: string configPath?: string config?: any + modules?: { + tailwindcss: any + postcss: any + } separator?: string plugins?: any[] variants?: string[] @@ -46,9 +64,19 @@ export type State = null | { export type DocumentClassList = { classList: string range: Range + important?: boolean } export type DocumentClassName = { className: string range: Range + relativeRange: Range + classList: DocumentClassList +} + +export type ClassNameMeta = { + source: 'base' | 'components' | 'utilities' + pseudo: string[] + scope: string[] + context: string[] } diff --git a/src/lsp/util/stringToPath.ts b/src/lsp/util/stringToPath.ts new file mode 100644 index 0000000..b06e153 --- /dev/null +++ b/src/lsp/util/stringToPath.ts @@ -0,0 +1,15 @@ +// https://github.com/lodash/lodash/blob/4.17.15/lodash.js#L6735-L6744 +let rePropName = /[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g +let reEscapeChar = /\\(\\)?/g + +export function stringToPath(string: string): string[] { + let result: string[] = [] + if (string.charCodeAt(0) === 46 /* . */) { + result.push('') + } + // @ts-ignore + string.replace(rePropName, (match, number, quote, subString) => { + result.push(quote ? subString.replace(reEscapeChar, '$1') : number || match) + }) + return result +} diff --git a/src/lsp/util/validateApply.ts b/src/lsp/util/validateApply.ts new file mode 100644 index 0000000..52f2b2c --- /dev/null +++ b/src/lsp/util/validateApply.ts @@ -0,0 +1,44 @@ +import { State } from './state' +import { getClassNameMeta } from './getClassNameMeta' + +export function validateApply( + state: State, + classNameOrParts: string | string[] +): { isApplyable: true } | { isApplyable: false; reason: string } | null { + const meta = getClassNameMeta(state, classNameOrParts) + if (!meta) return null + + const className = Array.isArray(classNameOrParts) + ? classNameOrParts.join(state.separator) + : classNameOrParts + + let reason: string + + if (Array.isArray(meta)) { + reason = `'@apply' cannot be used with '${className}' because it is included in multiple rulesets.` + } else if (meta.source !== 'utilities') { + reason = `'@apply' cannot be used with '${className}' because it is not a utility.` + } else if (meta.context && meta.context.length > 0) { + if (meta.context.length === 1) { + reason = `'@apply' cannot be used with '${className}' because it is nested inside of an at-rule ('${meta.context[0]}').` + } else { + reason = `'@apply' cannot be used with '${className}' because it is nested inside of at-rules (${meta.context + .map((c) => `'${c}'`) + .join(', ')}).` + } + } else if (meta.pseudo && meta.pseudo.length > 0) { + if (meta.pseudo.length === 1) { + reason = `'@apply' cannot be used with '${className}' because its definition includes a pseudo-selector ('${meta.pseudo[0]}')` + } else { + reason = `'@apply' cannot be used with '${className}' because its definition includes pseudo-selectors (${meta.pseudo + .map((p) => `'${p}'`) + .join(', ')}).` + } + } + + if (reason) { + return { isApplyable: false, reason } + } + + return { isApplyable: true } +} diff --git a/src/util/array.ts b/src/util/array.ts index b40dd24..869eb9f 100644 --- a/src/util/array.ts +++ b/src/util/array.ts @@ -2,6 +2,16 @@ export function dedupe(arr: Array): Array { return arr.filter((value, index, self) => self.indexOf(value) === index) } +export function dedupeBy( + arr: Array, + transform: (item: T) => any +): Array { + return arr.filter( + (value, index, self) => + self.map(transform).indexOf(transform(value)) === index + ) +} + export function ensureArray(value: T | T[]): T[] { return Array.isArray(value) ? value : [value] }