diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af79a008..2b25480a 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 75fb8733..25d81a19 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 04160569..2a71e961 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 20ac0158..5a4807c7 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 eccb1446..55fb35a7 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 4780a4fb..1bf3ad9a 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 da506bf1..d5706666 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 39cbb515..5561d773 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 c98280a1..9f15fbcc 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 15fcb457..5fbcb8ac 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 7e9da86b..b6a3b0f5 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 379f4199..69335887 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 8c08a518..7ee08d34 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 2a1e3079..84b79230 100644 --- a/packages/tailwindcss-language-service/package.json +++ b/packages/tailwindcss-language-service/package.json @@ -45,13 +45,18 @@ "@types/dedent": "^0.7.2", "@types/line-column": "^1.0.2", "@types/node": "^18.19.33", + "@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", "minimist": "^1.2.8", + "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 128426be..6ffb8d29 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 e80bb38f..144d69e6 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 7ebf79cb..4e1c6c77 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/colorPresentationProvider.ts b/packages/tailwindcss-language-service/src/colorPresentationProvider.ts new file mode 100644 index 00000000..d551345b --- /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/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts index 075cea38..d4dc2e35 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 cef0729f..f9603661 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 00000000..f1a31354 --- /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 00000000..904e09dc --- /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 00000000..fca75e09 --- /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 583cc80f..5e6675a0 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 b8ad02f6..5c5737df 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/service.ts b/packages/tailwindcss-language-service/src/service.ts new file mode 100644 index 00000000..e6fc04c2 --- /dev/null +++ b/packages/tailwindcss-language-service/src/service.ts @@ -0,0 +1,193 @@ +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 picomatch from 'picomatch' +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' + +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? + let exclusions = settings.tailwindCSS.files.exclude.map((pattern) => { + return picomatch(`${state.editor.folder}/${pattern}`) + }) + + for (let isExcluded of exclusions) { + if (isExcluded(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 9c982640..52379e34 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/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 839fb6d0..3befc1d4 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -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 }, @@ -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 }, @@ -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 @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 9118403d..0fd6d25d 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 42a4a495..f794d2b4 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 483a4ead..89da4416 100644 --- a/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts +++ b/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts @@ -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 }, @@ -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 }, @@ -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 }, @@ -140,6 +147,7 @@ test('Vue files detect