From e601610ce5397576a0bc257e61b1d54f78dd215f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 4 Sep 2024 12:31:04 -0400 Subject: [PATCH] Improve support for new v4 at rules (#1045) --- .../src/language/cssServer.ts | 2 +- .../src/projects.ts | 12 +- .../tests/completions/at-config.test.js | 215 +++++++++++++ .../tests/completions/completions.test.js | 30 ++ .../document-links/document-links.test.js | 91 ++++++ .../tests/fixtures/v4/dependencies/app.css | 1 + .../tests/fixtures/v4/dependencies/index.html | 1 + .../v4/dependencies/package-lock.json | 17 ++ .../fixtures/v4/dependencies/package.json | 5 + .../v4/dependencies/sub-dir/colors.js | 3 + .../v4/dependencies/tailwind.config.js | 9 + .../src/completionProvider.ts | 284 ++++++++++++------ .../src/documentLinksProvider.ts | 22 +- .../src/metadata/extensions.ts | 76 +++++ packages/vscode-tailwindcss/CHANGELOG.md | 2 +- .../syntaxes/at-rules.tmLanguage.json | 227 +++++++++++++- 16 files changed, 890 insertions(+), 107 deletions(-) create mode 100644 packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/app.css create mode 100644 packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/index.html create mode 100644 packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/package-lock.json create mode 100644 packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/package.json create mode 100644 packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/sub-dir/colors.js create mode 100644 packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/tailwind.config.js create mode 100644 packages/tailwindcss-language-service/src/metadata/extensions.ts diff --git a/packages/tailwindcss-language-server/src/language/cssServer.ts b/packages/tailwindcss-language-server/src/language/cssServer.ts index 6c47a727..11caad45 100644 --- a/packages/tailwindcss-language-server/src/language/cssServer.ts +++ b/packages/tailwindcss-language-server/src/language/cssServer.ts @@ -381,7 +381,7 @@ async function validateTextDocument(textDocument: TextDocument): Promise { .filter((diagnostic) => { if ( diagnostic.code === 'unknownAtRules' && - /Unknown at rule @(tailwind|apply|config|theme|plugin|source)/.test(diagnostic.message) + /Unknown at rule @(tailwind|apply|config|theme|plugin|source|utility|variant)/.test(diagnostic.message) ) { return false } diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index 09d4196a..3e53d7df 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -223,18 +223,22 @@ export async function createProjectService( 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( + let shouldRemove = await isExcluded( state, document, path.join(directory, dirent.name, isDirectory ? '/' : ''), - )) - ? null - : [dirent.name, { isDirectory }] + ) + + if (shouldRemove) return null + + return [dirent.name, { isDirectory }] }), ) + return result.filter((item) => item !== null) } catch { return [] diff --git a/packages/tailwindcss-language-server/tests/completions/at-config.test.js b/packages/tailwindcss-language-server/tests/completions/at-config.test.js index 5c79bfca..b97d1873 100644 --- a/packages/tailwindcss-language-server/tests/completions/at-config.test.js +++ b/packages/tailwindcss-language-server/tests/completions/at-config.test.js @@ -82,3 +82,218 @@ withFixture('dependencies', (c) => { }) }) }) + +withFixture('v4/dependencies', (c) => { + async function completion({ + lang, + text, + position, + context = { + triggerKind: 1, + }, + settings, + }) { + let textDocument = await c.openDocument({ text, lang, settings }) + + return c.sendRequest('textDocument/completion', { + textDocument, + position, + context, + }) + } + + test.concurrent('@config', async ({ expect }) => { + let result = await completion({ + text: '@config "', + lang: 'css', + position: { + line: 0, + character: 9, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'sub-dir/', + kind: 19, + command: { command: 'editor.action.triggerSuggest', title: '' }, + data: expect.anything(), + textEdit: { + newText: 'sub-dir/', + range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } }, + }, + }, + { + label: 'tailwind.config.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'tailwind.config.js', + range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } }, + }, + }, + ], + }) + }) + + test.concurrent('@config directory', async ({ expect }) => { + let result = await completion({ + text: '@config "./sub-dir/', + lang: 'css', + position: { + line: 0, + character: 19, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'colors.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'colors.js', + range: { start: { line: 0, character: 19 }, end: { line: 0, character: 19 } }, + }, + }, + ], + }) + }) + + test.concurrent('@plugin', async ({ expect }) => { + let result = await completion({ + text: '@plugin "', + lang: 'css', + position: { + line: 0, + character: 9, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'sub-dir/', + kind: 19, + command: { command: 'editor.action.triggerSuggest', title: '' }, + data: expect.anything(), + textEdit: { + newText: 'sub-dir/', + range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } }, + }, + }, + { + label: 'tailwind.config.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'tailwind.config.js', + range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } }, + }, + }, + ], + }) + }) + + test.concurrent('@plugin directory', async ({ expect }) => { + let result = await completion({ + text: '@plugin "./sub-dir/', + lang: 'css', + position: { + line: 0, + character: 19, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'colors.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'colors.js', + range: { start: { line: 0, character: 19 }, end: { line: 0, character: 19 } }, + }, + }, + ], + }) + }) + + test.concurrent('@source', async ({ expect }) => { + let result = await completion({ + text: '@source "', + lang: 'css', + position: { + line: 0, + character: 9, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'index.html', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'index.html', + range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } }, + }, + }, + { + label: 'sub-dir/', + kind: 19, + command: { command: 'editor.action.triggerSuggest', title: '' }, + data: expect.anything(), + textEdit: { + newText: 'sub-dir/', + range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } }, + }, + }, + { + label: 'tailwind.config.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'tailwind.config.js', + range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } }, + }, + }, + ], + }) + }) + + test.concurrent('@source directory', async ({ expect }) => { + let result = await completion({ + text: '@source "./sub-dir/', + lang: 'css', + position: { + line: 0, + character: 19, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'colors.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'colors.js', + range: { start: { line: 0, character: 19 }, end: { line: 0, character: 19 } }, + }, + }, + ], + }) + }) +}) diff --git a/packages/tailwindcss-language-server/tests/completions/completions.test.js b/packages/tailwindcss-language-server/tests/completions/completions.test.js index 3a8b16f4..5e7d8e98 100644 --- a/packages/tailwindcss-language-server/tests/completions/completions.test.js +++ b/packages/tailwindcss-language-server/tests/completions/completions.test.js @@ -477,6 +477,36 @@ withFixture('v4/basic', (c) => { expect(result.items.filter((item) => item.label.startsWith('--')).length).toBe(23) }) + test.concurrent('@slot is suggeted inside @variant', async ({ expect }) => { + let result = await completion({ + lang: 'css', + text: '@', + position: { line: 0, character: 1 }, + }) + + // Make sure `@slot` is NOT suggested by default + expect(result.items.length).toBe(10) + expect(result.items).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 14, label: '@slot', sortText: '-0000000' }), + ]), + ) + + result = await completion({ + lang: 'css', + text: '@variant foo {\n@', + position: { line: 1, character: 1 }, + }) + + // Make sure `@slot` is suggested + expect(result.items.length).toBe(11) + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 14, label: '@slot', sortText: '-0000000' }), + ]), + ) + }) + test.concurrent('resolve', async ({ expect }) => { let result = await completion({ text: '
', diff --git a/packages/tailwindcss-language-server/tests/document-links/document-links.test.js b/packages/tailwindcss-language-server/tests/document-links/document-links.test.js index d910d72a..595eeec2 100644 --- a/packages/tailwindcss-language-server/tests/document-links/document-links.test.js +++ b/packages/tailwindcss-language-server/tests/document-links/document-links.test.js @@ -40,3 +40,94 @@ withFixture('basic', (c) => { ], }) }) + +withFixture('v4/basic', (c) => { + async function testDocumentLinks(name, { text, lang, expected }) { + test.concurrent(name, async ({ expect }) => { + let textDocument = await c.openDocument({ text, lang }) + let res = await c.sendRequest('textDocument/documentLink', { + textDocument, + }) + + expect(res).toEqual(expected) + }) + } + + testDocumentLinks('config: file exists', { + text: '@config "tailwind.config.js";', + lang: 'css', + expected: [ + { + target: `file://${path + .resolve('./tests/fixtures/v4/basic/tailwind.config.js') + .replace(/@/g, '%40')}`, + range: { start: { line: 0, character: 8 }, end: { line: 0, character: 28 } }, + }, + ], + }) + + testDocumentLinks('config: file does not exist', { + text: '@config "does-not-exist.js";', + lang: 'css', + expected: [ + { + target: `file://${path + .resolve('./tests/fixtures/v4/basic/does-not-exist.js') + .replace(/@/g, '%40')}`, + range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, + }, + ], + }) + + testDocumentLinks('plugin: file exists', { + text: '@plugin "plugin.js";', + lang: 'css', + expected: [ + { + target: `file://${path + .resolve('./tests/fixtures/v4/basic/plugin.js') + .replace(/@/g, '%40')}`, + range: { start: { line: 0, character: 8 }, end: { line: 0, character: 19 } }, + }, + ], + }) + + testDocumentLinks('plugin: file does not exist', { + text: '@plugin "does-not-exist.js";', + lang: 'css', + expected: [ + { + target: `file://${path + .resolve('./tests/fixtures/v4/basic/does-not-exist.js') + .replace(/@/g, '%40')}`, + range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, + }, + ], + }) + + testDocumentLinks('source: file exists', { + text: '@source "index.html";', + lang: 'css', + expected: [ + { + target: `file://${path + .resolve('./tests/fixtures/v4/basic/index.html') + .replace(/@/g, '%40')}`, + range: { start: { line: 0, character: 8 }, end: { line: 0, character: 20 } }, + }, + ], + }) + + testDocumentLinks('source: file does not exist', { + text: '@source "does-not-exist.html";', + lang: 'css', + expected: [ + { + target: `file://${path + .resolve('./tests/fixtures/v4/basic/does-not-exist.html') + .replace(/@/g, '%40')}`, + range: { start: { line: 0, character: 8 }, end: { line: 0, character: 29 } }, + }, + ], + }) +}) diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/app.css b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/app.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/index.html b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/index.html new file mode 100644 index 00000000..55a9e4dd --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/index.html @@ -0,0 +1 @@ +
foo
diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/package-lock.json b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/package-lock.json new file mode 100644 index 00000000..ab22fceb --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/package-lock.json @@ -0,0 +1,17 @@ +{ + "name": "dependencies", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "tailwindcss": "^4.0.0-alpha.21" + } + }, + "node_modules/tailwindcss": { + "version": "4.0.0-alpha.21", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0-alpha.21.tgz", + "integrity": "sha512-Ps3oqLhvjPt/XSm4dx4ky4sYkCSMHfn7JWattsEQyFkbLYo77t4/FOnRuQzjATLCMQz10e/HbZYjosSD/pBfSg==" + } + } +} diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/package.json b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/package.json new file mode 100644 index 00000000..f6f1f85e --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "tailwindcss": "^4.0.0-alpha.21" + } +} diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/sub-dir/colors.js b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/sub-dir/colors.js new file mode 100644 index 00000000..45baae14 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/sub-dir/colors.js @@ -0,0 +1,3 @@ +module.exports = { + foo: 'red', +} diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/tailwind.config.js b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/tailwind.config.js new file mode 100644 index 00000000..d37dff6c --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/tailwind.config.js @@ -0,0 +1,9 @@ +const colors = require('./sub-dir/colors') + +module.exports = { + theme: { + extend: { + colors, + }, + }, +} diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 680bc63e..a8f1f21a 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -37,6 +37,7 @@ import { addPixelEquivalentsToValue, } from './util/pixelEquivalents' import { customClassesIn } from './util/classes' +import { IS_SCRIPT_SOURCE, IS_TEMPLATE_SOURCE } from './metadata/extensions' import * as postcss from 'postcss' let isUtil = (className) => @@ -1396,106 +1397,167 @@ function provideCssDirectiveCompletions( if (match === null) return null - const items: CompletionItem[] = [ - { - label: '@tailwind', + let items: CompletionItem[] = [] + + items.push({ + label: '@tailwind', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@tailwind\` directive to insert Tailwind’s \`base\`, \`components\`, \`utilities\` and \`${ + state.jit && semver.gte(state.version, '2.1.99') ? 'variants' : 'screens' + }\` styles into your CSS.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#tailwind', + )})`, + }, + }) + + items.push({ + label: '@screen', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `The \`@screen\` directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#screen', + )})`, + }, + }) + + items.push({ + label: '@apply', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use \`@apply\` to inline any existing utility classes into your own custom CSS.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#apply', + )})`, + }, + }) + + if (semver.gte(state.version, '1.8.0')) { + items.push({ + label: '@layer', documentation: { kind: 'markdown' as typeof MarkupKind.Markdown, - value: `Use the \`@tailwind\` directive to insert Tailwind’s \`base\`, \`components\`, \`utilities\` and \`${ - state.jit && semver.gte(state.version, '2.1.99') ? 'variants' : 'screens' - }\` styles into your CSS.\n\n[Tailwind CSS Documentation](${docsUrl( + value: `Use the \`@layer\` directive to tell Tailwind which "bucket" a set of custom styles belong to. Valid layers are \`base\`, \`components\`, and \`utilities\`.\n\n[Tailwind CSS Documentation](${docsUrl( state.version, - 'functions-and-directives/#tailwind', + 'functions-and-directives/#layer', )})`, }, - }, - { - label: '@screen', + }) + } + + if (semver.gte(state.version, '2.99.0')) { + // + } else { + items.push({ + label: '@variants', documentation: { kind: 'markdown' as typeof MarkupKind.Markdown, - value: `The \`@screen\` directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.\n\n[Tailwind CSS Documentation](${docsUrl( + value: `You can generate \`responsive\`, \`hover\`, \`focus\`, \`active\`, and other variants of your own utilities by wrapping their definitions in the \`@variants\` directive.\n\n[Tailwind CSS Documentation](${docsUrl( state.version, - 'functions-and-directives/#screen', + 'functions-and-directives/#variants', )})`, }, - }, - { - label: '@apply', + }) + items.push({ + label: '@responsive', documentation: { kind: 'markdown' as typeof MarkupKind.Markdown, - value: `Use \`@apply\` to inline any existing utility classes into your own custom CSS.\n\n[Tailwind CSS Documentation](${docsUrl( + value: `You can generate responsive variants of your own classes by wrapping their definitions in the \`@responsive\` directive.\n\n[Tailwind CSS Documentation](${docsUrl( state.version, - 'functions-and-directives/#apply', + 'functions-and-directives/#responsive', )})`, }, - }, - ...(semver.gte(state.version, '1.8.0') - ? [ - { - label: '@layer', - documentation: { - kind: 'markdown' as typeof MarkupKind.Markdown, - value: `Use the \`@layer\` directive to tell Tailwind which "bucket" a set of custom styles belong to. Valid layers are \`base\`, \`components\`, and \`utilities\`.\n\n[Tailwind CSS Documentation](${docsUrl( - state.version, - 'functions-and-directives/#layer', - )})`, - }, - }, - ] - : []), - ...(semver.gte(state.version, '2.99.0') - ? [] - : [ - { - label: '@variants', - documentation: { - kind: 'markdown' as typeof MarkupKind.Markdown, - value: `You can generate \`responsive\`, \`hover\`, \`focus\`, \`active\`, and other variants of your own utilities by wrapping their definitions in the \`@variants\` directive.\n\n[Tailwind CSS Documentation](${docsUrl( - state.version, - 'functions-and-directives/#variants', - )})`, - }, - }, - { - label: '@responsive', - documentation: { - kind: 'markdown' as typeof MarkupKind.Markdown, - value: `You can generate responsive variants of your own classes by wrapping their definitions in the \`@responsive\` directive.\n\n[Tailwind CSS Documentation](${docsUrl( - state.version, - 'functions-and-directives/#responsive', - )})`, - }, - }, - ]), - ...(semver.gte(state.version, '3.2.0') - ? [ - { - label: '@config', - documentation: { - kind: 'markdown' as typeof MarkupKind.Markdown, - value: `Use the \`@config\` directive to specify which config file Tailwind should use when compiling that CSS file.\n\n[Tailwind CSS Documentation](${docsUrl( - state.version, - 'functions-and-directives/#config', - )})`, - }, - }, - ] - : []), - ...(semver.gte(state.version, '4.0.0') - ? [ - { - label: '@theme', - documentation: { - kind: 'markdown' as typeof MarkupKind.Markdown, - value: `Use the \`@theme\` directive to specify which config file Tailwind should use when compiling that CSS file.\n\n[Tailwind CSS Documentation](${docsUrl( - state.version, - 'functions-and-directives/#config', - )})`, - }, - }, - ] - : []), - ] + }) + } + + if (semver.gte(state.version, '3.2.0')) { + items.push({ + label: '@config', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@config\` directive to specify which config file Tailwind should use when compiling that CSS file.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#config', + )})`, + }, + }) + } + + if (state.v4) { + items.push({ + label: '@theme', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@theme\` directive to specify which config file Tailwind should use when compiling that CSS file.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#config', + )})`, + }, + }) + + items.push({ + label: '@utility', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@utility\` directive to define a custom utility.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#utility', + )})`, + }, + }) + + items.push({ + label: '@variant', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@variant\` directive to define a custom variant or override an existing one.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#variant', + )})`, + }, + }) + + items.push({ + label: '@source', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@source\` directive to scan additional files for classes.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#source', + )})`, + }, + }) + + items.push({ + label: '@plugin', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@plugin\` directive to include a JS plugin in your Tailwind CSS build.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#source', + )})`, + }, + }) + + // If we're inside an @variant directive, also add `@slot` + if (isInsideAtRule('variant', document, position)) { + items.push({ + label: '@slot', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@slot\` directive to define where rules go in a custom variant.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#slot', + )})`, + }, + + // Make sure this appears as the first at-rule + sortText: '-0000000', + }) + } + } return withDefaults( { @@ -1522,7 +1584,23 @@ function provideCssDirectiveCompletions( ) } -async function provideConfigDirectiveCompletions( +function isInsideAtRule(name: string, document: TextDocument, position: Position) { + // 1. Get all text up to the current position + let text = document.getText({ + start: { line: 0, character: 0 }, + end: position, + }) + + // 2. Find the last instance of the at-rule + let block = text.lastIndexOf(`@${name}`) + if (block === -1) return false + + // 4. Count the number of open and close braces following the rule to determine if we're inside it + return braceLevel(text.slice(block)) > 0 +} + +// Provide completions for directives that take file paths +async function provideFileDirectiveCompletions( state: State, document: TextDocument, position: Position, @@ -1535,27 +1613,39 @@ async function provideConfigDirectiveCompletions( return null } + let pattern = state.v4 + ? /@(?config|plugin|source)\s*(?'[^']*|"[^"]*)$/ + : /@(?config)\s*(?'[^']*|"[^"]*)$/ + let text = document.getText({ start: { line: position.line, character: 0 }, end: position }) - let match = text.match(/@config\s*(?'[^']*|"[^"]*)$/) + let match = text.match(pattern) if (!match) { return null } + let directive = match.groups.directive let partial = match.groups.partial.slice(1) // remove quote let valueBeforeLastSlash = partial.substring(0, partial.lastIndexOf('/')) let valueAfterLastSlash = partial.substring(partial.lastIndexOf('/') + 1) + let entries = await state.editor.readDirectory(document, valueBeforeLastSlash || '.') + + let isAllowedFile = directive === 'source' ? IS_TEMPLATE_SOURCE : IS_SCRIPT_SOURCE + + // Only show directories and JavaScript/TypeScript files + entries = entries.filter(([name, type]) => { + return type.isDirectory || isAllowedFile.test(name) + }) + return withDefaults( { 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, - command: type.isDirectory - ? { command: 'editor.action.triggerSuggest', title: '' } - : undefined, - })), + items: entries.map(([name, type]) => ({ + label: type.isDirectory ? name + '/' : name, + kind: type.isDirectory ? 19 : 17, + command: type.isDirectory + ? { command: 'editor.action.triggerSuggest', title: '' } + : undefined, + })), }, { data: { @@ -1667,7 +1757,7 @@ export async function doComplete( provideVariantsDirectiveCompletions(state, document, position) || provideTailwindDirectiveCompletions(state, document, position) || provideLayerDirectiveCompletions(state, document, position) || - (await provideConfigDirectiveCompletions(state, document, position)) || + (await provideFileDirectiveCompletions(state, document, position)) || (await provideCustomClassNameCompletions(state, document, position, context)) || provideThemeVariableCompletions(state, document, position, context) diff --git a/packages/tailwindcss-language-service/src/documentLinksProvider.ts b/packages/tailwindcss-language-service/src/documentLinksProvider.ts index 92b964f9..3dcc15bb 100644 --- a/packages/tailwindcss-language-service/src/documentLinksProvider.ts +++ b/packages/tailwindcss-language-service/src/documentLinksProvider.ts @@ -13,12 +13,24 @@ export function getDocumentLinks( document: TextDocument, resolveTarget: (linkPath: string) => string, ): DocumentLink[] { - return getConfigDirectiveLinks(state, document, resolveTarget) + let patterns = [ + /@config\s*(?'[^']+'|"[^"]+")/g, + ] + + if (state.v4) { + patterns.push( + /@plugin\s*(?'[^']+'|"[^"]+")/g, + /@source\s*(?'[^']+'|"[^"]+")/g, + ) + } + + return getDirectiveLinks(state, document, patterns, resolveTarget) } -function getConfigDirectiveLinks( +function getDirectiveLinks( state: State, document: TextDocument, + patterns: RegExp[], resolveTarget: (linkPath: string) => string, ): DocumentLink[] { if (!semver.gte(state.version, '3.2.0')) { @@ -38,7 +50,11 @@ function getConfigDirectiveLinks( for (let range of ranges) { let text = getTextWithoutComments(document, 'css', range) - let matches = findAll(/@config\s*(?'[^']+'|"[^"]+")/g, text) + let matches: RegExpMatchArray[] = [] + + for (let pattern of patterns) { + matches.push(...findAll(pattern, text)) + } for (let match of matches) { links.push({ diff --git a/packages/tailwindcss-language-service/src/metadata/extensions.ts b/packages/tailwindcss-language-service/src/metadata/extensions.ts new file mode 100644 index 00000000..dab890d5 --- /dev/null +++ b/packages/tailwindcss-language-service/src/metadata/extensions.ts @@ -0,0 +1,76 @@ +let scriptExtensions = [ + // JS + 'js', + 'cjs', + 'mjs', + 'ts', + 'mts', + 'cts', +] + +let templateExtensions = [ + // HTML + 'html', + 'pug', + + // Glimmer + 'gjs', + 'gts', + + // JS + 'astro', + 'cjs', + 'cts', + 'jade', + 'js', + 'jsx', + 'mjs', + 'mts', + 'svelte', + 'ts', + 'tsx', + 'vue', + + // Markdown + 'md', + 'mdx', + + // ASP + 'aspx', + 'razor', + + // Handlebars + 'handlebars', + 'hbs', + 'mustache', + + // PHP + 'php', + 'twig', + + // Ruby + 'erb', + 'haml', + 'liquid', + 'rb', + 'rhtml', + 'slim', + + // Elixir / Phoenix + 'eex', + 'heex', + + // Nunjucks + 'njk', + 'nunjucks', + + // Python + 'py', + 'tpl', + + // Rust + 'rs', +] + +export const IS_SCRIPT_SOURCE = new RegExp(`\\.(${scriptExtensions.join('|')})$`) +export const IS_TEMPLATE_SOURCE = new RegExp(`\\.(${templateExtensions.join('|')})$`) diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 01586dfe..59db0ece 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,7 @@ ## Prerelease -- Nothing yet! +- Improve support for new v4 at rules ([#1045](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1045)) ## 0.12.9 diff --git a/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json b/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json index a39ac96d..d50b1007 100644 --- a/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json +++ b/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json @@ -141,6 +141,92 @@ } ] }, + { + "begin": "(?i)((@)plugin)(?:\\s+|$|(?=['\"]|/\\*))", + "beginCaptures": { + "1": { + "name": "keyword.control.at-rule.plugin.tailwind" + }, + "2": { + "name": "punctuation.definition.keyword.tailwind" + } + }, + "end": ";", + "endCaptures": { + "0": { + "name": "punctuation.terminator.rule.css" + } + }, + "patterns": [ + { + "include": "source.css#string" + }, + { + "begin": "{", + "beginCaptures": { + "0": { + "name": "punctuation.section.plugin.begin.bracket.curly.tailwind" + } + }, + "end": "}", + "endCaptures": { + "0": { + "name": "punctuation.section.plugin.end.bracket.curly.tailwind" + } + }, + "name": "meta.at-rule.plugin.body.tailwind", + "patterns": [ + { + "include": "#property-list" + } + ] + } + ] + }, + { + "begin": "(?i)((@)source)(?:\\s+|$|(?=['\"]|/\\*))", + "beginCaptures": { + "1": { + "name": "keyword.control.at-rule.source.tailwind" + }, + "2": { + "name": "punctuation.definition.keyword.tailwind" + } + }, + "end": ";", + "endCaptures": { + "0": { + "name": "punctuation.terminator.rule.css" + } + }, + "patterns": [ + { + "include": "source.css#string" + } + ] + }, + { + "begin": "(?i)((@)config)(?:\\s+|$|(?=['\"]|/\\*))", + "beginCaptures": { + "1": { + "name": "keyword.control.at-rule.config.tailwind" + }, + "2": { + "name": "punctuation.definition.keyword.tailwind" + } + }, + "end": ";", + "endCaptures": { + "0": { + "name": "punctuation.terminator.rule.css" + } + }, + "patterns": [ + { + "include": "source.css#string" + } + ] + }, { "begin": "(?i)((@)variants)(?=[\\s{]|/\\*|$)", "beginCaptures": { @@ -185,6 +271,102 @@ } ] }, + { + "begin": "(?i)((@)utility)(?=[\\s{]|/\\*|$)", + "beginCaptures": { + "1": { + "name": "keyword.control.at-rule.utility.tailwind" + }, + "2": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": "(?<=})(?!\\G)", + "patterns": [ + { + "match": "[^\\s{,]+?", + "name": "variable.parameter.utility.tailwind" + }, + { + "begin": "{", + "beginCaptures": { + "0": { + "name": "punctuation.section.utility.begin.bracket.curly.tailwind" + } + }, + "end": "}", + "endCaptures": { + "0": { + "name": "punctuation.section.utility.end.bracket.curly.tailwind" + } + }, + "name": "meta.at-rule.utility.body.tailwind", + "patterns": [ + { + "include": "source.css#rule-list" + } + ] + } + ] + }, + { + "begin": "(?i)((@)variant)(?=[\\s{(]|$)", + "beginCaptures": { + "1": { + "name": "keyword.control.at-rule.variant.tailwind" + }, + "2": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": "(?<=[};])(?!\\G)", + "patterns": [ + { + "match": "[^\\s({;,]+?", + "name": "variable.parameter.variant.tailwind" + }, + { + "begin": "[(]", + "beginCaptures": { + "0": { + "name": "punctuation.section.variant.begin.bracket.paren.tailwind" + } + }, + "end": "[)]", + "endCaptures": { + "0": { + "name": "punctuation.section.variant.end.bracket.paren.tailwind" + } + }, + "name": "meta.selector.tailwind", + "patterns": [ + { + "include": "source.css#selector-innards" + } + ] + }, + { + "begin": "{", + "beginCaptures": { + "0": { + "name": "punctuation.section.variant.begin.bracket.curly.tailwind" + } + }, + "end": "}", + "endCaptures": { + "0": { + "name": "punctuation.section.variant.end.bracket.curly.tailwind" + } + }, + "name": "meta.at-rule.variant.body.tailwind", + "patterns": [ + { + "include": "source.css" + } + ] + } + ] + }, { "begin": "(?i)((@)responsive)(?=[\\s{]|/\\*|$)", "beginCaptures": { @@ -222,5 +404,48 @@ } ] } - ] + ], + "repository": { + "property-list": { + "patterns": [ + { + "begin": "(?