diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af79a0082..2b25480a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,11 @@ jobs: - name: Install dependencies run: pnpm install - - name: Run tests + - name: Run service tests + working-directory: packages/tailwindcss-language-service + run: pnpm run build && pnpm run test + + - name: Run server tests + working-directory: packages/tailwindcss-language-server run: | - cd packages/tailwindcss-language-server && - pnpm run build && - pnpm run test + pnpm run build && pnpm run test diff --git a/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts b/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts index 75fb87333..25d81a191 100644 --- a/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts @@ -1,22 +1,21 @@ import type { TextDocument } from 'vscode-languageserver-textdocument' import type { State } from '@tailwindcss/language-service/src/util/state' -import { doValidate } from '@tailwindcss/language-service/src/diagnostics/diagnosticsProvider' -import isExcluded from '../util/isExcluded' +import type { LanguageService } from '@tailwindcss/language-service/src/service' -export async function provideDiagnostics(state: State, document: TextDocument) { - if (await isExcluded(state, document)) { - clearDiagnostics(state, document) - } else { - state.editor?.connection.sendDiagnostics({ - uri: document.uri, - diagnostics: await doValidate(state, document), - }) - } -} +export async function provideDiagnostics( + service: LanguageService, + state: State, + document: TextDocument, +) { + if (!state.enabled) return + let doc = await service.open(document.uri) + let report = await doc?.diagnostics() + + // No need to send diagnostics if the document is unchanged + if (report.kind === 'unchanged') return -export function clearDiagnostics(state: State, document: TextDocument): void { state.editor?.connection.sendDiagnostics({ uri: document.uri, - diagnostics: [], + diagnostics: report?.items ?? [], }) } diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index 04160569e..2a71e9610 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -36,13 +36,8 @@ import pkgUp from 'pkg-up' import stackTrace from 'stack-trace' import extractClassNames from './lib/extractClassNames' import { klona } from 'klona/full' -import { doHover } from '@tailwindcss/language-service/src/hoverProvider' -import { getCodeLens } from '@tailwindcss/language-service/src/codeLensProvider' +import { createLanguageService } from '@tailwindcss/language-service/src/service' import { Resolver } from './resolver' -import { - doComplete, - resolveCompletionItem, -} from '@tailwindcss/language-service/src/completionProvider' import type { State, FeatureFlags, @@ -52,17 +47,12 @@ import type { ClassEntry, } from '@tailwindcss/language-service/src/util/state' import { provideDiagnostics } from './lsp/diagnosticsProvider' -import { doCodeActions } from '@tailwindcss/language-service/src/codeActions/codeActionProvider' -import { getDocumentColors } from '@tailwindcss/language-service/src/documentColorProvider' -import { getDocumentLinks } from '@tailwindcss/language-service/src/documentLinksProvider' import { debounce } from 'debounce' import { getModuleDependencies } from './util/getModuleDependencies' import assert from 'node:assert' // import postcssLoadConfig from 'postcss-load-config' import { bigSign } from '@tailwindcss/language-service/src/util/jit' import { getColor } from '@tailwindcss/language-service/src/util/color' -import * as culori from 'culori' -import namedColors from 'color-name' import tailwindPlugins from './lib/plugins' import isExcluded from './util/isExcluded' import { getFileFsPath } from './util/uri' @@ -72,7 +62,6 @@ import { firstOptional, withoutLogs, clearRequireCache, - withFallback, isObject, pathToFileURL, changeAffectsFile, @@ -85,8 +74,7 @@ import { supportedFeatures } from '@tailwindcss/language-service/src/features' import { loadDesignSystem } from './util/v4' import { readCssFile } from './util/css' import type { DesignSystem } from '@tailwindcss/language-service/src/util/v4' - -const colorNames = Object.keys(namedColors) +import type { File, FileType } from '@tailwindcss/language-service/src/fs' function getConfigId(configPath: string, configDependencies: string[]): string { return JSON.stringify( @@ -102,7 +90,7 @@ export interface ProjectService { state: State tryInit: () => Promise dispose: () => Promise - onUpdateSettings: (settings: any) => void + onUpdateSettings: () => void onFileEvents: (changes: Array<{ file: string; type: FileChangeType }>) => void onHover(params: TextDocumentPositionParams): Promise onCompletion(params: CompletionParams): Promise @@ -234,36 +222,71 @@ export async function createProjectService( getDocumentSymbols: (uri: string) => { return connection.sendRequest('@/tailwindCSS/getDocumentSymbols', { uri }) }, - async readDirectory(document, directory) { + async readDirectory() { + // NOTE: This is overwritten in `createLanguageDocument` + throw new Error('Not implemented') + }, + }, + } + + let service = createLanguageService({ + state: () => state, + fs: { + async document(uri: string) { + return documentService.getDocument(uri) + }, + async resolve(document: TextDocument, relativePath: string): Promise { + let documentPath = URI.parse(document.uri).fsPath + let baseDir = path.dirname(documentPath) + + let resolved = await resolver.substituteId(relativePath, baseDir) + resolved ??= relativePath + + return URI.file(path.resolve(baseDir, resolved)).toString() + }, + + async readDirectory(document: TextDocument, filepath: string): Promise { try { let baseDir = path.dirname(getFileFsPath(document.uri)) - directory = await resolver.substituteId(`${directory}/`, baseDir) - directory = path.resolve(baseDir, directory) - - let dirents = await fs.promises.readdir(directory, { withFileTypes: true }) - - let result: Array<[string, { isDirectory: boolean }] | null> = await Promise.all( - dirents.map(async (dirent) => { - let isDirectory = dirent.isDirectory() - let shouldRemove = await isExcluded( - state, - document, - path.join(directory, dirent.name, isDirectory ? '/' : ''), - ) + filepath = await resolver.substituteId(`${filepath}/`, baseDir) + filepath = path.resolve(baseDir, filepath) - if (shouldRemove) return null + let dirents = await fs.promises.readdir(filepath, { withFileTypes: true }) - return [dirent.name, { isDirectory }] - }), - ) + let results: File[] = [] + + for (let dirent of dirents) { + let isDirectory = dirent.isDirectory() + let shouldRemove = await isExcluded( + state, + document, + path.join(filepath, dirent.name, isDirectory ? '/' : ''), + ) + if (shouldRemove) continue + + let type: FileType = 'unknown' - return result.filter((item) => item !== null) + if (dirent.isFile()) { + type = 'file' + } else if (dirent.isDirectory()) { + type = 'directory' + } else if (dirent.isSymbolicLink()) { + type = 'symbolic-link' + } + + results.push({ + name: dirent.name, + type, + }) + } + + return results } catch { return [] } }, }, - } + }) if (projectConfig.configPath && projectConfig.config.source === 'js') { let deps = [] @@ -1186,7 +1209,9 @@ export async function createProjectService( ;(await disposable).dispose() } }, - async onUpdateSettings(settings: any): Promise { + async onUpdateSettings(): Promise { + service.onUpdateSettings() + if (state.enabled) { refreshDiagnostics() } @@ -1196,139 +1221,79 @@ export async function createProjectService( }, onFileEvents, async onHover(params: TextDocumentPositionParams): Promise { - return withFallback(async () => { - if (!state.enabled) return null - let document = documentService.getDocument(params.textDocument.uri) - if (!document) return null - let settings = await state.editor.getConfiguration(document.uri) - if (!settings.tailwindCSS.hovers) return null - if (await isExcluded(state, document)) return null - return doHover(state, document, params.position) - }, null) + try { + let doc = await service.open(params.textDocument.uri) + if (!doc) return null + return doc.hover(params.position) + } catch { + return null + } }, async onCodeLens(params: CodeLensParams): Promise { - return withFallback(async () => { - if (!state.enabled) return null - let document = documentService.getDocument(params.textDocument.uri) - if (!document) return null - let settings = await state.editor.getConfiguration(document.uri) - if (!settings.tailwindCSS.codeLens) return null - if (await isExcluded(state, document)) return null - return getCodeLens(state, document) - }, null) + try { + let doc = await service.open(params.textDocument.uri) + if (!doc) return null + return doc.codeLenses() + } catch { + return [] + } }, async onCompletion(params: CompletionParams): Promise { - return withFallback(async () => { - if (!state.enabled) return null - let document = documentService.getDocument(params.textDocument.uri) - if (!document) return null - let settings = await state.editor.getConfiguration(document.uri) - if (!settings.tailwindCSS.suggestions) return null - if (await isExcluded(state, document)) return null - return doComplete(state, document, params.position, params.context) - }, null) + try { + let doc = await service.open(params.textDocument.uri) + if (!doc) return null + return doc.completions(params.position) + } catch { + return null + } }, - onCompletionResolve(item: CompletionItem): Promise { - return withFallback(() => { - if (!state.enabled) return null - return resolveCompletionItem(state, item) - }, null) + async onCompletionResolve(item: CompletionItem): Promise { + try { + return await service.resolveCompletion(item) + } catch { + return null + } }, async onCodeAction(params: CodeActionParams): Promise { - return withFallback(async () => { - if (!state.enabled) return null - let document = documentService.getDocument(params.textDocument.uri) - if (!document) return null - let settings = await state.editor.getConfiguration(document.uri) - if (!settings.tailwindCSS.codeActions) return null - return doCodeActions(state, params, document) - }, null) + try { + let doc = await service.open(params.textDocument.uri) + if (!doc) return null + return doc.codeActions(params.range, params.context) + } catch { + return [] + } }, - onDocumentLinks(params: DocumentLinkParams): Promise { - if (!state.enabled) return null - let document = documentService.getDocument(params.textDocument.uri) - if (!document) return null - - let documentPath = URI.parse(document.uri).fsPath - let baseDir = path.dirname(documentPath) - - async function resolveTarget(linkPath: string) { - linkPath = (await resolver.substituteId(linkPath, baseDir)) ?? linkPath - - return URI.file(path.resolve(baseDir, linkPath)).toString() + async onDocumentLinks(params: DocumentLinkParams): Promise { + try { + let doc = await service.open(params.textDocument.uri) + if (!doc) return null + return doc.documentLinks() + } catch { + return [] } - - return getDocumentLinks(state, document, resolveTarget) }, provideDiagnostics: debounce( - (document: TextDocument) => { - if (!state.enabled) return - provideDiagnostics(state, document) - }, + (document) => provideDiagnostics(service, state, document), params.initializationOptions?.testMode ? 0 : 500, ), - provideDiagnosticsForce: (document: TextDocument) => { - if (!state.enabled) return - provideDiagnostics(state, document) - }, + provideDiagnosticsForce: (document) => provideDiagnostics(service, state, document), async onDocumentColor(params: DocumentColorParams): Promise { - return withFallback(async () => { - if (!state.enabled) return [] - let document = documentService.getDocument(params.textDocument.uri) - if (!document) return [] - if (await isExcluded(state, document)) return null - return getDocumentColors(state, document) - }, null) + try { + let doc = await service.open(params.textDocument.uri) + if (!doc) return null + return doc.documentColors() + } catch { + return [] + } }, async onColorPresentation(params: ColorPresentationParams): Promise { - let document = documentService.getDocument(params.textDocument.uri) - if (!document) return [] - let className = document.getText(params.range) - let match = className.match( - new RegExp(`-\\[(${colorNames.join('|')}|(?:(?:#|rgba?\\(|hsla?\\())[^\\]]+)\\]$`, 'i'), - ) - // let match = className.match(/-\[((?:#|rgba?\(|hsla?\()[^\]]+)\]$/i) - if (match === null) return [] - - let currentColor = match[1] - - let isNamedColor = colorNames.includes(currentColor) - - let color: culori.Color = { - mode: 'rgb', - r: params.color.red, - g: params.color.green, - b: params.color.blue, - alpha: params.color.alpha, - } - - let hexValue = culori.formatHex8(color) - - if (!isNamedColor && (currentColor.length === 4 || currentColor.length === 5)) { - let [, ...chars] = - hexValue.match(/^#([a-f\d])\1([a-f\d])\2([a-f\d])\3(?:([a-f\d])\4)?$/i) ?? [] - if (chars.length) { - hexValue = `#${chars.filter(Boolean).join('')}` - } - } - - if (hexValue.length === 5) { - hexValue = hexValue.replace(/f$/, '') - } else if (hexValue.length === 9) { - hexValue = hexValue.replace(/ff$/, '') + try { + let doc = await service.open(params.textDocument.uri) + if (!doc) return null + return doc.colorPresentation(params.color, params.range) + } catch { + return [] } - - let prefix = className.substr(0, match.index) - - return [ - hexValue, - culori.formatRgb(color).replace(/ /g, ''), - culori - .formatHsl(color) - .replace(/ /g, '') - // round numbers - .replace(/\d+\.\d+(%?)/g, (value, suffix) => `${Math.round(parseFloat(value))}${suffix}`), - ].map((value) => ({ label: `${prefix}-[${value}]` })) }, sortClassLists(classLists: string[]): string[] { if (!state.jit) { diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index 20ac01581..5a4807c76 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -643,7 +643,7 @@ export class TW { this.setupLSPHandlers() this.disposables.push( - this.connection.onDidChangeConfiguration(async ({ settings }) => { + this.connection.onDidChangeConfiguration(async () => { let previousExclude = globalSettings.tailwindCSS.files.exclude this.settingsCache.clear() @@ -656,7 +656,7 @@ export class TW { } for (let [, project] of this.projects) { - project.onUpdateSettings(settings) + project.onUpdateSettings() } }), ) diff --git a/packages/tailwindcss-language-server/tests/code-actions/conflict.json b/packages/tailwindcss-language-server/tests/code-actions/conflict.json index eccb14464..55fb35a77 100644 --- a/packages/tailwindcss-language-server/tests/code-actions/conflict.json +++ b/packages/tailwindcss-language-server/tests/code-actions/conflict.json @@ -14,7 +14,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [12, 31] }, "relativeRange": { "start": { "line": 0, "character": 0 }, @@ -23,7 +24,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } - } + }, + "span": [12, 21] }, "otherClassNames": [ { @@ -33,7 +35,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [12, 31] }, "relativeRange": { "start": { "line": 0, "character": 10 }, @@ -42,7 +45,8 @@ "range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [22, 31] } ], "range": { @@ -92,7 +96,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [12, 31] }, "relativeRange": { "start": { "line": 0, "character": 10 }, @@ -101,7 +106,8 @@ "range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [22, 31] }, "otherClassNames": [ { @@ -111,7 +117,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [12, 31] }, "relativeRange": { "start": { "line": 0, "character": 0 }, @@ -120,7 +127,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } - } + }, + "span": [12, 21] } ], "range": { diff --git a/packages/tailwindcss-language-server/tests/colors/colors.test.js b/packages/tailwindcss-language-server/tests/colors/colors.test.js index 4780a4fb2..1bf3ad9a1 100644 --- a/packages/tailwindcss-language-server/tests/colors/colors.test.js +++ b/packages/tailwindcss-language-server/tests/colors/colors.test.js @@ -9,6 +9,7 @@ const range = (startLine, startCol, endLine, endCol) => ({ end: { line: endLine, character: endCol }, }) +// TODO: Find a way to test these in the language service withFixture('basic', (c) => { async function testColors(name, { text, expected }) { test.concurrent(name, async ({ expect }) => { @@ -159,6 +160,7 @@ withFixture('basic', (c) => { }) }) +// TODO: Remove. These are all tested in the language service now withFixture('v4/basic', (c) => { async function testColors(name, { text, expected }) { test.concurrent(name, async ({ expect }) => { @@ -309,6 +311,7 @@ withFixture('v4/basic', (c) => { }) }) +// TODO: Remove. These are all tested in the language service now defineTest({ name: 'v4: colors are recursively resolved from the theme', fs: { @@ -354,6 +357,7 @@ defineTest({ }, }) +// TODO: Remove. These are all tested in the language service now defineTest({ name: 'colors that use light-dark() resolve to their light color', fs: { diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json index da506bf12..d57066663 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json +++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json @@ -12,13 +12,15 @@ "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 34 } }, + "span": [15, 34], "important": false }, "relativeRange": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 9 } }, - "range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } } + "range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } }, + "span": [15, 24] }, "otherClassNames": [ { @@ -29,6 +31,7 @@ "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 34 } }, + "span": [15, 34], "important": false }, "relativeRange": { @@ -38,7 +41,8 @@ "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } - } + }, + "span": [25, 34] } ], "range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } }, @@ -67,13 +71,15 @@ "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 34 } }, + "span": [15, 34], "important": false }, "relativeRange": { "start": { "line": 0, "character": 10 }, "end": { "line": 0, "character": 19 } }, - "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } } + "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } }, + "span": [25, 34] }, "otherClassNames": [ { @@ -84,6 +90,7 @@ "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 34 } }, + "span": [15, 34], "important": false }, "relativeRange": { @@ -93,7 +100,8 @@ "range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } - } + }, + "span": [15, 24] } ], "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } }, diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/jsx-concat-positive.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/jsx-concat-positive.json index 39cbb515a..5561d773e 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/jsx-concat-positive.json +++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/jsx-concat-positive.json @@ -11,13 +11,15 @@ "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 36 } - } + }, + "span": [17, 36] }, "relativeRange": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 9 } }, - "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } } + "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } }, + "span": [17, 26] }, "otherClassNames": [ { @@ -27,7 +29,8 @@ "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 36 } - } + }, + "span": [17, 36] }, "relativeRange": { "start": { "line": 0, "character": 10 }, @@ -36,7 +39,8 @@ "range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } - } + }, + "span": [27, 36] } ], "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } }, @@ -64,13 +68,15 @@ "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 36 } - } + }, + "span": [17, 36] }, "relativeRange": { "start": { "line": 0, "character": 10 }, "end": { "line": 0, "character": 19 } }, - "range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } } + "range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } }, + "span": [27, 36] }, "otherClassNames": [ { @@ -80,7 +86,8 @@ "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 36 } - } + }, + "span": [17, 36] }, "relativeRange": { "start": { "line": 0, "character": 0 }, @@ -89,7 +96,8 @@ "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } - } + }, + "span": [17, 26] } ], "range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } }, diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/simple.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/simple.json index c98280a11..9f15fbcc2 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/simple.json +++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/simple.json @@ -10,13 +10,15 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [12, 31] }, "relativeRange": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 9 } }, - "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } } + "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } }, + "span": [12, 21] }, "otherClassNames": [ { @@ -26,7 +28,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [12, 31] }, "relativeRange": { "start": { "line": 0, "character": 10 }, @@ -35,7 +38,8 @@ "range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [22, 31] } ], "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } }, @@ -63,13 +67,15 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [12, 31] }, "relativeRange": { "start": { "line": 0, "character": 10 }, "end": { "line": 0, "character": 19 } }, - "range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } } + "range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } }, + "span": [22, 31] }, "otherClassNames": [ { @@ -79,7 +85,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 31 } - } + }, + "span": [12, 31] }, "relativeRange": { "start": { "line": 0, "character": 0 }, @@ -88,7 +95,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } - } + }, + "span": [12, 21] } ], "range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } }, diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/variants-positive.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/variants-positive.json index 15fcb4572..5fbcb8aca 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/variants-positive.json +++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/variants-positive.json @@ -10,13 +10,15 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 37 } - } + }, + "span": [12, 37] }, "relativeRange": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 12 } }, - "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } } + "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } }, + "span": [12, 24] }, "otherClassNames": [ { @@ -26,7 +28,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 37 } - } + }, + "span": [12, 37] }, "relativeRange": { "start": { "line": 0, "character": 13 }, @@ -35,7 +38,8 @@ "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } - } + }, + "span": [25, 37] } ], "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } }, @@ -63,13 +67,15 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 37 } - } + }, + "span": [12, 37] }, "relativeRange": { "start": { "line": 0, "character": 13 }, "end": { "line": 0, "character": 25 } }, - "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } } + "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } }, + "span": [25, 37] }, "otherClassNames": [ { @@ -79,7 +85,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 37 } - } + }, + "span": [12, 37] }, "relativeRange": { "start": { "line": 0, "character": 0 }, @@ -88,7 +95,8 @@ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } - } + }, + "span": [12, 24] } ], "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } }, diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/vue-style-lang-sass.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/vue-style-lang-sass.json index 7e9da86be..b6a3b0f55 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/vue-style-lang-sass.json +++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/vue-style-lang-sass.json @@ -12,13 +12,15 @@ "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 28 } }, + "span": [34, 53], "important": false }, "relativeRange": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 9 } }, - "range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } } + "range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } }, + "span": [34, 43] }, "otherClassNames": [ { @@ -29,6 +31,7 @@ "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 28 } }, + "span": [34, 53], "important": false }, "relativeRange": { @@ -38,7 +41,8 @@ "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } - } + }, + "span": [44, 53] } ], "range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } }, @@ -67,13 +71,15 @@ "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 28 } }, + "span": [34, 53], "important": false }, "relativeRange": { "start": { "line": 0, "character": 10 }, "end": { "line": 0, "character": 19 } }, - "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } } + "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } }, + "span": [44, 53] }, "otherClassNames": [ { @@ -84,6 +90,7 @@ "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 28 } }, + "span": [34, 53], "important": false }, "relativeRange": { @@ -93,7 +100,8 @@ "range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } - } + }, + "span": [34, 43] } ], "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } }, diff --git a/packages/tailwindcss-language-server/tests/hover/hover.test.js b/packages/tailwindcss-language-server/tests/hover/hover.test.js index 379f41996..693358872 100644 --- a/packages/tailwindcss-language-server/tests/hover/hover.test.js +++ b/packages/tailwindcss-language-server/tests/hover/hover.test.js @@ -3,6 +3,7 @@ import { withFixture } from '../common' import { css, defineTest } from '../../src/testing' import { createClient } from '../utils/client' +// TODO: Find a way to test these in the language service withFixture('basic', (c) => { async function testHover( name, @@ -177,6 +178,7 @@ withFixture('basic', (c) => { }) }) +// TODO: Remove. This are all tested in the language service now withFixture('v4/basic', (c) => { async function testHover( name, @@ -554,6 +556,7 @@ withFixture('v4/path-mappings', (c) => { }) }) +// TODO: Remove. This is tested in the language service now defineTest({ name: 'Can hover showing theme values used in var(…) and theme(…) functions', fs: { diff --git a/packages/tailwindcss-language-server/tests/utils/configuration.ts b/packages/tailwindcss-language-server/tests/utils/configuration.ts index 8c08a5181..7ee08d34c 100644 --- a/packages/tailwindcss-language-server/tests/utils/configuration.ts +++ b/packages/tailwindcss-language-server/tests/utils/configuration.ts @@ -3,7 +3,7 @@ import { type Settings, } from '@tailwindcss/language-service/src/util/state' import { URI } from 'vscode-uri' -import type { DeepPartial } from './types' +import type { DeepPartial } from '@tailwindcss/language-service/src/types' import { CacheMap } from '../../src/cache-map' import deepmerge from 'deepmerge' diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json index 2a1e3079e..2bf38ba11 100644 --- a/packages/tailwindcss-language-service/package.json +++ b/packages/tailwindcss-language-service/package.json @@ -45,13 +45,21 @@ "@types/dedent": "^0.7.2", "@types/line-column": "^1.0.2", "@types/node": "^18.19.33", + "@types/normalize-path": "^3.0.2", + "@types/picomatch": "^2.3.3", "@types/stringify-object": "^4.0.5", "dedent": "^1.5.3", + "deepmerge": "4.2.2", "esbuild": "^0.25.0", "esbuild-node-externals": "^1.9.0", + "memfs": "^4.17.0", "minimist": "^1.2.8", + "normalize-path": "3.0.0", + "picomatch": "^4.0.1", + "tailwindcss-v4": "npm:tailwindcss@4.1.1", "tslib": "2.2.0", "typescript": "^5.3.3", - "vitest": "^3.0.9" + "vitest": "^3.0.9", + "vscode-uri": "3.0.2" } } diff --git a/packages/tailwindcss-language-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs index 128426bed..6ffb8d299 100644 --- a/packages/tailwindcss-language-service/scripts/build.mjs +++ b/packages/tailwindcss-language-service/scripts/build.mjs @@ -3,8 +3,9 @@ import { spawnSync } from 'node:child_process' import esbuild from 'esbuild' import minimist from 'minimist' import { nodeExternalsPlugin } from 'esbuild-node-externals' +import { fileURLToPath } from 'node:url' -const __dirname = new URL('.', import.meta.url).pathname +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const args = minimist(process.argv.slice(2), { boolean: ['watch', 'minify'], @@ -26,11 +27,17 @@ let build = await esbuild.context({ { name: 'generate-types', async setup(build) { - build.onEnd(async (result) => { + build.onEnd(async () => { // Call the tsc command to generate the types spawnSync( 'tsc', - ['-p', path.resolve(__dirname, './tsconfig.build.json'), '--emitDeclarationOnly', '--outDir', path.resolve(__dirname, '../dist')], + [ + '-p', + path.resolve(__dirname, './tsconfig.build.json'), + '--emitDeclarationOnly', + '--outDir', + path.resolve(__dirname, '../dist'), + ], { stdio: 'inherit', }, diff --git a/packages/tailwindcss-language-service/scripts/tsconfig.build.json b/packages/tailwindcss-language-service/scripts/tsconfig.build.json index e80bb38fb..144d69e60 100644 --- a/packages/tailwindcss-language-service/scripts/tsconfig.build.json +++ b/packages/tailwindcss-language-service/scripts/tsconfig.build.json @@ -1,4 +1,7 @@ { - "extends": "../tsconfig.json", - "exclude": ["../src/**/*.test.ts"] -} \ No newline at end of file + "extends": "../tsconfig.json", + "exclude": ["../src/**/*.test.ts", "../tests/**/*.ts"], + "compilerOptions": { + "rootDir": "../src" + } +} diff --git a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts index 7ebf79cb1..4e1c6c77a 100644 --- a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts +++ b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts @@ -5,8 +5,8 @@ import { doValidate } from '../diagnostics/diagnosticsProvider' import { rangesEqual } from '../util/rangesEqual' import { type DiagnosticKind, - isInvalidApplyDiagnostic, type AugmentedDiagnostic, + isInvalidApplyDiagnostic, isCssConflictDiagnostic, isInvalidConfigPathDiagnostic, isInvalidTailwindDirectiveDiagnostic, @@ -26,7 +26,8 @@ async function getDiagnosticsFromCodeActionParams( only?: DiagnosticKind[], ): Promise { if (!document) return [] - let diagnostics = await doValidate(state, document, only) + let report = await doValidate(state, document, only) + let diagnostics = report.items as AugmentedDiagnostic[] return params.context.diagnostics .map((diagnostic) => { diff --git a/packages/tailwindcss-language-service/src/codeLensProvider.ts b/packages/tailwindcss-language-service/src/codeLensProvider.ts index b00df983d..66c3b5780 100644 --- a/packages/tailwindcss-language-service/src/codeLensProvider.ts +++ b/packages/tailwindcss-language-service/src/codeLensProvider.ts @@ -19,15 +19,15 @@ export async function getCodeLens(state: State, doc: TextDocument): Promise'[^']+'|"[^"]+")/dg +const countFormatter = new Intl.NumberFormat('en', { + maximumFractionDigits: 2, +}) + async function sourceInlineCodeLens(state: State, doc: TextDocument): Promise { if (!state.features.includes('source-inline')) return [] let text = doc.getText() - let countFormatter = new Intl.NumberFormat('en', { - maximumFractionDigits: 2, - }) - let lenses: CodeLens[] = [] for (let match of findAll(SOURCE_INLINE_PATTERN, text)) { diff --git a/packages/tailwindcss-language-service/src/colorPresentationProvider.ts b/packages/tailwindcss-language-service/src/colorPresentationProvider.ts new file mode 100644 index 000000000..d551345b0 --- /dev/null +++ b/packages/tailwindcss-language-service/src/colorPresentationProvider.ts @@ -0,0 +1,60 @@ +import type { State } from './util/state' +import type { Range, TextDocument } from 'vscode-languageserver-textdocument' +import type { Color, ColorPresentation, ColorPresentationParams } from 'vscode-languageserver' +import * as culori from 'culori' +import namedColors from 'color-name' + +const colorNames = Object.keys(namedColors) + +export async function provideColorPresentation( + state: State, + document: TextDocument, + lscolor: Color, + range: Range, +): Promise { + let className = document.getText(range) + let match = className.match( + new RegExp(`-\\[(${colorNames.join('|')}|(?:(?:#|rgba?\\(|hsla?\\())[^\\]]+)\\]$`, 'i'), + ) + // let match = className.match(/-\[((?:#|rgba?\(|hsla?\()[^\]]+)\]$/i) + if (match === null) return [] + + let currentColor = match[1] + + let isNamedColor = colorNames.includes(currentColor) + + let color: culori.Color = { + mode: 'rgb', + r: lscolor.red, + g: lscolor.green, + b: lscolor.blue, + alpha: lscolor.alpha, + } + + let hexValue = culori.formatHex8(color) + + if (!isNamedColor && (currentColor.length === 4 || currentColor.length === 5)) { + let [, ...chars] = hexValue.match(/^#([a-f\d])\1([a-f\d])\2([a-f\d])\3(?:([a-f\d])\4)?$/i) ?? [] + if (chars.length) { + hexValue = `#${chars.filter(Boolean).join('')}` + } + } + + if (hexValue.length === 5) { + hexValue = hexValue.replace(/f$/, '') + } else if (hexValue.length === 9) { + hexValue = hexValue.replace(/ff$/, '') + } + + let prefix = className.substr(0, match.index) + + return [ + hexValue, + culori.formatRgb(color).replace(/ /g, ''), + culori + .formatHsl(color) + .replace(/ /g, '') + // round numbers + .replace(/\d+\.\d+(%?)/g, (value, suffix) => `${Math.round(parseFloat(value))}${suffix}`), + ].map((value) => ({ label: `${prefix}-[${value}]` })) +} diff --git a/packages/tailwindcss-language-service/src/css/ast.ts b/packages/tailwindcss-language-service/src/css/ast.ts new file mode 100644 index 000000000..39b49c431 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/ast.ts @@ -0,0 +1,117 @@ +import { parseAtRule } from './parse' +import type { SourceLocation } from './source' + +const AT_SIGN = 0x40 + +export type StyleRule = { + kind: 'rule' + selector: string + nodes: AstNode[] + + src?: SourceLocation + dst?: SourceLocation +} + +export type AtRule = { + kind: 'at-rule' + name: string + params: string + nodes: AstNode[] + + src?: SourceLocation + dst?: SourceLocation +} + +export type Declaration = { + kind: 'declaration' + property: string + value: string | undefined + important: boolean + + src?: SourceLocation + dst?: SourceLocation +} + +export type Comment = { + kind: 'comment' + value: string + + src?: SourceLocation + dst?: SourceLocation +} + +export type Context = { + kind: 'context' + context: Record + nodes: AstNode[] + + src?: undefined + dst?: undefined +} + +export type AtRoot = { + kind: 'at-root' + nodes: AstNode[] + + src?: undefined + dst?: undefined +} + +export type Rule = StyleRule | AtRule +export type AstNode = StyleRule | AtRule | Declaration | Comment | Context | AtRoot + +export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule { + return { + kind: 'rule', + selector, + nodes, + } +} + +export function atRule(name: string, params: string = '', nodes: AstNode[] = []): AtRule { + return { + kind: 'at-rule', + name, + params, + nodes, + } +} + +export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRule { + if (selector.charCodeAt(0) === AT_SIGN) { + return parseAtRule(selector, nodes) + } + + return styleRule(selector, nodes) +} + +export function decl(property: string, value: string | undefined, important = false): Declaration { + return { + kind: 'declaration', + property, + value, + important, + } +} + +export function comment(value: string): Comment { + return { + kind: 'comment', + value: value, + } +} + +export function context(context: Record, nodes: AstNode[]): Context { + return { + kind: 'context', + context, + nodes, + } +} + +export function atRoot(nodes: AstNode[]): AtRoot { + return { + kind: 'at-root', + nodes, + } +} diff --git a/packages/tailwindcss-language-service/src/css/parse.ts b/packages/tailwindcss-language-service/src/css/parse.ts new file mode 100644 index 000000000..ddd83d0b8 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/parse.ts @@ -0,0 +1,633 @@ +import { + atRule, + comment, + decl, + rule, + type AstNode, + type AtRule, + type Comment, + type Declaration, + type Rule, +} from './ast' +import { createInputSource } from './source' + +const BACKSLASH = 0x5c +const SLASH = 0x2f +const ASTERISK = 0x2a +const DOUBLE_QUOTE = 0x22 +const SINGLE_QUOTE = 0x27 +const COLON = 0x3a +const SEMICOLON = 0x3b +const LINE_BREAK = 0x0a +const SPACE = 0x20 +const TAB = 0x09 +const OPEN_CURLY = 0x7b +const CLOSE_CURLY = 0x7d +const OPEN_PAREN = 0x28 +const CLOSE_PAREN = 0x29 +const OPEN_BRACKET = 0x5b +const CLOSE_BRACKET = 0x5d +const DASH = 0x2d +const AT_SIGN = 0x40 +const EXCLAMATION_MARK = 0x21 + +export interface ParseOptions { + from?: string +} + +export function parse(input: string, opts?: ParseOptions) { + let source = opts?.from ? createInputSource(opts.from, input) : null + + // Note: it is important that any transformations of the input string + // *before* processing do NOT change the length of the string. This + // would invalidate the mechanism used to track source locations. + if (input[0] === '\uFEFF') input = ' ' + input.slice(1) + input = input.replaceAll('\r\n', ' \n') + + let ast: AstNode[] = [] + let licenseComments: Comment[] = [] + + let stack: (Rule | null)[] = [] + + let parent = null as Rule | null + let node = null as AstNode | null + + let buffer = '' + let closingBracketStack = '' + + // The start of the first non-whitespace character in the buffer + let bufferStart = 0 + + let peekChar + + for (let i = 0; i < input.length; i++) { + let currentChar = input.charCodeAt(i) + + // Current character is a `\` therefore the next character is escaped, + // consume it together with the next character and continue. + // + // E.g.: + // + // ```css + // .hover\:foo:hover {} + // ^ + // ``` + // + if (currentChar === BACKSLASH) { + if (buffer === '') bufferStart = i + buffer += input.slice(i, i + 2) + i += 1 + } + + // Start of a comment. + // + // E.g.: + // + // ```css + // /* Example */ + // ^^^^^^^^^^^^^ + // .foo { + // color: red; /* Example */ + // ^^^^^^^^^^^^^ + // } + // .bar { + // color: /* Example */ red; + // ^^^^^^^^^^^^^ + // } + // ``` + else if (currentChar === SLASH && input.charCodeAt(i + 1) === ASTERISK) { + let start = i + + for (let j = i + 2; j < input.length; j++) { + peekChar = input.charCodeAt(j) + + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + j += 1 + } + + // End of the comment + else if (peekChar === ASTERISK && input.charCodeAt(j + 1) === SLASH) { + i = j + 1 + break + } + } + + let commentString = input.slice(start, i + 1) + + // Collect all license comments so that we can hoist them to the top of + // the AST. + if (commentString.charCodeAt(2) === EXCLAMATION_MARK) { + let node = comment(commentString.slice(2, -2)) + licenseComments.push(node) + + if (source) { + node.src = [source, start, i + 1] + node.dst = [source, start, i + 1] + } + } + } + + // Start of a string. + else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) { + let start = i + + // We need to ensure that the closing quote is the same as the opening + // quote. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a 'quote' in it"; + // ^ ^ -> These are not the end of the string. + // } + // ``` + for (let j = i + 1; j < input.length; j++) { + peekChar = input.charCodeAt(j) + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + j += 1 + } + + // End of the string. + else if (peekChar === currentChar) { + i = j + break + } + + // End of the line without ending the string but with a `;` at the end. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a; + // ^ Missing " + // } + // ``` + else if (peekChar === SEMICOLON && input.charCodeAt(j + 1) === LINE_BREAK) { + throw new Error( + `Unterminated string: ${input.slice(start, j + 1) + String.fromCharCode(currentChar)}`, + ) + } + + // End of the line without ending the string. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a + // ^ Missing " + // } + // ``` + else if (peekChar === LINE_BREAK) { + throw new Error( + `Unterminated string: ${input.slice(start, j) + String.fromCharCode(currentChar)}`, + ) + } + } + + // Adjust `buffer` to include the string. + buffer += input.slice(start, i + 1) + } + + // Skip whitespace if the next character is also whitespace. This allows us + // to reduce the amount of whitespace in the AST. + else if ( + (currentChar === SPACE || currentChar === LINE_BREAK || currentChar === TAB) && + (peekChar = input.charCodeAt(i + 1)) && + (peekChar === SPACE || peekChar === LINE_BREAK || peekChar === TAB) + ) { + continue + } + + // Replace new lines with spaces. + else if (currentChar === LINE_BREAK) { + if (buffer.length === 0) continue + + peekChar = buffer.charCodeAt(buffer.length - 1) + if (peekChar !== SPACE && peekChar !== LINE_BREAK && peekChar !== TAB) { + buffer += ' ' + } + } + + // Start of a custom property. + // + // Custom properties are very permissive and can contain almost any + // character, even `;` and `}`. Therefore we have to make sure that we are + // at the correct "end" of the custom property by making sure everything is + // balanced. + else if (currentChar === DASH && input.charCodeAt(i + 1) === DASH && buffer.length === 0) { + let closingBracketStack = '' + + let start = i + let colonIdx = -1 + + for (let j = i + 2; j < input.length; j++) { + peekChar = input.charCodeAt(j) + + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + j += 1 + } + + // Start of a comment. + else if (peekChar === SLASH && input.charCodeAt(j + 1) === ASTERISK) { + for (let k = j + 2; k < input.length; k++) { + peekChar = input.charCodeAt(k) + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + k += 1 + } + + // End of the comment + else if (peekChar === ASTERISK && input.charCodeAt(k + 1) === SLASH) { + j = k + 1 + break + } + } + } + + // End of the "property" of the property-value pair. + else if (colonIdx === -1 && peekChar === COLON) { + colonIdx = buffer.length + j - start + } + + // End of the custom property. + else if (peekChar === SEMICOLON && closingBracketStack.length === 0) { + buffer += input.slice(start, j) + i = j + break + } + + // Start of a block. + else if (peekChar === OPEN_PAREN) { + closingBracketStack += ')' + } else if (peekChar === OPEN_BRACKET) { + closingBracketStack += ']' + } else if (peekChar === OPEN_CURLY) { + closingBracketStack += '}' + } + + // End of the custom property if didn't use a `;` to end the custom + // property. + // + // E.g.: + // + // ```css + // .foo { + // --custom: value + // ^ + // } + // ``` + else if ( + (peekChar === CLOSE_CURLY || input.length - 1 === j) && + closingBracketStack.length === 0 + ) { + i = j - 1 + buffer += input.slice(start, j) + break + } + + // End of a block. + else if ( + peekChar === CLOSE_PAREN || + peekChar === CLOSE_BRACKET || + peekChar === CLOSE_CURLY + ) { + if ( + closingBracketStack.length > 0 && + input[j] === closingBracketStack[closingBracketStack.length - 1] + ) { + closingBracketStack = closingBracketStack.slice(0, -1) + } + } + } + + let declaration = parseDeclaration(buffer, colonIdx) + if (!declaration) throw new Error(`Invalid custom property, expected a value`) + + if (source) { + declaration.src = [source, start, i] + declaration.dst = [source, start, i] + } + + if (parent) { + parent.nodes.push(declaration) + } else { + ast.push(declaration) + } + + buffer = '' + } + + // End of a body-less at-rule. + // + // E.g.: + // + // ```css + // @charset "UTF-8"; + // ^ + // ``` + else if (currentChar === SEMICOLON && buffer.charCodeAt(0) === AT_SIGN) { + node = parseAtRule(buffer) + + if (source) { + node.src = [source, bufferStart, i] + node.dst = [source, bufferStart, i] + } + + // At-rule is nested inside of a rule, attach it to the parent. + if (parent) { + parent.nodes.push(node) + } + + // We are the root node which means we are done with the current node. + else { + ast.push(node) + } + + // Reset the state for the next node. + buffer = '' + node = null + } + + // End of a declaration. + // + // E.g.: + // + // ```css + // .foo { + // color: red; + // ^ + // } + // ``` + // + else if ( + currentChar === SEMICOLON && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { + let declaration = parseDeclaration(buffer) + if (!declaration) { + if (buffer.length === 0) throw new Error('Unexpected semicolon') + throw new Error(`Invalid declaration: \`${buffer.trim()}\``) + } + + if (source) { + declaration.src = [source, bufferStart, i] + declaration.dst = [source, bufferStart, i] + } + + if (parent) { + parent.nodes.push(declaration) + } else { + ast.push(declaration) + } + + buffer = '' + } + + // Start of a block. + else if ( + currentChar === OPEN_CURLY && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { + closingBracketStack += '}' + + // At this point `buffer` should resemble a selector or an at-rule. + node = rule(buffer.trim()) + + // Track the source location for source maps + if (source) { + node.src = [source, bufferStart, i] + node.dst = [source, bufferStart, i] + } + + // Attach the rule to the parent in case it's nested. + if (parent) { + parent.nodes.push(node) + } + + // Push the parent node to the stack, so that we can go back once the + // nested nodes are done. + stack.push(parent) + + // Make the current node the new parent, so that nested nodes can be + // attached to it. + parent = node + + // Reset the state for the next node. + buffer = '' + node = null + } + + // End of a block. + else if ( + currentChar === CLOSE_CURLY && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { + if (closingBracketStack === '') { + throw new Error('Missing opening {') + } + + closingBracketStack = closingBracketStack.slice(0, -1) + + // When we hit a `}` and `buffer` is filled in, then it means that we did + // not complete the previous node yet. This means that we hit a + // declaration without a `;` at the end. + if (buffer.length > 0) { + // This can happen for nested at-rules. + // + // E.g.: + // + // ```css + // @layer foo { + // @tailwind utilities + // ^ + // } + // ``` + if (buffer.charCodeAt(0) === AT_SIGN) { + node = parseAtRule(buffer) + + // Track the source location for source maps + if (source) { + node.src = [source, bufferStart, i] + node.dst = [source, bufferStart, i] + } + + // At-rule is nested inside of a rule, attach it to the parent. + if (parent) { + parent.nodes.push(node) + } + + // We are the root node which means we are done with the current node. + else { + ast.push(node) + } + + // Reset the state for the next node. + buffer = '' + node = null + } + + // But it can also happen for declarations. + // + // E.g.: + // + // ```css + // .foo { + // color: red + // ^ + // } + // ``` + else { + // Split `buffer` into a `property` and a `value`. At this point the + // comments are already removed which means that we don't have to worry + // about `:` inside of comments. + let colonIdx = buffer.indexOf(':') + + // Attach the declaration to the parent. + if (parent) { + let node = parseDeclaration(buffer, colonIdx) + if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``) + + if (source) { + node.src = [source, bufferStart, i] + node.dst = [source, bufferStart, i] + } + + parent.nodes.push(node) + } + } + } + + // We are done with the current node, which means we can go up one level + // in the stack. + let grandParent = stack.pop() ?? null + + // We are the root node which means we are done and continue with the next + // node. + if (grandParent === null && parent) { + ast.push(parent) + } + + // Go up one level in the stack. + parent = grandParent + + // Reset the state for the next node. + buffer = '' + node = null + } + + // `(` + else if (currentChar === OPEN_PAREN) { + closingBracketStack += ')' + buffer += '(' + } + + // `)` + else if (currentChar === CLOSE_PAREN) { + if (closingBracketStack[closingBracketStack.length - 1] !== ')') { + throw new Error('Missing opening (') + } + + closingBracketStack = closingBracketStack.slice(0, -1) + buffer += ')' + } + + // Any other character is part of the current node. + else { + // Skip whitespace at the start of a new node. + if ( + buffer.length === 0 && + (currentChar === SPACE || currentChar === LINE_BREAK || currentChar === TAB) + ) { + continue + } + + if (buffer === '') bufferStart = i + + buffer += String.fromCharCode(currentChar) + } + } + + // If we have a leftover `buffer` that happens to start with an `@` then it + // means that we have an at-rule that is not terminated with a semicolon at + // the end of the input. + if (buffer.charCodeAt(0) === AT_SIGN) { + let node = parseAtRule(buffer) + + // Track the source location for source maps + if (source) { + node.src = [source, bufferStart, input.length] + node.dst = [source, bufferStart, input.length] + } + + ast.push(node) + } + + // When we are done parsing then everything should be balanced. If we still + // have a leftover `parent`, then it means that we have an unterminated block. + if (closingBracketStack.length > 0 && parent) { + if (parent.kind === 'rule') { + throw new Error(`Missing closing } at ${parent.selector}`) + } + if (parent.kind === 'at-rule') { + throw new Error(`Missing closing } at ${parent.name} ${parent.params}`) + } + } + + if (licenseComments.length > 0) { + return (licenseComments as AstNode[]).concat(ast) + } + + return ast +} + +export function parseAtRule(buffer: string, nodes: AstNode[] = []): AtRule { + let name = buffer + let params = '' + + // Assumption: The smallest at-rule in CSS right now is `@page`, this means + // that we can always skip the first 5 characters and start at the + // sixth (at index 5). + // + // There is a chance someone is using a shorter at-rule, in that case we have + // to adjust this number back to 2, e.g.: `@x`. + // + // This issue can only occur if somebody does the following things: + // + // 1. Uses a shorter at-rule than `@page` + // 2. Disables Lightning CSS from `@tailwindcss/postcss` (because Lightning + // CSS doesn't handle custom at-rules properly right now) + // 3. Sandwiches the `@tailwindcss/postcss` plugin between two other plugins + // that can handle the shorter at-rule + // + // Let's use the more common case as the default and we can adjust this + // behavior if necessary. + for (let i = 5 /* '@page'.length */; i < buffer.length; i++) { + let currentChar = buffer.charCodeAt(i) + if (currentChar === SPACE || currentChar === OPEN_PAREN) { + name = buffer.slice(0, i) + params = buffer.slice(i) + break + } + } + + return atRule(name.trim(), params.trim(), nodes) +} + +function parseDeclaration( + buffer: string, + colonIdx: number = buffer.indexOf(':'), +): Declaration | null { + if (colonIdx === -1) return null + let importantIdx = buffer.indexOf('!important', colonIdx + 1) + return decl( + buffer.slice(0, colonIdx).trim(), + buffer.slice(colonIdx + 1, importantIdx === -1 ? buffer.length : importantIdx).trim(), + importantIdx !== -1, + ) +} diff --git a/packages/tailwindcss-language-service/src/css/source.ts b/packages/tailwindcss-language-service/src/css/source.ts new file mode 100644 index 000000000..9fe53e202 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/source.ts @@ -0,0 +1,37 @@ +/** + * The source code for one or more nodes in the AST + * + * This generally corresponds to a stylesheet + */ +export interface Source { + /** + * The path to the file that contains the referenced source code + * + * If this references the *output* source code, this is `null`. + */ + file: string | null + + /** + * The referenced source code + */ + code: string +} + +/** + * The file and offsets within it that this node covers + * + * This can represent either: + * - A location in the original CSS which caused this node to be created + * - A location in the output CSS where this node resides + */ +export type SourceLocation = [source: Source, start: number, end: number] + +/** + * The file and offsets within it that this node covers + */ +export function createInputSource(file: string, code: string): Source { + return { + file, + code, + } +} diff --git a/packages/tailwindcss-language-service/src/css/to-css.ts b/packages/tailwindcss-language-service/src/css/to-css.ts new file mode 100644 index 000000000..d38a707ec --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/to-css.ts @@ -0,0 +1,209 @@ +import { AstNode } from './ast' +import { Source } from './source' + +export function toCss(ast: AstNode[], track?: boolean) { + let pos = 0 + + let source: Source = { + file: null, + code: '', + } + + function stringify(node: AstNode, depth = 0): string { + let css = '' + let indent = ' '.repeat(depth) + + // Declaration + if (node.kind === 'declaration') { + css += `${indent}${node.property}: ${node.value}${node.important ? ' !important' : ''};\n` + + if (track) { + // indent + pos += indent.length + + // node.property + let start = pos + pos += node.property.length + + // `: ` + pos += 2 + + // node.value + pos += node.value?.length ?? 0 + + // !important + if (node.important) { + pos += 11 + } + + let end = pos + + // `;\n` + pos += 2 + + node.dst = [source, start, end] + } + } + + // Rule + else if (node.kind === 'rule') { + css += `${indent}${node.selector} {\n` + + if (track) { + // indent + pos += indent.length + + // node.selector + let start = pos + pos += node.selector.length + + // ` ` + pos += 1 + + let end = pos + node.dst = [source, start, end] + + // `{\n` + pos += 2 + } + + for (let child of node.nodes) { + css += stringify(child, depth + 1) + } + + css += `${indent}}\n` + + if (track) { + // indent + pos += indent.length + + // `}\n` + pos += 2 + } + } + + // AtRule + else if (node.kind === 'at-rule') { + // Print at-rules without nodes with a `;` instead of an empty block. + // + // E.g.: + // + // ```css + // @layer base, components, utilities; + // ``` + if (node.nodes.length === 0) { + let css = `${indent}${node.name} ${node.params};\n` + + if (track) { + // indent + pos += indent.length + + // node.name + let start = pos + pos += node.name.length + + // ` ` + pos += 1 + + // node.params + pos += node.params.length + let end = pos + + // `;\n` + pos += 2 + + node.dst = [source, start, end] + } + + return css + } + + css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n` + + if (track) { + // indent + pos += indent.length + + // node.name + let start = pos + pos += node.name.length + + if (node.params) { + // ` ` + pos += 1 + + // node.params + pos += node.params.length + } + + // ` ` + pos += 1 + + let end = pos + node.dst = [source, start, end] + + // `{\n` + pos += 2 + } + + for (let child of node.nodes) { + css += stringify(child, depth + 1) + } + + css += `${indent}}\n` + + if (track) { + // indent + pos += indent.length + + // `}\n` + pos += 2 + } + } + + // Comment + else if (node.kind === 'comment') { + css += `${indent}/*${node.value}*/\n` + + if (track) { + // indent + pos += indent.length + + // The comment itself. We do this instead of just the inside because + // it seems more useful to have the entire comment span tracked. + let start = pos + pos += 2 + node.value.length + 2 + let end = pos + + node.dst = [source, start, end] + + // `\n` + pos += 1 + } + } + + // These should've been handled already by `optimizeAst` which + // means we can safely ignore them here. We return an empty string + // immediately to signal that something went wrong. + else if (node.kind === 'context' || node.kind === 'at-root') { + return '' + } + + // Unknown + else { + node satisfies never + } + + return css + } + + let css = '' + + for (let node of ast) { + css += stringify(node, 0) + } + + source.code = css + + return css +} diff --git a/packages/tailwindcss-language-service/src/css/walk.ts b/packages/tailwindcss-language-service/src/css/walk.ts new file mode 100644 index 000000000..748daa9f2 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/walk.ts @@ -0,0 +1,79 @@ +import { AstNode } from './ast' + +export const enum WalkAction { + /** Continue walking, which is the default */ + Continue, + + /** Skip visiting the children of this node */ + Skip, + + /** Stop the walk entirely */ + Stop, +} + +export interface VisitorMeta { + path: AstNode[] + parent: AstNode | null + context: Record + + replaceWith(newNode: AstNode | AstNode[]): void +} + +export interface Visitor { + enter?(node: AstNode, meta: VisitorMeta): WalkAction + exit?(node: AstNode, meta: VisitorMeta): WalkAction +} + +export function walk( + ast: AstNode[], + visit: Visitor, + path: AstNode[] = [], + context: Record = {}, +) { + for (let i = 0; i < ast.length; i++) { + let node = ast[i] + let parent = path[path.length - 1] ?? null + + let meta: VisitorMeta = { + parent, + context, + path, + replaceWith(newNode) { + ast[i] = { + kind: 'context', + context: {}, + nodes: Array.isArray(newNode) ? newNode : [newNode], + } + }, + } + + path.push(node) + let status = visit.enter?.(node, meta) ?? WalkAction.Continue + path.pop() + + // Stop the walk entirely + if (status === WalkAction.Stop) return WalkAction.Stop + + // Skip visiting the children of this node + if (status === WalkAction.Skip) continue + + // These nodes do not have children + if (node.kind === 'comment' || node.kind === 'declaration') continue + + let nodeContext = node.kind === 'context' ? { ...context, ...node.context } : context + + path.push(node) + status = walk(node.nodes, visit, path, nodeContext) + path.pop() + + if (status === WalkAction.Stop) return WalkAction.Stop + + path.push(node) + status = visit.exit?.(node, meta) ?? WalkAction.Continue + path.pop() + + if (status === WalkAction.Stop) return WalkAction.Stop + } + + return WalkAction.Continue +} diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts index 075cea38e..d4dc2e35e 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts @@ -1,6 +1,10 @@ import type { TextDocument } from 'vscode-languageserver-textdocument' +import { + DocumentDiagnosticReportKind, + type FullDocumentDiagnosticReport, +} from 'vscode-languageserver' import type { State } from '../util/state' -import { DiagnosticKind, type AugmentedDiagnostic } from './types' +import { DiagnosticKind } from './types' import { getCssConflictDiagnostics } from './getCssConflictDiagnostics' import { getInvalidApplyDiagnostics } from './getInvalidApplyDiagnostics' import { getInvalidScreenDiagnostics } from './getInvalidScreenDiagnostics' @@ -25,10 +29,10 @@ export async function doValidate( DiagnosticKind.RecommendedVariantOrder, DiagnosticKind.UsedBlocklistedClass, ], -): Promise { - const settings = await state.editor.getConfiguration(document.uri) +): Promise { + let settings = await state.editor.getConfiguration(document.uri) - return settings.tailwindCSS.validate + let items = settings.tailwindCSS.validate ? [ ...(only.includes(DiagnosticKind.CssConflict) ? await getCssConflictDiagnostics(state, document, settings) @@ -59,4 +63,9 @@ export async function doValidate( : []), ] : [] + + return { + kind: DocumentDiagnosticReportKind.Full, + items, + } } diff --git a/packages/tailwindcss-language-service/src/documentColorProvider.ts b/packages/tailwindcss-language-service/src/documentColorProvider.ts index cef0729f0..f9603661b 100644 --- a/packages/tailwindcss-language-service/src/documentColorProvider.ts +++ b/packages/tailwindcss-language-service/src/documentColorProvider.ts @@ -1,51 +1,40 @@ -import type { State } from './util/state' -import { - findClassListsInDocument, - getClassNamesInClassList, - findHelperFunctionsInDocument, -} from './util/find' +import type { ColorInformation } from 'vscode-languageserver' +import type { Document } from './documents/document' import { getColor, getColorFromValue, culoriColorToVscodeColor } from './util/color' import { stringToPath } from './util/stringToPath' -import type { ColorInformation } from 'vscode-languageserver' -import type { TextDocument } from 'vscode-languageserver-textdocument' import dlv from 'dlv' import { dedupeByRange } from './util/array' -export async function getDocumentColors( - state: State, - document: TextDocument, -): Promise { +export function getDocumentColors(doc: Document): ColorInformation[] { let colors: ColorInformation[] = [] - if (!state.enabled) return colors - let settings = await state.editor.getConfiguration(document.uri) - if (settings.tailwindCSS.colorDecorators === false) return colors + for (let className of doc.classNames()) { + let color = getColor(doc.state, className.className) + if (!color) continue + if (typeof color === 'string') continue + if ((color.alpha ?? 1) === 0) continue - let classLists = await findClassListsInDocument(state, document) - classLists.forEach((classList) => { - let classNames = getClassNamesInClassList(classList, state.blocklist) - classNames.forEach((className) => { - let color = getColor(state, className.className) - if (color === null || typeof color === 'string' || (color.alpha ?? 1) === 0) { - return - } - colors.push({ - range: className.range, - color: culoriColorToVscodeColor(color), - }) + colors.push({ + range: className.range, + color: culoriColorToVscodeColor(color), }) - }) + } - let helperFns = findHelperFunctionsInDocument(state, document) - helperFns.forEach((fn) => { + for (let fn of doc.helperFns()) { let keys = stringToPath(fn.path) let base = fn.helper === 'theme' ? ['theme'] : [] - let value = dlv(state.config, [...base, ...keys]) + let value = dlv(doc.state.config, [...base, ...keys]) + let color = getColorFromValue(value) - if (color && typeof color !== 'string' && (color.alpha ?? 1) !== 0) { - colors.push({ range: fn.ranges.path, color: culoriColorToVscodeColor(color) }) - } - }) + if (!color) continue + if (typeof color === 'string') continue + if ((color.alpha ?? 1) === 0) continue + + colors.push({ + range: fn.ranges.path, + color: culoriColorToVscodeColor(color), + }) + } return dedupeByRange(colors) } diff --git a/packages/tailwindcss-language-service/src/documents/document.ts b/packages/tailwindcss-language-service/src/documents/document.ts new file mode 100644 index 000000000..f1a313548 --- /dev/null +++ b/packages/tailwindcss-language-service/src/documents/document.ts @@ -0,0 +1,190 @@ +import type { Position, TextDocument } from 'vscode-languageserver-textdocument' +import type { + DocumentClassList, + DocumentClassName, + DocumentHelperFunction, + Settings, + State, +} from '../util/state' +import type { ServiceOptions } from '../service' +import { isWithinRange } from '../util/isWithinRange' +import { getDocumentBlocks, type LanguageBlock } from '../util/language-blocks' +import { + findClassListsInCssRange, + findClassListsInHtmlRange, + findCustomClassLists, + findHelperFunctionsInDocument, + findHelperFunctionsInRange, + getClassNamesInClassList, +} from '../util/find' +import { dedupeBySpan } from '../util/array' + +export interface Document { + readonly state: State + readonly version: number + readonly uri: string + readonly settings: Settings + readonly storage: TextDocument + + /** + * Find the language block that contains the cursor + */ + blockAt(cursor: Position): LanguageBlock | null + + /** + * Find all class lists in the document + */ + classLists(): Iterable + + /** + * Find all class lists at a given cursor position + */ + classListsAt(cursor: Position): Iterable + + /** + * Find all class names in the document + */ + classNames(): Iterable + + /** + * Find all class names at a given cursor position + * + * Theoretically, this function should only ever contain one entry + * but the presence of custom regexes may produce multiple entries + */ + classNamesAt(cursor: Position): Iterable + + /** + * Find all helper functions in the document + * + * This only applies to CSS contexts. Other document types will produce + * zero entries. + */ + helperFns(): Iterable + + /** + * Find all helper functions at a given cursor position + */ + helperFnsAt(cursor: Position): Iterable +} + +export async function createVirtualDocument( + opts: ServiceOptions, + storage: TextDocument, +): Promise { + /** + * The state of the server at the time of creation + */ + let state = opts.state() + + /** + * The current settings for this document + */ + let settings = await state.editor.getConfiguration(storage.uri) + + /** + * Conceptual boundaries of the document where different languages are used + * + * This is used to determine how the document is structured and what parts + * are relevant to the current operation. + */ + let blocks = getDocumentBlocks(state, storage) + + /** + * All class lists in the document + */ + let classLists: DocumentClassList[] = [] + + for (let block of blocks) { + if (block.context === 'css') { + classLists.push(...findClassListsInCssRange(state, storage, block.range, block.lang)) + } else if (block.context === 'html') { + classLists.push(...(await findClassListsInHtmlRange(state, storage, 'html', block.range))) + } else if (block.context === 'js') { + classLists.push(...(await findClassListsInHtmlRange(state, storage, 'jsx', block.range))) + } + } + + classLists.push(...(await findCustomClassLists(state, storage))) + + classLists.sort((a, b) => a.span[0] - b.span[0] || b.span[1] - a.span[1]) + classLists = dedupeBySpan(classLists) + + /** + * All class names in the document + */ + let classNames: DocumentClassName[] = [] + + for (let classList of classLists) { + classNames.push(...getClassNamesInClassList(classList, state.blocklist ?? [])) + } + + classNames.sort((a, b) => a.span[0] - b.span[0] || b.span[1] - a.span[1]) + classNames = dedupeBySpan(classNames) + + /** + * Helper functions in CSS + */ + let helperFns: DocumentHelperFunction[] = [] + + for (let block of blocks) { + if (block.context === 'css') { + helperFns.push(...findHelperFunctionsInRange(storage, block.range)) + } + } + + function blockAt(cursor: Position): LanguageBlock | null { + for (let block of blocks) { + if (isWithinRange(cursor, block.range)) { + return block + } + } + + return null + } + + /** + * Find all class lists at a given cursor position + */ + function classListsAt(cursor: Position): DocumentClassList[] { + return classLists.filter((classList) => isWithinRange(cursor, classList.range)) + } + + /** + * Find all class names at a given cursor position + */ + function classNamesAt(cursor: Position): DocumentClassName[] { + return classNames.filter((className) => isWithinRange(cursor, className.range)) + } + + /** + * Find all class names at a given cursor position + */ + function helperFnsAt(cursor: Position): DocumentHelperFunction[] { + return helperFns.filter((fn) => isWithinRange(cursor, fn.ranges.full)) + } + + return { + settings, + storage, + uri: storage.uri, + + get version() { + return storage.version + }, + + get state() { + return opts.state() + }, + + blockAt, + + classLists: () => classLists.slice(), + classListsAt, + classNames: () => classNames.slice(), + classNamesAt, + + helperFns: () => helperFns.slice(), + helperFnsAt, + } +} diff --git a/packages/tailwindcss-language-service/src/documents/store.ts b/packages/tailwindcss-language-service/src/documents/store.ts new file mode 100644 index 000000000..904e09dc1 --- /dev/null +++ b/packages/tailwindcss-language-service/src/documents/store.ts @@ -0,0 +1,25 @@ +import type { TextDocument } from 'vscode-languageserver-textdocument' +import type { ServiceOptions } from '../service' +import { createVirtualDocument, type Document } from './document' + +export function createDocumentStore(opts: ServiceOptions) { + let documents = new Map() + + return { + clear: () => documents.clear(), + + async parse(uri: string | TextDocument) { + let textDoc = typeof uri === 'string' ? await opts.fs.document(uri) : uri + + // Return from the cache if the document has not changed + let found = documents.get(textDoc.uri) + if (found && found[0] === textDoc.version) return found[1] + + let doc = await createVirtualDocument(opts, textDoc) + + documents.set(textDoc.uri, [textDoc.version, doc]) + + return doc + }, + } +} diff --git a/packages/tailwindcss-language-service/src/fs.ts b/packages/tailwindcss-language-service/src/fs.ts new file mode 100644 index 000000000..fca75e099 --- /dev/null +++ b/packages/tailwindcss-language-service/src/fs.ts @@ -0,0 +1,25 @@ +import { TextDocument } from 'vscode-languageserver-textdocument' + +export interface FileSystem { + /** + * Get the document with the given URI + */ + document(uri: string): Promise + + /** + * Resolve the path relative to the given document + */ + resolve(document: TextDocument, relativePath: string): Promise + + /** + * List the files and directories in the given directory + */ + readDirectory(document: TextDocument, path: string): Promise +} + +export type FileType = 'unknown' | 'file' | 'directory' | 'symbolic-link' + +export interface File { + name: string + type: FileType +} diff --git a/packages/tailwindcss-language-service/src/hoverProvider.ts b/packages/tailwindcss-language-service/src/hoverProvider.ts index 583cc80f9..5e6675a06 100644 --- a/packages/tailwindcss-language-service/src/hoverProvider.ts +++ b/packages/tailwindcss-language-service/src/hoverProvider.ts @@ -3,60 +3,36 @@ import type { Hover, MarkupContent, Position, Range } from 'vscode-languageserve import { stringifyCss, stringifyConfigValue } from './util/stringify' import dlv from 'dlv' import { isCssContext } from './util/css' -import { - findAll, - findClassNameAtPosition, - findHelperFunctionsInRange, - indexToPosition, -} from './util/find' +import { findAll, indexToPosition } from './util/find' import { validateApply } from './util/validateApply' import { getClassNameParts } from './util/getClassNameAtPosition' import * as jit from './util/jit' import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostics' import { isWithinRange } from './util/isWithinRange' -import type { TextDocument } from 'vscode-languageserver-textdocument' import { addPixelEquivalentsToValue } from './util/pixelEquivalents' import { getTextWithoutComments } from './util/doc' import braces from 'braces' import { absoluteRange } from './util/absoluteRange' import { segment } from './util/segment' +import type { Document } from './documents/document' -export async function doHover( - state: State, - document: TextDocument, - position: Position, -): Promise { +export async function doHover(doc: Document, position: Position): Promise { return ( - (await provideClassNameHover(state, document, position)) || - (await provideThemeDirectiveHover(state, document, position)) || - (await provideCssHelperHover(state, document, position)) || - (await provideSourceGlobHover(state, document, position)) + (await provideClassNameHover(doc, position)) || + (await provideThemeDirectiveHover(doc, position)) || + (await provideCssHelperHover(doc, position)) || + (await provideSourceGlobHover(doc, position)) ) } -async function provideCssHelperHover( - state: State, - document: TextDocument, - position: Position, -): Promise { - if (!isCssContext(state, document, position)) { - return null - } - - const settings = await state.editor.getConfiguration(document.uri) - - let helperFns = findHelperFunctionsInRange(document, { - start: { line: position.line, character: 0 }, - end: { line: position.line + 1, character: 0 }, - }) - - for (let helperFn of helperFns) { +async function provideCssHelperHover(doc: Document, position: Position): Promise { + for (let helperFn of doc.helperFnsAt(position)) { if (!isWithinRange(position, helperFn.ranges.path)) continue - if (helperFn.helper === 'var' && !state.v4) continue + if (helperFn.helper === 'var' && !doc.state.v4) continue let validated = validateConfigPath( - state, + doc.state, helperFn.path, helperFn.helper === 'theme' ? ['theme'] : [], ) @@ -65,13 +41,13 @@ async function provideCssHelperHover( let value = validated.isValid ? stringifyConfigValue(validated.value) : null if (value === null) return null - if (settings.tailwindCSS.showPixelEquivalents) { - value = addPixelEquivalentsToValue(value, settings.tailwindCSS.rootFontSize) + if (doc.settings.tailwindCSS.showPixelEquivalents) { + value = addPixelEquivalentsToValue(value, doc.settings.tailwindCSS.rootFontSize) } let lines = ['```plaintext', value, '```'] - if (state.v4 && helperFn.path.startsWith('--')) { + if (doc.state.v4 && helperFn.path.startsWith('--')) { lines = [ // '```css', @@ -91,14 +67,11 @@ async function provideCssHelperHover( return null } -async function provideClassNameHover( - state: State, - document: TextDocument, - position: Position, -): Promise { - let className = await findClassNameAtPosition(state, document, position) - if (className === null) return null +async function provideClassNameHover(doc: Document, position: Position): Promise { + let className = Array.from(doc.classNamesAt(position))[0] + if (!className) return null + let state = doc.state if (state.v4) { let root = state.designSystem.compile([className.className])[0] @@ -109,7 +82,7 @@ async function provideClassNameHover( return { contents: { language: 'css', - value: await jit.stringifyRoot(state, root, document.uri), + value: await jit.stringifyRoot(state, root, doc.uri), }, range: className.range, } @@ -125,7 +98,7 @@ async function provideClassNameHover( return { contents: { language: 'css', - value: await jit.stringifyRoot(state, root, document.uri), + value: await jit.stringifyRoot(state, root, doc.uri), }, range: className.range, } @@ -134,14 +107,14 @@ async function provideClassNameHover( const parts = getClassNameParts(state, className.className) if (!parts) return null - if (isCssContext(state, document, position)) { + if (isCssContext(state, doc.storage, position)) { let validated = validateApply(state, parts) if (validated === null || validated.isApplyable === false) { return null } } - const settings = await state.editor.getConfiguration(document.uri) + const settings = await state.editor.getConfiguration(doc.uri) const css = stringifyCss( className.className, @@ -167,11 +140,10 @@ function markdown(lines: string[]): MarkupContent { } } -async function provideSourceGlobHover( - state: State, - document: TextDocument, - position: Position, -): Promise { +async function provideSourceGlobHover(doc: Document, position: Position): Promise { + let state = doc.state + let document = doc.storage + if (!isCssContext(state, document, position)) { return null } @@ -230,11 +202,10 @@ async function provideSourceGlobHover( const PATTERN_AT_THEME = /@(?theme)\s+(?[^{]+)\s*\{/dg const PATTERN_IMPORT_THEME = /@(?import)\s*[^;]+?theme\((?[^)]+)\)/dg -async function provideThemeDirectiveHover( - state: State, - document: TextDocument, - position: Position, -): Promise { +async function provideThemeDirectiveHover(doc: Document, position: Position): Promise { + let state = doc.state + let document = doc.storage + if (!state.v4) return null let range = { diff --git a/packages/tailwindcss-language-service/src/index.ts b/packages/tailwindcss-language-service/src/index.ts index b8ad02f69..5c5737dfe 100644 --- a/packages/tailwindcss-language-service/src/index.ts +++ b/packages/tailwindcss-language-service/src/index.ts @@ -1,9 +1,4 @@ -export { doComplete, resolveCompletionItem, completionsFromClassList } from './completionProvider' -export { doValidate } from './diagnostics/diagnosticsProvider' -export { doHover } from './hoverProvider' -export { doCodeActions } from './codeActions/codeActionProvider' -export { getDocumentColors } from './documentColorProvider' -export { getDocumentLinks } from './documentLinksProvider' export * from './util/state' export * from './diagnostics/types' export * from './util/color' +export * from './service' diff --git a/packages/tailwindcss-language-service/src/paths.ts b/packages/tailwindcss-language-service/src/paths.ts new file mode 100644 index 000000000..97eab6134 --- /dev/null +++ b/packages/tailwindcss-language-service/src/paths.ts @@ -0,0 +1,51 @@ +import picomatch from 'picomatch' +import normalizePathBase from 'normalize-path' + +export function createPathMatcher(base: string, patterns: string[]) { + // Should we ignore this file? + // TODO: Do we need to normalize windows paths? + let matchers = patterns.map((pattern) => { + pattern = `${base}/${pattern}` + pattern = normalizePath(pattern) + pattern = normalizeDriveLetter(pattern) + + return picomatch(`${base}/${pattern}`) + }) + + return (file: string) => { + file = normalizePath(file) + file = normalizeDriveLetter(file) + + return matchers.some((isMatch) => isMatch(file)) + } +} + +const WIN_DRIVE_LETTER = /^([a-zA-Z]):/ +const POSIX_DRIVE_LETTER = /^\/([a-zA-Z]):/ + +/** + * Windows drive letters are case-insensitive and we may get them as either + * lower or upper case. This function normalizes the drive letter to uppercase + * to be consistent with the rest of the codebase. + */ +export function normalizeDriveLetter(filepath: string) { + return filepath + .replace(WIN_DRIVE_LETTER, (_, letter) => `${letter.toUpperCase()}:`) + .replace(POSIX_DRIVE_LETTER, (_, letter) => `/${letter.toUpperCase()}:`) +} + +export function normalizePath(originalPath: string) { + let normalized = normalizePathBase(originalPath) + + // This is Windows network share but the normalize path had one of the leading + // slashes stripped so we need to add it back + if ( + originalPath.startsWith('\\\\') && + normalized.startsWith('/') && + !normalized.startsWith('//') + ) { + return `/${normalized}` + } + + return normalized +} diff --git a/packages/tailwindcss-language-service/src/project/color.ts b/packages/tailwindcss-language-service/src/project/color.ts new file mode 100644 index 000000000..66360d11d --- /dev/null +++ b/packages/tailwindcss-language-service/src/project/color.ts @@ -0,0 +1,15 @@ +// FIXME: This is a performance optimization and not strictly correct +let isNegative = /^-/ +let isNumericUtility = + /^-?((min-|max-)?[wh]|z|start|order|opacity|rounded|row|col|size|basis|end|duration|ease|font|top|left|bottom|right|inset|leading|cursor|(space|scale|skew|rotate)-[xyz]|gap(-[xy])?|(scroll-)?[pm][trblxyse]?)-/ +let isMaskUtility = /^-?mask-/ + +export function mayContainColors(className: string) { + if (isNegative.test(className)) return false + // TODO: This is **not** correct but is intentional because there are 5k mask utilities and a LOT of them are colors + // This causes a massive slowdown when building the design system + if (isMaskUtility.test(className)) return false + if (isNumericUtility.test(className)) return false + + return true +} diff --git a/packages/tailwindcss-language-service/src/project/project.ts b/packages/tailwindcss-language-service/src/project/project.ts new file mode 100644 index 000000000..faae9086e --- /dev/null +++ b/packages/tailwindcss-language-service/src/project/project.ts @@ -0,0 +1,125 @@ +import type { Feature } from '../features' +import type { ResolvedClass, ResolvedDesignToken, ResolvedVariant } from './tokens' +import { createProjectV4, type ProjectDescriptorV4 } from './v4' + +export interface Project { + /** + * The version of Tailwind CSS used by this project + */ + readonly version: string + + /** + * The features supported by this version of Tailwind CSS + * + * @internal These values are not stable and may change at any point + */ + readonly features: Feature[] + + /** + * A list of files this project depends on. If any of these files change the + * project must be re-created. + * + * These are normalized URIs + */ + depdendencies: string[] + + /** + * A list of glob patterns that represent known source / template paths + * + * v4.x, inferred from: + * - `@source "…"` + * - `@import "…" source(…)` + * - `@tailwind utilities source(…)` + * - `@config` -> `content` (compat) + * - `@plugin` -> `content` (compat) + * + * v3.x, inferred from: + * - `content` + * + * v2.x: always empty + * v1.x: always empty + * v0.x: always empty + */ + sources(): ProjectSource[] + + /** + * Get information about a given list of classes. + * + * - Postcondition: The returned list is the same length and in the same order as `classes`. + * - Postcondition: Unknown classes have .source = 'unknown' + */ + resolveClasses(classes: string[]): Promise + + /** + * Get information about a list of registered design tokens. + * + * - Postcondition: The returned list is the same length and in the same order as `tokens`. + * - Postcondition: Unknown tokens have .source = 'unknown' + */ + resolveDesignTokens(tokens: string[]): Promise + + /** + * Get information about a given list of variants. + * + * - Postcondition: The returned list is the same length and in the same order as `variants`. + * - Postcondition: Unknown classes have .source = 'unknown' + */ + resolveVariants(variants: string[]): Promise + + /** + * Return a list of classes that may match the given query + * This is generally a prefix search on a given class part (e.g. "bg-" or "red-") + * + * - Postcondition: Only known classes are returned. + */ + searchClasses(query: string): Promise + + /** + * Return a list of properties that may match the given query + * + * - Postcondition: Only known design tokens are returned. + */ + searchDesignTokens(query: string): Promise + + /** + * Return a list of variants that may match the given query + * This is generally a prefix search on a given variant part (e.g. "data-" or "red-") + * + * - Postcondition: Only known variants are returned. + */ + searchVariants(query: string): Promise + + /** + * Sort the given list of classes. + * + * - Postcondition: The returned list is the same length as `classes`. + * - Postcondition: Unknown classes are kept in their original, relative order + * but are moved to the beginning of the list. + */ + sortClasses(classes: string[]): Promise +} + +export interface ProjectSource { + /** + * The base (file) URI for this pattern + */ + readonly base: string + + /** + * The glob pattern to match against the base URI + */ + readonly pattern: string + + /** + * Whether or not this is a inverted/negative pattern + */ + readonly negated: boolean +} + +export type ProjectDescriptor = ProjectDescriptorV4 + +export function createProject(desc: ProjectDescriptor): Promise { + if (desc.kind === 'v4') return createProjectV4(desc) + + throw new Error('Unknown project kind') +} diff --git a/packages/tailwindcss-language-service/src/project/tokens.ts b/packages/tailwindcss-language-service/src/project/tokens.ts new file mode 100644 index 000000000..9906f7a0a --- /dev/null +++ b/packages/tailwindcss-language-service/src/project/tokens.ts @@ -0,0 +1,121 @@ +import type * as culori from 'culori' +import { AstNode } from '../css/ast' + +export type ResolvedColor = culori.Color + +export type ToenSource = + // A class not known to the language server + | 'unknown' + + // A class that was generated by Tailwind CSS + | 'generated' + + // A class that exists in a stylesheet + | 'authored' + +export interface ResolvedDesignToken { + readonly kind: 'design-token' + readonly source: ToenSource + + /** + * The name of this token as authored by the user + * + * It can represent one of the following: + * + * - a legacy-style config keypath (theme.colors.red.500) + * - a CSS property (--some-color, authored in CSS) + * - a theme namespace (--color-*, authored in `@theme`) + * - a theme value (--color-red-500, authored in `@theme`) + */ + readonly name: string + + /** + * The value of the given design token + */ + value(): string +} + +export interface ResolvedClass { + readonly kind: 'class' + readonly source: ToenSource + + /** + * The name of this class as authored by the user + */ + readonly name: string + + /** + * The variants present in this class + * + * These are not guaranteed to be valid. This is a syntactic check only. + */ + readonly variants: readonly string[] + + /** + * A list of known, allowed modifiers for this class + * This list is empty when: + * - Modifiers are not supported by the given version of Tailwind + * - The class does not support any modifiers + * - The class _already_ includes a modifier + * + * This list being empty *does not mean* that thie class cannot take any + * modifiers. Only that we don't know what modifiers could be supported. + */ + readonly modifiers: readonly string[] + + /** + * Whether or not this class can be used with @apply + */ + readonly apply: { allowed: true } | { allowed: false; reason: string } + + /** + * The CSS AST this class generates + */ + nodes(): AstNode[] + + /** + * A list of representative color swatches for this class + * + * The first color swatch must be the "primary" swatch presented to the user + * as it is used for suggestions. + * + * Most utilities will have at most one color + * + * Computing colors can be potentially costly as it requires resolving the + * class into an AST. + */ + colors(force?: boolean): ResolvedColor[] +} + +export interface ResolvedVariant { + readonly kind: 'variant' + readonly source: ToenSource + + /** + * The name of this variant as authored by the user + */ + readonly name: string + + /** + * A list of known, allowed modifiers for this variant + * This list is empty when: + * - Modifiers are not supported by the given version of Tailwind + * - The variant does not support any modifiers + * - The variant _already_ includes a modifier + * + * This list being empty *does not mean* that thie variant cannot take any + * modifiers. Only that we don't know what modifiers could be supported. + */ + readonly modifiers: readonly string[] + + /** + * The list of CSS selectors or at-rules produced by this variant + * + * - Sibling selectors/at-rules introduce an entry into the array + * - Nested selectors/at-rules produce a string like this: + * ```css + * selector1 { selector2 { some-at-rule { &:hover } } } + * ``` + */ + selectors(): string[] +} diff --git a/packages/tailwindcss-language-service/src/project/v4.test.ts b/packages/tailwindcss-language-service/src/project/v4.test.ts new file mode 100644 index 000000000..cbe7c68f5 --- /dev/null +++ b/packages/tailwindcss-language-service/src/project/v4.test.ts @@ -0,0 +1,597 @@ +import { test, expect } from 'vitest' +import { createProject } from './project' +import dedent from 'dedent' +import { decl, rule } from '../css/ast' +const css = dedent + +test('can create a v4 project', async () => { + let content = css` + @theme default { + --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; + + --color-red: #f00; + --color-red-50: oklch(97.1% 0.013 17.38); + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-200: oklch(88.5% 0.062 18.334); + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-red-900: oklch(39.6% 0.141 25.723); + --color-red-950: oklch(25.8% 0.092 26.042); + + --color-orange-50: oklch(98% 0.016 73.684); + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-orange-200: oklch(90.1% 0.076 70.697); + --color-orange-300: oklch(83.7% 0.128 66.29); + --color-orange-400: oklch(75% 0.183 55.934); + --color-orange-500: oklch(70.5% 0.213 47.604); + --color-orange-600: oklch(64.6% 0.222 41.116); + --color-orange-700: oklch(55.3% 0.195 38.402); + --color-orange-800: oklch(47% 0.157 37.304); + --color-orange-900: oklch(40.8% 0.123 38.172); + --color-orange-950: oklch(26.6% 0.079 36.259); + + --color-amber-50: oklch(98.7% 0.022 95.277); + --color-amber-100: oklch(96.2% 0.059 95.617); + --color-amber-200: oklch(92.4% 0.12 95.746); + --color-amber-300: oklch(87.9% 0.169 91.605); + --color-amber-400: oklch(82.8% 0.189 84.429); + --color-amber-500: oklch(76.9% 0.188 70.08); + --color-amber-600: oklch(66.6% 0.179 58.318); + --color-amber-700: oklch(55.5% 0.163 48.998); + --color-amber-800: oklch(47.3% 0.137 46.201); + --color-amber-900: oklch(41.4% 0.112 45.904); + --color-amber-950: oklch(27.9% 0.077 45.635); + + --color-yellow-50: oklch(98.7% 0.026 102.212); + --color-yellow-100: oklch(97.3% 0.071 103.193); + --color-yellow-200: oklch(94.5% 0.129 101.54); + --color-yellow-300: oklch(90.5% 0.182 98.111); + --color-yellow-400: oklch(85.2% 0.199 91.936); + --color-yellow-500: oklch(79.5% 0.184 86.047); + --color-yellow-600: oklch(68.1% 0.162 75.834); + --color-yellow-700: oklch(55.4% 0.135 66.442); + --color-yellow-800: oklch(47.6% 0.114 61.907); + --color-yellow-900: oklch(42.1% 0.095 57.708); + --color-yellow-950: oklch(28.6% 0.066 53.813); + + --color-lime-50: oklch(98.6% 0.031 120.757); + --color-lime-100: oklch(96.7% 0.067 122.328); + --color-lime-200: oklch(93.8% 0.127 124.321); + --color-lime-300: oklch(89.7% 0.196 126.665); + --color-lime-400: oklch(84.1% 0.238 128.85); + --color-lime-500: oklch(76.8% 0.233 130.85); + --color-lime-600: oklch(64.8% 0.2 131.684); + --color-lime-700: oklch(53.2% 0.157 131.589); + --color-lime-800: oklch(45.3% 0.124 130.933); + --color-lime-900: oklch(40.5% 0.101 131.063); + --color-lime-950: oklch(27.4% 0.072 132.109); + + --color-green-50: oklch(98.2% 0.018 155.826); + --color-green-100: oklch(96.2% 0.044 156.743); + --color-green-200: oklch(92.5% 0.084 155.995); + --color-green-300: oklch(87.1% 0.15 154.449); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-700: oklch(52.7% 0.154 150.069); + --color-green-800: oklch(44.8% 0.119 151.328); + --color-green-900: oklch(39.3% 0.095 152.535); + --color-green-950: oklch(26.6% 0.065 152.934); + + --color-emerald-50: oklch(97.9% 0.021 166.113); + --color-emerald-100: oklch(95% 0.052 163.051); + --color-emerald-200: oklch(90.5% 0.093 164.15); + --color-emerald-300: oklch(84.5% 0.143 164.978); + --color-emerald-400: oklch(76.5% 0.177 163.223); + --color-emerald-500: oklch(69.6% 0.17 162.48); + --color-emerald-600: oklch(59.6% 0.145 163.225); + --color-emerald-700: oklch(50.8% 0.118 165.612); + --color-emerald-800: oklch(43.2% 0.095 166.913); + --color-emerald-900: oklch(37.8% 0.077 168.94); + --color-emerald-950: oklch(26.2% 0.051 172.552); + + --color-teal-50: oklch(98.4% 0.014 180.72); + --color-teal-100: oklch(95.3% 0.051 180.801); + --color-teal-200: oklch(91% 0.096 180.426); + --color-teal-300: oklch(85.5% 0.138 181.071); + --color-teal-400: oklch(77.7% 0.152 181.912); + --color-teal-500: oklch(70.4% 0.14 182.503); + --color-teal-600: oklch(60% 0.118 184.704); + --color-teal-700: oklch(51.1% 0.096 186.391); + --color-teal-800: oklch(43.7% 0.078 188.216); + --color-teal-900: oklch(38.6% 0.063 188.416); + --color-teal-950: oklch(27.7% 0.046 192.524); + + --color-cyan-50: oklch(98.4% 0.019 200.873); + --color-cyan-100: oklch(95.6% 0.045 203.388); + --color-cyan-200: oklch(91.7% 0.08 205.041); + --color-cyan-300: oklch(86.5% 0.127 207.078); + --color-cyan-400: oklch(78.9% 0.154 211.53); + --color-cyan-500: oklch(71.5% 0.143 215.221); + --color-cyan-600: oklch(60.9% 0.126 221.723); + --color-cyan-700: oklch(52% 0.105 223.128); + --color-cyan-800: oklch(45% 0.085 224.283); + --color-cyan-900: oklch(39.8% 0.07 227.392); + --color-cyan-950: oklch(30.2% 0.056 229.695); + + --color-sky-50: oklch(97.7% 0.013 236.62); + --color-sky-100: oklch(95.1% 0.026 236.824); + --color-sky-200: oklch(90.1% 0.058 230.902); + --color-sky-300: oklch(82.8% 0.111 230.318); + --color-sky-400: oklch(74.6% 0.16 232.661); + --color-sky-500: oklch(68.5% 0.169 237.323); + --color-sky-600: oklch(58.8% 0.158 241.966); + --color-sky-700: oklch(50% 0.134 242.749); + --color-sky-800: oklch(44.3% 0.11 240.79); + --color-sky-900: oklch(39.1% 0.09 240.876); + --color-sky-950: oklch(29.3% 0.066 243.157); + + --color-blue-50: oklch(97% 0.014 254.604); + --color-blue-100: oklch(93.2% 0.032 255.585); + --color-blue-200: oklch(88.2% 0.059 254.128); + --color-blue-300: oklch(80.9% 0.105 251.813); + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-blue-800: oklch(42.4% 0.199 265.638); + --color-blue-900: oklch(37.9% 0.146 265.522); + --color-blue-950: oklch(28.2% 0.091 267.935); + + --color-indigo-50: oklch(96.2% 0.018 272.314); + --color-indigo-100: oklch(93% 0.034 272.788); + --color-indigo-200: oklch(87% 0.065 274.039); + --color-indigo-300: oklch(78.5% 0.115 274.713); + --color-indigo-400: oklch(67.3% 0.182 276.935); + --color-indigo-500: oklch(58.5% 0.233 277.117); + --color-indigo-600: oklch(51.1% 0.262 276.966); + --color-indigo-700: oklch(45.7% 0.24 277.023); + --color-indigo-800: oklch(39.8% 0.195 277.366); + --color-indigo-900: oklch(35.9% 0.144 278.697); + --color-indigo-950: oklch(25.7% 0.09 281.288); + + --color-violet-50: oklch(96.9% 0.016 293.756); + --color-violet-100: oklch(94.3% 0.029 294.588); + --color-violet-200: oklch(89.4% 0.057 293.283); + --color-violet-300: oklch(81.1% 0.111 293.571); + --color-violet-400: oklch(70.2% 0.183 293.541); + --color-violet-500: oklch(60.6% 0.25 292.717); + --color-violet-600: oklch(54.1% 0.281 293.009); + --color-violet-700: oklch(49.1% 0.27 292.581); + --color-violet-800: oklch(43.2% 0.232 292.759); + --color-violet-900: oklch(38% 0.189 293.745); + --color-violet-950: oklch(28.3% 0.141 291.089); + + --color-purple-50: oklch(97.7% 0.014 308.299); + --color-purple-100: oklch(94.6% 0.033 307.174); + --color-purple-200: oklch(90.2% 0.063 306.703); + --color-purple-300: oklch(82.7% 0.119 306.383); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-600: oklch(55.8% 0.288 302.321); + --color-purple-700: oklch(49.6% 0.265 301.924); + --color-purple-800: oklch(43.8% 0.218 303.724); + --color-purple-900: oklch(38.1% 0.176 304.987); + --color-purple-950: oklch(29.1% 0.149 302.717); + + --color-fuchsia-50: oklch(97.7% 0.017 320.058); + --color-fuchsia-100: oklch(95.2% 0.037 318.852); + --color-fuchsia-200: oklch(90.3% 0.076 319.62); + --color-fuchsia-300: oklch(83.3% 0.145 321.434); + --color-fuchsia-400: oklch(74% 0.238 322.16); + --color-fuchsia-500: oklch(66.7% 0.295 322.15); + --color-fuchsia-600: oklch(59.1% 0.293 322.896); + --color-fuchsia-700: oklch(51.8% 0.253 323.949); + --color-fuchsia-800: oklch(45.2% 0.211 324.591); + --color-fuchsia-900: oklch(40.1% 0.17 325.612); + --color-fuchsia-950: oklch(29.3% 0.136 325.661); + + --color-pink-50: oklch(97.1% 0.014 343.198); + --color-pink-100: oklch(94.8% 0.028 342.258); + --color-pink-200: oklch(89.9% 0.061 343.231); + --color-pink-300: oklch(82.3% 0.12 346.018); + --color-pink-400: oklch(71.8% 0.202 349.761); + --color-pink-500: oklch(65.6% 0.241 354.308); + --color-pink-600: oklch(59.2% 0.249 0.584); + --color-pink-700: oklch(52.5% 0.223 3.958); + --color-pink-800: oklch(45.9% 0.187 3.815); + --color-pink-900: oklch(40.8% 0.153 2.432); + --color-pink-950: oklch(28.4% 0.109 3.907); + + --color-rose-50: oklch(96.9% 0.015 12.422); + --color-rose-100: oklch(94.1% 0.03 12.58); + --color-rose-200: oklch(89.2% 0.058 10.001); + --color-rose-300: oklch(81% 0.117 11.638); + --color-rose-400: oklch(71.2% 0.194 13.428); + --color-rose-500: oklch(64.5% 0.246 16.439); + --color-rose-600: oklch(58.6% 0.253 17.585); + --color-rose-700: oklch(51.4% 0.222 16.935); + --color-rose-800: oklch(45.5% 0.188 13.697); + --color-rose-900: oklch(41% 0.159 10.272); + --color-rose-950: oklch(27.1% 0.105 12.094); + + --color-slate-50: oklch(98.4% 0.003 247.858); + --color-slate-100: oklch(96.8% 0.007 247.896); + --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-300: oklch(86.9% 0.022 252.894); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-slate-950: oklch(12.9% 0.042 264.695); + + --color-gray-50: oklch(98.5% 0.002 247.839); + --color-gray-100: oklch(96.7% 0.003 264.542); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-gray-900: oklch(21% 0.034 264.665); + --color-gray-950: oklch(13% 0.028 261.692); + + --color-zinc-50: oklch(98.5% 0 0); + --color-zinc-100: oklch(96.7% 0.001 286.375); + --color-zinc-200: oklch(92% 0.004 286.32); + --color-zinc-300: oklch(87.1% 0.006 286.286); + --color-zinc-400: oklch(70.5% 0.015 286.067); + --color-zinc-500: oklch(55.2% 0.016 285.938); + --color-zinc-600: oklch(44.2% 0.017 285.786); + --color-zinc-700: oklch(37% 0.013 285.805); + --color-zinc-800: oklch(27.4% 0.006 286.033); + --color-zinc-900: oklch(21% 0.006 285.885); + --color-zinc-950: oklch(14.1% 0.005 285.823); + + --color-neutral-50: oklch(98.5% 0 0); + --color-neutral-100: oklch(97% 0 0); + --color-neutral-200: oklch(92.2% 0 0); + --color-neutral-300: oklch(87% 0 0); + --color-neutral-400: oklch(70.8% 0 0); + --color-neutral-500: oklch(55.6% 0 0); + --color-neutral-600: oklch(43.9% 0 0); + --color-neutral-700: oklch(37.1% 0 0); + --color-neutral-800: oklch(26.9% 0 0); + --color-neutral-900: oklch(20.5% 0 0); + --color-neutral-950: oklch(14.5% 0 0); + + --color-stone-50: oklch(98.5% 0.001 106.423); + --color-stone-100: oklch(97% 0.001 106.424); + --color-stone-200: oklch(92.3% 0.003 48.717); + --color-stone-300: oklch(86.9% 0.005 56.366); + --color-stone-400: oklch(70.9% 0.01 56.259); + --color-stone-500: oklch(55.3% 0.013 58.071); + --color-stone-600: oklch(44.4% 0.011 73.639); + --color-stone-700: oklch(37.4% 0.01 67.558); + --color-stone-800: oklch(26.8% 0.007 34.298); + --color-stone-900: oklch(21.6% 0.006 56.043); + --color-stone-950: oklch(14.7% 0.004 49.25); + + --color-black: #000; + --color-white: #fff; + + --spacing: 0.25rem; + + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 64rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + + --container-3xs: 16rem; + --container-2xs: 18rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --container-7xl: 80rem; + + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --text-7xl: 4.5rem; + --text-7xl--line-height: 1; + --text-8xl: 6rem; + --text-8xl--line-height: 1; + --text-9xl: 8rem; + --text-9xl--line-height: 1; + + --font-weight-thin: 100; + --font-weight-extralight: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --font-weight-extrabold: 800; + --font-weight-black: 900; + + --tracking-tighter: -0.05em; + --tracking-tight: -0.025em; + --tracking-normal: 0em; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --tracking-widest: 0.1em; + + --leading-tight: 1.25; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 2; + + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-3xl: 1.5rem; + --radius-4xl: 2rem; + + --shadow-2xs: 0 1px rgb(0 0 0 / 0.05); + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05); + --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05); + --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05); + + --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05); + --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15); + --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12); + --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15); + --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1); + --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15); + + --text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15); + --text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2); + --text-shadow-sm: 0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), + 0px 2px 2px rgb(0 0 0 / 0.075); + --text-shadow-md: 0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), + 0px 2px 4px rgb(0 0 0 / 0.1); + --text-shadow-lg: 0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), + 0px 4px 8px rgb(0 0 0 / 0.1); + + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + --animate-spin: spin 1s linear infinite; + --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-bounce: bounce 1s infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + @keyframes ping { + 75%, + 100% { + transform: scale(2); + opacity: 0; + } + } + + @keyframes pulse { + 50% { + opacity: 0.5; + } + } + + @keyframes bounce { + 0%, + 100% { + transform: translateY(-25%); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + + 50% { + transform: none; + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + } + + --blur-xs: 4px; + --blur-sm: 8px; + --blur-md: 12px; + --blur-lg: 16px; + --blur-xl: 24px; + --blur-2xl: 40px; + --blur-3xl: 64px; + + --perspective-dramatic: 100px; + --perspective-near: 300px; + --perspective-normal: 500px; + --perspective-midrange: 800px; + --perspective-distant: 1200px; + + --aspect-video: 16 / 9; + + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: --theme(--font-sans, initial); + --default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial); + --default-font-variation-settings: --theme(--font-sans--font-variation-settings, initial); + --default-mono-font-family: --theme(--font-mono, initial); + --default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings, initial); + --default-mono-font-variation-settings: --theme( + --font-mono--font-variation-settings, + initial + ); + } + + /* Deprecated */ + @theme default inline reference { + --blur: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); + --drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06); + --radius: 0.25rem; + --max-width-prose: 65ch; + } + ` + + let { version } = require('tailwindcss-v4/package.json') + let tailwindcss = await import('tailwindcss-v4') + let design: any = await tailwindcss.__unstable__loadDesignSystem(content) + + let now = process.hrtime.bigint() + let project = await createProject({ + kind: 'v4', + version, + design, + tailwindcss, + }) + let elapsed = process.hrtime.bigint() - now + console.log(`${Number(elapsed) / 1e6}ms`) + + expect(project.version).toEqual('4.1.1') + expect(project.features).toEqual([ + 'css-at-theme', + 'layer:base', + 'content-list', + 'source-inline', + 'source-not', + ]) + + expect(project.depdendencies).toEqual([]) + expect(project.sources()).toEqual([]) + + let classes = await project.resolveClasses([ + // Unknown utility + 'does-not-exist', + + // static + 'underline', + + // functional + 'text-red', + + // with variants + 'active:accent-[#fc3]', + ]) + + expect(classes[0]).toMatchObject({ + kind: 'class', + source: 'unknown', + + name: 'does-not-exist', + modifiers: [], + variants: [], + + apply: { allowed: false }, + }) + + expect(classes[1]).toMatchObject({ + kind: 'class', + source: 'generated', + + name: 'underline', + variants: [], + modifiers: [], + + apply: { allowed: true }, + }) + + expect(classes[2]).toMatchObject({ + kind: 'class', + source: 'generated', + + name: 'text-red', + variants: [], + modifiers: [ + '0', + '5', + '10', + '15', + '20', + '25', + '30', + '35', + '40', + '45', + '50', + '55', + '60', + '65', + '70', + '75', + '80', + '85', + '90', + '95', + '100', + ], + + apply: { allowed: true }, + }) + + expect(classes[3]).toMatchObject({ + kind: 'class', + source: 'generated', + + name: 'active:accent-[#fc3]', + variants: ['active'], + + // TODO: Core needs to provide an API to produce modifiers for this + modifiers: [], + + apply: { allowed: true }, + }) + + expect(classes.map((cls) => cls.nodes())).toEqual([ + [], + [rule('.underline', [decl('text-decoration-line', 'underline')])], + [rule('.text-red', [decl('color', 'var(--color-red)')])], + [rule('.active\\:accent-\\[\\#fc3\\]', [rule('&:active', [decl('accent-color', '#fc3')])])], + ]) + + expect(classes.map((cls) => cls.colors())).toEqual([ + // + [], + [], + [{ mode: 'rgb', r: 1, g: 0, b: 0 }], + [{ mode: 'rgb', r: 1, g: 0.8, b: 0.2 }], + ]) +}) diff --git a/packages/tailwindcss-language-service/src/project/v4.ts b/packages/tailwindcss-language-service/src/project/v4.ts new file mode 100644 index 000000000..08dd98719 --- /dev/null +++ b/packages/tailwindcss-language-service/src/project/v4.ts @@ -0,0 +1,143 @@ +import { supportedFeatures } from '../features' +import { DesignSystem } from '../util/v4' +import { Project } from './project' +import { ResolvedClass } from './tokens' +import { bigSign } from '../util/big-sign' +import { lazy } from '../util/lazy' +import { segment } from '../util/segment' +import * as CSS from '../css/parse' +import { colorsInAst } from '../util/color' +import { AstNode } from '../css/ast' +import { mayContainColors } from './color' + +export interface ProjectDescriptorV4 { + kind: 'v4' + + /** + * The version of Tailwind CSS in use + */ + version: string + + /** + * The module returned by import("tailwindcss") + */ + tailwindcss: unknown + + /** + * The design system returned by `__unstable_loadDesignSystem(…)` + */ + design: DesignSystem +} + +export async function createProjectV4(desc: ProjectDescriptorV4): Promise { + let classCache = new Map() + let modifierCache = new Map() + + async function resolveClasses(classes: string[]) { + // Compile anything not in the cache + let uncached = classes.filter((className) => !classCache.has(className)) + let results = compileClasses(uncached) + + // Populate the class cache + for (let result of results) classCache.set(result.name, result) + + // Collect the results in order + let resolved = classes.map((name) => classCache.get(name) ?? null) + + // Remove unknown classes from the cache otherwise these jsut waste memory + for (let result of results) { + if (result.source !== 'unknown') continue + classCache.delete(result.name) + } + + return resolved + } + + function compileClasses(classes: string[]): ResolvedClass[] { + let errors: any[] = [] + + let css = desc.design.candidatesToCss(classes) + + let parsed = css.map((str) => + lazy(() => { + let ast: AstNode[] = [] + let colors: ReturnType = [] + + if (str) { + try { + ast = CSS.parse(str) + colors = colorsInAst(state, ast) + } catch (err) { + errors.push(err) + } + } + + return { ast, colors } + }), + ) + + if (errors.length > 0) { + console.error(JSON.stringify(errors)) + } + + return classes.map((name, idx) => ({ + kind: 'class', + source: css[idx] ? 'generated' : 'unknown', + name, + variants: css[idx] ? segment(name, ':').slice(0, -1) : [], + modifiers: modifierCache.get(name) ?? [], + apply: css[idx] ? { allowed: true } : { allowed: false, reason: 'class does not exist' }, + nodes: () => parsed[idx]().ast, + colors: (force?: boolean) => { + let res = parsed[idx] + if (res.status === 'pending' && !force) { + return [] + } + + return parsed[idx]().colors + }, + })) + } + + async function sortClasses(classes: string[]) { + return desc.design + .getClassOrder(classes) + .sort(([, a], [, z]) => { + if (a === z) return 0 + if (a === null) return -1 + if (z === null) return 1 + return bigSign(a - z) + }) + .map(([className]) => className) + } + + let classList = desc.design.getClassList() + + for (let [className, meta] of classList) { + modifierCache.set(className, meta.modifiers) + } + + // Pre-compute color values + let state = { designSystem: desc.design } as any + let colors = classList.map((entry) => entry[0]).filter(mayContainColors) + let resolved = await resolveClasses(colors) + for (let cls of resolved) cls.colors(true) + + return { + version: desc.version, + features: supportedFeatures(desc.version, desc.tailwindcss), + depdendencies: [], + + sources: () => [], + + resolveClasses, + resolveDesignTokens: async () => [], + resolveVariants: async () => [], + + searchClasses: async () => [], + searchDesignTokens: async () => [], + searchVariants: async () => [], + + sortClasses, + } +} diff --git a/packages/tailwindcss-language-service/src/service.ts b/packages/tailwindcss-language-service/src/service.ts new file mode 100644 index 000000000..3f8466412 --- /dev/null +++ b/packages/tailwindcss-language-service/src/service.ts @@ -0,0 +1,189 @@ +import type { Position, Range, TextDocument } from 'vscode-languageserver-textdocument' +import { + type Color, + type CodeAction, + type CodeActionContext, + type CodeActionParams, + type CodeLens, + type ColorInformation, + type CompletionContext, + type CompletionItem, + type CompletionList, + type DocumentLink, + type Hover, + type ColorPresentation, + type DocumentDiagnosticReport, +} from 'vscode-languageserver' +import type { State } from './util/state' +import type { DiagnosticKind } from './diagnostics/types' +import type { FileSystem } from './fs' +import { doHover } from './hoverProvider' +import { getDocumentLinks } from './documentLinksProvider' +import { getDocumentColors } from './documentColorProvider' +import { getCodeLens } from './codeLensProvider' +import { doComplete, resolveCompletionItem, completionsFromClassList } from './completionProvider' +import { doValidate } from './diagnostics/diagnosticsProvider' +import { doCodeActions } from './codeActions/codeActionProvider' +import { provideColorPresentation } from './colorPresentationProvider' +import { getColor, KeywordColor } from './util/color' +import * as culori from 'culori' +import { Document } from './documents/document' +import { createDocumentStore } from './documents/store' +import { createPathMatcher } from './paths' + +export interface ServiceOptions { + fs: FileSystem + state: () => State +} + +export interface LanguageDocument { + hover(position: Position): Promise + colorPresentation(color: Color, range: Range): Promise + documentLinks(): Promise + documentColors(): Promise + codeLenses(): Promise + diagnostics(kinds?: DiagnosticKind[]): Promise + codeActions(range: Range, context: CodeActionContext): Promise + completions(position: Position, ctx?: CompletionContext): Promise + resolveCompletion(item: CompletionItem): Promise +} + +export interface LanguageService { + open(doc: TextDocument | string): Promise + resolveCompletion(item: CompletionItem): Promise + onUpdateSettings(): Promise + + /** @internal */ + getColor(className: string): Promise + + /** @internal */ + completionsFromClassList(classList: string, range: Range): Promise +} + +export function createLanguageService(opts: ServiceOptions): LanguageService { + let store = createDocumentStore(opts) + + async function open(doc: TextDocument | string) { + return createLanguageDocument(opts, await store.parse(doc)) + } + + return { + open, + async getColor(className: string) { + return getColor(opts.state(), className) + }, + async completionsFromClassList(classList: string, range: Range) { + return completionsFromClassList(opts.state(), classList, range, 16) + }, + async resolveCompletion(item: CompletionItem) { + // Figure out what document this completion item belongs to + let uri = item.data?.uri + if (!uri) return Promise.resolve(item) + + let textDoc = await opts.fs.document(uri) + if (!textDoc) return Promise.resolve(item) + + let doc = await open(textDoc) + + return doc.resolveCompletion(item) + }, + + async onUpdateSettings() { + store.clear() + }, + } +} + +async function createLanguageDocument( + opts: ServiceOptions, + doc: Document, +): Promise { + let state = opts.state() + if (!state.enabled) return null + if (!state.editor) throw new Error('No editor provided') + + state.editor.readDirectory = async (doc, filepath) => { + let files = await opts.fs.readDirectory(doc, filepath) + + return files.map((file) => [file.name, { isDirectory: file.type === 'directory' }]) + } + + // Get the settings for the current document + let settings = await state.editor.getConfiguration(doc.uri) + if (!settings) throw new Error('Unable to get the settings for the current document') + + // Should we ignore this file? + // TODO: Do we need to normalize windows paths? + let isExcludedPath = createPathMatcher(state.editor.folder, settings.tailwindCSS.files.exclude) + if (isExcludedPath(doc.uri)) return null + + return { + async hover(position: Position) { + if (!state.enabled || !settings.tailwindCSS.hovers) return null + + return doHover(doc, position) + }, + + async documentLinks() { + if (!state.enabled) return [] + + return getDocumentLinks(state, doc.storage, (path) => { + return opts.fs.resolve(doc.storage, path) + }) + }, + + async documentColors() { + if (!state.enabled || !settings.tailwindCSS.colorDecorators) return [] + + return getDocumentColors(doc) + }, + + async colorPresentation(color: Color, range: Range) { + if (!state.enabled || !settings.tailwindCSS.colorDecorators) return [] + + return provideColorPresentation(state, doc.storage, color, range) + }, + + async codeLenses() { + if (!state.enabled || !settings.tailwindCSS.codeLens) return [] + + return getCodeLens(state, doc.storage) + }, + + async diagnostics(kinds?: DiagnosticKind[]) { + if (!state.enabled || !settings.tailwindCSS.validate) { + return { + kind: 'full', + items: [], + } + } + + return doValidate(state, doc.storage, kinds) + }, + + async codeActions(range: Range, context: CodeActionContext) { + if (!state.enabled || !settings.tailwindCSS.codeActions) return [] + + let params: CodeActionParams = { + textDocument: { uri: doc.uri }, + range, + context, + } + + return doCodeActions(state, params, doc.storage) + }, + + async completions(position: Position, ctx?: CompletionContext) { + if (!state.enabled || !settings.tailwindCSS.suggestions) return null + + state.completionItemData.uri = doc.uri + return doComplete(state, doc.storage, position, ctx) + }, + + async resolveCompletion(item: CompletionItem) { + if (!state.enabled || !settings.tailwindCSS.suggestions) return item + + return resolveCompletionItem(state, item) + }, + } +} diff --git a/packages/tailwindcss-language-service/src/util/array.ts b/packages/tailwindcss-language-service/src/util/array.ts index 9c9826405..52379e347 100644 --- a/packages/tailwindcss-language-service/src/util/array.ts +++ b/packages/tailwindcss-language-service/src/util/array.ts @@ -1,5 +1,7 @@ import type { Range } from 'vscode-languageserver' import { rangesEqual } from './rangesEqual' +import { Span } from './state' +import { spansEqual } from './spans-equal' export function dedupe(arr: Array): Array { return arr.filter((value, index, self) => self.indexOf(value) === index) @@ -16,6 +18,13 @@ export function dedupeByRange(arr: Array): Array< ) } +export function dedupeBySpan(arr: Array): Array { + return arr.filter( + (classList, classListIndex) => + classListIndex === arr.findIndex((c) => spansEqual(c.span, classList.span)), + ) +} + export function ensureArray(value: T | T[]): T[] { return Array.isArray(value) ? value : [value] } diff --git a/packages/tailwindcss-language-service/src/util/big-sign.ts b/packages/tailwindcss-language-service/src/util/big-sign.ts new file mode 100644 index 000000000..e29ce3c00 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/big-sign.ts @@ -0,0 +1,3 @@ +export function bigSign(value: bigint) { + return Number(value > 0n) - Number(value < 0n) +} diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index a1a99d661..ab7a612ac 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -9,6 +9,8 @@ import * as culori from 'culori' import namedColors from 'color-name' import postcss from 'postcss' import { replaceCssVarsWithFallbacks } from './rewriting' +import { AstNode } from '../css/ast' +import { walk, WalkAction } from '../css/walk' const COLOR_PROPS = [ 'accent-color', @@ -34,7 +36,7 @@ const COLOR_PROPS = [ 'text-decoration-color', ] -export type KeywordColor = 'transparent' | 'currentColor' +export type KeywordColor = 'transparent' | 'currentColor' | 'inherit' function getKeywordColor(value: unknown): KeywordColor | null { if (typeof value !== 'string') return null @@ -315,3 +317,32 @@ const LIGHT_DARK_REGEX = /light-dark\(\s*(.*?)\s*,\s*.*?\s*\)/g function resolveLightDark(str: string) { return str.replace(LIGHT_DARK_REGEX, (_, lightColor) => lightColor) } + +export function colorsInAst(state: State, ast: AstNode[]) { + let decls: Record = {} + + walk(ast, { + enter(node) { + if (node.kind === 'at-rule') { + if (node.name === '@property') { + return WalkAction.Skip + } + + // Tailwind CSS v4 alpha and beta + if (node.name === '@supports' && node.params === '(-moz-orient: inline)') { + return WalkAction.Skip + } + } else if (node.kind === 'declaration') { + decls[node.property] ??= [] + decls[node.property].push(node.value) + } + + return WalkAction.Continue + }, + }) + + let color = getColorFromDecls(state, decls) + if (!color) return [] + if (typeof color === 'string') return [] + return [color] +} diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 839fb6d09..bd60281a0 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -1,8 +1,8 @@ -import { test } from 'vitest' +import { expect, test } from 'vitest' import { findClassListsInHtmlRange } from './find' import { js, html, pug, createDocument } from './test-utils' -test('class regex works in astro', async ({ expect }) => { +test('class regex works in astro', async () => { let file = createDocument({ name: 'file.astro', lang: 'astro', @@ -29,6 +29,7 @@ test('class regex works in astro', async ({ expect }) => { expect(classLists).toEqual([ { classList: 'p-4 sm:p-2 $', + span: [10, 22], range: { start: { line: 0, character: 10 }, end: { line: 0, character: 22 }, @@ -36,6 +37,7 @@ test('class regex works in astro', async ({ expect }) => { }, { classList: 'underline', + span: [33, 42], range: { start: { line: 0, character: 33 }, end: { line: 0, character: 42 }, @@ -43,6 +45,7 @@ test('class regex works in astro', async ({ expect }) => { }, { classList: 'line-through', + span: [46, 58], range: { start: { line: 0, character: 46 }, end: { line: 0, character: 58 }, @@ -51,7 +54,7 @@ test('class regex works in astro', async ({ expect }) => { ]) }) -test('find class lists in functions', async ({ expect }) => { +test('find class lists in functions', async () => { let fileA = createDocument({ name: 'file.jsx', lang: 'javascriptreact', @@ -101,6 +104,7 @@ test('find class lists in functions', async ({ expect }) => { // from clsx(…) { classList: 'flex p-4', + span: [45, 53], range: { start: { line: 2, character: 3 }, end: { line: 2, character: 11 }, @@ -108,6 +112,7 @@ test('find class lists in functions', async ({ expect }) => { }, { classList: 'block sm:p-0', + span: [59, 71], range: { start: { line: 3, character: 3 }, end: { line: 3, character: 15 }, @@ -115,6 +120,7 @@ test('find class lists in functions', async ({ expect }) => { }, { classList: 'text-white', + span: [96, 106], range: { start: { line: 4, character: 22 }, end: { line: 4, character: 32 }, @@ -122,6 +128,7 @@ test('find class lists in functions', async ({ expect }) => { }, { classList: 'text-black', + span: [111, 121], range: { start: { line: 4, character: 37 }, end: { line: 4, character: 47 }, @@ -131,6 +138,7 @@ test('find class lists in functions', async ({ expect }) => { // from cva(…) { classList: 'flex p-4', + span: [171, 179], range: { start: { line: 9, character: 3 }, end: { line: 9, character: 11 }, @@ -138,6 +146,7 @@ test('find class lists in functions', async ({ expect }) => { }, { classList: 'block sm:p-0', + span: [185, 197], range: { start: { line: 10, character: 3 }, end: { line: 10, character: 15 }, @@ -145,6 +154,7 @@ test('find class lists in functions', async ({ expect }) => { }, { classList: 'text-white', + span: [222, 232], range: { start: { line: 11, character: 22 }, end: { line: 11, character: 32 }, @@ -152,6 +162,7 @@ test('find class lists in functions', async ({ expect }) => { }, { classList: 'text-black', + span: [237, 247], range: { start: { line: 11, character: 37 }, end: { line: 11, character: 47 }, @@ -163,7 +174,7 @@ test('find class lists in functions', async ({ expect }) => { expect(classListsB).toEqual([]) }) -test('find class lists in nested fn calls', async ({ expect }) => { +test('find class lists in nested fn calls', async () => { let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', @@ -209,6 +220,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { expect(classLists).toMatchObject([ { classList: 'flex', + span: [193, 197], range: { start: { line: 3, character: 3 }, end: { line: 3, character: 7 }, @@ -218,6 +230,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { // TODO: This should be ignored because they're inside cn(…) { classList: 'bg-red-500', + span: [212, 222], range: { start: { line: 5, character: 5 }, end: { line: 5, character: 15 }, @@ -227,6 +240,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { // TODO: This should be ignored because they're inside cn(…) { classList: 'text-white', + span: [236, 246], range: { start: { line: 6, character: 5 }, end: { line: 6, character: 15 }, @@ -235,6 +249,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { { classList: 'fixed', + span: [286, 291], range: { start: { line: 9, character: 5 }, end: { line: 9, character: 10 }, @@ -242,6 +257,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { }, { classList: 'absolute inset-0', + span: [299, 315], range: { start: { line: 10, character: 5 }, end: { line: 10, character: 21 }, @@ -249,6 +265,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { }, { classList: 'bottom-0', + span: [335, 343], range: { start: { line: 13, character: 6 }, end: { line: 13, character: 14 }, @@ -256,6 +273,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { }, { classList: 'border', + span: [347, 353], range: { start: { line: 13, character: 18 }, end: { line: 13, character: 24 }, @@ -263,6 +281,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { }, { classList: 'bottom-0 left-0', + span: [419, 434], range: { start: { line: 17, character: 20 }, end: { line: 17, character: 35 }, @@ -270,6 +289,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { }, { classList: `inset-0\n rounded-none\n `, + span: [468, 500], range: { start: { line: 19, character: 12 }, // TODO: Fix the range calculation. Its wrong on this one @@ -279,7 +299,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { ]) }) -test('find class lists in nested fn calls (only nested matches)', async ({ expect }) => { +test('find class lists in nested fn calls (only nested matches)', async () => { let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', @@ -311,6 +331,7 @@ test('find class lists in nested fn calls (only nested matches)', async ({ expec expect(classLists).toMatchObject([ { classList: 'fixed', + span: [228, 233], range: { start: { line: 9, character: 5 }, end: { line: 9, character: 10 }, @@ -318,6 +339,7 @@ test('find class lists in nested fn calls (only nested matches)', async ({ expec }, { classList: 'absolute inset-0', + span: [241, 257], range: { start: { line: 10, character: 5 }, end: { line: 10, character: 21 }, @@ -326,7 +348,7 @@ test('find class lists in nested fn calls (only nested matches)', async ({ expec ]) }) -test('find class lists in tagged template literals', async ({ expect }) => { +test('find class lists in tagged template literals', async () => { let fileA = createDocument({ name: 'file.jsx', lang: 'javascriptreact', @@ -376,6 +398,7 @@ test('find class lists in tagged template literals', async ({ expect }) => { // from clsx`…` { classList: 'flex p-4\n block sm:p-0\n $', + span: [44, 71], range: { start: { line: 2, character: 2 }, end: { line: 4, character: 3 }, @@ -383,6 +406,7 @@ test('find class lists in tagged template literals', async ({ expect }) => { }, { classList: 'text-white', + span: [92, 102], range: { start: { line: 4, character: 24 }, end: { line: 4, character: 34 }, @@ -390,6 +414,7 @@ test('find class lists in tagged template literals', async ({ expect }) => { }, { classList: 'text-black', + span: [107, 117], range: { start: { line: 4, character: 39 }, end: { line: 4, character: 49 }, @@ -399,6 +424,7 @@ test('find class lists in tagged template literals', async ({ expect }) => { // from cva`…` { classList: 'flex p-4\n block sm:p-0\n $', + span: [166, 193], range: { start: { line: 9, character: 2 }, end: { line: 11, character: 3 }, @@ -406,6 +432,7 @@ test('find class lists in tagged template literals', async ({ expect }) => { }, { classList: 'text-white', + span: [214, 224], range: { start: { line: 11, character: 24 }, end: { line: 11, character: 34 }, @@ -413,6 +440,7 @@ test('find class lists in tagged template literals', async ({ expect }) => { }, { classList: 'text-black', + span: [229, 239], range: { start: { line: 11, character: 39 }, end: { line: 11, character: 49 }, @@ -424,7 +452,7 @@ test('find class lists in tagged template literals', async ({ expect }) => { expect(classListsB).toEqual([]) }) -test('classFunctions can be a regex', async ({ expect }) => { +test('classFunctions can be a regex', async () => { let fileA = createDocument({ name: 'file.jsx', lang: 'javascriptreact', @@ -457,6 +485,7 @@ test('classFunctions can be a regex', async ({ expect }) => { expect(classListsA).toEqual([ { classList: 'flex p-4', + span: [22, 30], range: { start: { line: 0, character: 22 }, end: { line: 0, character: 30 }, @@ -468,7 +497,7 @@ test('classFunctions can be a regex', async ({ expect }) => { expect(classListsB).toEqual([]) }) -test('classFunctions regexes only match on function names', async ({ expect }) => { +test('classFunctions regexes only match on function names', async () => { let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', @@ -489,7 +518,7 @@ test('classFunctions regexes only match on function names', async ({ expect }) = expect(classLists).toEqual([]) }) -test('Finds consecutive instances of a class function', async ({ expect }) => { +test('Finds consecutive instances of a class function', async () => { let file = createDocument({ name: 'file.js', lang: 'javascript', @@ -512,6 +541,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => { expect(classLists).toEqual([ { classList: 'relative flex bg-red-500', + span: [28, 52], range: { start: { line: 1, character: 6 }, end: { line: 1, character: 30 }, @@ -519,6 +549,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => { }, { classList: 'relative flex bg-red-500', + span: [62, 86], range: { start: { line: 2, character: 6 }, end: { line: 2, character: 30 }, @@ -526,6 +557,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => { }, { classList: 'relative flex bg-red-500', + span: [96, 120], range: { start: { line: 3, character: 6 }, end: { line: 3, character: 30 }, @@ -534,7 +566,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => { ]) }) -test('classFunctions & classAttributes should not duplicate matches', async ({ expect }) => { +test('classFunctions & classAttributes should not duplicate matches', async () => { let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', @@ -575,6 +607,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e expect(classLists).toEqual([ { classList: 'relative flex', + span: [74, 87], range: { start: { line: 3, character: 7 }, end: { line: 3, character: 20 }, @@ -582,6 +615,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e }, { classList: 'inset-0 md:h-[calc(100%-2rem)]', + span: [97, 127], range: { start: { line: 4, character: 7 }, end: { line: 4, character: 37 }, @@ -589,6 +623,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e }, { classList: 'rounded-none bg-blue-700', + span: [142, 166], range: { start: { line: 5, character: 12 }, end: { line: 5, character: 36 }, @@ -596,6 +631,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e }, { classList: 'relative flex', + span: [294, 307], range: { start: { line: 14, character: 7 }, end: { line: 14, character: 20 }, @@ -603,6 +639,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e }, { classList: 'inset-0 md:h-[calc(100%-2rem)]', + span: [317, 347], range: { start: { line: 15, character: 7 }, end: { line: 15, character: 37 }, @@ -610,6 +647,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e }, { classList: 'rounded-none bg-blue-700', + span: [362, 386], range: { start: { line: 16, character: 12 }, end: { line: 16, character: 36 }, @@ -618,7 +656,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e ]) }) -test('classFunctions should only match in JS-like contexts', async ({ expect }) => { +test('classFunctions should only match in JS-like contexts', async () => { let file = createDocument({ name: 'file.html', lang: 'html', @@ -654,6 +692,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect }) expect(classLists).toEqual([ { classList: 'relative flex', + span: [130, 143], range: { start: { line: 5, character: 16 }, end: { line: 5, character: 29 }, @@ -661,6 +700,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect }) }, { classList: 'relative flex', + span: [162, 175], range: { start: { line: 6, character: 16 }, end: { line: 6, character: 29 }, @@ -668,6 +708,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect }) }, { classList: 'relative flex', + span: [325, 338], range: { start: { line: 14, character: 16 }, end: { line: 14, character: 29 }, @@ -675,6 +716,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect }) }, { classList: 'relative flex', + span: [357, 370], range: { start: { line: 15, character: 16 }, end: { line: 15, character: 29 }, @@ -683,7 +725,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect }) ]) }) -test('classAttributes find class lists inside variables in JS(X)/TS(X)', async ({ expect }) => { +test('classAttributes find class lists inside variables in JS(X)/TS(X)', async () => { let file = createDocument({ name: 'file.html', lang: 'javascript', @@ -714,6 +756,7 @@ test('classAttributes find class lists inside variables in JS(X)/TS(X)', async ( expect(classLists).toEqual([ { classList: 'relative flex', + span: [24, 37], range: { start: { line: 1, character: 6 }, end: { line: 1, character: 19 }, @@ -721,6 +764,7 @@ test('classAttributes find class lists inside variables in JS(X)/TS(X)', async ( }, { classList: 'relative flex', + span: [60, 73], range: { start: { line: 3, character: 8 }, end: { line: 3, character: 21 }, @@ -728,6 +772,7 @@ test('classAttributes find class lists inside variables in JS(X)/TS(X)', async ( }, { classList: 'relative flex', + span: [102, 115], range: { start: { line: 6, character: 8 }, end: { line: 6, character: 21 }, @@ -736,7 +781,7 @@ test('classAttributes find class lists inside variables in JS(X)/TS(X)', async ( ]) }) -test('classAttributes find class lists inside pug', async ({ expect }) => { +test('classAttributes find class lists inside pug', async () => { let file = createDocument({ name: 'file.pug', lang: 'pug', @@ -755,6 +800,7 @@ test('classAttributes find class lists inside pug', async ({ expect }) => { expect(classLists).toEqual([ { classList: 'relative flex', + span: [15, 28], range: { start: { line: 0, character: 15 }, end: { line: 0, character: 28 }, @@ -763,7 +809,7 @@ test('classAttributes find class lists inside pug', async ({ expect }) => { ]) }) -test('classAttributes find class lists inside Vue bindings', async ({ expect }) => { +test('classAttributes find class lists inside Vue bindings', async () => { let file = createDocument({ name: 'file.pug', lang: 'vue', @@ -784,6 +830,7 @@ test('classAttributes find class lists inside Vue bindings', async ({ expect }) expect(classLists).toEqual([ { classList: 'relative flex', + span: [28, 41], range: { start: { line: 1, character: 17 }, end: { line: 1, character: 30 }, diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 9118403da..0fd6d25db 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -33,7 +33,7 @@ export function findLast(re: RegExp, str: string): RegExpMatchArray { } export function getClassNamesInClassList( - { classList, range, important }: DocumentClassList, + { classList, span, range, important }: DocumentClassList, blocklist: State['blocklist'], ): DocumentClassName[] { const parts = classList.split(/(\s+)/) @@ -41,13 +41,16 @@ export function getClassNamesInClassList( let index = 0 for (let i = 0; i < parts.length; i++) { if (i % 2 === 0 && !blocklist.includes(parts[i])) { + const classNameSpan = [index, index + parts[i].length] const start = indexToPosition(classList, index) const end = indexToPosition(classList, index + parts[i].length) names.push({ className: parts[i], + span: [span[0] + classNameSpan[0], span[0] + classNameSpan[1]], classList: { classList, range, + span, important, }, relativeRange: { @@ -107,11 +110,19 @@ export function findClassListsInCssRange( const matches = findAll(regex, text) const globalStart: Position = range ? range.start : { line: 0, character: 0 } + const rangeStartOffset = doc.offsetAt(globalStart) + return matches.map((match) => { - const start = indexToPosition(text, match.index + match[1].length) - const end = indexToPosition(text, match.index + match[1].length + match.groups.classList.length) + let span = [ + match.index + match[1].length, + match.index + match[1].length + match.groups.classList.length, + ] as [number, number] + + const start = indexToPosition(text, span[0]) + const end = indexToPosition(text, span[1]) return { classList: match.groups.classList, + span: [rangeStartOffset + span[0], rangeStartOffset + span[1]], important: Boolean(match.groups.important), range: { start: { @@ -127,7 +138,7 @@ export function findClassListsInCssRange( }) } -async function findCustomClassLists( +export async function findCustomClassLists( state: State, doc: TextDocument, range?: Range, @@ -143,6 +154,7 @@ async function findCustomClassLists( for (let match of customClassesIn({ text, filters: regexes })) { result.push({ classList: match.classList, + span: match.range, range: { start: doc.positionAt(match.range[0]), end: doc.positionAt(match.range[1]), @@ -225,6 +237,8 @@ export async function findClassListsInHtmlRange( const existingResultSet = new Set() const results: DocumentClassList[] = [] + const rangeStartOffset = doc.offsetAt(range?.start || { line: 0, character: 0 }) + matches.forEach((match) => { const subtext = text.substr(match.index + match[0].length - 1) @@ -278,13 +292,16 @@ export async function findClassListsInHtmlRange( const after = value.match(/\s*$/) const afterOffset = after === null ? 0 : -after[0].length - const start = indexToPosition(text, match.index + match[0].length - 1 + offset + beforeOffset) - const end = indexToPosition( - text, + let span = [ + match.index + match[0].length - 1 + offset + beforeOffset, match.index + match[0].length - 1 + offset + value.length + afterOffset, - ) + ] + + const start = indexToPosition(text, span[0]) + const end = indexToPosition(text, span[1]) const result: DocumentClassList = { + span: [rangeStartOffset + span[0], rangeStartOffset + span[1]] as [number, number], classList: value.substr(beforeOffset, value.length + afterOffset), range: { start: { @@ -409,6 +426,8 @@ export function findHelperFunctionsInRange( text, ) + let rangeStartOffset = range?.start ? doc.offsetAt(range.start) : 0 + // Eliminate matches that are on an `@import` matches = matches.filter((match) => { // Scan backwards to see if we're in an `@import` statement @@ -477,6 +496,16 @@ export function findHelperFunctionsInRange( range, ), }, + spans: { + full: [ + rangeStartOffset + startIndex, + rangeStartOffset + startIndex + match.groups.path.length, + ], + path: [ + rangeStartOffset + startIndex + quotesBefore.length, + rangeStartOffset + startIndex + quotesBefore.length + path.length, + ], + }, } }) } diff --git a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts index 42a4a4950..f794d2b4a 100644 --- a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts +++ b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts @@ -4,7 +4,7 @@ import { isVueDoc, isHtmlDoc, isSvelteDoc } from './html' import type { State } from './state' import { indexToPosition } from './find' import { isJsDoc } from './js' -import moo from 'moo' +import moo, { type Rules } from 'moo' import Cache from 'tmp-cache' import { getTextWithoutComments } from './doc' import { isCssLanguage } from './css' @@ -12,6 +12,7 @@ import { isCssLanguage } from './css' export type LanguageBoundary = { type: 'html' | 'js' | 'jsx' | 'css' | (string & {}) range: Range + span: [number, number] lang?: string } @@ -29,9 +30,11 @@ let jsxScriptTypes = [ 'text/babel', ] +type States = { [x: string]: Rules } + let text = { text: { match: /[^]/, lineBreaks: true } } -let states = { +let states: States = { main: { cssBlockStart: { match: /\s])/, push: 'cssBlock' }, jsBlockStart: { match: ' { - if (isCssDoc(state, document)) { - yield { - document, - range: undefined, - lang: document.languageId, - get text() { - return getTextWithoutComments(document, 'css') - }, - } - } else { - let boundaries = getLanguageBoundaries(state, document) - if (!boundaries) return [] +export function getDocumentBlocks(state: State, doc: TextDocument): LanguageBlock[] { + let text = doc.getText() - for (let boundary of boundaries) { - if (boundary.type !== 'css') continue + let boundaries = getLanguageBoundaries(state, doc, text) + if (boundaries && boundaries.length > 0) { + return boundaries.map((boundary) => { + let context: 'html' | 'js' | 'css' | 'other' + + if (boundary.type === 'html') { + context = 'html' + } else if (boundary.type === 'css') { + context = 'css' + } else if (boundary.type === 'js' || boundary.type === 'jsx') { + context = 'js' + } else { + context = 'other' + } - yield { - document, + let text = doc.getText(boundary.range) + + return { + context, range: boundary.range, - lang: boundary.lang ?? document.languageId, - get text() { - return getTextWithoutComments(document, 'css', boundary.range) - }, + span: boundary.span, + lang: boundary.lang ?? doc.languageId, + text: context === 'other' ? text : getTextWithoutComments(text, context), } - } + }) } + + // If we get here we most likely have non-HTML document in a single language + let context: 'html' | 'js' | 'css' | 'other' + + if (isHtmlDoc(state, doc)) { + context = 'html' + } else if (isCssDoc(state, doc)) { + context = 'css' + } else if (isJsDoc(state, doc)) { + context = 'js' + } else { + context = 'other' + } + + return [ + { + context, + range: { + start: doc.positionAt(0), + end: doc.positionAt(text.length), + }, + span: [0, text.length], + lang: doc.languageId, + text: context === 'other' ? text : getTextWithoutComments(text, context), + }, + ] +} + +export function getCssBlocks(state: State, document: TextDocument): LanguageBlock[] { + return getDocumentBlocks(state, document).filter((block) => block.context === 'css') } diff --git a/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts b/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts index 483a4ead7..72fc3cdee 100644 --- a/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts +++ b/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts @@ -1,8 +1,8 @@ -import { test } from 'vitest' +import { expect, test } from 'vitest' import { getLanguageBoundaries } from './getLanguageBoundaries' import { jsx, createDocument, html } from './test-utils' -test('regex literals are ignored when determining language boundaries', ({ expect }) => { +test('regex literals are ignored when determining language boundaries', () => { let file = createDocument({ name: 'file.js', lang: 'javascript', @@ -19,6 +19,7 @@ test('regex literals are ignored when determining language boundaries', ({ expec expect(boundaries).toEqual([ { type: 'jsx', + span: [0, 147], range: { start: { line: 0, character: 0 }, end: { line: 3, character: 1 }, @@ -27,7 +28,7 @@ test('regex literals are ignored when determining language boundaries', ({ expec ]) }) -test('style tags in HTML are treated as a separate boundary', ({ expect }) => { +test('style tags in HTML are treated as a separate boundary', () => { let file = createDocument({ name: 'file.html', lang: 'html', @@ -48,6 +49,7 @@ test('style tags in HTML are treated as a separate boundary', ({ expect }) => { expect(boundaries).toEqual([ { type: 'html', + span: [0, 8], range: { start: { line: 0, character: 0 }, end: { line: 1, character: 2 }, @@ -55,6 +57,7 @@ test('style tags in HTML are treated as a separate boundary', ({ expect }) => { }, { type: 'css', + span: [8, 64], range: { start: { line: 1, character: 2 }, end: { line: 5, character: 2 }, @@ -62,6 +65,7 @@ test('style tags in HTML are treated as a separate boundary', ({ expect }) => { }, { type: 'html', + span: [64, 117], range: { start: { line: 5, character: 2 }, end: { line: 7, character: 6 }, @@ -70,7 +74,7 @@ test('style tags in HTML are treated as a separate boundary', ({ expect }) => { ]) }) -test('script tags in HTML are treated as a separate boundary', ({ expect }) => { +test('script tags in HTML are treated as a separate boundary', () => { let file = createDocument({ name: 'file.html', lang: 'html', @@ -91,6 +95,7 @@ test('script tags in HTML are treated as a separate boundary', ({ expect }) => { expect(boundaries).toEqual([ { type: 'html', + span: [0, 8], range: { start: { line: 0, character: 0 }, end: { line: 1, character: 2 }, @@ -98,6 +103,7 @@ test('script tags in HTML are treated as a separate boundary', ({ expect }) => { }, { type: 'js', + span: [8, 67], range: { start: { line: 1, character: 2 }, end: { line: 5, character: 2 }, @@ -105,6 +111,7 @@ test('script tags in HTML are treated as a separate boundary', ({ expect }) => { }, { type: 'html', + span: [67, 121], range: { start: { line: 5, character: 2 }, end: { line: 7, character: 6 }, @@ -113,7 +120,7 @@ test('script tags in HTML are treated as a separate boundary', ({ expect }) => { ]) }) -test('Vue files detect