From 20e1448636779702c9b20c196b777504b75412e7 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 15 Nov 2024 14:10:56 -0500 Subject: [PATCH] =?UTF-8?q?Improve=20support=20for=20`source(=E2=80=A6)`?= =?UTF-8?q?=20feature=20in=20v4=20(#1083)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR mades a fair number of changes to improve the developer experience in v4. Most of it is related to `@source` and `source(…)` but there are some additional tweaks: - [x] Make sure `@import "…" source(…)` does not issue syntax warnings - [x] Make sure `@import "…" theme(…)` does not issue syntax warnings - [x] Make sure `@import "…" prefix(…)` does not issue syntax warnings - [x] Make sure `@tailwind utilities source(…)` isn't diagnosed as invalid - [x] Add suggestions for `@theme` options - [x] Add suggestions for `@import "…" theme(…)` options - [x] Directory auto-completion for `source(…)` - [x] Directory auto-completion for `@source "…"` - [x] Don’t suggest TypeScript declaration files for `@config`, `@plugin`, and `@source` - [x] In a new workspace creating/editing a CSS file should check if it's the language server needs to start. - [x] Show brace expansion when hovering `@source` - [x] Highlight `@import "…" source(…)` properly - [x] Highlight `@import "…" theme(…)` properly - [x] Highlight `@import "…" prefix(…)` properly - [x] Highlight `@tailwind utilities source(…)` properly - [x] Highlight uses of `source(none)` explicitly - [x] Link paths in valid uses of `source("…")` - [x] Don't link Windows-style paths in `@source`, `@config`, and `@plugin` - [x] Warn that `@source none` is invalid - [x] Highlight `@source none` as invalid (theme-dependent) - [x] Warn when `source(…)` is not passed anything - [x] Warn when `source(none)` is mispelled - [x] Warn when a v4 project uses `@tailwind base` or `@tailwind components` - [x] Warn when non-POSIX paths are passed to `@source` and `source(…)` - [x] Warn when `@tailwind base` is used in a v4 project - [x] Warn when `@tailwind preflight` is used in a v4 project - [x] Warn when `@tailwind components` is used in a v4 project - [x] Warn when `@tailwind screens` is used in a v4 project - [x] Warn when `@tailwind variants` is used in a v4 project I had some stretch goals but I don't think I'll get to them in this PR unless we think they're important enough to hold up the PR: - [ ] Warn when braces surround a single item in `@source` globs - [ ] Warn when unsupported glob syntax is used in `@source` - [ ] Warn when a v4 project uses `@import "tailwindcss/tailwind"` - [ ] Warn when a v4 project uses `@import "tailwindcss/tailwind.css"` - [ ] Highlight glob parts in `@source "…"` strings - [ ] Auto-complete `source(none)` when typing `source(…)` --------- Co-authored-by: Robin Malfait --- .../tailwindcss-language-server/src/config.ts | 1 + .../src/language/cssServer.ts | 20 ++- .../tests/completions/at-config.test.js | 86 ++++++++++ .../tests/completions/completions.test.js | 34 ++++ .../tests/diagnostics/diagnostics.test.js | 68 ++++++++ .../diagnostics/source-diagnostics.test.js | 148 ++++++++++++++++ .../document-links/document-links.test.js | 42 +++++ .../tests/fixtures/v4/dependencies/file.d.ts | 1 + .../tests/hover/hover.test.js | 54 ++++-- .../tailwindcss-language-service/package.json | 1 + .../src/completionProvider.ts | 135 ++++++++++++--- .../src/completions/file-paths.test.ts | 56 ++++++ .../src/completions/file-paths.ts | 53 ++++++ .../src/diagnostics/diagnosticsProvider.ts | 5 + .../getInvalidSourceDiagnostics.ts | 155 +++++++++++++++++ .../getInvalidTailwindDirectiveDiagnostics.ts | 124 ++++++++++---- .../src/diagnostics/types.ts | 12 ++ .../src/documentLinksProvider.ts | 51 +++--- .../src/hoverProvider.ts | 78 ++++++++- .../src/metadata/extensions.ts | 12 +- .../src/util/find.ts | 18 ++ .../src/util/language-blocks.ts | 45 +++++ .../src/util/state.ts | 1 + .../src/util/v4/design-system.ts | 2 +- packages/vscode-tailwindcss/CHANGELOG.md | 16 +- packages/vscode-tailwindcss/src/extension.ts | 2 +- .../syntaxes/at-rules.tmLanguage.json | 162 ++++++++++++++++++ pnpm-lock.yaml | 3 + 28 files changed, 1276 insertions(+), 109 deletions(-) create mode 100644 packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js create mode 100644 packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/file.d.ts create mode 100644 packages/tailwindcss-language-service/src/completions/file-paths.test.ts create mode 100644 packages/tailwindcss-language-service/src/completions/file-paths.ts create mode 100644 packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts create mode 100644 packages/tailwindcss-language-service/src/util/language-blocks.ts diff --git a/packages/tailwindcss-language-server/src/config.ts b/packages/tailwindcss-language-server/src/config.ts index 2e417fce..90fb3207 100644 --- a/packages/tailwindcss-language-server/src/config.ts +++ b/packages/tailwindcss-language-server/src/config.ts @@ -27,6 +27,7 @@ function getDefaultSettings(): Settings { invalidVariant: 'error', invalidConfigPath: 'error', invalidTailwindDirective: 'error', + invalidSourceDirective: 'error', recommendedVariantOrder: 'warning', }, showPixelEquivalents: true, diff --git a/packages/tailwindcss-language-server/src/language/cssServer.ts b/packages/tailwindcss-language-server/src/language/cssServer.ts index 11caad45..c33e3ebd 100644 --- a/packages/tailwindcss-language-server/src/language/cssServer.ts +++ b/packages/tailwindcss-language-server/src/language/cssServer.ts @@ -336,11 +336,7 @@ function replace(delta = 0) { } function createVirtualCssDocument(textDocument: TextDocument): TextDocument { - return TextDocument.create( - textDocument.uri, - textDocument.languageId, - textDocument.version, - textDocument + let content = textDocument .getText() .replace(/@screen(\s+[^{]+){/g, replace(-2)) .replace(/@variants(\s+[^{]+){/g, replace()) @@ -350,7 +346,19 @@ function createVirtualCssDocument(textDocument: TextDocument): TextDocument { /@media(\s+screen\s*\([^)]+\))/g, (_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`, ) - .replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_'), + // Remove`source(…)`, `theme(…)`, and `prefix(…)` from `@import`s + // otherwise we'll show syntax-error diagnostics which we don't want + .replace( + /@import\s*("(?:[^"]+)"|'(?:[^']+)')\s*((source|theme|prefix)\([^)]+\)\s*)+/g, + (_match, url) => `@import "${url.slice(1, -1)}"`, + ) + .replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_') + + return TextDocument.create( + textDocument.uri, + textDocument.languageId, + textDocument.version, + content, ) } 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 b97d1873..15d99ac6 100644 --- a/packages/tailwindcss-language-server/tests/completions/at-config.test.js +++ b/packages/tailwindcss-language-server/tests/completions/at-config.test.js @@ -296,4 +296,90 @@ withFixture('v4/dependencies', (c) => { ], }) }) + + test.concurrent('@import "…" source(…)', async ({ expect }) => { + let result = await completion({ + text: '@import "tailwindcss" source("', + lang: 'css', + position: { + line: 0, + character: 30, + }, + }) + + 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: 30 }, end: { line: 0, character: 30 } }, + }, + }, + ], + }) + }) + + test.concurrent('@tailwind utilities source(…)', async ({ expect }) => { + let result = await completion({ + text: '@tailwind utilities source("', + lang: 'css', + position: { + line: 0, + character: 28, + }, + }) + + 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: 28 }, end: { line: 0, character: 28 } }, + }, + }, + ], + }) + }) + + test.concurrent('@import "…" source(…) directory', async ({ expect }) => { + let result = await completion({ + text: '@import "tailwindcss" source("sub-dir/', + lang: 'css', + position: { + line: 0, + character: 38, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [], + }) + }) + + test.concurrent('@tailwind utilities source(…) directory', async ({ expect }) => { + let result = await completion({ + text: '@tailwind utilities source("sub-dir/', + lang: 'css', + position: { + line: 0, + character: 36, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [], + }) + }) }) diff --git a/packages/tailwindcss-language-server/tests/completions/completions.test.js b/packages/tailwindcss-language-server/tests/completions/completions.test.js index 767a3102..cd678845 100644 --- a/packages/tailwindcss-language-server/tests/completions/completions.test.js +++ b/packages/tailwindcss-language-server/tests/completions/completions.test.js @@ -507,6 +507,40 @@ withFixture('v4/basic', (c) => { ) }) + test.concurrent('@theme suggests options', async ({ expect }) => { + let result = await completion({ + lang: 'css', + text: '@theme ', + position: { line: 0, character: 7 }, + }) + + expect(result.items.length).toBe(3) + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'reference' }), + expect.objectContaining({ label: 'inline' }), + expect.objectContaining({ label: 'default' }), + ]), + ) + }) + + test.concurrent('@import "…" theme(…) suggests options', async ({ expect }) => { + let result = await completion({ + lang: 'css', + text: '@import "tailwindcss/theme" theme()', + position: { line: 0, character: 34 }, + }) + + expect(result.items.length).toBe(3) + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'reference' }), + expect.objectContaining({ label: 'inline' }), + expect.objectContaining({ label: 'default' }), + ]), + ) + }) + test.concurrent('resolve', async ({ expect }) => { let result = await completion({ text: '
', diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js index 48cdd4f7..56fe9f7f 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js @@ -314,4 +314,72 @@ withFixture('v4/basic', (c) => { }, ], }) + + testMatch('Old Tailwind directives warn when used in a v4 project', { + language: 'css', + code: ` + @tailwind base; + @tailwind preflight; + @tailwind components; + @tailwind screens; + @tailwind variants; + `, + expected: [ + { + code: 'invalidTailwindDirective', + message: + "'@tailwind base' is no longer available in v4. Use '@import \"tailwindcss/preflight\"' instead.", + suggestions: [], + range: { + start: { line: 1, character: 16 }, + end: { line: 1, character: 20 }, + }, + severity: 1, + }, + { + code: 'invalidTailwindDirective', + message: + "'@tailwind preflight' is no longer available in v4. Use '@import \"tailwindcss/preflight\"' instead.", + suggestions: [], + range: { + start: { line: 2, character: 16 }, + end: { line: 2, character: 25 }, + }, + severity: 1, + }, + { + code: 'invalidTailwindDirective', + message: + "'@tailwind components' is no longer available in v4. Use '@tailwind utilities' instead.", + suggestions: ['utilities'], + range: { + start: { line: 3, character: 16 }, + end: { line: 3, character: 26 }, + }, + severity: 1, + }, + { + code: 'invalidTailwindDirective', + message: + "'@tailwind screens' is no longer available in v4. Use '@tailwind utilities' instead.", + suggestions: ['utilities'], + range: { + start: { line: 4, character: 16 }, + end: { line: 4, character: 23 }, + }, + severity: 1, + }, + { + code: 'invalidTailwindDirective', + message: + "'@tailwind variants' is no longer available in v4. Use '@tailwind utilities' instead.", + suggestions: ['utilities'], + range: { + start: { line: 5, character: 16 }, + end: { line: 5, character: 24 }, + }, + severity: 1, + }, + ], + }) }) diff --git a/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js new file mode 100644 index 00000000..f6287b63 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js @@ -0,0 +1,148 @@ +import { expect, test } from 'vitest' +import { withFixture } from '../common' + +withFixture('v4/basic', (c) => { + function runTest(name, { code, expected, language }) { + test(name, async () => { + let promise = new Promise((resolve) => { + c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { + resolve(diagnostics) + }) + }) + + let doc = await c.openDocument({ text: code, lang: language }) + let diagnostics = await promise + + expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) + + expect(diagnostics).toMatchObject(expected) + }) + } + + runTest('Source directives require paths', { + language: 'css', + code: ` + @import 'tailwindcss' source(); + @import 'tailwindcss' source(''); + @import 'tailwindcss' source(""); + @tailwind utilities source(); + @tailwind utilities source(''); + @tailwind utilities source(""); + `, + expected: [ + { + code: 'invalidSourceDirective', + message: 'The source directive requires a path to a directory.', + range: { + start: { line: 1, character: 35 }, + end: { line: 1, character: 35 }, + }, + }, + { + code: 'invalidSourceDirective', + message: 'The source directive requires a path to a directory.', + range: { + start: { line: 2, character: 35 }, + end: { line: 2, character: 37 }, + }, + }, + { + code: 'invalidSourceDirective', + message: 'The source directive requires a path to a directory.', + range: { + start: { line: 3, character: 35 }, + end: { line: 3, character: 37 }, + }, + }, + { + code: 'invalidSourceDirective', + message: 'The source directive requires a path to a directory.', + range: { + start: { line: 4, character: 33 }, + end: { line: 4, character: 33 }, + }, + }, + { + code: 'invalidSourceDirective', + message: 'The source directive requires a path to a directory.', + range: { + start: { line: 5, character: 33 }, + end: { line: 5, character: 35 }, + }, + }, + { + code: 'invalidSourceDirective', + message: 'The source directive requires a path to a directory.', + range: { + start: { line: 6, character: 33 }, + end: { line: 6, character: 35 }, + }, + }, + ], + }) + + runTest('source(none) must not be misspelled', { + language: 'css', + code: ` + @import 'tailwindcss' source(no); + @tailwind utilities source(no); + `, + expected: [ + { + code: 'invalidSourceDirective', + message: '`source(no)` is invalid. Did you mean `source(none)`?', + range: { + start: { line: 1, character: 35 }, + end: { line: 1, character: 37 }, + }, + }, + { + code: 'invalidSourceDirective', + message: '`source(no)` is invalid. Did you mean `source(none)`?', + range: { + start: { line: 2, character: 33 }, + end: { line: 2, character: 35 }, + }, + }, + ], + }) + + runTest('source("…") does not produce diagnostics', { + language: 'css', + code: ` + @import 'tailwindcss' source('../app'); + @tailwind utilities source('../app'); + @import 'tailwindcss' source("../app"); + @tailwind utilities source("../app"); + `, + expected: [], + }) + + runTest('paths given to source("…") must error when not POSIX', { + language: 'css', + code: String.raw` + @import 'tailwindcss' source('C:\\absolute\\path'); + @import 'tailwindcss' source('C:relative.txt'); + `, + expected: [ + { + code: 'invalidSourceDirective', + message: + 'POSIX-style paths are required with `source(…)` but `C:\\absolute\\path` is a Windows-style path.', + range: { + start: { line: 1, character: 35 }, + end: { line: 1, character: 55 }, + }, + }, + { + code: 'invalidSourceDirective', + message: + 'POSIX-style paths are required with `source(…)` but `C:relative.txt` is a Windows-style path.', + range: { + start: { line: 2, character: 35 }, + end: { line: 2, character: 51 }, + }, + }, + ], + }) +}) 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 595eeec2..861f74c9 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 @@ -130,4 +130,46 @@ withFixture('v4/basic', (c) => { }, ], }) + + testDocumentLinks('Directories in source(…) show links', { + text: ` + @import "tailwindcss" source("../../"); + @tailwind utilities source("../../"); + `, + lang: 'css', + expected: [ + { + target: `file://${path.resolve('./tests/fixtures').replace(/@/g, '%40')}`, + range: { start: { line: 1, character: 35 }, end: { line: 1, character: 43 } }, + }, + { + target: `file://${path.resolve('./tests/fixtures').replace(/@/g, '%40')}`, + range: { start: { line: 2, character: 33 }, end: { line: 2, character: 41 } }, + }, + ], + }) + + testDocumentLinks('Globs in source(…) do not show links', { + text: ` + @import "tailwindcss" source("../{a,b,c}"); + @tailwind utilities source("../{a,b,c}"); + `, + lang: 'css', + expected: [], + }) + + testDocumentLinks('Windows paths in source(…) do not show links', { + text: String.raw` + @import "tailwindcss" source("..\foo\bar"); + @tailwind utilities source("..\foo\bar"); + + @import "tailwindcss" source("C:\foo\bar"); + @tailwind utilities source("C:\foo\bar"); + + @import "tailwindcss" source("C:foo"); + @tailwind utilities source("C:bar"); + `, + lang: 'css', + expected: [], + }) }) diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/file.d.ts b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/file.d.ts new file mode 100644 index 00000000..39a995a9 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/file.d.ts @@ -0,0 +1 @@ +export type ColorSpace = 'srgb' | 'display-p3' diff --git a/packages/tailwindcss-language-server/tests/hover/hover.test.js b/packages/tailwindcss-language-server/tests/hover/hover.test.js index ce271963..878fed21 100644 --- a/packages/tailwindcss-language-server/tests/hover/hover.test.js +++ b/packages/tailwindcss-language-server/tests/hover/hover.test.js @@ -157,7 +157,10 @@ withFixture('basic', (c) => { }) withFixture('v4/basic', (c) => { - async function testHover(name, { text, lang, position, expected, expectedRange, settings }) { + async function testHover( + name, + { text, exact = false, lang, position, expected, expectedRange, settings }, + ) { test.concurrent(name, async ({ expect }) => { let textDocument = await c.openDocument({ text, lang, settings }) let res = await c.sendRequest('textDocument/hover', { @@ -165,17 +168,17 @@ withFixture('v4/basic', (c) => { position, }) - expect(res).toEqual( - expected - ? { - contents: { - language: 'css', - value: expected, - }, - range: expectedRange, - } - : expected, - ) + if (!exact && expected) { + expected = { + contents: { + language: 'css', + value: expected, + }, + range: expectedRange, + } + } + + expect(res).toEqual(expected) }) } @@ -242,6 +245,33 @@ withFixture('v4/basic', (c) => { end: { line: 2, character: 18 }, }, }) + + testHover('css @source glob expansion', { + exact: true, + lang: 'css', + text: `@source "../{app,components}/**/*.jsx"`, + position: { line: 0, character: 23 }, + expected: { + contents: { + kind: 'markdown', + value: [ + '**Expansion**', + '```plaintext', + '- ../app/**/*.jsx', + '- ../components/**/*.jsx', + '```', + ].join('\n'), + }, + range: { + start: { line: 0, character: 8 }, + end: { line: 0, character: 38 }, + }, + }, + expectedRange: { + start: { line: 2, character: 9 }, + end: { line: 2, character: 18 }, + }, + }) }) withFixture('v4/css-loading-js', (c) => { diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json index 3c66323d..29034fb0 100644 --- a/packages/tailwindcss-language-service/package.json +++ b/packages/tailwindcss-language-service/package.json @@ -19,6 +19,7 @@ "@types/culori": "^2.1.0", "@types/moo": "0.5.3", "@types/semver": "7.3.10", + "braces": "3.0.3", "color-name": "1.1.4", "css.escape": "1.5.1", "culori": "^4.0.1", diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index a8f1f21a..157c4610 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -39,6 +39,7 @@ import { import { customClassesIn } from './util/classes' import { IS_SCRIPT_SOURCE, IS_TEMPLATE_SOURCE } from './metadata/extensions' import * as postcss from 'postcss' +import { findFileDirective } from './completions/file-paths' let isUtil = (className) => Array.isArray(className.__info) @@ -1599,6 +1600,95 @@ function isInsideAtRule(name: string, document: TextDocument, position: Position return braceLevel(text.slice(block)) > 0 } +// Provide completions for directives that take file paths +const PATTERN_AT_THEME = /@(?theme)\s+(?:(?[^{]+)\s$|$)/ +const PATTERN_IMPORT_THEME = /@(?import)\s*[^;]+?theme\((?:(?[^)]+)\s$|$)/ + +async function provideThemeDirectiveCompletions( + state: State, + document: TextDocument, + position: Position, +): Promise { + if (!state.v4) return null + + let text = document.getText({ start: { line: position.line, character: 0 }, end: position }) + + let match = text.match(PATTERN_AT_THEME) ?? text.match(PATTERN_IMPORT_THEME) + + // Are we in a context where suggesting theme(…) stuff makes sense? + if (!match) return null + + let directive = match.groups.directive + let parts = new Set( + (match.groups.parts ?? '') + .trim() + .split(/\s+/) + .map((part) => part.trim()) + .filter((part) => part !== ''), + ) + + let items: CompletionItem[] = [ + { + label: 'reference', + documentation: { + kind: 'markdown', + value: + directive === 'import' + ? `Don't emit CSS variables for imported theme values.` + : `Don't emit CSS variables for these theme values.`, + }, + sortText: '-000000', + }, + { + label: 'inline', + documentation: { + kind: 'markdown', + value: + directive === 'import' + ? `Inline imported theme values into generated utilities instead of using \`var(…)\`.` + : `Inline these theme values into generated utilities instead of using \`var(…)\`.`, + }, + sortText: '-000001', + }, + { + label: 'default', + documentation: { + kind: 'markdown', + value: + directive === 'import' + ? `Allow imported theme values to be overriden by JS configs and plugins.` + : `Allow these theme values to be overriden by JS configs and plugins.`, + }, + sortText: '-000003', + }, + ] + + items = items.filter((item) => !parts.has(item.label)) + + if (items.length === 0) return null + + return withDefaults( + { + isIncomplete: false, + items, + }, + { + data: { + ...(state.completionItemData ?? {}), + _type: 'filesystem', + }, + range: { + start: { + line: position.line, + character: position.character, + }, + end: position, + }, + }, + state.editor.capabilities.itemDefaults, + ) +} + // Provide completions for directives that take file paths async function provideFileDirectiveCompletions( state: State, @@ -1613,39 +1703,43 @@ async function provideFileDirectiveCompletions( 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(pattern) - if (!match) { - return null + + let fd = await findFileDirective(state, text) + if (!fd) return null + + let { partial, suggest } = fd + + function isAllowedFile(name: string) { + if (suggest === 'script') return IS_SCRIPT_SOURCE.test(name) + + if (suggest === 'source') return IS_TEMPLATE_SOURCE.test(name) + + // Files are not allowed but directories are + if (suggest === 'directory') return false + + return false } - 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 type.isDirectory || isAllowedFile(name) }) + let items: CompletionItem[] = entries.map(([name, type]) => ({ + label: type.isDirectory ? name + '/' : name, + kind: type.isDirectory ? 19 : 17, + command: type.isDirectory ? { command: 'editor.action.triggerSuggest', title: '' } : undefined, + })) + return withDefaults( { isIncomplete: false, - items: entries.map(([name, type]) => ({ - label: type.isDirectory ? name + '/' : name, - kind: type.isDirectory ? 19 : 17, - command: type.isDirectory - ? { command: 'editor.action.triggerSuggest', title: '' } - : undefined, - })), + items, }, { data: { @@ -1751,6 +1845,7 @@ export async function doComplete( const result = (await provideClassNameCompletions(state, document, position, context)) || + (await provideThemeDirectiveCompletions(state, document, position)) || provideCssHelperCompletions(state, document, position) || provideCssDirectiveCompletions(state, document, position) || provideScreenDirectiveCompletions(state, document, position) || diff --git a/packages/tailwindcss-language-service/src/completions/file-paths.test.ts b/packages/tailwindcss-language-service/src/completions/file-paths.test.ts new file mode 100644 index 00000000..c79fcbdd --- /dev/null +++ b/packages/tailwindcss-language-service/src/completions/file-paths.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'vitest' +import { findFileDirective } from './file-paths' + +test('Detecting v3 directives that point to files', async () => { + function find(text: string) { + return findFileDirective({ enabled: true, v4: false }, text) + } + + await expect(find('@config "./')).resolves.toEqual({ + directive: 'config', + partial: './', + suggest: 'script', + }) + + // The following are not supported in v3 + await expect(find('@plugin "./')).resolves.toEqual(null) + await expect(find('@source "./')).resolves.toEqual(null) + await expect(find('@import "tailwindcss" source("./')).resolves.toEqual(null) + await expect(find('@tailwind utilities source("./')).resolves.toEqual(null) +}) + +test('Detecting v4 directives that point to files', async () => { + function find(text: string) { + return findFileDirective({ enabled: true, v4: true }, text) + } + + await expect(find('@config "./')).resolves.toEqual({ + directive: 'config', + partial: './', + suggest: 'script', + }) + + await expect(find('@plugin "./')).resolves.toEqual({ + directive: 'plugin', + partial: './', + suggest: 'script', + }) + + await expect(find('@source "./')).resolves.toEqual({ + directive: 'source', + partial: './', + suggest: 'source', + }) + + await expect(find('@import "tailwindcss" source("./')).resolves.toEqual({ + directive: 'import', + partial: './', + suggest: 'directory', + }) + + await expect(find('@tailwind utilities source("./')).resolves.toEqual({ + directive: 'tailwind', + partial: './', + suggest: 'directory', + }) +}) diff --git a/packages/tailwindcss-language-service/src/completions/file-paths.ts b/packages/tailwindcss-language-service/src/completions/file-paths.ts new file mode 100644 index 00000000..f9b1898d --- /dev/null +++ b/packages/tailwindcss-language-service/src/completions/file-paths.ts @@ -0,0 +1,53 @@ +import type { State } from '../util/state' + +// @config, @plugin, @source +const PATTERN_CUSTOM_V4 = /@(?config|plugin|source)\s*(?'[^']*|"[^"]*)$/ +const PATTERN_CUSTOM_V3 = /@(?config)\s*(?'[^']*|"[^"]*)$/ + +// @import … source('…') +// @tailwind utilities source('…') +const PATTERN_IMPORT_SOURCE = /@(?import)\s*(?'[^']*'|"[^"]*")\s*source\((?'[^']*|"[^"]*)$/ +const PATTERN_UTIL_SOURCE = /@(?tailwind)\s+utilities\s+source\((?'[^']*|"[^"]*)?$/ + +export type FileDirective = { + directive: string + partial: string + suggest: 'script' | 'source' | 'directory' +} + +export async function findFileDirective(state: State, text: string): Promise { + if (state.v4) { + let match = text.match(PATTERN_CUSTOM_V4) + ?? text.match(PATTERN_IMPORT_SOURCE) + ?? text.match(PATTERN_UTIL_SOURCE) + + if (!match) return null + + let directive = match.groups.directive + let partial = match.groups.partial?.slice(1) ?? "" // remove leading quote + + // Most suggestions are for JS files so we'll default to that + let suggest: FileDirective['suggest'] = 'script' + + // If we're looking at @source then it's for a template file + if (directive === 'source') { + suggest = 'source' + } + + // If we're looking at @import … source('…') or @tailwind … source('…') then + // we want to list directories instead of files + else if (directive === 'import' || directive === 'tailwind') { + suggest = 'directory' + } + + return { directive, partial, suggest } + } + + let match = text.match(PATTERN_CUSTOM_V3) + if (!match) return null + + let directive = match.groups.directive + let partial = match.groups.partial.slice(1) // remove leading quote + + return { directive, partial, suggest: 'script' } +} diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts index 34c03b22..18994526 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts @@ -8,6 +8,7 @@ import { getInvalidVariantDiagnostics } from './getInvalidVariantDiagnostics' import { getInvalidConfigPathDiagnostics } from './getInvalidConfigPathDiagnostics' import { getInvalidTailwindDirectiveDiagnostics } from './getInvalidTailwindDirectiveDiagnostics' import { getRecommendedVariantOrderDiagnostics } from './getRecommendedVariantOrderDiagnostics' +import { getInvalidSourceDiagnostics } from './getInvalidSourceDiagnostics' export async function doValidate( state: State, @@ -19,6 +20,7 @@ export async function doValidate( DiagnosticKind.InvalidVariant, DiagnosticKind.InvalidConfigPath, DiagnosticKind.InvalidTailwindDirective, + DiagnosticKind.InvalidSourceDirective, DiagnosticKind.RecommendedVariantOrder, ], ): Promise { @@ -44,6 +46,9 @@ export async function doValidate( ...(only.includes(DiagnosticKind.InvalidTailwindDirective) ? getInvalidTailwindDirectiveDiagnostics(state, document, settings) : []), + ...(only.includes(DiagnosticKind.InvalidSourceDirective) + ? getInvalidSourceDiagnostics(state, document, settings) + : []), ...(only.includes(DiagnosticKind.RecommendedVariantOrder) ? await getRecommendedVariantOrderDiagnostics(state, document, settings) : []), diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts new file mode 100644 index 00000000..e8b4ba56 --- /dev/null +++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts @@ -0,0 +1,155 @@ +import type { State, Settings } from '../util/state' +import { DiagnosticKind, InvalidSourceDirectiveDiagnostic } from './types' +import { findAll, indexToPosition } from '../util/find' +import type { TextDocument } from 'vscode-languageserver-textdocument' +import { getCssBlocks } from '../util/language-blocks' +import { absoluteRange } from '../util/absoluteRange' + +// @import … source('…') +// @tailwind utilities source('…') +const PATTERN_IMPORT_SOURCE = + /(?:\s|^)@(?import)\s*(?'[^']*'|"[^"]*")\s*source\((?'[^']*'?|"[^"]*"?|[a-z]*|\)|;)/dg +const PATTERN_UTIL_SOURCE = + /(?:\s|^)@(?tailwind)\s+(?\S+)\s+source\((?'[^']*'?|"[^"]*"?|[a-z]*|\)|;)/dg + +// @source … +const PATTERN_AT_SOURCE = + /(?:\s|^)@(?source)\s*(?'[^']*'?|"[^"]*"?|[a-z]*|\)|;)/dg + +const HAS_DRIVE_LETTER = /^[A-Z]:/ + +export function getInvalidSourceDiagnostics( + state: State, + document: TextDocument, + settings: Settings, +): InvalidSourceDirectiveDiagnostic[] { + let severity = settings.tailwindCSS.lint.invalidSourceDirective + if (severity === 'ignore') return [] + + let diagnostics: InvalidSourceDirectiveDiagnostic[] = [] + + function add(diag: Omit) { + diagnostics.push({ + code: DiagnosticKind.InvalidSourceDirective, + severity: + severity === 'error' + ? 1 /* DiagnosticSeverity.Error */ + : 2 /* DiagnosticSeverity.Warning */, + ...diag, + }) + } + + for (let block of getCssBlocks(state, document)) { + let text = block.text + + let matches = [ + ...findAll(PATTERN_IMPORT_SOURCE, text), + ...findAll(PATTERN_UTIL_SOURCE, text), + ...findAll(PATTERN_AT_SOURCE, text), + ] + + for (let match of matches) { + let directive = match.groups.directive + let source = match.groups.source?.trim() ?? '' + let rawSource = source + let sourceRange = match.indices.groups.source + let isQuoted = false + + if (source.startsWith("'")) { + source = source.slice(1) + isQuoted = true + } else if (source.startsWith('"')) { + source = source.slice(1) + isQuoted = true + } + + if (source.endsWith("'")) { + source = source.slice(0, -1) + isQuoted = true + } else if (source.endsWith('"')) { + source = source.slice(0, -1) + isQuoted = true + } + + source = source.trim() + + // - `@import "tailwindcss" source()` + // - `@import "tailwindcss" source('')` + // - `@import "tailwindcss" source("")` + + // - `@source ;` + // - `@source '';` + // - `@source "";` + if (source === '' || source === ')' || source === ';') { + let range = { + start: indexToPosition(text, sourceRange[0]), + end: indexToPosition(text, sourceRange[1]), + } + + add({ + message: 'The source directive requires a path to a directory.', + range: absoluteRange(range, block.range), + }) + } + + // - `@import "tailwindcss" source(no)` + // - `@tailwind utilities source('')` + else if (directive !== 'source' && source !== 'none' && !isQuoted) { + let range = { + start: indexToPosition(text, sourceRange[0]), + end: indexToPosition(text, sourceRange[1]), + } + + add({ + message: `\`source(${source})\` is invalid. Did you mean \`source(none)\`?`, + range: absoluteRange(range, block.range), + }) + } + + // Detection of Windows-style paths + else if (source.includes('\\') || HAS_DRIVE_LETTER.test(source)) { + source = source.replaceAll('\\\\', '\\') + + let range = { + start: indexToPosition(text, sourceRange[0]), + end: indexToPosition(text, sourceRange[1]), + } + + add({ + message: `POSIX-style paths are required with \`source(…)\` but \`${source}\` is a Windows-style path.`, + range: absoluteRange(range, block.range), + }) + } + + // `@source none` is invalid + else if (directive === 'source' && source === 'none') { + let range = { + start: indexToPosition(text, sourceRange[0]), + end: indexToPosition(text, sourceRange[1]), + } + + add({ + message: + '`@source none;` is not valid. Did you mean to use `source(none)` on an `@import`?', + range: absoluteRange(range, block.range), + }) + } + + // - `@import "tailwindcss" source(no)` + // - `@tailwind utilities source('')` + else if (directive === 'source' && source !== 'none' && !isQuoted) { + let range = { + start: indexToPosition(text, sourceRange[0]), + end: indexToPosition(text, sourceRange[1]), + } + + add({ + message: `\`@source ${rawSource};\` is invalid.`, + range: absoluteRange(range, block.range), + }) + } + } + } + + return diagnostics +} diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts index 7325d49e..480bb8cd 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts @@ -37,51 +37,23 @@ export function getInvalidTailwindDirectiveDiagnostics( regex = /(?:\s|^)@tailwind\s+(?[^;]+)/g } - let hasVariantsDirective = state.jit && semver.gte(state.version, '2.1.99') - ranges.forEach((range) => { let text = getTextWithoutComments(document, 'css', range) let matches = findAll(regex, text) - let valid = [ - 'utilities', - 'components', - 'screens', - semver.gte(state.version, '1.0.0-beta.1') ? 'base' : 'preflight', - hasVariantsDirective && 'variants', - ].filter(Boolean) - - let suggestable = valid - - if (hasVariantsDirective) { - // Don't suggest `screens`, because it's deprecated - suggestable = suggestable.filter((value) => value !== 'screens') - } - matches.forEach((match) => { - if (valid.includes(match.groups.value)) { - return null - } + let layerName = match.groups.value - let message = `'${match.groups.value}' is not a valid value.` - let suggestions: string[] = [] - - if (match.groups.value === 'preflight') { - suggestions.push('base') - message += ` Did you mean 'base'?` - } else { - let suggestion = closest(match.groups.value, suggestable) - if (suggestion) { - suggestions.push(suggestion) - message += ` Did you mean '${suggestion}'?` - } - } + let result = validateLayerName(state, layerName) + if (!result) return + + let { message, suggestions } = result diagnostics.push({ code: DiagnosticKind.InvalidTailwindDirective, range: absoluteRange( { - start: indexToPosition(text, match.index + match[0].length - match.groups.value.length), + start: indexToPosition(text, match.index + match[0].length - layerName.length), end: indexToPosition(text, match.index + match[0].length), }, range, @@ -98,3 +70,87 @@ export function getInvalidTailwindDirectiveDiagnostics( return diagnostics } + +function validateLayerName( + state: State, + layerName: string, +): { message: string; suggestions: string[] } | null { + if (state.v4) { + // `@tailwind utilities` is valid + if (layerName === 'utilities') { + return null + } + + // `@tailwind base | preflight` do not exist in v4 + if (layerName === 'base' || layerName === 'preflight') { + return { + message: `'@tailwind ${layerName}' is no longer available in v4. Use '@import "tailwindcss/preflight"' instead.`, + suggestions: [], + } + } + + // `@tailwind components | screens | variants` do not exist in v4 + if (layerName === 'components' || layerName === 'screens' || layerName === 'variants') { + return { + message: `'@tailwind ${layerName}' is no longer available in v4. Use '@tailwind utilities' instead.`, + suggestions: ['utilities'], + } + } + + let parts = layerName.split(/\s+/) + + // `@tailwind utilities source(…)` is valid + if (parts[0] === 'utilities' && parts[1]?.startsWith('source(')) { + return null + } + + return { + message: `'${layerName}' is not a valid value.`, + suggestions: [], + } + } + + let valid = ['utilities', 'components', 'screens'] + + if (semver.gte(state.version, '1.0.0-beta.1')) { + valid.push('base') + } else { + valid.push('preflight') + } + + let hasVariantsDirective = state.jit && semver.gte(state.version, '2.1.99') + + if (hasVariantsDirective) { + valid.push('variants') + } + + if (valid.includes(layerName)) { + return null + } + + let suggestable = valid + + if (hasVariantsDirective) { + // Don't suggest `screens`, because it's deprecated + suggestable = suggestable.filter((value) => value !== 'screens') + } + + let message = `'${layerName}' is not a valid value.` + let suggestions: string[] = [] + + if (layerName === 'preflight') { + suggestions.push('base') + message += ` Did you mean 'base'?` + } else { + let suggestion = closest(layerName, suggestable) + if (suggestion) { + suggestions.push(suggestion) + message += ` Did you mean '${suggestion}'?` + } + } + + return { + message, + suggestions, + } +} diff --git a/packages/tailwindcss-language-service/src/diagnostics/types.ts b/packages/tailwindcss-language-service/src/diagnostics/types.ts index 115079a2..7cb68a7e 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/types.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/types.ts @@ -8,6 +8,7 @@ export enum DiagnosticKind { InvalidVariant = 'invalidVariant', InvalidConfigPath = 'invalidConfigPath', InvalidTailwindDirective = 'invalidTailwindDirective', + InvalidSourceDirective = 'invalidSourceDirective', RecommendedVariantOrder = 'recommendedVariantOrder', } @@ -78,6 +79,16 @@ export function isInvalidTailwindDirectiveDiagnostic( return diagnostic.code === DiagnosticKind.InvalidTailwindDirective } +export type InvalidSourceDirectiveDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidSourceDirective +} + +export function isInvalidSourceDirectiveDiagnostic( + diagnostic: AugmentedDiagnostic, +): diagnostic is InvalidSourceDirectiveDiagnostic { + return diagnostic.code === DiagnosticKind.InvalidSourceDirective +} + export type RecommendedVariantOrderDiagnostic = Diagnostic & { code: DiagnosticKind.RecommendedVariantOrder suggestions: string[] @@ -96,4 +107,5 @@ export type AugmentedDiagnostic = | InvalidVariantDiagnostic | InvalidConfigPathDiagnostic | InvalidTailwindDirectiveDiagnostic + | InvalidSourceDirectiveDiagnostic | RecommendedVariantOrderDiagnostic diff --git a/packages/tailwindcss-language-service/src/documentLinksProvider.ts b/packages/tailwindcss-language-service/src/documentLinksProvider.ts index 3dcc15bb..b18a4711 100644 --- a/packages/tailwindcss-language-service/src/documentLinksProvider.ts +++ b/packages/tailwindcss-language-service/src/documentLinksProvider.ts @@ -1,26 +1,26 @@ import type { TextDocument } from 'vscode-languageserver-textdocument' import type { State } from './util/state' import type { DocumentLink, Range } 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' +import { getCssBlocks } from './util/language-blocks' + +const HAS_DRIVE_LETTER = /^[A-Z]:/ export function getDocumentLinks( state: State, document: TextDocument, resolveTarget: (linkPath: string) => string, ): DocumentLink[] { - let patterns = [ - /@config\s*(?'[^']+'|"[^"]+")/g, - ] + let patterns = [/@config\s*(?'[^']+'|"[^"]+")/g] if (state.v4) { patterns.push( /@plugin\s*(?'[^']+'|"[^"]+")/g, /@source\s*(?'[^']+'|"[^"]+")/g, + /@import\s*('[^']*'|"[^"]*")\s*source\((?'[^']*'?|"[^"]*"?)/g, + /@tailwind\s*utilities\s*source\((?'[^']*'?|"[^"]*"?)/g, ) } @@ -38,18 +38,10 @@ function getDirectiveLinks( } 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 block of getCssBlocks(state, document)) { + let text = block.text - for (let range of ranges) { - let text = getTextWithoutComments(document, 'css', range) let matches: RegExpMatchArray[] = [] for (let pattern of patterns) { @@ -57,15 +49,26 @@ function getDirectiveLinks( } for (let match of matches) { + let path = match.groups.path.slice(1, -1) + + // Ignore glob-like paths + if (path.includes('*') || path.includes('{') || path.includes('}')) { + continue + } + + // Ignore Windows-style paths + if (path.includes('\\') || HAS_DRIVE_LETTER.test(path)) { + continue + } + + let range = { + start: indexToPosition(text, match.index + match[0].length - match.groups.path.length), + end: indexToPosition(text, match.index + match[0].length), + } + 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, - ), + target: resolveTarget(path), + range: absoluteRange(range, block.range), }) } } diff --git a/packages/tailwindcss-language-service/src/hoverProvider.ts b/packages/tailwindcss-language-service/src/hoverProvider.ts index 1ef981a0..51ef67e3 100644 --- a/packages/tailwindcss-language-service/src/hoverProvider.ts +++ b/packages/tailwindcss-language-service/src/hoverProvider.ts @@ -1,9 +1,14 @@ import type { State } from './util/state' -import type { Hover, Position } from 'vscode-languageserver' +import type { Hover, MarkupContent, Position, Range } from 'vscode-languageserver' import { stringifyCss, stringifyConfigValue } from './util/stringify' import dlv from 'dlv' import { isCssContext } from './util/css' -import { findClassNameAtPosition, findHelperFunctionsInRange } from './util/find' +import { + findAll, + findClassNameAtPosition, + findHelperFunctionsInRange, + indexToPosition, +} from './util/find' import { validateApply } from './util/validateApply' import { getClassNameParts } from './util/getClassNameAtPosition' import * as jit from './util/jit' @@ -11,6 +16,9 @@ import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostic 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' export async function doHover( state: State, @@ -19,7 +27,8 @@ export async function doHover( ): Promise { return ( (await provideClassNameHover(state, document, position)) || - (await provideCssHelperHover(state, document, position)) + (await provideCssHelperHover(state, document, position)) || + (await provideSourceGlobHover(state, document, position)) ) } @@ -48,7 +57,7 @@ async function provideCssHelperHover( helperFn.helper === 'theme' ? ['theme'] : [], ) - // This property may not exist in the state object because of compatability with Tailwind Play + // This property may not exist in the state object because of compatibility with Tailwind Play let value = validated.isValid ? stringifyConfigValue(validated.value) : null if (value === null) return null @@ -133,3 +142,64 @@ async function provideClassNameHover( range: className.range, } } + +function markdown(lines: string[]): MarkupContent { + return { + kind: 'markdown', + value: lines.join('\n'), + } +} + +async function provideSourceGlobHover( + state: State, + document: TextDocument, + position: Position, +): Promise { + if (!isCssContext(state, document, position)) { + return null + } + + let range = { + start: { line: position.line, character: 0 }, + end: { line: position.line + 1, character: 0 }, + } + + let text = getTextWithoutComments(document, 'css', range) + + let pattern = /@source\s*(?'[^']+'|"[^"]+")/dg + + for (let match of findAll(pattern, text)) { + let path = match.groups.path.slice(1, -1) + + // Ignore paths that don't need brace expansion + if (!path.includes('{') || !path.includes('}')) continue + + // Ignore paths that don't contain the current position + let slice: Range = absoluteRange( + { + start: indexToPosition(text, match.indices.groups.path[0]), + end: indexToPosition(text, match.indices.groups.path[1]), + }, + range, + ) + + if (!isWithinRange(position, slice)) continue + + // Perform brace expansion + let paths = new Set(braces.expand(path)) + if (paths.size < 2) continue + + return { + range: slice, + contents: markdown([ + // + '**Expansion**', + '```plaintext', + ...Array.from(paths, (path) => `- ${path}`), + '```', + ]), + } + } + + return null +} diff --git a/packages/tailwindcss-language-service/src/metadata/extensions.ts b/packages/tailwindcss-language-service/src/metadata/extensions.ts index dab890d5..15babe94 100644 --- a/packages/tailwindcss-language-service/src/metadata/extensions.ts +++ b/packages/tailwindcss-language-service/src/metadata/extensions.ts @@ -3,9 +3,9 @@ let scriptExtensions = [ 'js', 'cjs', 'mjs', - 'ts', - 'mts', - 'cts', + '(? { + // Scan backwards to see if we're in an `@import` statement + for (let i = match.index - 1; i >= 0; i--) { + let char = text[i] + if (char === '\n') break + if (char === ';') break + // Detecting theme(…) inside the media query list of `@import` is okay + if (char === '(') break + if (char === ')') break + if (text.startsWith('@import', i)) { + return false + } + } + + return true + }) + return matches.map((match) => { let quotesBefore = '' let path = match.groups.path diff --git a/packages/tailwindcss-language-service/src/util/language-blocks.ts b/packages/tailwindcss-language-service/src/util/language-blocks.ts new file mode 100644 index 00000000..10a3fe14 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/language-blocks.ts @@ -0,0 +1,45 @@ +import type { State } from '../util/state' +import { type Range } from 'vscode-languageserver' +import type { TextDocument } from 'vscode-languageserver-textdocument' +import { getLanguageBoundaries } from '../util/getLanguageBoundaries' +import { isCssDoc } from '../util/css' +import { getTextWithoutComments } from './doc' + +export interface LanguageBlock { + document: TextDocument + range: Range | undefined + lang: string + readonly text: string +} + +export function* getCssBlocks( + state: State, + document: TextDocument, +): Iterable { + 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 [] + + for (let boundary of boundaries) { + if (boundary.type !== 'css') continue + + yield { + document, + range: boundary.range, + lang: boundary.lang ?? document.languageId, + get text() { + return getTextWithoutComments(document, 'css', boundary.range) + }, + } + } + } +} diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index abe8863c..dd1966ce 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -59,6 +59,7 @@ export type TailwindCssSettings = { invalidVariant: DiagnosticSeveritySetting invalidConfigPath: DiagnosticSeveritySetting invalidTailwindDirective: DiagnosticSeveritySetting + invalidSourceDirective: DiagnosticSeveritySetting recommendedVariantOrder: DiagnosticSeveritySetting } experimental: { diff --git a/packages/tailwindcss-language-service/src/util/v4/design-system.ts b/packages/tailwindcss-language-service/src/util/v4/design-system.ts index ca2f9ad2..ee9396a8 100644 --- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts @@ -3,7 +3,7 @@ import type { Rule } from './ast' import type { NamedVariant } from './candidate' export interface Theme { - // Prefix didn't exist on + // Prefix didn't exist for earlier Tailwind versions prefix?: string entries(): [string, any][] } diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 665d847e..69321177 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,21 @@ ## Prerelease -- Nothing yet! +- Add suggestions for theme options ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Add suggestions when using `@source "…"` and `source(…)` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Show brace expansion when hovering `@source` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Highlight `source(…)`, `theme(…)`, and `prefix(…)` when used with `@import "…"` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Highlight `@tailwind utilities source(…)` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Show document links when using `source(…)` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) + +- Ensure language server starts as needed ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Don't show syntax errors when using `source(…)`, `theme(…)`, or `prefix(…)` with `@import "…"` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Don't show warning when using `@tailwind utilities source(…)` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Don't suggest TypeScript declaration files for `@config`, `@plugin`, and `@source` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Don't link Windows-style paths in `@source`, `@config`, and `@plugin` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) + +- Warn on invalid uses of `source(…)`, `@source`, `@config`, and `@plugin` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) +- Warn when a v4 project uses an old `@tailwind` directive ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083)) ## 0.12.13 diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index 3df1c216..d717d5a5 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -609,7 +609,7 @@ export async function activate(context: ExtensionContext) { return } - if (!anyFolderNeedsLanguageServer(Workspace.workspaceFolders ?? [])) { + if (!await anyFolderNeedsLanguageServer(Workspace.workspaceFolders ?? [])) { return } diff --git a/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json b/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json index d50b1007..92d02bd3 100644 --- a/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json +++ b/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json @@ -4,6 +4,53 @@ "injectionSelector": "L:source.css -comment -source.css.scss", "name": "TailwindCSS", "patterns": [ + { + "begin": "(?i)((@)import)(?:\\s+|$|(?=['\"]|/\\*))", + "beginCaptures": { + "1": { + "name": "keyword.control.at-rule.import.css" + }, + "2": { + "name": "punctuation.definition.keyword.css" + } + }, + "end": ";", + "endCaptures": { + "0": { + "name": "punctuation.terminator.rule.css" + } + }, + "name": "meta.at-rule.import.css", + "patterns": [ + { + "begin": "\\G\\s*(?=/\\*)", + "end": "(?<=\\*/)\\s*", + "patterns": [ + { + "include": "source.css#comment-block" + } + ] + }, + { + "include": "source.css#string" + }, + { + "include": "source.css#url" + }, + { + "include": "#source-fn" + }, + { + "include": "#theme-meta-fn" + }, + { + "include": "#prefix-meta-fn" + }, + { + "include": "source.css#media-query-list" + } + ] + }, { "begin": "(?i)((@)tailwind)(?=\\s|/\\*|$)", "beginCaptures": { @@ -28,6 +75,9 @@ { "include": "source.css#escapes" }, + { + "include": "#source-fn" + }, { "match": "[^\\s;]+?", "name": "variable.parameter.tailwind.tailwind" @@ -200,6 +250,10 @@ } }, "patterns": [ + { + "match": "none(?=;)", + "name": "invalid.illegal.invalid-source.css" + }, { "include": "source.css#string" } @@ -446,6 +500,114 @@ "name": "punctuation.terminator.rule.css" } ] + }, + "source-fn": { + "patterns": [ + { + "begin": "(?i)(?:\\s*)(?