Merge branch 'diagnostics'

master
Brad Cornes 2020-06-23 19:15:36 +01:00
commit 3d57b3c9c8
51 changed files with 1967 additions and 231 deletions

BIN
.github/autocomplete.png vendored 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

BIN
.github/banner.png vendored 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
.github/hover.png vendored 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
.github/linting.png vendored 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

View File

@ -8,4 +8,4 @@ contributing.md
node_modules/** node_modules/**
src/** src/**
tests/** tests/**
img/** .github/**

View File

@ -1,5 +1,22 @@
# Changelog # 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 ## 0.3.0
### General ### General

View File

@ -1,60 +1,36 @@
# Tailwind CSS IntelliSense <img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/diagnostics/.github/banner.png" alt="" />
> [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
<img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/master/img/html.gif" alt="HTML autocompletion" width="750"> **[Install via the Visual Studio Code Marketplace →](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss)**
## Requirements 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.
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/`)
## Features ## 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.) <img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/diagnostics/.github/autocomplete.png" alt="" />
- [Class name suggestions, including support for Emmet syntax](#class-name-suggestions-including-support-for-emmet-syntax) ### Linting
- 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)
### 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) <img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/diagnostics/.github/linting.png" alt="" />
- 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.
<img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/master/img/html.gif" alt="HTML autocompletion" width="750"> <img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/diagnostics/.github/hover.png" alt="" />
#### CSS preview when hovering over class names ### CSS Syntax Highlighting
<img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/master/img/html-hover.gif" alt="HTML hover preview" width="750"> Provides syntax definitions so that Tailwind features are highlighted correctly.
#### Suggestions when using `@apply` and config helpers
<img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/master/img/css.gif" alt="CSS autocompletion" width="750">
#### Improves syntax highlighting when using `@apply` and config helpers
Before:
<img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/master/img/css-highlighting-before.png" alt="CSS syntax highlighting before" width="400">
After:
<img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/master/img/css-highlighting-after.png" alt="CSS syntax highlighting after" width="400">
## Settings ## 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`** 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.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 youre 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

12
package-lock.json generated
View File

@ -2026,6 +2026,12 @@
"integrity": "sha1-OjYof1A05pnnV3kBBSwubJQlFjE=", "integrity": "sha1-OjYof1A05pnnV3kBBSwubJQlFjE=",
"dev": true "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": { "detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@ -6084,6 +6090,12 @@
"dev": true, "dev": true,
"optional": 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": { "signal-exit": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",

View File

@ -1,19 +1,19 @@
{ {
"name": "vscode-tailwindcss", "name": "vscode-tailwindcss",
"displayName": "Tailwind CSS IntelliSense", "displayName": "Tailwind CSS IntelliSense",
"description": "Tailwind CSS class name completion", "description": "Intelligent Tailwind CSS tooling for VS Code",
"preview": true, "preview": true,
"author": "Brad Cornes <hello@bradley.dev>", "author": "Brad Cornes <hello@bradley.dev>",
"license": "MIT", "license": "MIT",
"version": "0.3.1", "version": "0.3.1",
"homepage": "https://github.com/bradlc/vscode-tailwindcss", "homepage": "https://github.com/tailwindcss/intellisense",
"bugs": { "bugs": {
"url": "https://github.com/bradlc/vscode-tailwindcss/issues", "url": "https://github.com/tailwindcss/intellisense/issues",
"email": "hello@bradley.dev" "email": "hello@bradley.dev"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/bradlc/vscode-tailwindcss.git" "url": "https://github.com/tailwindcss/intellisense.git"
}, },
"publisher": "bradlc", "publisher": "bradlc",
"keywords": [ "keywords": [
@ -28,10 +28,11 @@
"vscode": "^1.33.0" "vscode": "^1.33.0"
}, },
"categories": [ "categories": [
"Linters",
"Other" "Other"
], ],
"galleryBanner": { "galleryBanner": {
"color": "#f1f5f8" "color": "#f9fafb"
}, },
"icon": "media/icon.png", "icon": "media/icon.png",
"activationEvents": [ "activationEvents": [
@ -69,6 +70,78 @@
}, },
"default": {}, "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\"}`" "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", "chokidar": "^3.3.1",
"concurrently": "^5.1.0", "concurrently": "^5.1.0",
"css.escape": "^1.5.1", "css.escape": "^1.5.1",
"detect-indent": "^6.0.0",
"dlv": "^1.1.3", "dlv": "^1.1.3",
"dset": "^2.0.1", "dset": "^2.0.1",
"esm": "^3.2.25", "esm": "^3.2.25",
@ -111,6 +185,7 @@
"resolve-from": "^5.0.0", "resolve-from": "^5.0.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"semver": "^7.3.2", "semver": "^7.3.2",
"sift-string": "0.0.2",
"stack-trace": "0.0.10", "stack-trace": "0.0.10",
"terser": "^4.6.12", "terser": "^4.6.12",
"tiny-invariant": "^1.1.0", "tiny-invariant": "^1.1.0",

View File

@ -141,6 +141,10 @@ export default async function getClassNames(
postcss, postcss,
browserslist, browserslist,
}), }),
modules: {
tailwindcss,
postcss,
},
} }
} }

View File

@ -11,6 +11,7 @@ import {
OutputChannel, OutputChannel,
WorkspaceFolder, WorkspaceFolder,
Uri, Uri,
ConfigurationScope,
} from 'vscode' } from 'vscode'
import { import {
LanguageClient, LanguageClient,
@ -22,6 +23,7 @@ import { DEFAULT_LANGUAGES } from './lib/languages'
import isObject from './util/isObject' import isObject from './util/isObject'
import { dedupe, equal } from './util/array' import { dedupe, equal } from './util/array'
import { createEmitter } from './lib/emitter' import { createEmitter } from './lib/emitter'
import { onMessage } from './lsp/notifications'
const CLIENT_ID = 'tailwindcss-intellisense' const CLIENT_ID = 'tailwindcss-intellisense'
const CLIENT_NAME = 'Tailwind CSS IntelliSense' const CLIENT_NAME = 'Tailwind CSS IntelliSense'
@ -150,6 +152,9 @@ export function activate(context: ExtensionContext) {
client.onReady().then(() => { client.onReady().then(() => {
let emitter = createEmitter(client) let emitter = createEmitter(client)
registerConfigErrorHandler(emitter) registerConfigErrorHandler(emitter)
onMessage(client, 'getConfiguration', async (scope) => {
return Workspace.getConfiguration('tailwindCSS', scope)
})
}) })
client.start() client.start()

View File

@ -1,6 +1,7 @@
import mitt from 'mitt' import mitt from 'mitt'
import { LanguageClient } from 'vscode-languageclient' import { LanguageClient } from 'vscode-languageclient'
import crypto from 'crypto' import crypto from 'crypto'
import { Connection } from 'vscode-languageserver'
export interface NotificationEmitter { export interface NotificationEmitter {
on: (name: string, handler: (args: any) => void) => void on: (name: string, handler: (args: any) => void) => void
@ -8,7 +9,9 @@ export interface NotificationEmitter {
emit: (name: string, args: any) => Promise<any> emit: (name: string, args: any) => Promise<any>
} }
export function createEmitter(client: LanguageClient): NotificationEmitter { export function createEmitter(
client: LanguageClient | Connection
): NotificationEmitter {
const emitter = mitt() const emitter = mitt()
const registered: string[] = [] const registered: string[] = []
@ -26,7 +29,7 @@ export function createEmitter(client: LanguageClient): NotificationEmitter {
emitter.off(name, handler) emitter.off(name, handler)
} }
const emit = (name: string, params: any) => { const emit = (name: string, params: Record<string, any> = {}) => {
return new Promise((resolve, _reject) => { return new Promise((resolve, _reject) => {
const id = crypto.randomBytes(16).toString('hex') const id = crypto.randomBytes(16).toString('hex')
on(`${name}Response`, (result) => { on(`${name}Response`, (result) => {

View File

@ -1,9 +1,10 @@
import { Connection } from 'vscode-languageserver' import { Connection } from 'vscode-languageserver'
import { LanguageClient } from 'vscode-languageclient'
export function onMessage( export function onMessage(
connection: Connection, connection: LanguageClient | Connection,
name: string, name: string,
handler: (params: any) => any handler: (params: any) => Thenable<Record<string, any>>
): void { ): void {
connection.onNotification(`tailwindcss/${name}`, async (params: any) => { connection.onNotification(`tailwindcss/${name}`, async (params: any) => {
const { _id, ...rest } = params const { _id, ...rest } = params

View File

@ -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<AugmentedDiagnostic[]> {
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<CodeAction[]> {
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)))
}

View File

@ -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<CodeAction[]> {
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
)
),
},
],
},
},
},
]
}

View File

@ -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<CodeAction[]> {
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
}

View File

@ -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,
},
],
},
},
}))
}

View File

@ -618,11 +618,11 @@ async function provideEmmetCompletions(
state: State, state: State,
{ position, textDocument }: CompletionParams { position, textDocument }: CompletionParams
): Promise<CompletionList> { ): Promise<CompletionList> {
let settings = await getDocumentSettings(state, textDocument.uri)
if (settings.emmetCompletions !== true) return null
let doc = state.editor.documents.get(textDocument.uri) 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) const syntax = isHtmlContext(state, doc, position)
? 'html' ? 'html'
: isJsContext(state, doc, position) : isJsContext(state, doc, position)

View File

@ -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<AugmentedDiagnostic[]> {
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)
})
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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(
/(?<prefix>\s|^)(?<helper>config|theme)\((?<quote>['"])(?<key>[^)]+)\k<quote>\)/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
}

View File

@ -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+(?<screen>[^\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
}

View File

@ -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+(?<value>[^;]+)/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
}

View File

@ -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+(?<variants>[^{]+)/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
}

View File

@ -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

View File

@ -1,10 +1,11 @@
import { State } from '../util/state' import { State } from '../util/state'
import { Hover, TextDocumentPositionParams } from 'vscode-languageserver' import { Hover, TextDocumentPositionParams } from 'vscode-languageserver'
import { getClassNameParts } from '../util/getClassNameAtPosition'
import { stringifyCss, stringifyConfigValue } from '../util/stringify' import { stringifyCss, stringifyConfigValue } from '../util/stringify'
const dlv = require('dlv') const dlv = require('dlv')
import { isCssContext } from '../util/css' import { isCssContext } from '../util/css'
import { findClassNameAtPosition } from '../util/find' import { findClassNameAtPosition } from '../util/find'
import { validateApply } from '../util/validateApply'
import { getClassNameParts } from '../util/getClassNameAtPosition'
export function provideHover( export function provideHover(
state: State, state: State,
@ -81,6 +82,13 @@ function provideClassNameHover(
const parts = getClassNameParts(state, className.className) const parts = getClassNameParts(state, className.className)
if (!parts) return null if (!parts) return null
if (isCssContext(state, doc, position)) {
let validated = validateApply(state, parts)
if (validated === null || validated.isApplyable === false) {
return null
}
}
return { return {
contents: { contents: {
language: 'css', language: 'css',

View File

@ -16,6 +16,8 @@ import {
Hover, Hover,
TextDocumentPositionParams, TextDocumentPositionParams,
DidChangeConfigurationNotification, DidChangeConfigurationNotification,
CodeActionParams,
CodeAction,
} from 'vscode-languageserver' } from 'vscode-languageserver'
import getTailwindState from '../class-names/index' import getTailwindState from '../class-names/index'
import { State, Settings, EditorState } from './util/state' import { State, Settings, EditorState } from './util/state'
@ -26,25 +28,44 @@ import {
import { provideHover } from './providers/hoverProvider' import { provideHover } from './providers/hoverProvider'
import { URI } from 'vscode-uri' import { URI } from 'vscode-uri'
import { getDocumentSettings } from './util/getDocumentSettings' 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 connection = createConnection(ProposedFeatures.all)
let state: State = { enabled: false, emitter: createEmitter(connection) }
let documents = new TextDocuments() let documents = new TextDocuments()
let workspaceFolder: string | null let workspaceFolder: string | null
const defaultSettings: Settings = { const defaultSettings: Settings = {
emmetCompletions: false, emmetCompletions: false,
includeLanguages: {}, includeLanguages: {},
validate: true,
lint: {
cssConflict: 'warning',
invalidApply: 'error',
invalidScreen: 'error',
invalidVariant: 'error',
invalidConfigPath: 'error',
invalidTailwindDirective: 'error',
},
} }
let globalSettings: Settings = defaultSettings let globalSettings: Settings = defaultSettings
let documentSettings: Map<string, Settings> = new Map() let documentSettings: Map<string, Settings> = new Map()
documents.onDidOpen((event) => { documents.onDidOpen((event) => {
getDocumentSettings(state, event.document.uri) getDocumentSettings(state, event.document)
}) })
documents.onDidClose((event) => { documents.onDidClose((event) => {
documentSettings.delete(event.document.uri) documentSettings.delete(event.document.uri)
}) })
documents.onDidChangeContent((change) => {
provideDiagnostics(state, change.document)
})
documents.listen(connection) documents.listen(connection)
connection.onInitialize( connection.onInitialize(
@ -64,6 +85,10 @@ connection.onInitialize(
capabilities: { capabilities: {
configuration: configuration:
capabilities.workspace && !!capabilities.workspace.configuration, capabilities.workspace && !!capabilities.workspace.configuration,
diagnosticRelatedInformation:
capabilities.textDocument &&
capabilities.textDocument.publishDiagnostics &&
capabilities.textDocument.publishDiagnostics.relatedInformation,
}, },
} }
@ -73,14 +98,24 @@ connection.onInitialize(
// @ts-ignore // @ts-ignore
onChange: (newState: State): void => { onChange: (newState: State): void => {
if (newState && !newState.error) { if (newState && !newState.error) {
state = { ...newState, enabled: true, editor: editorState } state = {
...newState,
enabled: true,
emitter: state.emitter,
editor: editorState,
}
connection.sendNotification('tailwindcss/configUpdated', [ connection.sendNotification('tailwindcss/configUpdated', [
state.configPath, state.configPath,
state.config, state.config,
state.plugins, state.plugins,
]) ])
updateAllDiagnostics(state)
} else { } else {
state = { enabled: false, editor: editorState } state = {
enabled: false,
emitter: state.emitter,
editor: editorState,
}
if (newState && newState.error) { if (newState && newState.error) {
const payload: { const payload: {
message: string message: string
@ -95,6 +130,7 @@ connection.onInitialize(
} }
connection.sendNotification('tailwindcss/configError', [payload]) connection.sendNotification('tailwindcss/configError', [payload])
} }
clearAllDiagnostics(state)
// TODO // TODO
// connection.sendNotification('tailwindcss/configUpdated', [null]) // connection.sendNotification('tailwindcss/configUpdated', [null])
} }
@ -103,9 +139,14 @@ connection.onInitialize(
) )
if (tailwindState) { if (tailwindState) {
state = { enabled: true, editor: editorState, ...tailwindState } state = {
enabled: true,
emitter: state.emitter,
editor: editorState,
...tailwindState,
}
} else { } else {
state = { enabled: false, editor: editorState } state = { enabled: false, emitter: state.emitter, editor: editorState }
} }
return { return {
@ -133,6 +174,7 @@ connection.onInitialize(
], ],
}, },
hoverProvider: true, hoverProvider: true,
codeActionProvider: true,
}, },
} }
} }
@ -164,9 +206,7 @@ connection.onDidChangeConfiguration((change) => {
) )
} }
state.editor.documents updateAllDiagnostics(state)
.all()
.forEach((doc) => getDocumentSettings(state, doc.uri))
}) })
connection.onCompletion( connection.onCompletion(
@ -190,4 +230,11 @@ connection.onHover(
} }
) )
connection.onCodeAction(
(params: CodeActionParams): Promise<CodeAction[]> => {
if (!state.enabled) return null
return provideCodeActions(state, params)
}
)
connection.listen() connection.listen()

View File

@ -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,
},
}
}

View File

@ -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]
}

View File

@ -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, [])
}

View File

@ -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 <andrey@sitnik.ru>
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
}

View File

@ -5,10 +5,12 @@ import { isCssContext, isCssDoc } from './css'
import { isHtmlContext, isHtmlDoc, isSvelteDoc, isVueDoc } from './html' import { isHtmlContext, isHtmlDoc, isSvelteDoc, isVueDoc } from './html'
import { isWithinRange } from './isWithinRange' import { isWithinRange } from './isWithinRange'
import { isJsContext, isJsDoc } from './js' import { isJsContext, isJsDoc } from './js'
import { flatten } from '../../util/array'
import { import {
getClassAttributeLexer, getClassAttributeLexer,
getComputedClassAttributeLexer, getComputedClassAttributeLexer,
} from './lexers' } from './lexers'
import { getLanguageBoundaries } from './getLanguageBoundaries'
export function findAll(re: RegExp, str: string): RegExpMatchArray[] { export function findAll(re: RegExp, str: string): RegExpMatchArray[] {
let match: RegExpMatchArray let match: RegExpMatchArray
@ -27,15 +29,11 @@ export function findLast(re: RegExp, str: string): RegExpMatchArray {
return matches[matches.length - 1] return matches[matches.length - 1]
} }
export function findClassNamesInRange( export function getClassNamesInClassList({
doc: TextDocument, classList,
range?: Range, range,
mode?: 'html' | 'css' important,
): DocumentClassName[] { }: DocumentClassList): DocumentClassName[] {
const classLists = findClassListsInRange(doc, range, mode)
return [].concat.apply(
[],
classLists.map(({ classList, range }) => {
const parts = classList.split(/(\s+)/) const parts = classList.split(/(\s+)/)
const names: DocumentClassName[] = [] const names: DocumentClassName[] = []
let index = 0 let index = 0
@ -45,12 +43,20 @@ export function findClassNamesInRange(
const end = indexToPosition(classList, index + parts[i].length) const end = indexToPosition(classList, index + parts[i].length)
names.push({ names.push({
className: parts[i], className: parts[i],
classList: {
classList,
range,
important,
},
relativeRange: {
start,
end,
},
range: { range: {
start: { start: {
line: range.start.line + start.line, line: range.start.line + start.line,
character: character:
(end.line === 0 ? range.start.character : 0) + (end.line === 0 ? range.start.character : 0) + start.character,
start.character,
}, },
end: { end: {
line: range.start.line + end.line, line: range.start.line + end.line,
@ -63,8 +69,23 @@ export function findClassNamesInRange(
index += parts[i].length index += parts[i].length
} }
return names return names
}) }
)
export function findClassNamesInRange(
doc: TextDocument,
range?: Range,
mode?: 'html' | 'css'
): DocumentClassName[] {
const classLists = findClassListsInRange(doc, range, mode)
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( export function findClassListsInCssRange(
@ -72,7 +93,10 @@ export function findClassListsInCssRange(
range?: Range range?: Range
): DocumentClassList[] { ): DocumentClassList[] {
const text = doc.getText(range) const text = doc.getText(range)
const matches = findAll(/(@apply\s+)(?<classList>[^;}]+)[;}]/g, text) const matches = findAll(
/(@apply\s+)(?<classList>[^;}]+?)(?<important>\s*!important)?\s*[;}]/g,
text
)
const globalStart: Position = range ? range.start : { line: 0, character: 0 } const globalStart: Position = range ? range.start : { line: 0, character: 0 }
return matches.map((match) => { return matches.map((match) => {
@ -83,6 +107,7 @@ export function findClassListsInCssRange(
) )
return { return {
classList: match.groups.classList, classList: match.groups.classList,
important: Boolean(match.groups.important),
range: { range: {
start: { start: {
line: globalStart.line + start.line, line: globalStart.line + start.line,
@ -101,7 +126,7 @@ export function findClassListsInCssRange(
export function findClassListsInHtmlRange( export function findClassListsInHtmlRange(
doc: TextDocument, doc: TextDocument,
range: Range range?: Range
): DocumentClassList[] { ): DocumentClassList[] {
const text = doc.getText(range) const text = doc.getText(range)
const matches = findAll(/(?:\b|:)class(?:Name)?=['"`{]/g, text) const matches = findAll(/(?:\b|:)class(?:Name)?=['"`{]/g, text)
@ -180,15 +205,16 @@ export function findClassListsInHtmlRange(
classList: value.substr(beforeOffset, value.length + afterOffset), classList: value.substr(beforeOffset, value.length + afterOffset),
range: { range: {
start: { start: {
line: range.start.line + start.line, line: (range?.start.line || 0) + start.line,
character: character:
(end.line === 0 ? range.start.character : 0) + (end.line === 0 ? range?.start.character || 0 : 0) +
start.character, start.character,
}, },
end: { end: {
line: range.start.line + end.line, line: (range?.start.line || 0) + end.line,
character: 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( export function findClassListsInRange(
doc: TextDocument, doc: TextDocument,
range: Range, range?: Range,
mode: 'html' | 'css' mode?: 'html' | 'css'
): DocumentClassList[] { ): DocumentClassList[] {
if (mode === 'css') { if (mode === 'css') {
return findClassListsInCssRange(doc, range) return findClassListsInCssRange(doc, range)
@ -219,73 +245,16 @@ export function findClassListsInDocument(
return findClassListsInCssRange(doc) return findClassListsInCssRange(doc)
} }
if (isVueDoc(doc)) { let boundaries = getLanguageBoundaries(state, doc)
let text = doc.getText() if (!boundaries) return []
let blocks = findAll(
/<(?<type>template|style|script)\b[^>]*>.*?(<\/\k<type>>|$)/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)),
]
)
}
if (isHtmlDoc(state, doc) || isJsDoc(state, doc) || isSvelteDoc(doc)) { return flatten([
let text = doc.getText() ...boundaries.html.map((range) => findClassListsInHtmlRange(doc, range)),
let styleBlocks = findAll(/<style(?:\s[^>]*>|>).*?(<\/style>|$)/gis, text) ...boundaries.css.map((range) => findClassListsInCssRange(doc, range)),
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 []
} }
function indexToPosition(str: string, index: number): Position { export function indexToPosition(str: string, index: number): Position {
const { line, col } = lineColumn(str + '\n', index) const { line, col } = lineColumn(str + '\n', index)
return { line: line - 1, character: col - 1 } return { line: line - 1, character: col - 1 }
} }

View File

@ -1,48 +1,7 @@
import { TextDocument, Range, Position } from 'vscode-languageserver' import { State } from './state'
import { State, DocumentClassName } from './state' import { combinations } from './combinations'
const dlv = require('dlv') 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[] { export function getClassNameParts(state: State, className: string): string[] {
let separator = state.separator let separator = state.separator
className = className.replace(/^\./, '') className = className.replace(/^\./, '')
@ -83,17 +42,3 @@ export function getClassNameParts(state: State, className: string): string[] {
return false 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, [])
}

View File

@ -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<string, string> | Record<string, string>[] | 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)
}

View File

@ -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,
}
}

View File

@ -1,19 +1,19 @@
import { State, Settings } from './state' import { State, Settings } from './state'
import { TextDocument } from 'vscode-languageserver'
export async function getDocumentSettings( export async function getDocumentSettings(
state: State, state: State,
resource: string document: TextDocument
): Promise<Settings> { ): Promise<Settings> {
if (!state.editor.capabilities.configuration) { if (!state.editor.capabilities.configuration) {
return Promise.resolve(state.editor.globalSettings) return Promise.resolve(state.editor.globalSettings)
} }
let result = state.editor.documentSettings.get(resource) let result = state.editor.documentSettings.get(document.uri)
if (!result) { if (!result) {
result = await state.editor.connection.workspace.getConfiguration({ result = await state.emitter.emit('getConfiguration', {
scopeUri: resource, languageId: document.languageId,
section: 'tailwindCSS',
}) })
state.editor.documentSettings.set(resource, result) state.editor.documentSettings.set(document.uri, result)
} }
return result return result
} }

View File

@ -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(
/(?<open><(?<type>template|style|script)\b[^>]*>).*?(?<close><\/\k<type>>|$)/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(
/(?<open><style(?:\s[^>]*>|>)).*?(?<close><\/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
}

View File

@ -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}`
}, '')
}

View File

@ -0,0 +1,5 @@
import * as util from 'util'
export function logFull(object: any): void {
console.log(util.inspect(object, { showHidden: false, depth: null }))
}

View File

@ -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
)
}

View File

@ -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()
}

View File

@ -1,4 +1,5 @@
import { TextDocuments, Connection, Range } from 'vscode-languageserver' import { TextDocuments, Connection, Range } from 'vscode-languageserver'
import { NotificationEmitter } from '../../lib/emitter'
export type ClassNamesTree = { export type ClassNamesTree = {
[key: string]: ClassNamesTree [key: string]: ClassNamesTree
@ -21,19 +22,36 @@ export type EditorState = {
userLanguages: Record<string, string> userLanguages: Record<string, string>
capabilities: { capabilities: {
configuration: boolean configuration: boolean
diagnosticRelatedInformation: boolean
} }
} }
type DiagnosticSeveritySetting = 'ignore' | 'warning' | 'error'
export type Settings = { export type Settings = {
emmetCompletions: boolean emmetCompletions: boolean
includeLanguages: Record<string, string> includeLanguages: Record<string, string>
validate: boolean
lint: {
cssConflict: DiagnosticSeveritySetting
invalidApply: DiagnosticSeveritySetting
invalidScreen: DiagnosticSeveritySetting
invalidVariant: DiagnosticSeveritySetting
invalidConfigPath: DiagnosticSeveritySetting
invalidTailwindDirective: DiagnosticSeveritySetting
}
} }
export type State = null | { export type State = null | {
enabled: boolean enabled: boolean
emitter: NotificationEmitter
version?: string version?: string
configPath?: string configPath?: string
config?: any config?: any
modules?: {
tailwindcss: any
postcss: any
}
separator?: string separator?: string
plugins?: any[] plugins?: any[]
variants?: string[] variants?: string[]
@ -46,9 +64,19 @@ export type State = null | {
export type DocumentClassList = { export type DocumentClassList = {
classList: string classList: string
range: Range range: Range
important?: boolean
} }
export type DocumentClassName = { export type DocumentClassName = {
className: string className: string
range: Range range: Range
relativeRange: Range
classList: DocumentClassList
}
export type ClassNameMeta = {
source: 'base' | 'components' | 'utilities'
pseudo: string[]
scope: string[]
context: string[]
} }

View File

@ -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
}

View File

@ -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 }
}

View File

@ -2,6 +2,16 @@ export function dedupe<T>(arr: Array<T>): Array<T> {
return arr.filter((value, index, self) => self.indexOf(value) === index) return arr.filter((value, index, self) => self.indexOf(value) === index)
} }
export function dedupeBy<T>(
arr: Array<T>,
transform: (item: T) => any
): Array<T> {
return arr.filter(
(value, index, self) =>
self.map(transform).indexOf(transform(value)) === index
)
}
export function ensureArray<T>(value: T | T[]): T[] { export function ensureArray<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value] return Array.isArray(value) ? value : [value]
} }