diff --git a/packages/tailwindcss-language-server/src/language/cssServer.ts b/packages/tailwindcss-language-server/src/language/cssServer.ts index 731eca3..587ea2c 100644 --- a/packages/tailwindcss-language-server/src/language/cssServer.ts +++ b/packages/tailwindcss-language-server/src/language/cssServer.ts @@ -393,7 +393,7 @@ async function validateTextDocument(textDocument: TextDocument): Promise { .filter((diagnostic) => { if ( diagnostic.code === 'unknownAtRules' && - /Unknown at rule @(tailwind|apply)/.test(diagnostic.message) + /Unknown at rule @(tailwind|apply|config)/.test(diagnostic.message) ) { return false } diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index a3b6131..d98ab34 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -27,6 +27,9 @@ import { FileChangeType, Disposable, TextDocumentIdentifier, + DocumentLinkRequest, + DocumentLinkParams, + DocumentLink, } from 'vscode-languageserver/node' import { TextDocument } from 'vscode-languageserver-textdocument' import { URI } from 'vscode-uri' @@ -68,6 +71,7 @@ import { } 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 'assert' @@ -188,6 +192,7 @@ interface ProjectService { onDocumentColor(params: DocumentColorParams): Promise onColorPresentation(params: ColorPresentationParams): Promise onCodeAction(params: CodeActionParams): Promise + onDocumentLinks(params: DocumentLinkParams): DocumentLink[] } type ProjectConfig = { folder: string; configPath?: string; documentSelector?: string[] } @@ -299,6 +304,27 @@ async function createProjectService( getDocumentSymbols: (uri: string) => { return connection.sendRequest('@/tailwindCSS/getDocumentSymbols', { uri }) }, + async readDirectory(document, directory) { + try { + directory = path.resolve(path.dirname(getFileFsPath(document.uri)), 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() + return (await isExcluded( + state, + document, + path.join(directory, dirent.name, isDirectory ? '/' : '') + )) + ? null + : [dirent.name, { isDirectory }] + }) + ) + return result.filter((item) => item !== null) + } catch { + return [] + } + }, }, } @@ -1028,6 +1054,14 @@ async function createProjectService( if (!settings.tailwindCSS.codeActions) return null return doCodeActions(state, params) }, + onDocumentLinks(params: DocumentLinkParams): DocumentLink[] { + if (!state.enabled) return null + let document = documentService.getDocument(params.textDocument.uri) + if (!document) return null + return getDocumentLinks(state, document, (linkPath) => + URI.file(path.resolve(path.dirname(URI.parse(document.uri).fsPath), linkPath)).toString() + ) + }, provideDiagnostics: debounce((document: TextDocument) => { if (!state.enabled) return provideDiagnostics(state, document) @@ -1485,6 +1519,7 @@ class TW { this.connection.onDocumentColor(this.onDocumentColor.bind(this)) this.connection.onColorPresentation(this.onColorPresentation.bind(this)) this.connection.onCodeAction(this.onCodeAction.bind(this)) + this.connection.onDocumentLinks(this.onDocumentLinks.bind(this)) } private updateCapabilities() { @@ -1499,6 +1534,7 @@ class TW { capabilities.add(HoverRequest.type, { documentSelector: null }) capabilities.add(DocumentColorRequest.type, { documentSelector: null }) capabilities.add(CodeActionRequest.type, { documentSelector: null }) + capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) capabilities.add(CompletionRequest.type, { documentSelector: null, @@ -1564,6 +1600,10 @@ class TW { return this.getProject(params.textDocument)?.onCodeAction(params) ?? null } + onDocumentLinks(params: DocumentLinkParams): DocumentLink[] { + return this.getProject(params.textDocument)?.onDocumentLinks(params) ?? null + } + listen() { this.connection.listen() } @@ -1605,7 +1645,8 @@ function supportsDynamicRegistration(connection: Connection, params: InitializeP params.capabilities.textDocument.hover?.dynamicRegistration && params.capabilities.textDocument.colorProvider?.dynamicRegistration && params.capabilities.textDocument.codeAction?.dynamicRegistration && - params.capabilities.textDocument.completion?.dynamicRegistration + params.capabilities.textDocument.completion?.dynamicRegistration && + params.capabilities.textDocument.documentLink?.dynamicRegistration ) } @@ -1630,6 +1671,7 @@ connection.onInitialize(async (params: InitializeParams): Promise { +export default async function isExcluded( + state: State, + document: TextDocument, + file: string = getFileFsPath(document.uri) +): Promise { let settings = await state.editor.getConfiguration(document.uri) - let file = getFileFsPath(document.uri) for (let pattern of settings.tailwindCSS.files.exclude) { if (minimatch(file, path.join(state.editor.folder, pattern))) { diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 86f9fb7..941554f 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -994,6 +994,20 @@ function provideCssDirectiveCompletions( }, }, ]), + ...(semver.gte(state.version, '3.2.0') + ? [ + { + label: '@config', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#config' + )})`, + }, + }, + ] + : []), ] return { @@ -1016,6 +1030,52 @@ function provideCssDirectiveCompletions( } } +async function provideConfigDirectiveCompletions( + state: State, + document: TextDocument, + position: Position +): Promise { + if (!isCssContext(state, document, position)) { + return null + } + + if (!semver.gte(state.version, '3.2.0')) { + return null + } + + let text = document.getText({ start: { line: position.line, character: 0 }, end: position }) + let match = text.match(/@config\s*(?'[^']*|"[^"]*)$/) + if (!match) { + return null + } + let partial = match.groups.partial.slice(1) // remove quote + let valueBeforeLastSlash = partial.substring(0, partial.lastIndexOf('/')) + let valueAfterLastSlash = partial.substring(partial.lastIndexOf('/') + 1) + + return { + isIncomplete: false, + items: (await state.editor.readDirectory(document, valueBeforeLastSlash || '.')) + .filter(([name, type]) => type.isDirectory || /\.c?js$/.test(name)) + .map(([name, type]) => ({ + label: type.isDirectory ? name + '/' : name, + kind: type.isDirectory ? 19 : 17, + textEdit: { + newText: type.isDirectory ? name + '/' : name, + range: { + start: { + line: position.line, + character: position.character - valueAfterLastSlash.length, + }, + end: position, + }, + }, + command: type.isDirectory + ? { command: 'editor.action.triggerSuggest', title: '' } + : undefined, + })), + } +} + async function provideEmmetCompletions( state: State, document: TextDocument, @@ -1104,6 +1164,7 @@ export async function doComplete( provideVariantsDirectiveCompletions(state, document, position) || provideTailwindDirectiveCompletions(state, document, position) || provideLayerDirectiveCompletions(state, document, position) || + (await provideConfigDirectiveCompletions(state, document, position)) || (await provideCustomClassNameCompletions(state, document, position)) if (result) return result diff --git a/packages/tailwindcss-language-service/src/documentLinksProvider.ts b/packages/tailwindcss-language-service/src/documentLinksProvider.ts new file mode 100644 index 0000000..987acbf --- /dev/null +++ b/packages/tailwindcss-language-service/src/documentLinksProvider.ts @@ -0,0 +1,57 @@ +import { State } from './util/state' +import type { DocumentLink, Range, TextDocument } from 'vscode-languageserver' +import { isCssDoc } from './util/css' +import { getLanguageBoundaries } from './util/getLanguageBoundaries' +import { findAll, indexToPosition } from './util/find' +import { getTextWithoutComments } from './util/doc' +import { absoluteRange } from './util/absoluteRange' +import * as semver from './util/semver' + +export function getDocumentLinks( + state: State, + document: TextDocument, + resolveTarget: (linkPath: string) => string +): DocumentLink[] { + return getConfigDirectiveLinks(state, document, resolveTarget) +} + +function getConfigDirectiveLinks( + state: State, + document: TextDocument, + resolveTarget: (linkPath: string) => string +): DocumentLink[] { + if (!semver.gte(state.version, '3.2.0')) { + return [] + } + + let links: DocumentLink[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range)) + } + + for (let range of ranges) { + let text = getTextWithoutComments(document, 'css', range) + let matches = findAll(/@config\s*(?'[^']+'|"[^"]+")/g, text) + + for (let match of matches) { + links.push({ + target: resolveTarget(match.groups.path.slice(1, -1)), + range: absoluteRange( + { + start: indexToPosition(text, match.index + match[0].length - match.groups.path.length), + end: indexToPosition(text, match.index + match[0].length), + }, + range + ), + }) + } + } + + return links +} diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 3194643..cb86377 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -29,6 +29,10 @@ export type EditorState = { } getConfiguration: (uri?: string) => Promise getDocumentSymbols: (uri: string) => Promise + readDirectory: ( + document: TextDocument, + directory: string + ) => Promise> } type DiagnosticSeveritySetting = 'ignore' | 'warning' | 'error'