diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 3809535cb..770012e87 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -26,10 +26,10 @@ For example: npm, yarn For example: macOS, Windows -**Tailwind config** +**Tailwind CSS Stylesheet (v4) or config file (v3)** ```js -// Paste the contents of your Tailwind config file here +// Paste the contents of your CSS file or config file here ``` **VS Code settings** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..a01f382e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: Run Tests +on: + pull_request: + branches: + - main + +jobs: + tests: + strategy: + fail-fast: false + matrix: + node: [18, 20, 22, 24] + os: + - namespace-profile-default + - namespace-profile-macos-arm64 + - namespace-profile-windows-amd64 + + runs-on: ${{ matrix.os }} + name: Run Tests - Node v${{ matrix.node }} / ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + cache: 'pnpm' + node-version: ${{ matrix.node }} + + - name: Install dependencies + run: pnpm install + + - name: Run syntax tests + working-directory: packages/tailwindcss-language-syntax + run: pnpm run build && pnpm run test + + - name: Run service tests + working-directory: packages/tailwindcss-language-service + run: pnpm run build && pnpm run test + + - name: Run server tests + working-directory: packages/tailwindcss-language-server + run: pnpm run build && pnpm run test diff --git a/.vscode/launch.json b/.vscode/launch.json index fd173bdc4..044786528 100755 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,15 @@ "request": "launch", "name": "Launch Client", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceRoot}/packages/vscode-tailwindcss"], + "args": [ + // enable this flag if you want to activate the extension only when you are debugging the extension + // "--disable-extensions", + "--disable-updates", + "--disable-workspace-trust", + "--skip-release-notes", + "--skip-welcome", + "--extensionDevelopmentPath=${workspaceRoot}/packages/vscode-tailwindcss" + ], "stopOnEntry": false, "sourceMaps": true, "outFiles": ["${workspaceRoot}/packages/vscode-tailwindcss/dist/**/*.js"], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1abde264e..aec43080f 100755 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -23,6 +23,9 @@ "panel": "dedicated", "reveal": "never" }, + "options": { + "cwd": "${workspaceFolder}/packages/vscode-tailwindcss" + }, "problemMatcher": ["$tsc-watch"] } ] diff --git a/package.json b/package.json index ff4394fba..e89380519 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "@npmcli/package-json": "^5.0.0", "@types/culori": "^2.1.0", "culori": "^4.0.1", - "esbuild": "^0.25.0", + "esbuild": "^0.25.5", "minimist": "^1.2.8", "prettier": "^3.2.5", "semver": "^7.7.1" diff --git a/packages/tailwindcss-language-server/package.json b/packages/tailwindcss-language-server/package.json index 5ed431fb5..768232e54 100644 --- a/packages/tailwindcss-language-server/package.json +++ b/packages/tailwindcss-language-server/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/language-server", - "version": "0.14.9", + "version": "0.14.22", "description": "Tailwind CSS Language Server", "license": "MIT", "repository": { @@ -34,14 +34,23 @@ "access": "public" }, "devDependencies": { - "@parcel/watcher": "2.0.3", + "@parcel/watcher": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", "@tailwindcss/aspect-ratio": "0.4.2", "@tailwindcss/container-queries": "0.1.0", "@tailwindcss/forms": "0.5.3", "@tailwindcss/language-service": "workspace:*", "@tailwindcss/line-clamp": "0.4.2", - "@tailwindcss/oxide": "^4.0.0-alpha.19", + "@tailwindcss/oxide": "^4.1.0", "@tailwindcss/typography": "0.5.7", + "@types/braces": "3.0.1", "@types/color-name": "^1.1.3", "@types/culori": "^2.1.0", "@types/debounce": "1.2.0", @@ -65,8 +74,7 @@ "dlv": "1.1.3", "dset": "3.1.4", "enhanced-resolve": "^5.16.1", - "esbuild": "^0.25.0", - "fast-glob": "3.2.4", + "esbuild": "^0.25.5", "find-up": "5.0.0", "jiti": "^2.3.3", "klona": "2.0.4", @@ -75,7 +83,7 @@ "normalize-path": "3.0.0", "picomatch": "^4.0.1", "pkg-up": "3.1.0", - "postcss": "8.4.31", + "postcss": "8.5.4", "postcss-import": "^16.1.0", "postcss-load-config": "3.0.1", "postcss-selector-parser": "6.0.2", @@ -83,18 +91,20 @@ "rimraf": "3.0.2", "stack-trace": "0.0.10", "tailwindcss": "3.4.17", - "tailwindcss-v4": "npm:tailwindcss@4.0.6", + "tailwindcss-v4": "npm:tailwindcss@4.1.1", + "tinyglobby": "^0.2.12", "tsconfck": "^3.1.4", "tsconfig-paths": "^4.2.0", - "typescript": "5.3.3", - "vite-tsconfig-paths": "^4.3.1", - "vitest": "^1.6.1", - "vscode-css-languageservice": "6.2.9", + "typescript": "5.8.3", + "vite": "^6.3.5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.1", + "vscode-css-languageservice": "6.3.6", "vscode-jsonrpc": "8.2.0", "vscode-languageclient": "8.1.0", "vscode-languageserver": "8.1.0", "vscode-languageserver-protocol": "^3.17.5", - "vscode-languageserver-textdocument": "1.0.11", + "vscode-languageserver-textdocument": "1.0.12", "vscode-uri": "3.0.2" }, "engines": { diff --git a/packages/tailwindcss-language-server/src/config.ts b/packages/tailwindcss-language-server/src/config.ts index d8364d061..a5e68f7df 100644 --- a/packages/tailwindcss-language-server/src/config.ts +++ b/packages/tailwindcss-language-server/src/config.ts @@ -1,6 +1,9 @@ import merge from 'deepmerge' import { isObject } from './utils' -import type { Settings } from '@tailwindcss/language-service/src/util/state' +import { + getDefaultTailwindSettings, + type Settings, +} from '@tailwindcss/language-service/src/util/state' import type { Connection } from 'vscode-languageserver' export interface SettingsCache { @@ -8,40 +11,6 @@ export interface SettingsCache { clear(): void } -function getDefaultSettings(): Settings { - return { - editor: { tabSize: 2 }, - tailwindCSS: { - inspectPort: null, - emmetCompletions: false, - classAttributes: ['class', 'className', 'ngClass', 'class:list'], - codeActions: true, - hovers: true, - suggestions: true, - validate: true, - colorDecorators: true, - rootFontSize: 16, - lint: { - cssConflict: 'warning', - invalidApply: 'error', - invalidScreen: 'error', - invalidVariant: 'error', - invalidConfigPath: 'error', - invalidTailwindDirective: 'error', - invalidSourceDirective: 'error', - recommendedVariantOrder: 'warning', - }, - showPixelEquivalents: true, - includeLanguages: {}, - files: { exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'] }, - experimental: { - classRegex: [], - configFile: null, - }, - }, - } -} - export function createSettingsCache(connection: Connection): SettingsCache { const cache: Map = new Map() @@ -73,7 +42,7 @@ export function createSettingsCache(connection: Connection): SettingsCache { tailwindCSS = isObject(tailwindCSS) ? tailwindCSS : {} return merge( - getDefaultSettings(), + getDefaultTailwindSettings(), { editor, tailwindCSS }, { arrayMerge: (_destinationArray, sourceArray, _options) => sourceArray }, ) diff --git a/packages/tailwindcss-language-server/src/css/extract-source-directives.ts b/packages/tailwindcss-language-server/src/css/extract-source-directives.ts index 9de33a9b3..a97e35594 100644 --- a/packages/tailwindcss-language-server/src/css/extract-source-directives.ts +++ b/packages/tailwindcss-language-server/src/css/extract-source-directives.ts @@ -1,12 +1,21 @@ import type { Plugin } from 'postcss' +import type { SourcePattern } from '../project-locator' -export function extractSourceDirectives(sources: string[]): Plugin { +export function extractSourceDirectives(sources: SourcePattern[]): Plugin { return { postcssPlugin: 'extract-at-rules', AtRule: { source: ({ params }) => { + let negated = /^not\s+/.test(params) + + if (negated) params = params.slice(4).trimStart() + if (params[0] !== '"' && params[0] !== "'") return - sources.push(params.slice(1, -1)) + + sources.push({ + pattern: params.slice(1, -1), + negated, + }) }, }, } diff --git a/packages/tailwindcss-language-server/src/language/css-server.ts b/packages/tailwindcss-language-server/src/language/css-server.ts index a43910b22..73e967fcc 100644 --- a/packages/tailwindcss-language-server/src/language/css-server.ts +++ b/packages/tailwindcss-language-server/src/language/css-server.ts @@ -13,7 +13,7 @@ import { CompletionItemKind, Connection, } from 'vscode-languageserver/node' -import { TextDocument } from 'vscode-languageserver-textdocument' +import { Position, TextDocument } from 'vscode-languageserver-textdocument' import { Utils, URI } from 'vscode-uri' import { getLanguageModelCache } from './languageModelCache' import { Stylesheet } from 'vscode-css-languageservice' @@ -121,6 +121,7 @@ export class CssServer { async function withDocumentAndSettings( uri: string, callback: (result: { + original: TextDocument document: TextDocument settings: LanguageSettings | undefined }) => T | Promise, @@ -130,13 +131,64 @@ export class CssServer { return null } return await callback({ + original: document, document: createVirtualCssDocument(document), settings: await getDocumentSettings(document), }) } + function isInImportDirective(doc: TextDocument, pos: Position) { + let text = doc.getText({ + start: { line: pos.line, character: 0 }, + end: pos, + }) + + // Scan backwards to see if we're inside an `@import` directive + let foundImport = false + let foundDirective = false + + for (let i = text.length - 1; i >= 0; i--) { + let char = text[i] + if (char === '\n') break + + if (char === '(' && !foundDirective) { + if (text.startsWith(' source(', i - 7)) { + foundDirective = true + } + + // + else if (text.startsWith(' theme(', i - 6)) { + foundDirective = true + } + + // + else if (text.startsWith(' prefix(', i - 7)) { + foundDirective = true + } + } + + // + else if (char === '@' && !foundImport) { + if (text.startsWith('@import ', i)) { + foundImport = true + } + } + } + + return foundImport && foundDirective + } + connection.onCompletion(async ({ textDocument, position }, _token) => - withDocumentAndSettings(textDocument.uri, async ({ document, settings }) => { + withDocumentAndSettings(textDocument.uri, async ({ original, document, settings }) => { + // If we're inside source(…), prefix(…), or theme(…), don't show + // completions from the CSS language server + if (isInImportDirective(original, position)) { + return { + isIncomplete: false, + items: [], + } + } + let result = await cssLanguageService.doComplete2( document, position, diff --git a/packages/tailwindcss-language-server/src/matching.ts b/packages/tailwindcss-language-server/src/matching.ts new file mode 100644 index 000000000..a373b116c --- /dev/null +++ b/packages/tailwindcss-language-server/src/matching.ts @@ -0,0 +1,24 @@ +import picomatch from 'picomatch' +import { DefaultMap } from './util/default-map' + +export interface PathMatcher { + anyMatches(pattern: string, paths: string[]): boolean + clear(): void +} + +export function createPathMatcher(): PathMatcher { + let matchers = new DefaultMap((pattern) => { + // Escape picomatch special characters so they're matched literally + pattern = pattern.replace(/[\[\]{}()]/g, (m) => `\\${m}`) + + return picomatch(pattern, { dot: true }) + }) + + return { + anyMatches: (pattern, paths) => { + let check = matchers.get(pattern) + return paths.some((path) => check(path)) + }, + clear: () => matchers.clear(), + } +} diff --git a/packages/tailwindcss-language-server/src/oxide.ts b/packages/tailwindcss-language-server/src/oxide.ts index bb8700ff4..4dd529dfc 100644 --- a/packages/tailwindcss-language-server/src/oxide.ts +++ b/packages/tailwindcss-language-server/src/oxide.ts @@ -36,8 +36,8 @@ declare namespace OxideV2 { } } -// This covers the Oxide API from v4.0.0-alpha.20+ -declare namespace OxideV3 { +// This covers the Oxide API from v4.0.0-alpha.30+ +declare namespace OxideV3And4 { interface GlobEntry { base: string pattern: string @@ -58,17 +58,44 @@ declare namespace OxideV3 { } } +// This covers the Oxide API from v4.1.0+ +declare namespace OxideV5 { + interface GlobEntry { + base: string + pattern: string + } + + interface SourceEntry { + base: string + pattern: string + negated: boolean + } + + interface ScannerOptions { + sources: Array + } + + interface ScannerConstructor { + new (options: ScannerOptions): Scanner + } + + interface Scanner { + get files(): Array + get globs(): Array + } +} + interface Oxide { scanDir?(options: OxideV1.ScanOptions): OxideV1.ScanResult scanDir?(options: OxideV2.ScanOptions): OxideV2.ScanResult - Scanner?: OxideV3.ScannerConstructor + Scanner?: OxideV3And4.ScannerConstructor | OxideV5.ScannerConstructor } async function loadOxideAtPath(id: string): Promise { let oxide = await import(id) // This is a much older, unsupported version of Oxide before v4.0.0-alpha.1 - if (!oxide.scanDir) return null + if (!oxide.scanDir && !oxide.Scanner) return null return oxide } @@ -78,11 +105,17 @@ interface GlobEntry { pattern: string } +interface SourceEntry { + base: string + pattern: string + negated: boolean +} + interface ScanOptions { oxidePath: string oxideVersion: string basePath: string - sources: Array + sources: Array } interface ScanResult { @@ -101,7 +134,7 @@ interface ScanResult { * For example, the `sources` option is ignored before v4.0.0-alpha.19. */ export async function scan(options: ScanOptions): Promise { - const oxide = await loadOxideAtPath(options.oxidePath) + let oxide = await loadOxideAtPath(options.oxidePath) if (!oxide) return null // V1 @@ -118,38 +151,58 @@ export async function scan(options: ScanOptions): Promise { } // V2 - if (lte(options.oxideVersion, '4.0.0-alpha.19')) { + else if (lte(options.oxideVersion, '4.0.0-alpha.19')) { let result = oxide.scanDir({ base: options.basePath, - sources: options.sources, + sources: options.sources.map((g) => ({ base: g.base, pattern: g.pattern })), }) return { files: result.files, - globs: result.globs, + globs: result.globs.map((g) => ({ base: g.base, pattern: g.pattern })), } } // V3 - if (lte(options.oxideVersion, '4.0.0-alpha.30')) { - let scanner = new oxide.Scanner({ + else if (lte(options.oxideVersion, '4.0.0-alpha.30')) { + let scanner = new (oxide.Scanner as OxideV3And4.ScannerConstructor)({ detectSources: { base: options.basePath }, - sources: options.sources, + sources: options.sources.map((g) => ({ base: g.base, pattern: g.pattern })), }) return { files: scanner.files, - globs: scanner.globs, + globs: scanner.globs.map((g) => ({ base: g.base, pattern: g.pattern })), } } // V4 - let scanner = new oxide.Scanner({ - sources: [{ base: options.basePath, pattern: '**/*' }, ...options.sources], - }) + else if (lte(options.oxideVersion, '4.0.9999')) { + let scanner = new (oxide.Scanner as OxideV3And4.ScannerConstructor)({ + sources: [ + { base: options.basePath, pattern: '**/*' }, + ...options.sources.map((g) => ({ base: g.base, pattern: g.pattern })), + ], + }) - return { - files: scanner.files, - globs: scanner.globs, + return { + files: scanner.files, + globs: scanner.globs.map((g) => ({ base: g.base, pattern: g.pattern })), + } + } + + // V5 + else { + let scanner = new (oxide.Scanner as OxideV5.ScannerConstructor)({ + sources: [ + { base: options.basePath, pattern: '**/*', negated: false }, + ...options.sources.map((g) => ({ base: g.base, pattern: g.pattern, negated: g.negated })), + ], + }) + + return { + files: scanner.files, + globs: scanner.globs.map((g) => ({ base: g.base, pattern: g.pattern })), + } } } diff --git a/packages/tailwindcss-language-server/src/project-locator.test.ts b/packages/tailwindcss-language-server/src/project-locator.test.ts index 6414a099c..7728d7957 100644 --- a/packages/tailwindcss-language-server/src/project-locator.test.ts +++ b/packages/tailwindcss-language-server/src/project-locator.test.ts @@ -4,12 +4,13 @@ import { ProjectLocator } from './project-locator' import { URL, fileURLToPath } from 'url' import { Settings } from '@tailwindcss/language-service/src/util/state' import { createResolver } from './resolver' -import { css, defineTest, js, json, scss, Storage, TestUtils } from './testing' +import { css, defineTest, html, js, json, scss, Storage, symlinkTo, TestUtils } from './testing' +import { normalizePath } from './utils' let settings: Settings = { tailwindCSS: { files: { - exclude: [], + exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'], }, }, } as any @@ -29,12 +30,14 @@ function testFixture(fixture: string, details: any[]) { let detail = details[i] - let configPath = path.relative(fixturePath, project.config.path) + let configPath = path.posix.relative(normalizePath(fixturePath), project.config.path) expect(configPath).toEqual(detail?.config) if (detail?.content) { - let expected = detail?.content.map((path) => path.replace('{URL}', fixturePath)).sort() + let expected = detail?.content + .map((path) => path.replace('{URL}', normalizePath(fixturePath))) + .sort() let actual = project.documentSelector .filter((selector) => selector.priority === 1 /** content */) @@ -45,7 +48,9 @@ function testFixture(fixture: string, details: any[]) { } if (detail?.selectors) { - let expected = detail?.selectors.map((path) => path.replace('{URL}', fixturePath)).sort() + let expected = detail?.selectors + .map((path) => path.replace('{URL}', normalizePath(fixturePath))) + .sort() let actual = project.documentSelector.map((selector) => selector.pattern).sort() @@ -114,27 +119,22 @@ testFixture('v4/workspaces', [ { config: 'packages/admin/app.css', selectors: [ - '{URL}/node_modules/tailwindcss/**', - '{URL}/node_modules/tailwindcss/index.css', - '{URL}/node_modules/tailwindcss/theme.css', - '{URL}/node_modules/tailwindcss/utilities.css', + '{URL}/packages/admin/*', '{URL}/packages/admin/**', '{URL}/packages/admin/app.css', '{URL}/packages/admin/package.json', + '{URL}/packages/admin/tw.css', ], }, { config: 'packages/web/app.css', selectors: [ - '{URL}/node_modules/tailwindcss/**', - '{URL}/node_modules/tailwindcss/index.css', - '{URL}/node_modules/tailwindcss/theme.css', - '{URL}/node_modules/tailwindcss/utilities.css', '{URL}/packages/style-export/**', '{URL}/packages/style-export/lib.css', '{URL}/packages/style-export/theme.css', '{URL}/packages/style-main-field/**', '{URL}/packages/style-main-field/lib.css', + '{URL}/packages/web/*', '{URL}/packages/web/**', '{URL}/packages/web/app.css', '{URL}/packages/web/package.json', @@ -142,68 +142,173 @@ testFixture('v4/workspaces', [ }, ]) -testFixture('v4/auto-content', [ - // - { - config: 'src/app.css', - content: [ - '{URL}/package.json', - '{URL}/src/index.html', - '{URL}/src/components/example.html', - '{URL}/src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - ], +testLocator({ + name: 'automatic content detection with Oxide', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "4.1.0", + "@tailwindcss/oxide": "4.1.0" + } + } + `, + 'src/index.html': html`
Test
`, + 'src/app.css': css` + @import 'tailwindcss'; + `, + 'src/components/example.html': html`
Test
`, }, -]) + expected: [ + { + config: '/src/app.css', + content: [ + '/*', + '/package.json', + '/src/**/*.{aspx,astro,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '/src/components/example.html', + '/src/index.html', + ], + }, + ], +}) -testFixture('v4/auto-content-split', [ - // - { - // TODO: This should _probably_ not be present - config: 'node_modules/tailwindcss/index.css', - content: [], - }, - { - config: 'src/app.css', - content: [ - '{URL}/package.json', - '{URL}/src/index.html', - '{URL}/src/components/example.html', - '{URL}/src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - ], +testLocator({ + name: 'automatic content detection with Oxide using split config', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "4.1.0", + "@tailwindcss/oxide": "4.1.0" + } + } + `, + 'src/index.html': html`
Test
`, + 'src/app.css': css` + @import 'tailwindcss/preflight' layer(base); + @import 'tailwindcss/theme' layer(theme); + @import 'tailwindcss/utilities' layer(utilities); + `, + 'src/components/example.html': html`
Test
`, }, -]) + expected: [ + { + config: '/src/app.css', + content: [ + '/*', + '/package.json', + '/src/**/*.{aspx,astro,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '/src/components/example.html', + '/src/index.html', + ], + }, + ], +}) -testFixture('v4/custom-source', [ - // - { - config: 'admin/app.css', - content: [ - '{URL}/admin/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - '{URL}/admin/**/*.bin', - '{URL}/admin/foo.bin', - '{URL}/package.json', - '{URL}/shared.html', - '{URL}/web/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - ], +testLocator({ + name: 'automatic content detection with custom sources', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "4.1.0", + "@tailwindcss/oxide": "4.1.0" + } + } + `, + 'admin/app.css': css` + @import './tw.css'; + @import './ui.css'; + `, + 'admin/tw.css': css` + @import 'tailwindcss'; + @source './**/*.bin'; + `, + 'admin/ui.css': css` + @theme { + --color-potato: #907a70; + } + `, + 'admin/foo.bin': html`

Admin

`, + + 'web/app.css': css` + @import 'tailwindcss'; + @source './*.bin'; + `, + 'web/bar.bin': html`

Web

`, + + 'shared.html': html`

I belong to no one!

`, }, - { - config: 'web/app.css', - content: [ - '{URL}/admin/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - '{URL}/web/*.bin', - '{URL}/web/bar.bin', - '{URL}/package.json', - '{URL}/shared.html', - '{URL}/web/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - ], + expected: [ + { + config: '/admin/app.css', + content: [ + '/*', + '/admin/foo.bin', + '/admin/tw.css', + '/admin/ui.css', + '/admin/{**/*.bin,**/*.{aspx,astro,bin,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}}', + '/package.json', + '/shared.html', + '/web/**/*.{aspx,astro,bin,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '/web/app.css', + ], + }, + { + config: '/web/app.css', + content: [ + '/*', + '/admin/**/*.{aspx,astro,bin,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '/admin/app.css', + '/admin/tw.css', + '/admin/ui.css', + '/package.json', + '/shared.html', + '/web/bar.bin', + '/web/{**/*.{aspx,astro,bin,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue},*.bin}', + ], + }, + ], +}) + +testLocator({ + name: 'automatic content detection with negative custom sources', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "4.1.0", + "@tailwindcss/oxide": "4.1.0" + } + } + `, + 'src/app.css': css` + @import 'tailwindcss'; + @source './**/*.html'; + @source not './ignored.html'; + `, + 'src/index.html': html`
`, + 'src/ignored.html': html`
`, }, -]) + expected: [ + { + config: '/src/app.css', + content: [ + '/*', + '/package.json', + '/src/index.html', + '/src/{**/*.html,**/*.{aspx,astro,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}}', + ], + }, + ], +}) testFixture('v4/missing-files', [ // { config: 'app.css', - content: ['{URL}/package.json'], + content: ['{URL}/*', '{URL}/i-exist.css', '{URL}/package.json'], }, ]) @@ -212,8 +317,10 @@ testFixture('v4/path-mappings', [ { config: 'app.css', content: [ + '{URL}/*', '{URL}/package.json', - '{URL}/src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', + '{URL}/src/**/*.{aspx,astro,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '{URL}/src/a/file.css', '{URL}/src/a/my-config.ts', '{URL}/src/a/my-plugin.ts', '{URL}/tsconfig.json', @@ -225,7 +332,7 @@ testFixture('v4/invalid-import-order', [ // { config: 'tailwind.css', - content: ['{URL}/package.json'], + content: ['{URL}/*', '{URL}/a.css', '{URL}/b.css', '{URL}/package.json'], }, ]) @@ -237,7 +344,7 @@ testLocator({ 'package.json': json` { "dependencies": { - "tailwindcss": "^4.0.2" + "tailwindcss": "4.1.0" } } `, @@ -285,7 +392,7 @@ testLocator({ 'package.json': json` { "dependencies": { - "tailwindcss": "4.0.6" + "tailwindcss": "4.1.0" } } `, @@ -314,13 +421,173 @@ testLocator({ }, expected: [ { - version: '4.0.6', + version: '4.1.0', config: '/src/articles/articles.css', content: [], }, ], }) +testLocator({ + name: 'Recursive symlinks do not cause infinite traversal loops', + fs: { + 'src/a/b/c/index.css': css` + @import 'tailwindcss'; + `, + 'src/a/b/c/z': symlinkTo('src', 'dir'), + 'src/a/b/x': symlinkTo('src', 'dir'), + 'src/a/b/y': symlinkTo('src', 'dir'), + 'src/a/b/z': symlinkTo('src', 'dir'), + 'src/a/x': symlinkTo('src', 'dir'), + + 'src/b/c/d/z': symlinkTo('src', 'dir'), + 'src/b/c/d/index.css': css``, + 'src/b/c/x': symlinkTo('src', 'dir'), + 'src/b/c/y': symlinkTo('src', 'dir'), + 'src/b/c/z': symlinkTo('src', 'dir'), + 'src/b/x': symlinkTo('src', 'dir'), + + 'src/c/d/e/z': symlinkTo('src', 'dir'), + 'src/c/d/x': symlinkTo('src', 'dir'), + 'src/c/d/y': symlinkTo('src', 'dir'), + 'src/c/d/z': symlinkTo('src', 'dir'), + 'src/c/x': symlinkTo('src', 'dir'), + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/src/a/b/c/index.css', + content: [], + }, + ], +}) + +testLocator({ + name: 'File exclusions starting with `/` do not cause traversal to loop forever', + fs: { + 'index.css': css` + @import 'tailwindcss'; + `, + 'vendor/a.css': css` + @import 'tailwindcss'; + `, + 'vendor/nested/b.css': css` + @import 'tailwindcss'; + `, + 'src/vendor/c.css': css` + @import 'tailwindcss'; + `, + }, + settings: { + tailwindCSS: { + files: { + exclude: ['/vendor'], + }, + } as Settings['tailwindCSS'], + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/index.css', + content: [], + }, + { + version: '4.1.1 (bundled)', + config: '/src/vendor/c.css', + content: [], + }, + ], +}) + +testLocator({ + name: 'Stylesheets that import Tailwind CSS are picked over ones that dont', + fs: { + 'a/foo.css': css` + @import './bar.css'; + .a { + color: red; + } + `, + 'a/bar.css': css` + .b { + color: red; + } + `, + 'src/app.css': css` + @import 'tailwindcss'; + `, + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/src/app.css', + content: [], + }, + { + version: '4.1.1 (bundled)', + config: '/a/foo.css', + content: [], + }, + ], +}) + +testLocator({ + name: 'Stylesheets that import Tailwind CSS indirectly are picked over ones that dont', + fs: { + 'a/foo.css': css` + @import './bar.css'; + .a { + color: red; + } + `, + 'a/bar.css': css` + .b { + color: red; + } + `, + 'src/app.css': css` + @import './tw.css'; + `, + 'src/tw.css': css` + @import 'tailwindcss'; + `, + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/src/app.css', + content: [], + }, + { + version: '4.1.1 (bundled)', + config: '/a/foo.css', + content: [], + }, + ], +}) + +testLocator({ + name: 'Stylesheets that only have URL imports are not considered roots', + fs: { + 'a/fonts.css': css` + @import 'https://example.com/fonts/some-font.css'; + .a { + color: red; + } + `, + 'src/app.css': css` + @import 'tailwindcss'; + `, + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/src/app.css', + content: [], + }, + ], +}) + // --- function testLocator({ @@ -361,7 +628,7 @@ function testLocator({ }) } -async function prepare({ root }: TestUtils) { +async function prepare({ root }: TestUtils) { let defaultSettings = { tailwindCSS: { files: { @@ -373,7 +640,7 @@ async function prepare({ root }: TestUtils) { } as Settings function adjustPath(filepath: string) { - filepath = filepath.replace(root, '{URL}') + filepath = filepath.replace(normalizePath(root), '{URL}') if (filepath.startsWith('{URL}/')) { filepath = filepath.slice(5) diff --git a/packages/tailwindcss-language-server/src/project-locator.ts b/packages/tailwindcss-language-server/src/project-locator.ts index 460b14233..bec102900 100644 --- a/packages/tailwindcss-language-server/src/project-locator.ts +++ b/packages/tailwindcss-language-server/src/project-locator.ts @@ -1,7 +1,6 @@ -import * as os from 'node:os' import * as path from 'node:path' import * as fs from 'node:fs/promises' -import glob from 'fast-glob' +import { glob } from 'tinyglobby' import picomatch from 'picomatch' import type { Settings } from '@tailwindcss/language-service/src/util/state' import { CONFIG_GLOB, CSS_GLOB } from './lib/constants' @@ -23,10 +22,17 @@ export interface ProjectConfig { folder: string /** The path to the config file (if it exists) */ - configPath?: string + configPath: string /** The list of documents that are related to this project */ - documentSelector?: DocumentSelector[] + documentSelector: DocumentSelector[] + + /** + * Additional selectors that should be matched with this project + * + * These are *never* reset + */ + additionalSelectors: DocumentSelector[] /** Whether or not this project was explicitly defined by the user */ isUserConfigured: boolean @@ -66,7 +72,7 @@ export class ProjectLocator { } if (projects.length === 1) { - projects[0].documentSelector.push({ + projects[0].additionalSelectors.push({ pattern: normalizePath(path.join(this.base, '**')), priority: DocumentSelectorPriority.ROOT_DIRECTORY, }) @@ -86,6 +92,10 @@ export class ProjectLocator { for (let selector of project.documentSelector) { selector.pattern = normalizeDriveLetter(selector.pattern) } + + for (let selector of project.additionalSelectors) { + selector.pattern = normalizeDriveLetter(selector.pattern) + } } return projects @@ -133,6 +143,7 @@ export class ProjectLocator { priority: DocumentSelectorPriority.USER_CONFIGURED, pattern: selector, })), + additionalSelectors: [], tailwind, } } @@ -207,62 +218,7 @@ export class ProjectLocator { // Look for the package root for the config config.packageRoot = await getPackageRoot(path.dirname(config.path), this.base) - let selectors: DocumentSelector[] = [] - - // selectors: - // - CSS files - for (let entry of config.entries) { - if (entry.type !== 'css') continue - selectors.push({ - pattern: entry.path, - priority: DocumentSelectorPriority.CSS_FILE, - }) - } - - // - Config File - selectors.push({ - pattern: config.path, - priority: DocumentSelectorPriority.CONFIG_FILE, - }) - - // - Content patterns from config - for await (let selector of contentSelectorsFromConfig( - config, - tailwind.features, - this.resolver, - )) { - selectors.push(selector) - } - - // - Directories containing the CSS files - for (let entry of config.entries) { - if (entry.type !== 'css') continue - selectors.push({ - pattern: normalizePath(path.join(path.dirname(entry.path), '**')), - priority: DocumentSelectorPriority.CSS_DIRECTORY, - }) - } - - // - Directory containing the config - selectors.push({ - pattern: normalizePath(path.join(path.dirname(config.path), '**')), - priority: DocumentSelectorPriority.CONFIG_DIRECTORY, - }) - - // - Root of package that contains the config - selectors.push({ - pattern: normalizePath(path.join(config.packageRoot, '**')), - priority: DocumentSelectorPriority.PACKAGE_DIRECTORY, - }) - - // Reorder selectors from most specific to least specific - selectors.sort((a, z) => a.priority - z.priority) - - // Eliminate duplicate selector patterns - selectors = selectors.filter( - ({ pattern }, index, documentSelectors) => - documentSelectors.findIndex(({ pattern: p }) => p === pattern) === index, - ) + let selectors = await calculateDocumentSelectors(config, tailwind.features, this.resolver) return { config, @@ -270,20 +226,32 @@ export class ProjectLocator { isUserConfigured: false, configPath: config.path, documentSelector: selectors, + additionalSelectors: [], tailwind, } } private async findConfigs(): Promise { + let ignore = this.settings.tailwindCSS.files.exclude + + // NOTE: This is a temporary workaround for a bug in the `fdir` package used + // by `tinyglobby`. It infinite loops when the ignore pattern starts with + // a `/`. This should be removed once the bug is fixed. + ignore = ignore.map((pattern) => { + if (!pattern.startsWith('/')) return pattern + + return pattern.slice(1) + }) + // Look for config files and CSS files - let files = await glob([`**/${CONFIG_GLOB}`, `**/${CSS_GLOB}`], { + let files = await glob({ + patterns: [`**/${CONFIG_GLOB}`, `**/${CSS_GLOB}`], cwd: this.base, - ignore: this.settings.tailwindCSS.files.exclude, + ignore, onlyFiles: true, absolute: true, - suppressErrors: true, + followSymbolicLinks: true, dot: true, - concurrency: Math.max(os.cpus().length, 1), }) let realpaths = await Promise.all(files.map((file) => fs.realpath(file))) @@ -390,6 +358,17 @@ export class ProjectLocator { // Resolve all @source directives await Promise.all(imports.map((file) => file.resolveSourceDirectives())) + let byRealPath: Record = {} + for (let file of imports) byRealPath[file.realpath] = file + + // TODO: Link every entry in the import graph + // This breaks things tho + // for (let file of imports) file.deps = file.deps.map((dep) => byRealPath[dep.realpath] ?? dep) + + // Check if each file has a direct or indirect tailwind import + // TODO: Remove the `byRealPath` argument and use linked deps instead + await Promise.all(imports.map((file) => file.resolveImportsTailwind(byRealPath))) + // Create a graph of all the CSS files that might (indirectly) use Tailwind let graph = new Graph() @@ -427,14 +406,20 @@ export class ProjectLocator { if (indexPath && themePath) graph.connect(indexPath, themePath) if (indexPath && utilitiesPath) graph.connect(indexPath, utilitiesPath) - // Sort the graph so potential "roots" appear first - // The entire concept of roots needs to be rethought because it's not always - // clear what the root of a project is. Even when imports are present a file - // may import a file that is the actual "root" of the project. let roots = Array.from(graph.roots()) roots.sort((a, b) => { - return a.meta.root === b.meta.root ? 0 : a.meta.root ? -1 : 1 + return ( + // Sort the graph so potential "roots" appear first + // The entire concept of roots needs to be rethought because it's not always + // clear what the root of a project is. Even when imports are present a file + // may import a file that is the actual "root" of the project. + Number(b.meta.root) - Number(a.meta.root) || + // Move stylesheets with an explicit tailwindcss import before others + Number(b.importsTailwind) - Number(a.importsTailwind) || + // Otherwise stylesheets are kept in discovery order + 0 + ) }) for (let root of roots) { @@ -535,13 +520,14 @@ function contentSelectorsFromConfig( entry: ConfigEntry, features: Feature[], resolver: Resolver, + actualConfig?: any, ): AsyncIterable { if (entry.type === 'css') { return contentSelectorsFromCssConfig(entry, resolver) } if (entry.type === 'js') { - return contentSelectorsFromJsConfig(entry, features) + return contentSelectorsFromJsConfig(entry, features, actualConfig) } } @@ -576,11 +562,18 @@ async function* contentSelectorsFromJsConfig( if (typeof item !== 'string') continue let filepath = item.startsWith('!') - ? `!${path.resolve(contentBase, item.slice(1))}` + ? path.resolve(contentBase, item.slice(1)) : path.resolve(contentBase, item) + filepath = normalizePath(filepath) + filepath = normalizeDriveLetter(filepath) + + if (item.startsWith('!')) { + filepath = `!${filepath}` + } + yield { - pattern: normalizePath(filepath), + pattern: filepath, priority: DocumentSelectorPriority.CONTENT_FILE, } } @@ -593,8 +586,11 @@ async function* contentSelectorsFromCssConfig( let auto = false for (let item of entry.content) { if (item.kind === 'file') { + let filepath = item.file + filepath = normalizePath(filepath) + filepath = normalizeDriveLetter(filepath) yield { - pattern: normalizePath(item.file), + pattern: filepath, priority: DocumentSelectorPriority.CONTENT_FILE, } } else if (item.kind === 'auto' && !auto) { @@ -623,25 +619,23 @@ async function* contentSelectorsFromCssConfig( async function* detectContentFiles( base: string, inputFile: string, - sources: string[], + sources: SourcePattern[], resolver: Resolver, ): AsyncIterable { try { - let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', path.dirname(base)) + let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', base) oxidePath = pathToFileURL(oxidePath).href - let oxidePackageJsonPath = await resolver.resolveJsId( - '@tailwindcss/oxide/package.json', - path.dirname(base), - ) + let oxidePackageJsonPath = await resolver.resolveJsId('@tailwindcss/oxide/package.json', base) let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8')) let result = await oxide.scan({ oxidePath, oxideVersion: oxidePackageJson.version, basePath: base, - sources: sources.map((pattern) => ({ + sources: sources.map((source) => ({ base: path.dirname(inputFile), - pattern, + pattern: source.pattern, + negated: source.negated, })), }) @@ -649,12 +643,16 @@ async function* detectContentFiles( if (!result) return for (let file of result.files) { - yield normalizePath(file) + file = normalizePath(file) + file = normalizeDriveLetter(file) + yield file } for (let { base, pattern } of result.globs) { // Do not normalize the glob itself as it may contain escape sequences - yield normalizePath(base) + '/' + pattern + base = normalizePath(base) + base = normalizeDriveLetter(base) + yield `${base}/${pattern}` } } catch { // @@ -675,11 +673,16 @@ type ConfigEntry = { content: ContentItem[] } +export interface SourcePattern { + pattern: string + negated: boolean +} + class FileEntry { content: string | null deps: FileEntry[] = [] realpath: string | null - sources: string[] = [] + sources: SourcePattern[] = [] meta: TailwindStylesheet | null = null constructor( @@ -752,7 +755,31 @@ class FileEntry { * Determine which Tailwind versions this file might be using */ async resolvePossibleVersions() { - this.meta = this.content ? analyzeStylesheet(this.content) : null + this.meta ??= this.content ? analyzeStylesheet(this.content) : null + } + + /** + * Determine if this entry or any of its dependencies import a Tailwind CSS + * stylesheet + */ + importsTailwind: boolean | null = null + + resolveImportsTailwind(byPath: Record) { + // Already calculated so nothing to do + if (this.importsTailwind !== null) return + + // We import it directly + let self = byPath[this.realpath] + + if (this.meta?.explicitImport || self?.meta?.explicitImport) { + this.importsTailwind = true + return + } + + // Maybe one of our deps does + for (let dep of this.deps) dep.resolveImportsTailwind(byPath) + + this.importsTailwind = this.deps.some((dep) => dep.importsTailwind) } /** @@ -780,3 +807,74 @@ function requiresPreprocessor(filepath: string) { return ext === '.scss' || ext === '.sass' || ext === '.less' || ext === '.styl' || ext === '.pcss' } + +export async function calculateDocumentSelectors( + config: ConfigEntry, + features: Feature[], + resolver: Resolver, + actualConfig?: any, +) { + let selectors: DocumentSelector[] = [] + + // selectors: + // - CSS files + for (let entry of config.entries) { + if (entry.type !== 'css') continue + + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(entry.path)), + priority: DocumentSelectorPriority.CSS_FILE, + }) + } + + // - Config File + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(config.path)), + priority: DocumentSelectorPriority.CONFIG_FILE, + }) + + // - Content patterns from config + for await (let selector of contentSelectorsFromConfig(config, features, resolver, actualConfig)) { + selectors.push(selector) + } + + // - Directories containing the CSS files + for (let entry of config.entries) { + if (entry.type !== 'css') continue + + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(path.join(path.dirname(entry.path), '**'))), + priority: DocumentSelectorPriority.CSS_DIRECTORY, + }) + } + + // - Directory containing the config + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(path.join(path.dirname(config.path), '**'))), + priority: DocumentSelectorPriority.CONFIG_DIRECTORY, + }) + + // - Root of package that contains the config + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(path.join(config.packageRoot, '**'))), + priority: DocumentSelectorPriority.PACKAGE_DIRECTORY, + }) + + // Reorder selectors from most specific to least specific + selectors.sort((a, z) => a.priority - z.priority) + + // Eliminate duplicate selector patterns + selectors = selectors.filter( + ({ pattern }, index, documentSelectors) => + documentSelectors.findIndex(({ pattern: p }) => p === pattern) === index, + ) + + // Move all the negated patterns to the front + selectors = selectors.sort((a, z) => { + if (a.pattern.startsWith('!') && !z.pattern.startsWith('!')) return -1 + if (!a.pattern.startsWith('!') && z.pattern.startsWith('!')) return 1 + return 0 + }) + + return selectors +} diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index b55ee0781..1038d31bd 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -15,6 +15,8 @@ import type { Disposable, DocumentLinkParams, DocumentLink, + CodeLensParams, + CodeLens, } from 'vscode-languageserver/node' import { FileChangeType } from 'vscode-languageserver/node' import type { TextDocument } from 'vscode-languageserver-textdocument' @@ -35,6 +37,7 @@ import stackTrace from 'stack-trace' import extractClassNames from './lib/extractClassNames' import { klona } from 'klona/full' import { doHover } from '@tailwindcss/language-service/src/hoverProvider' +import { getCodeLens } from '@tailwindcss/language-service/src/codeLensProvider' import { Resolver } from './resolver' import { doComplete, @@ -77,7 +80,7 @@ import { normalizeDriveLetter, } from './utils' import type { DocumentService } from './documents' -import type { ProjectConfig } from './project-locator' +import { calculateDocumentSelectors, type ProjectConfig } from './project-locator' import { supportedFeatures } from '@tailwindcss/language-service/src/features' import { loadDesignSystem } from './util/v4' import { readCssFile } from './util/css' @@ -110,6 +113,7 @@ export interface ProjectService { onColorPresentation(params: ColorPresentationParams): Promise onCodeAction(params: CodeActionParams): Promise onDocumentLinks(params: DocumentLinkParams): Promise + onCodeLens(params: CodeLensParams): Promise sortClassLists(classLists: string[]): string[] dependencies(): Iterable @@ -212,6 +216,7 @@ export async function createProjectService( let state: State = { enabled: false, + features: [], completionItemData: { _projectKey: projectKey, }, @@ -281,7 +286,9 @@ export async function createProjectService( ) } - function onFileEvents(changes: Array<{ file: string; type: FileChangeType }>): void { + async function onFileEvents( + changes: Array<{ file: string; type: FileChangeType }>, + ): Promise { let needsInit = false let needsRebuild = false @@ -302,16 +309,11 @@ export async function createProjectService( projectConfig.configPath && (isConfigFile || isDependency) ) { - documentSelector = [ - ...documentSelector.filter( - ({ priority }) => priority !== DocumentSelectorPriority.CONTENT_FILE, - ), - ...getContentDocumentSelectorFromConfigFile( - projectConfig.configPath, - initialTailwindVersion, - projectConfig.folder, - ), - ] + documentSelector = await calculateDocumentSelectors( + projectConfig.config, + state.features, + resolver, + ) checkOpenDocuments() } @@ -462,6 +464,14 @@ export async function createProjectService( // and this should be determined there and passed in instead let features = supportedFeatures(tailwindcssVersion, tailwindcss) log(`supported features: ${JSON.stringify(features)}`) + state.features = features + + if (params.initializationOptions?.testMode) { + state.features = [ + ...state.features, + ...(params.initializationOptions.additionalFeatures ?? []), + ] + } if (!features.includes('css-at-theme')) { tailwindcss = tailwindcss.default ?? tailwindcss @@ -688,6 +698,15 @@ export async function createProjectService( state.v4 = true state.v4Fallback = true state.jit = true + state.features = features + + if (params.initializationOptions?.testMode) { + state.features = [ + ...state.features, + ...(params.initializationOptions.additionalFeatures ?? []), + ] + } + state.modules = { tailwindcss: { version: tailwindcssVersion, module: tailwindcss }, postcss: { version: null, module: null }, @@ -815,6 +834,7 @@ export async function createProjectService( ) state.designSystem = designSystem + state.blocklist = Array.from(designSystem.invalidCandidates ?? []) let deps = designSystem.dependencies() @@ -940,17 +960,12 @@ export async function createProjectService( ///////////////////// if (!projectConfig.isUserConfigured) { - documentSelector = [ - ...documentSelector.filter( - ({ priority }) => priority !== DocumentSelectorPriority.CONTENT_FILE, - ), - ...getContentDocumentSelectorFromConfigFile( - state.configPath, - tailwindcss.version, - projectConfig.folder, - originalConfig, - ), - ] + documentSelector = await calculateDocumentSelectors( + projectConfig.config, + state.features, + resolver, + originalConfig, + ) } ////////////////////// @@ -960,7 +975,9 @@ export async function createProjectService( if (typeof state.separator !== 'string') { state.separator = ':' } - state.blocklist = Array.isArray(state.config.blocklist) ? state.config.blocklist : [] + if (!state.v4) { + state.blocklist = Array.isArray(state.config.blocklist) ? state.config.blocklist : [] + } delete state.config.blocklist if (state.v4) { @@ -1061,6 +1078,11 @@ export async function createProjectService( refreshDiagnostics() updateCapabilities() + + let isTestMode = params.initializationOptions?.testMode ?? false + if (!isTestMode) return + + connection.sendNotification('@/tailwindCSS/projectReloaded') } for (let entry of projectConfig.config.entries) { @@ -1126,6 +1148,7 @@ export async function createProjectService( state.designSystem = designSystem state.classList = classList state.variants = getVariants(state) + state.blocklist = Array.from(designSystem.invalidCandidates ?? []) let deps = designSystem.dependencies() @@ -1142,15 +1165,20 @@ export async function createProjectService( let elapsed = process.hrtime.bigint() - start console.log(`---- RELOADED IN ${(Number(elapsed) / 1e6).toFixed(2)}ms ----`) + + let isTestMode = params.initializationOptions?.testMode ?? false + if (!isTestMode) return + + connection.sendNotification('@/tailwindCSS/projectReloaded') }, state, documentSelector() { - return documentSelector + return [...documentSelector, ...projectConfig.additionalSelectors] }, tryInit, async dispose() { - state = { enabled: false } + state = { enabled: false, features: [] } for (let disposable of disposables) { ;(await disposable).dispose() } @@ -1159,11 +1187,9 @@ export async function createProjectService( if (state.enabled) { refreshDiagnostics() } - if (settings.editor?.colorDecorators) { - updateCapabilities() - } else { - connection.sendNotification('@/tailwindCSS/clearColors') - } + + updateCapabilities() + connection.sendNotification('@/tailwindCSS/clearColors') }, onFileEvents, async onHover(params: TextDocumentPositionParams): Promise { @@ -1177,6 +1203,17 @@ export async function createProjectService( return doHover(state, document, params.position) }, null) }, + async onCodeLens(params: CodeLensParams): Promise { + return withFallback(async () => { + if (!state.enabled) return null + let document = documentService.getDocument(params.textDocument.uri) + if (!document) return null + let settings = await state.editor.getConfiguration(document.uri) + if (!settings.tailwindCSS.codeLens) return null + if (await isExcluded(state, document)) return null + return getCodeLens(state, document) + }, null) + }, async onCompletion(params: CompletionParams): Promise { return withFallback(async () => { if (!state.enabled) return null diff --git a/packages/tailwindcss-language-server/src/testing/index.ts b/packages/tailwindcss-language-server/src/testing/index.ts index 21296fc71..f4737e196 100644 --- a/packages/tailwindcss-language-server/src/testing/index.ts +++ b/packages/tailwindcss-language-server/src/testing/index.ts @@ -1,42 +1,67 @@ -import { onTestFinished, test, TestOptions } from 'vitest' +import { onTestFinished, test, TestContext, TestOptions } from 'vitest' +import * as os from 'node:os' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as proc from 'node:child_process' -import dedent from 'dedent' +import dedent, { type Dedent } from 'dedent' -export interface TestUtils { +export interface TestUtils> { /** The "cwd" for this test */ root: string + + /** + * The input for this test — taken from the `inputs` in the test config + * + * @see {TestConfig} + */ + input?: TestInput +} + +export interface StorageSymlink { + [IS_A_SYMLINK]: true + filepath: string + type: 'file' | 'dir' | undefined } export interface Storage { /** A list of files and their content */ - [filePath: string]: string | Uint8Array | { [IS_A_SYMLINK]: true; filepath: string } + [filePath: string]: string | Uint8Array | StorageSymlink } -export interface TestConfig { +export interface TestConfig> { name: string + inputs?: TestInput[] + + skipNPM?: boolean fs?: Storage debug?: boolean - prepare?(utils: TestUtils): Promise - handle(utils: TestUtils & Extras): void | Promise + prepare?(utils: TestUtils): Promise + handle(utils: TestUtils & Extras): void | Promise options?: TestOptions } -export function defineTest(config: TestConfig) { - return test(config.name, config.options ?? {}, async ({ expect }) => { - let utils = await setup(config) +export function defineTest(config: TestConfig) { + async function runTest(ctx: TestContext, input?: I) { + let utils = await setup(config, input) let extras = await config.prepare?.(utils) await config.handle({ ...utils, ...extras, }) - }) + } + + if (config.inputs) { + return test.for(config.inputs ?? [])(config.name, config.options ?? {}, (input, ctx) => + runTest(ctx, input), + ) + } + + return test(config.name, config.options ?? {}, runTest) } -async function setup(config: TestConfig): Promise { +async function setup(config: TestConfig, input: I): Promise> { let randomId = Math.random().toString(36).substring(7) let baseDir = path.resolve(process.cwd(), `../../.debug/${randomId}`) @@ -46,14 +71,23 @@ async function setup(config: TestConfig): Promise { if (config.fs) { await prepareFileSystem(baseDir, config.fs) - await installDependencies(baseDir, config.fs) + + if (!config.skipNPM) { + await installDependencies(baseDir, config.fs) + } } - onTestFinished(async (result) => { + onTestFinished(async (ctx) => { // Once done, move all the files to a new location - await fs.rename(baseDir, doneDir) + try { + await fs.rename(baseDir, doneDir) + } catch { + // If it fails it doesn't really matter. It only fails on Windows and then + // only randomly so whatever + console.error('Failed to move test files to done directory') + } - if (result.state === 'fail') return + if (ctx.task.result?.state === 'fail') return if (path.sep === '\\') return @@ -66,14 +100,16 @@ async function setup(config: TestConfig): Promise { return { root: baseDir, + input, } } const IS_A_SYMLINK = Symbol('is-a-symlink') -export const symlinkTo = function (filepath: string) { +export function symlinkTo(filepath: string, type?: 'file' | 'dir'): StorageSymlink { return { [IS_A_SYMLINK]: true as const, filepath, + type, } } @@ -88,7 +124,14 @@ async function prepareFileSystem(base: string, storage: Storage) { if (typeof content === 'object' && IS_A_SYMLINK in content) { let target = path.resolve(base, content.filepath) - await fs.symlink(target, fullPath) + + let type: string = content.type + + if (os.platform() === 'win32' && content.type === 'dir') { + type = 'junction' + } + + await fs.symlink(target, fullPath, type) continue } @@ -121,8 +164,8 @@ async function installDependenciesIn(dir: string) { }) } -export const css = dedent -export const scss = dedent -export const html = dedent -export const js = dedent -export const json = dedent +export const css: Dedent = dedent +export const scss: Dedent = dedent +export const html: Dedent = dedent +export const js: Dedent = dedent +export const json: Dedent = dedent diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index 35da9386e..07d3956a9 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -19,6 +19,10 @@ import type { DocumentLink, InitializeResult, WorkspaceFolder, + CodeLensParams, + CodeLens, + ServerCapabilities, + ClientCapabilities, } from 'vscode-languageserver/node' import { CompletionRequest, @@ -30,10 +34,14 @@ import { FileChangeType, DocumentLinkRequest, TextDocumentSyncKind, + CodeLensRequest, + DidChangeConfigurationNotification, } from 'vscode-languageserver/node' import { URI } from 'vscode-uri' import normalizePath from 'normalize-path' import * as path from 'node:path' +import * as fs from 'node:fs/promises' +import * as fsSync from 'node:fs' import type * as chokidar from 'chokidar' import picomatch from 'picomatch' import * as parcel from './watcher/index.js' @@ -47,7 +55,8 @@ import { readCssFile } from './util/css' import { ProjectLocator, type ProjectConfig } from './project-locator' import type { TailwindCssSettings } from '@tailwindcss/language-service/src/util/state' import { createResolver, Resolver } from './resolver' -import { retry } from './util/retry' +import { analyzeStylesheet } from './version-guesser.js' +import { createPathMatcher, PathMatcher } from './matching.js' const TRIGGER_CHARACTERS = [ // class attributes @@ -96,12 +105,14 @@ export class TW { private watched: string[] = [] private settingsCache: SettingsCache + private pathMatcher: PathMatcher constructor(private connection: Connection) { this.documentService = new DocumentService(this.connection) this.projects = new Map() this.projectCounter = 0 this.settingsCache = createSettingsCache(connection) + this.pathMatcher = createPathMatcher() } async init(): Promise { @@ -111,38 +122,75 @@ export class TW { await this.initPromise } + private validateFolderUri(uri: URI): boolean { + if (uri.scheme !== 'file') { + console.warn( + `The workspace folder [${uri.toString()}] will be ignored: it does not use the file scheme.`, + ) + return false + } + + if (uri.fsPath === '/' || uri.fsPath === '\\\\') { + console.warn( + `The workspace folder [${uri.toString()}] will be ignored: it starts at the root of the filesystem which is most likely an error.`, + ) + return false + } + + return true + } + private getWorkspaceFolders(): WorkspaceFolder[] { if (this.initializeParams.workspaceFolders?.length) { - return this.initializeParams.workspaceFolders.map((folder) => ({ - uri: URI.parse(folder.uri).fsPath, - name: folder.name, - })) + return this.initializeParams.workspaceFolders.flatMap((folder) => { + let uri = URI.parse(folder.uri) + + if (!this.validateFolderUri(uri)) return [] + + return [ + { + uri: uri.fsPath, + name: folder.name, + }, + ] + }) } if (this.initializeParams.rootUri) { + let uri = URI.parse(this.initializeParams.rootUri) + + if (!this.validateFolderUri(uri)) return [] + return [ { - uri: URI.parse(this.initializeParams.rootUri).fsPath, + uri: uri.fsPath, name: 'Root', }, ] } if (this.initializeParams.rootPath) { + let uri = URI.file(this.initializeParams.rootPath) + + if (!this.validateFolderUri(uri)) return [] + return [ { - uri: URI.file(this.initializeParams.rootPath).fsPath, + uri: uri.fsPath, name: 'Root', }, ] } + console.warn(`No workspace folders detected`) + return [] } private async _init(): Promise { clearRequireCache() + this.pathMatcher.clear() let folders = this.getWorkspaceFolders().map((folder) => normalizePath(folder.uri)) if (folders.length === 0) { @@ -166,10 +214,35 @@ export class TW { } } + if (results.some((result) => result.status === 'fulfilled')) { + await this.updateCommonCapabilities() + } + await this.listenForEvents() } private async _initFolder(baseUri: URI): Promise { + // NOTE: We do this check because on Linux when using an LSP client that does + // not support watching files on behalf of the server, we'll use Parcel + // Watcher (if possible). If we start the watcher with a non-existent or + // inaccessible directory, it will throw an error with a very unhelpful + // message: "Bad file descriptor" + // + // The best thing we can do is an initial check for access to the directory + // and log a more helpful error message if it fails. + let base = baseUri.fsPath + + try { + // TODO: Change this to fs.constants after the node version bump + await fs.access(base, fsSync.constants.F_OK | fsSync.constants.R_OK) + } catch (err) { + console.error( + `Unable to access the workspace folder [${base}]. This may happen if the directory does not exist or the current user does not have the necessary permissions to access it.`, + ) + console.error(err) + return + } + let initUserLanguages = this.initializeParams.initializationOptions?.userLanguages ?? {} if (Object.keys(initUserLanguages).length > 0) { @@ -178,7 +251,6 @@ export class TW { ) } - let base = baseUri.fsPath let workspaceFolders: Array = [] let globalSettings = await this.settingsCache.get() let ignore = globalSettings.tailwindCSS.files.exclude @@ -279,7 +351,7 @@ export class TW { return { folder: workspace.folder, config: workspace.config.path, - selectors: workspace.documentSelector, + selectors: [...workspace.documentSelector, ...workspace.additionalSelectors], user: workspace.isUserConfigured, tailwind: workspace.tailwind, } @@ -293,6 +365,7 @@ export class TW { let needsRestart = false let needsSoftRestart = false + // TODO: This should use the server-level path matcher let isPackageMatcher = picomatch(`**/${PACKAGE_LOCK_GLOB}`, { dot: true }) let isCssMatcher = picomatch(`**/${CSS_GLOB}`, { dot: true }) let isConfigMatcher = picomatch(`**/${CONFIG_GLOB}`, { dot: true }) @@ -307,6 +380,7 @@ export class TW { normalizedFilename = normalizeDriveLetter(normalizedFilename) for (let ignorePattern of ignore) { + // TODO: This should use the server-level path matcher let isIgnored = picomatch(ignorePattern, { dot: true }) if (isIgnored(normalizedFilename)) { @@ -358,6 +432,13 @@ export class TW { for (let [, project] of this.projects) { if (!project.state.v4) continue + if ( + change.type === FileChangeType.Deleted && + changeAffectsFile(normalizedFilename, [project.projectConfig.configPath]) + ) { + continue + } + if (!changeAffectsFile(normalizedFilename, project.dependencies())) continue needsSoftRestart = true @@ -381,6 +462,31 @@ export class TW { needsRestart = true break } + + // + else { + // If the main CSS file in a project is deleted and then re-created + // the server won't restart because the project is gone by now and + // there's no concept of a "config file" for us to compare with + // + // So we'll check if the stylesheet could *potentially* create + // a new project but we'll only do so if no projects were found + // + // If we did this all the time we'd potentially restart the server + // unncessarily a lot while the user is editing their stylesheets + if (this.projects.size > 0) continue + + let content = await readCssFile(change.file) + if (!content) continue + + let stylesheet = analyzeStylesheet(content) + if (!stylesheet.root) continue + + if (!stylesheet.versions.includes('4')) continue + + needsRestart = true + break + } } let isConfigFile = isConfigMatcher(normalizedFilename) @@ -465,6 +571,10 @@ export class TW { } } } else if (parcel.getBinding()) { + console.log( + '[Global] Your LSP client does not support watching files on behalf of the server', + ) + console.log('[Global] Using bundled file watcher: @parcel/watcher') let typeMap = { create: FileChangeType.Created, update: FileChangeType.Changed, @@ -491,6 +601,10 @@ export class TW { }, }) } else { + console.log( + '[Global] Your LSP client does not support watching files on behalf of the server', + ) + console.log('[Global] Using bundled file watcher: chokidar') let watch: typeof chokidar.watch = require('chokidar').watch let chokidarWatcher = watch( [`**/${CONFIG_GLOB}`, `**/${PACKAGE_LOCK_GLOB}`, `**/${CSS_GLOB}`, `**/${TSCONFIG_GLOB}`], @@ -576,8 +690,6 @@ export class TW { console.log(`[Global] Initialized ${enabledProjectCount} projects`) - this.setupLSPHandlers() - this.disposables.push( this.connection.onDidChangeConfiguration(async ({ settings }) => { let previousExclude = globalSettings.tailwindCSS.files.exclude @@ -699,7 +811,7 @@ export class TW { this.connection, params, this.documentService, - () => this.updateCapabilities(), + () => this.updateProjectCapabilities(), () => { for (let document of this.documentService.getAllDocuments()) { let project = this.getProject(document) @@ -746,9 +858,7 @@ export class TW { } setupLSPHandlers() { - if (this.lspHandlersAdded) { - return - } + if (this.lspHandlersAdded) return this.lspHandlersAdded = true this.connection.onHover(this.onHover.bind(this)) @@ -757,6 +867,7 @@ export 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.onCodeLens(this.onCodeLens.bind(this)) this.connection.onDocumentLinks(this.onDocumentLinks.bind(this)) this.connection.onRequest(this.onRequest.bind(this)) } @@ -793,37 +904,106 @@ export class TW { } } - private updateCapabilities() { - if (!supportsDynamicRegistration(this.initializeParams)) { - return + // Common capabilities are always supported by the language server and do not + // require any project-specific information to know how to configure them. + // + // These capabilities will stay valid until/unless the server has to restart + // in which case they'll be unregistered and then re-registered once project + // discovery has completed + private commonRegistrations: BulkUnregistration | undefined + private async updateCommonCapabilities() { + let capabilities = BulkRegistration.create() + + let client = this.initializeParams.capabilities + + if (client.textDocument?.hover?.dynamicRegistration) { + capabilities.add(HoverRequest.type, { documentSelector: null }) } - if (this.registrations) { - this.registrations.then((r) => r.dispose()) + if (client.textDocument?.colorProvider?.dynamicRegistration) { + capabilities.add(DocumentColorRequest.type, { documentSelector: null }) } - let projects = Array.from(this.projects.values()) + if (client.textDocument?.codeAction?.dynamicRegistration) { + capabilities.add(CodeActionRequest.type, { documentSelector: null }) + } - let capabilities = BulkRegistration.create() + if (client.textDocument?.codeLens?.dynamicRegistration) { + capabilities.add(CodeLensRequest.type, { documentSelector: null }) + } + + if (client.textDocument?.documentLink?.dynamicRegistration) { + capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) + } + + if (client.workspace?.didChangeConfiguration?.dynamicRegistration) { + capabilities.add(DidChangeConfigurationNotification.type, undefined) + } + + this.commonRegistrations?.dispose() + this.commonRegistrations = await this.connection.client.register(capabilities) + } - capabilities.add(HoverRequest.type, { documentSelector: null }) - capabilities.add(DocumentColorRequest.type, { documentSelector: null }) - capabilities.add(CodeActionRequest.type, { documentSelector: null }) - capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) + // These capabilities depend on the projects we've found to appropriately + // configure them. This may mean collecting information from all discovered + // projects to determine what we can do and how + private updateProjectCapabilities() { + this.updateTriggerCharacters() + } + + private lastTriggerCharacters: Set | undefined + private completionRegistration: Promise | undefined + private async updateTriggerCharacters() { + // If the client does not suppory dynamic registration of completions then + // we cannot update the set of trigger characters + let client = this.initializeParams.capabilities + if (!client.textDocument?.completion?.dynamicRegistration) return + + // The new set of trigger characters is all the static ones plus + // any characters from any separator in v3 config + let chars = new Set(TRIGGER_CHARACTERS) + + for (let project of this.projects.values()) { + let sep = project.state.separator + if (typeof sep !== 'string') continue + + sep = sep.slice(-1) + if (!sep) continue + + chars.add(sep) + } - capabilities.add(CompletionRequest.type, { + // If the trigger characters haven't changed then we don't need to do anything + if ( + this.completionRegistration && + equal(Array.from(chars), Array.from(this.lastTriggerCharacters ?? [])) + ) { + return + } + + this.lastTriggerCharacters = chars + + let current = this.completionRegistration + this.completionRegistration = this.connection.client.register(CompletionRequest.type, { documentSelector: null, resolveProvider: true, - triggerCharacters: [ - ...TRIGGER_CHARACTERS, - ...projects - .map((project) => project.state.separator) - .filter((sep) => typeof sep === 'string') - .map((sep) => sep.slice(-1)), - ].filter(Boolean), + triggerCharacters: Array.from(chars), }) - this.registrations = this.connection.client.register(capabilities) + // NOTE: + // This weird setup works around a race condition where multiple projects + // with different separators update their capabilities at the same time. It + // is extremely unlikely but it could cause `CompletionRequest` to be + // registered more than once with the LSP client. + // + // We store the promises meaning everything up to this point is synchronous + // so it should be fine but really the proper fix here is to: + // - Refactor workspace folder initialization so discovery, initialization, + // file events, config watchers, etc… are all shared. + // - Remove the need for the "restart" concept in the server for as much as + // possible. Each project should be capable of reloading its modules. + await current?.then((r) => r.dispose()) + await this.completionRegistration } private getProject(document: TextDocumentIdentifier): ProjectService { @@ -832,6 +1012,15 @@ export class TW { let matchedPriority: number = Infinity let uri = URI.parse(document.uri) + + if (uri.scheme !== 'file') { + console.debug(`Cannot get project for a non-file document. They are unsupported.`, { + uri: uri.toString(), + }) + + return null + } + let fsPath = uri.fsPath let normalPath = uri.path @@ -846,44 +1035,20 @@ export class TW { continue } - let documentSelector = project - .documentSelector() - .concat() - // move all the negated patterns to the front - .sort((a, z) => { - if (a.pattern.startsWith('!') && !z.pattern.startsWith('!')) { - return -1 - } - if (!a.pattern.startsWith('!') && z.pattern.startsWith('!')) { - return 1 - } - return 0 - }) - - for (let selector of documentSelector) { - let pattern = selector.pattern.replace(/[\[\]{}()]/g, (m) => `\\${m}`) - - if (pattern.startsWith('!')) { - if (picomatch(pattern.slice(1), { dot: true })(fsPath)) { - break - } - - if (picomatch(pattern.slice(1), { dot: true })(normalPath)) { - break - } - } - - if (picomatch(pattern, { dot: true })(fsPath) && selector.priority < matchedPriority) { - matchedProject = project - matchedPriority = selector.priority - - continue + for (let selector of project.documentSelector()) { + if ( + selector.pattern.startsWith('!') && + this.pathMatcher.anyMatches(selector.pattern.slice(1), [fsPath, normalPath]) + ) { + break } - if (picomatch(pattern, { dot: true })(normalPath) && selector.priority < matchedPriority) { + if ( + selector.priority < matchedPriority && + this.pathMatcher.anyMatches(selector.pattern, [fsPath, normalPath]) + ) { matchedProject = project matchedPriority = selector.priority - continue } } @@ -931,6 +1096,11 @@ export class TW { return this.getProject(params.textDocument)?.onCodeAction(params) ?? null } + async onCodeLens(params: CodeLensParams): Promise { + await this.init() + return this.getProject(params.textDocument)?.onCodeLens(params) ?? null + } + async onDocumentLinks(params: DocumentLinkParams): Promise { await this.init() return this.getProject(params.textDocument)?.onDocumentLinks(params) ?? null @@ -940,44 +1110,58 @@ export class TW { this.connection.onInitialize(async (params: InitializeParams): Promise => { this.initializeParams = params - if (supportsDynamicRegistration(params)) { - return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - workspace: { - workspaceFolders: { - changeNotifications: true, - }, - }, - }, - } - } - this.setupLSPHandlers() return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - hoverProvider: true, - colorProvider: true, - codeActionProvider: true, - documentLinkProvider: {}, - completionProvider: { - resolveProvider: true, - triggerCharacters: [...TRIGGER_CHARACTERS, ':'], - }, - workspace: { - workspaceFolders: { - changeNotifications: true, - }, - }, - }, + capabilities: this.computeServerCapabilities(params.capabilities), } }) this.connection.onInitialized(() => this.init()) } + computeServerCapabilities(client: ClientCapabilities) { + let capabilities: ServerCapabilities = { + textDocumentSync: TextDocumentSyncKind.Full, + workspace: { + workspaceFolders: { + changeNotifications: true, + }, + }, + } + + if (!client.textDocument?.hover?.dynamicRegistration) { + capabilities.hoverProvider = true + } + + if (!client.textDocument?.colorProvider?.dynamicRegistration) { + capabilities.colorProvider = true + } + + if (!client.textDocument?.codeAction?.dynamicRegistration) { + capabilities.codeActionProvider = true + } + + if (!client.textDocument?.codeLens?.dynamicRegistration) { + capabilities.codeLensProvider = { + resolveProvider: false, + } + } + + if (!client.textDocument?.completion?.dynamicRegistration) { + capabilities.completionProvider = { + resolveProvider: true, + triggerCharacters: [...TRIGGER_CHARACTERS, ':'], + } + } + + if (!client.textDocument?.documentLink?.dynamicRegistration) { + capabilities.documentLinkProvider = {} + } + + return capabilities + } + listen() { this.connection.listen() } @@ -991,10 +1175,12 @@ export class TW { this.refreshDiagnostics() - if (this.registrations) { - this.registrations.then((r) => r.dispose()) - this.registrations = undefined - } + this.commonRegistrations?.dispose() + this.commonRegistrations = undefined + + this.lastTriggerCharacters?.clear() + this.completionRegistration?.then((r) => r.dispose()) + this.completionRegistration = undefined this.disposables.forEach((d) => d.dispose()) this.disposables.length = 0 @@ -1002,11 +1188,17 @@ export class TW { this.watched.length = 0 } - restart(): void { + async restart(): Promise { + let isTestMode = this.initializeParams.initializationOptions?.testMode ?? false + console.log('----------\nRESTARTING\n----------') this.dispose() this.initPromise = undefined - this.init() + await this.init() + + if (isTestMode) { + this.connection.sendNotification('@/tailwindCSS/serverRestarted') + } } async softRestart(): Promise { @@ -1021,13 +1213,3 @@ export class TW { } } } - -function supportsDynamicRegistration(params: InitializeParams): boolean { - return ( - params.capabilities.textDocument?.hover?.dynamicRegistration && - params.capabilities.textDocument?.colorProvider?.dynamicRegistration && - params.capabilities.textDocument?.codeAction?.dynamicRegistration && - params.capabilities.textDocument?.completion?.dynamicRegistration && - params.capabilities.textDocument?.documentLink?.dynamicRegistration - ) -} diff --git a/packages/tailwindcss-language-server/src/util/isExcluded.ts b/packages/tailwindcss-language-server/src/util/isExcluded.ts index beb4115a8..635473049 100644 --- a/packages/tailwindcss-language-server/src/util/isExcluded.ts +++ b/packages/tailwindcss-language-server/src/util/isExcluded.ts @@ -3,6 +3,7 @@ import * as path from 'node:path' import type { TextDocument } from 'vscode-languageserver-textdocument' import type { State } from '@tailwindcss/language-service/src/util/state' import { getFileFsPath } from './uri' +import { normalizePath, normalizeDriveLetter } from '../utils' export default async function isExcluded( state: State, @@ -11,8 +12,16 @@ export default async function isExcluded( ): Promise { let settings = await state.editor.getConfiguration(document.uri) + file = normalizePath(file) + file = normalizeDriveLetter(file) + for (let pattern of settings.tailwindCSS.files.exclude) { - if (picomatch(path.join(state.editor.folder, pattern))(file)) { + pattern = path.join(state.editor.folder, pattern) + pattern = normalizePath(pattern) + pattern = normalizeDriveLetter(pattern) + + // TODO: This should use the server-level path matcher + if (picomatch(pattern)(file)) { return true } } diff --git a/packages/tailwindcss-language-server/src/util/v4/design-system.ts b/packages/tailwindcss-language-server/src/util/v4/design-system.ts index 05d2ecd3e..f312b95c0 100644 --- a/packages/tailwindcss-language-server/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-server/src/util/v4/design-system.ts @@ -219,6 +219,14 @@ export async function loadDesignSystem( Object.assign(design, { dependencies: () => dependencies, + // TODOs: + // + // 1. Remove PostCSS parsing — its roughly 60% of the processing time + // ex: compiling 19k classes take 650ms and 400ms of that is PostCSS + // + // - Replace `candidatesToCss` with a `candidatesToAst` API + // First step would be to convert to a PostCSS AST by transforming the nodes directly + // Then it would be to drop the PostCSS AST representation entirely in all v4 code paths compile(classes: string[]): (postcss.Root | null)[] { let css = design.candidatesToCss(classes) let errors: any[] = [] diff --git a/packages/tailwindcss-language-server/src/version-guesser.ts b/packages/tailwindcss-language-server/src/version-guesser.ts index a151dea77..51b7782bd 100644 --- a/packages/tailwindcss-language-server/src/version-guesser.ts +++ b/packages/tailwindcss-language-server/src/version-guesser.ts @@ -10,6 +10,11 @@ export interface TailwindStylesheet { * The likely Tailwind version used by the given file */ versions: TailwindVersion[] + + /** + * Whether or not this stylesheet explicitly imports Tailwind CSS + */ + explicitImport: boolean } // It's likely this is a v4 file if it has a v4 import: @@ -44,7 +49,8 @@ const HAS_TAILWIND = /@tailwind\s*[^;]+;/ const HAS_COMMON_DIRECTIVE = /@(config|apply)\s*[^;{]+[;{]/ // If it's got imports at all it could be either -const HAS_IMPORT = /@import\s*['"]/ +// Note: We only care about non-url imports +const HAS_NON_URL_IMPORT = /@import\s*['"](?!([a-z]+:|\/\/))/ /** * Determine the likely Tailwind version used by the given file @@ -60,6 +66,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: true, versions: ['4'], + explicitImport: true, } } @@ -71,6 +78,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: true, versions: ['4'], + explicitImport: false, } } @@ -78,6 +86,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { // This file MUST be imported by another file to be a valid root root: false, versions: ['4'], + explicitImport: false, } } @@ -87,6 +96,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { // This file MUST be imported by another file to be a valid root root: false, versions: ['4'], + explicitImport: false, } } @@ -96,6 +106,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { // Roots are only a valid concept in v4 root: false, versions: ['3'], + explicitImport: false, } } @@ -104,6 +115,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: true, versions: ['4', '3'], + explicitImport: false, } } @@ -112,14 +124,16 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: false, versions: ['4', '3'], + explicitImport: false, } } // Files that import other files could be either and are potentially roots - if (HAS_IMPORT.test(content)) { + if (HAS_NON_URL_IMPORT.test(content)) { return { root: true, versions: ['4', '3'], + explicitImport: false, } } @@ -127,5 +141,6 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: false, versions: [], + explicitImport: false, } } diff --git a/packages/tailwindcss-language-server/src/watcher/index.js b/packages/tailwindcss-language-server/src/watcher/index.js index ecf582e6a..46cec8e84 100644 --- a/packages/tailwindcss-language-server/src/watcher/index.js +++ b/packages/tailwindcss-language-server/src/watcher/index.js @@ -13,21 +13,24 @@ const uv = (process.versions.uv || '').split('.')[0] const prebuilds = { 'darwin-arm64': { - 'node.napi.glibc.node': () => - require('@parcel/watcher/prebuilds/darwin-arm64/node.napi.glibc.node'), + 'node.napi.glibc.node': () => require('@parcel/watcher-darwin-arm64/watcher.node'), }, 'darwin-x64': { - 'node.napi.glibc.node': () => - require('@parcel/watcher/prebuilds/darwin-x64/node.napi.glibc.node'), + 'node.napi.glibc.node': () => require('@parcel/watcher-darwin-x64/watcher.node'), }, 'linux-x64': { - 'node.napi.glibc.node': () => - require('@parcel/watcher/prebuilds/linux-x64/node.napi.glibc.node'), - 'node.napi.musl.node': () => require('@parcel/watcher/prebuilds/linux-x64/node.napi.musl.node'), + 'node.napi.glibc.node': () => require('@parcel/watcher-linux-x64-glibc/watcher.node'), + 'node.napi.musl.node': () => require('@parcel/watcher-linux-x64-musl/watcher.node'), + }, + 'linux-arm64': { + 'node.napi.glibc.node': () => require('@parcel/watcher-linux-arm64-glibc/watcher.node'), + 'node.napi.musl.node': () => require('@parcel/watcher-linux-arm64-musl/watcher.node'), }, 'win32-x64': { - 'node.napi.glibc.node': () => - require('@parcel/watcher/prebuilds/win32-x64/node.napi.glibc.node'), + 'node.napi.glibc.node': () => require('@parcel/watcher-win32-x64/watcher.node'), + }, + 'win32-arm64': { + 'node.napi.glibc.node': () => require('@parcel/watcher-win32-arm64/watcher.node'), }, } diff --git a/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts b/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts new file mode 100644 index 000000000..55f6f22e6 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts @@ -0,0 +1,101 @@ +import { expect } from 'vitest' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' + +defineTest({ + name: 'Code lenses are displayed for @source inline(…)', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + features: ['source-inline'], + }), + }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'css', + text: css` + @import 'tailwindcss'; + @source inline("{,{hover,focus}:}{flex,underline,bg-red-{50,{100..900.100},950}}"); + `, + }) + + let lenses = await document.codeLenses() + + expect(lenses).toEqual([ + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 81 }, + }, + command: { + title: 'Generates 15 classes', + command: '', + }, + }, + ]) + }, +}) + +defineTest({ + name: 'The user is warned when @source inline(…) generates a lerge amount of CSS', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + features: ['source-inline'], + }), + }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'css', + text: css` + @import 'tailwindcss'; + @source inline("{,dark:}{,{sm,md,lg,xl,2xl}:}{,{hover,focus,active}:}{flex,underline,bg-red-{50,{100..900.100},950}{,/{0..100}}}"); + `, + }) + + let lenses = await document.codeLenses() + + expect(lenses).toEqual([ + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'Generates 14,784 classes', + command: '', + }, + }, + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'At least 3MB of CSS', + command: '', + }, + }, + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'This may slow down your bundler/browser', + command: '', + }, + }, + ]) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/colors/colors.test.js b/packages/tailwindcss-language-server/tests/colors/colors.test.js index 5016bacc3..4780a4fb2 100644 --- a/packages/tailwindcss-language-server/tests/colors/colors.test.js +++ b/packages/tailwindcss-language-server/tests/colors/colors.test.js @@ -334,7 +334,7 @@ defineTest({ expect(c.project).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) @@ -373,7 +373,7 @@ defineTest({ expect(c.project).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) 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 15d99ac69..60ee14952 100644 --- a/packages/tailwindcss-language-server/tests/completions/at-config.test.js +++ b/packages/tailwindcss-language-server/tests/completions/at-config.test.js @@ -271,6 +271,51 @@ withFixture('v4/dependencies', (c) => { }) }) + test.concurrent('@source not', async ({ expect }) => { + let result = await completion({ + text: '@source not "', + lang: 'css', + position: { + line: 0, + character: 13, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'index.html', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'index.html', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + { + label: 'sub-dir/', + kind: 19, + command: { command: 'editor.action.triggerSuggest', title: '' }, + data: expect.anything(), + textEdit: { + newText: 'sub-dir/', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + { + label: 'tailwind.config.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'tailwind.config.js', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + ], + }) + }) + test.concurrent('@source directory', async ({ expect }) => { let result = await completion({ text: '@source "./sub-dir/', @@ -297,6 +342,58 @@ withFixture('v4/dependencies', (c) => { }) }) + test.concurrent('@source not directory', async ({ expect }) => { + let result = await completion({ + text: '@source not "./sub-dir/', + lang: 'css', + position: { + line: 0, + character: 23, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'colors.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'colors.js', + range: { start: { line: 0, character: 23 }, end: { line: 0, character: 23 } }, + }, + }, + ], + }) + }) + + test.concurrent('@source inline(…)', async ({ expect }) => { + let result = await completion({ + text: '@source inline("', + lang: 'css', + position: { + line: 0, + character: 16, + }, + }) + + expect(result).toEqual(null) + }) + + test.concurrent('@source not inline(…)', async ({ expect }) => { + let result = await completion({ + text: '@source not inline("', + lang: 'css', + position: { + line: 0, + character: 20, + }, + }) + + expect(result).toEqual(null) + }) + test.concurrent('@import "…" source(…)', async ({ expect }) => { let result = await completion({ text: '@import "tailwindcss" source("', diff --git a/packages/tailwindcss-language-server/tests/completions/completions.test.js b/packages/tailwindcss-language-server/tests/completions/completions.test.js index 2727fcb0f..090ec872e 100644 --- a/packages/tailwindcss-language-server/tests/completions/completions.test.js +++ b/packages/tailwindcss-language-server/tests/completions/completions.test.js @@ -1,5 +1,8 @@ -import { test } from 'vitest' +import { test, expect } from 'vitest' import { withFixture } from '../common' +import { css, defineTest, html, js } from '../../src/testing' +import { createClient } from '../utils/client' +import { CompletionItemKind } from 'vscode-languageserver' function buildCompletion(c) { return async function completion({ @@ -310,8 +313,8 @@ withFixture('v4/basic', (c) => { let result = await completion({ lang, text, position, settings }) let textEdit = expect.objectContaining({ range: { start: position, end: position } }) - expect(result.items.length).toBe(12314) - expect(result.items.filter((item) => item.label.endsWith(':')).length).toBe(304) + expect(result.items.length).not.toBe(0) + expect(result.items.filter((item) => item.label.endsWith(':')).length).not.toBe(0) expect(result).toEqual({ isIncomplete: false, items: expect.arrayContaining([ @@ -485,7 +488,7 @@ withFixture('v4/basic', (c) => { }) // Make sure `@slot` is NOT suggested by default - expect(result.items.length).toBe(7) + expect(result.items.length).toBe(8) expect(result.items).not.toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 14, label: '@slot', sortText: '-0000000' }), @@ -624,7 +627,7 @@ withFixture('v4/basic', (c) => { expect(resolved).toEqual({ ...item, - detail: 'background-color: oklch(0.637 0.237 25.331);', + detail: 'background-color: oklch(63.7% 0.237 25.331);', documentation: '#fb2c36', }) }) @@ -670,3 +673,379 @@ withFixture('v4/workspaces', (c) => { }) }) }) + +defineTest({ + name: 'v4: Completions show after a variant arbitrary value', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 23 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Completions show after an arbitrary variant', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 22 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Completions show after a variant with a bare value', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 31 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Completions show after a variant arbitrary value, using prefixes', + fs: { + 'app.css': css` + @import 'tailwindcss' prefix(tw); + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 26 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Variant and utility suggestions show prefix when one has been typed', + fs: { + 'app.css': css` + @import 'tailwindcss' prefix(tw); + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 12 }) + + expect(completion?.items.length).not.toBe(0) + + // Verify that variants and utilities are all prefixed + let prefixed = completion.items.filter((item) => !item.label.startsWith('tw:')) + expect(prefixed).toHaveLength(0) + }, +}) + +defineTest({ + name: 'v4: Variant and utility suggestions hide prefix when it has been typed', + fs: { + 'app.css': css` + @import 'tailwindcss' prefix(tw); + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 15 }) + + expect(completion?.items.length).not.toBe(0) + + // Verify that no variants and utilities have prefixes + let prefixed = completion.items.filter((item) => item.label.startsWith('tw:')) + expect(prefixed).toHaveLength(0) + }, +}) + +defineTest({ + name: 'v4: Completions show inside class functions in JS/TS files', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + settings: { + tailwindCSS: { + classFunctions: ['clsx'], + }, + }, + lang: 'javascript', + text: js` + let classes = clsx(''); + `, + }) + + // let classes = clsx(''); + // ^ + let completion = await document.completions({ line: 0, character: 20 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Completions show inside class functions in JS/TS contexts', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + settings: { + tailwindCSS: { + classFunctions: ['clsx'], + }, + }, + lang: 'html', + text: html` + + `, + }) + + // let classes = clsx('') + // ^ + let completion = await document.completions({ line: 1, character: 22 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Theme key completions show in var(…)', + fs: { + 'app.css': css` + @import 'tailwindcss'; + + @theme { + --color-custom: #000; + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + settings: { + tailwindCSS: { + classFunctions: ['clsx'], + }, + }, + lang: 'css', + text: css` + .foo { + color: var(); + } + `, + }) + + // color: var(); + // ^ + let completion = await document.completions({ line: 1, character: 13 }) + + expect(completion).toEqual({ + isIncomplete: false, + items: expect.arrayContaining([ + // From the default theme + expect.objectContaining({ label: '--font-sans' }), + + // From the `@theme` block in the CSS file + expect.objectContaining({ + label: '--color-custom', + + // And it's shown as a color + kind: CompletionItemKind.Color, + documentation: '#000000', + }), + ]), + }) + }, +}) + +defineTest({ + name: 'v4: class function completions mixed with class attribute completions work', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + settings: { + tailwindCSS: { + classAttributes: ['className'], + classFunctions: ['cn', 'cva'], + }, + }, + lang: 'javascriptreact', + text: js` + let x = cva("") + + export function Button() { + return + } + + export function Button2() { + return + } + + let y = cva("") + `, + }) + + // let x = cva(""); + // ^ + let completionA = await document.completions({ line: 0, character: 13 }) + + expect(completionA?.items.length).not.toBe(0) + + // return ; + // ^ + let completionB = await document.completions({ line: 3, character: 30 }) + + expect(completionB?.items.length).not.toBe(0) + + // return ; + // ^ + let completionC = await document.completions({ line: 7, character: 30 }) + + expect(completionC?.items.length).not.toBe(0) + + // let y = cva(""); + // ^ + let completionD = await document.completions({ line: 10, character: 13 }) + + expect(completionD?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'Completions for several utilities have simplified details', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: html`
`, + }) + + //
+ // ^ + let list = await document.completions({ line: 0, character: 12 }) + let items = list?.items ?? [] + + let map = { + 'border-0': 'border-width: 0px;', + 'outline-0': 'outline-width: 0px;', + 'leading-0': 'line-height: 0rem /* 0px */;', + 'duration-1000': 'transition-duration: 1000ms;', + 'font-bold': 'font-weight: 700;', + 'ease-linear': 'transition-timing-function: linear;', + 'ease-initial': '--tw-ease: initial;', + + 'space-x-0': + 'margin-inline-start: calc(0rem /* 0px */ * var(--tw-space-x-reverse)); margin-inline-end: calc(0rem /* 0px */ * calc(1 - var(--tw-space-x-reverse)));', + 'space-y-0': + 'margin-block-start: calc(0rem /* 0px */ * var(--tw-space-y-reverse)); margin-block-end: calc(0rem /* 0px */ * calc(1 - var(--tw-space-y-reverse)));', + 'divide-x-0': + 'border-inline-start-width: calc(0px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(0px * calc(1 - var(--tw-divide-x-reverse)));', + 'divide-y-0': + 'border-top-width: calc(0px * var(--tw-divide-y-reverse)); border-bottom-width: calc(0px * calc(1 - var(--tw-divide-y-reverse)));', + + 'tracking-wide': 'letter-spacing: 0.025em;', + + 'from-red-500': '--tw-gradient-from: oklch(63.7% 0.237 25.331);', + 'via-red-500': '--tw-gradient-via: oklch(63.7% 0.237 25.331);', + 'to-red-500': '--tw-gradient-to: oklch(63.7% 0.237 25.331);', + + 'scale-100': '--tw-scale-x: 100%; --tw-scale-y: 100%; --tw-scale-z: 100%;', + 'scale-z-100': '--tw-scale-z: 100%;', + + 'translate-1': '--tw-translate-x: 0.25rem /* 4px */; --tw-translate-y: 0.25rem /* 4px */;', + 'translate-z-1': '--tw-translate-z: 0.25rem /* 4px */;', + + 'bg-conic-0': + '--tw-gradient-position: from 0deg in oklab; background-image: conic-gradient(var(--tw-gradient-stops));', + } + + let requests = await Promise.all( + Object.keys(map).map(async (label) => { + let item = items.find((item) => item.label === label) + if (!item) throw new Error(`Item not found for label: ${label}`) + + let resolved = await client.conn.sendRequest('completionItem/resolve', item) + + return [label, resolved.detail] + }), + ) + + expect(Object.fromEntries(requests)).toEqual(map) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/css/css-server.test.ts b/packages/tailwindcss-language-server/tests/css/css-server.test.ts index e8970e08a..388e94af7 100644 --- a/packages/tailwindcss-language-server/tests/css/css-server.test.ts +++ b/packages/tailwindcss-language-server/tests/css/css-server.test.ts @@ -689,3 +689,49 @@ defineTest({ expect(await doc.diagnostics()).toEqual([]) }, }) + +defineTest({ + name: 'completions are hidden inside @import source(…)/theme(…)/prefix(…) functions', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @import './file.css' source(none); + @import './file.css' theme(inline); + @import './file.css' prefix(tw); + @import './file.css' source(none) theme(inline) prefix(tw); + `, + }) + + // @import './file.css' source(none) + // ^ + // @import './file.css' theme(inline); + // ^ + // @import './file.css' prefix(tw); + // ^ + let completionsA = await doc.completions({ line: 0, character: 29 }) + let completionsB = await doc.completions({ line: 1, character: 28 }) + let completionsC = await doc.completions({ line: 2, character: 29 }) + + expect(completionsA).toEqual({ isIncomplete: false, items: [] }) + expect(completionsB).toEqual({ isIncomplete: false, items: [] }) + expect(completionsC).toEqual({ isIncomplete: false, items: [] }) + + // @import './file.css' source(none) theme(inline) prefix(tw); + // ^ ^ ^ + let completionsD = await doc.completions({ line: 3, character: 29 }) + let completionsE = await doc.completions({ line: 3, character: 41 }) + let completionsF = await doc.completions({ line: 3, character: 56 }) + + expect(completionsD).toEqual({ isIncomplete: false, items: [] }) + expect(completionsE).toEqual({ isIncomplete: false, items: [] }) + expect(completionsF).toEqual({ isIncomplete: false, items: [] }) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js index 56fe9f7ff..afda88373 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js @@ -1,6 +1,8 @@ +import * as fs from 'node:fs/promises' import { expect, test } from 'vitest' import { withFixture } from '../common' -import * as fs from 'node:fs/promises' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' withFixture('basic', (c) => { function testFixture(fixture) { @@ -383,3 +385,43 @@ withFixture('v4/basic', (c) => { ], }) }) + +defineTest({ + name: 'Shows warning when using blocklisted classes', + fs: { + 'app.css': css` + @import 'tailwindcss'; + @source not inline("{,hover:}flex"); + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + let diagnostics = await doc.diagnostics() + + expect(diagnostics).toEqual([ + { + code: 'usedBlocklistedClass', + message: 'The class "flex" will not be generated as it has been blocklisted', + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 16 }, + }, + severity: 2, + }, + { + code: 'usedBlocklistedClass', + message: 'The class "hover:flex" will not be generated as it has been blocklisted', + range: { + start: { line: 0, character: 27 }, + end: { line: 0, character: 37 }, + }, + severity: 2, + }, + ]) + }, +}) 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 861f74c9e..f187cc468 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 @@ -1,6 +1,7 @@ import { test } from 'vitest' -import { withFixture } from '../common' import * as path from 'path' +import { URI } from 'vscode-uri' +import { withFixture } from '../common' withFixture('basic', (c) => { async function testDocumentLinks(name, { text, lang, expected }) { @@ -19,9 +20,7 @@ withFixture('basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/basic/tailwind.config.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/basic/tailwind.config.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 28 } }, }, ], @@ -32,9 +31,7 @@ withFixture('basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/basic/does-not-exist.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/basic/does-not-exist.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, }, ], @@ -58,9 +55,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/tailwind.config.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/tailwind.config.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 28 } }, }, ], @@ -71,9 +66,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/does-not-exist.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/does-not-exist.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, }, ], @@ -84,9 +77,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/plugin.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/plugin.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 19 } }, }, ], @@ -97,9 +88,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/does-not-exist.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/does-not-exist.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, }, ], @@ -110,9 +99,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/index.html') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/index.html')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 20 } }, }, ], @@ -123,14 +110,46 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/does-not-exist.html') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/does-not-exist.html')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 29 } }, }, ], }) + testDocumentLinks('source not: file exists', { + text: '@source not "index.html";', + lang: 'css', + expected: [ + { + target: URI.file(path.resolve('./tests/fixtures/v4/basic/index.html')).toString(), + range: { start: { line: 0, character: 12 }, end: { line: 0, character: 24 } }, + }, + ], + }) + + testDocumentLinks('source not: file does not exist', { + text: '@source not "does-not-exist.html";', + lang: 'css', + expected: [ + { + target: URI.file(path.resolve('./tests/fixtures/v4/basic/does-not-exist.html')).toString(), + range: { start: { line: 0, character: 12 }, end: { line: 0, character: 33 } }, + }, + ], + }) + + testDocumentLinks('@source inline(…)', { + text: '@source inline("m-{1,2,3}");', + lang: 'css', + expected: [], + }) + + testDocumentLinks('@source not inline(…)', { + text: '@source not inline("m-{1,2,3}");', + lang: 'css', + expected: [], + }) + testDocumentLinks('Directories in source(…) show links', { text: ` @import "tailwindcss" source("../../"); @@ -139,11 +158,11 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path.resolve('./tests/fixtures').replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures')).toString(), range: { start: { line: 1, character: 35 }, end: { line: 1, character: 43 } }, }, { - target: `file://${path.resolve('./tests/fixtures').replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures')).toString(), range: { start: { line: 2, character: 33 }, end: { line: 2, character: 41 } }, }, ], diff --git a/packages/tailwindcss-language-server/tests/env/capabilities.test.ts b/packages/tailwindcss-language-server/tests/env/capabilities.test.ts new file mode 100644 index 000000000..6799c0ba2 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/env/capabilities.test.ts @@ -0,0 +1,246 @@ +import { expect } from 'vitest' +import { defineTest, js } from '../../src/testing' +import { createClient } from '../utils/client' +import * as fs from 'node:fs/promises' + +defineTest({ + name: 'Changing the separator registers new trigger characters', + fs: { + 'tailwind.config.js': js` + module.exports = { + separator: ':', + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ root, client }) => { + // Initially don't have any registered capabilities because dynamic + // registration is delayed until after project initialization + expect(client.serverCapabilities).toEqual([]) + + // We open a document so a project gets initialized + await client.open({ + lang: 'html', + text: '
', + }) + + // And now capabilities are registered + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/hover', + }), + + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + + let countBeforeChange = client.serverCapabilities.length + let capabilitiesDidChange = Promise.race([ + new Promise((_, reject) => { + setTimeout(() => reject('capabilities did not change within 5s'), 5_000) + }), + + new Promise((resolve) => { + client.onServerCapabilitiesChanged(() => { + if (client.serverCapabilities.length !== countBeforeChange) return + resolve() + }) + }), + ]) + + await fs.writeFile( + `${root}/tailwind.config.js`, + js` + module.exports = { + separator: '_', + } + `, + ) + + // After changing the config + client.notifyChangedFiles({ + changed: [`${root}/tailwind.config.js`], + }) + + // We should see that the capabilities have changed + await capabilitiesDidChange + + // Capabilities are now registered + expect(client.serverCapabilities).toContainEqual( + expect.objectContaining({ + method: 'textDocument/hover', + }), + ) + + expect(client.serverCapabilities).toContainEqual( + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', '_'], + }, + }), + ) + + expect(client.serverCapabilities).not.toContainEqual( + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ) + }, +}) + +defineTest({ + name: 'Config updates do not register new trigger characters if the separator has not changed', + fs: { + 'tailwind.config.js': js` + module.exports = { + separator: ':', + theme: { + colors: { + primary: '#f00', + } + } + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ root, client }) => { + // Initially don't have any registered capabilities because dynamic + // registration is delayed until after project initialization + expect(client.serverCapabilities).toEqual([]) + + // We open a document so a project gets initialized + await client.open({ + lang: 'html', + text: '
', + }) + + // And now capabilities are registered + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/hover', + }), + + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + + let idsBefore = client.serverCapabilities.map((cap) => cap.id) + + await fs.writeFile( + `${root}/tailwind.config.js`, + js` + module.exports = { + separator: ':', + theme: { + colors: { + primary: '#0f0', + } + } + } + `, + ) + + let didReload = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/projectReloaded', resolve) + }) + + // After changing the config + client.notifyChangedFiles({ + changed: [`${root}/tailwind.config.js`], + }) + + // Wait for the project to finish building + await didReload + + // No capabilities should have changed + let idsAfter = client.serverCapabilities.map((cap) => cap.id) + + expect(idsBefore).toEqual(idsAfter) + }, +}) + +defineTest({ + name: 'Trigger characters are registered after a server restart', + fs: { + 'app.css': '@import "tailwindcss"', + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ root, client }) => { + // Initially don't have any registered capabilities because dynamic + // registration is delayed until after project initialization + expect(client.serverCapabilities).toEqual([]) + + // We open a document so a project gets initialized + await client.open({ + lang: 'html', + text: '
', + }) + + // And now capabilities are registered + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/hover', + }), + + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + + let didRestart = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) + }) + + // Force a server restart by telling the server tsconfig.json changed + client.notifyChangedFiles({ + changed: [`${root}/tsconfig.json`], + }) + + // Wait for the server initialization to finish + await didRestart + + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/env/ignored.test.ts b/packages/tailwindcss-language-server/tests/env/ignored.test.ts new file mode 100644 index 000000000..f5f7d01fb --- /dev/null +++ b/packages/tailwindcss-language-server/tests/env/ignored.test.ts @@ -0,0 +1,111 @@ +import { expect } from 'vitest' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' +import dedent from 'dedent' + +let ignored = css` + @import 'tailwindcss'; + @theme { + --color-primary: #c0ffee; + } +` + +let found = css` + @import 'tailwindcss'; + @theme { + --color-primary: rebeccapurple; + } +` + +defineTest({ + name: 'various build folders and caches are ignored by default', + fs: { + // All of these should be ignored + 'aaa/.git/app.css': ignored, + 'aaa/.hg/app.css': ignored, + 'aaa/.svn/app.css': ignored, + 'aaa/node_modules/app.css': ignored, + 'aaa/.yarn/app.css': ignored, + 'aaa/.venv/app.css': ignored, + 'aaa/venv/app.css': ignored, + 'aaa/.next/app.css': ignored, + 'aaa/.parcel-cache/app.css': ignored, + 'aaa/.svelte-kit/app.css': ignored, + 'aaa/.turbo/app.css': ignored, + 'aaa/__pycache__/app.css': ignored, + + // But this one should not be + 'zzz/app.css': found, + }, + + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .bg-primary { + background-color: var(--color-primary) /* rebeccapurple = #663399 */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 22 }, + }, + }) + }, +}) + +defineTest({ + name: 'ignores can be overridden', + fs: { + 'aaa/app.css': ignored, + 'bbb/.git/app.css': found, + }, + + prepare: async ({ root }) => ({ + client: await createClient({ + root, + settings: { + tailwindCSS: { + files: { + exclude: ['**/aaa/**'], + }, + }, + }, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .bg-primary { + background-color: var(--color-primary) /* rebeccapurple = #663399 */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 22 }, + }, + }) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js b/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js index 44b4cb64e..f9e7da2f9 100644 --- a/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js +++ b/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js @@ -1,38 +1,85 @@ -import { test } from 'vitest' -import { withFixture } from '../common' +import { expect } from 'vitest' +import { css, defineTest, html, js, json, symlinkTo } from '../../src/testing' +import dedent from 'dedent' +import { createClient } from '../utils/client' -withFixture('multi-config-content', (c) => { - test.concurrent('multi-config with content config - 1', async ({ expect }) => { - let textDocument = await c.openDocument({ text: '
', dir: 'one' }) - let res = await c.sendRequest('textDocument/hover', { - textDocument, - position: { line: 0, character: 13 }, +defineTest({ + name: 'multi-config with content config', + fs: { + 'tailwind.config.one.js': js` + module.exports = { + content: ['./one/**/*'], + theme: { + extend: { + colors: { + foo: 'red', + }, + }, + }, + } + `, + 'tailwind.config.two.js': js` + module.exports = { + content: ['./two/**/*'], + theme: { + extend: { + colors: { + foo: 'blue', + }, + }, + }, + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let one = await client.open({ + lang: 'html', + name: 'one/index.html', + text: '
', }) - expect(res).toEqual({ + let two = await client.open({ + lang: 'html', + name: 'two/index.html', + text: '
', + }) + + //
+ // ^ + let hoverOne = await one.hover({ line: 0, character: 13 }) + let hoverTwo = await two.hover({ line: 0, character: 13 }) + + expect(hoverOne).toEqual({ contents: { language: 'css', - value: - '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(255 0 0 / var(--tw-bg-opacity, 1)) /* #ff0000 */;\n}', + value: dedent` + .bg-foo { + --tw-bg-opacity: 1; + background-color: rgb(255 0 0 / var(--tw-bg-opacity, 1)) /* #ff0000 */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 18 }, }, - range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } }, - }) - }) - - test.concurrent('multi-config with content config - 2', async ({ expect }) => { - let textDocument = await c.openDocument({ text: '
', dir: 'two' }) - let res = await c.sendRequest('textDocument/hover', { - textDocument, - position: { line: 0, character: 13 }, }) - expect(res).toEqual({ + expect(hoverTwo).toEqual({ contents: { language: 'css', - value: - '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(0 0 255 / var(--tw-bg-opacity, 1)) /* #0000ff */;\n}', + value: dedent` + .bg-foo { + --tw-bg-opacity: 1; + background-color: rgb(0 0 255 / var(--tw-bg-opacity, 1)) /* #0000ff */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 18 }, }, - range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } }, }) - }) + }, }) diff --git a/packages/tailwindcss-language-server/tests/env/restart.test.ts b/packages/tailwindcss-language-server/tests/env/restart.test.ts new file mode 100644 index 000000000..fa56cfb76 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/env/restart.test.ts @@ -0,0 +1,268 @@ +import { expect } from 'vitest' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { css, defineTest } from '../../src/testing' +import dedent from 'dedent' +import { createClient } from '../utils/client' + +defineTest({ + name: 'The design system is reloaded when the CSS changes ($watcher)', + fs: { + 'app.css': css` + @import 'tailwindcss'; + + @theme { + --color-primary: #c0ffee; + } + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + capabilities(caps) { + caps.workspace!.didChangeWatchedFiles!.dynamicRegistration = false + }, + }), + }), + handle: async ({ root, client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .text-primary { + color: var(--color-primary) /* #c0ffee */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 24 }, + }, + }) + + let didReload = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/projectReloaded', resolve) + }) + + // Update the CSS + await fs.writeFile( + path.resolve(root, 'app.css'), + css` + @import 'tailwindcss'; + + @theme { + --color-primary: #bada55; + } + `, + ) + + await didReload + + //
+ // ^ + let hover2 = await doc.hover({ line: 0, character: 13 }) + + expect(hover2).toEqual({ + contents: { + language: 'css', + value: dedent` + .text-primary { + color: var(--color-primary) /* #bada55 */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 24 }, + }, + }) + }, +}) + +defineTest({ + options: { + retry: 3, + + // This test passes on all platforms but it is super flaky + // The server needs some re-working to ensure everything is awaited + // properly with respect to messages and server responses + skip: true, + }, + name: 'Server is "restarted" when a config file is removed', + fs: { + 'app.css': css` + @import 'tailwindcss'; + + @theme { + --color-primary: #c0ffee; + } + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + capabilities(caps) { + caps.workspace!.didChangeWatchedFiles!.dynamicRegistration = false + }, + }), + }), + handle: async ({ root, client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .text-primary { + color: var(--color-primary) /* #c0ffee */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 24 }, + }, + }) + + expect(client.serverCapabilities).not.toEqual([]) + let ids1 = client.serverCapabilities.map((cap) => cap.id) + + // Remove the CSS file + let didRestart = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) + }) + await fs.unlink(path.resolve(root, 'app.css')) + await didRestart + + expect(client.serverCapabilities).not.toEqual([]) + let ids2 = client.serverCapabilities.map((cap) => cap.id) + + //
+ // ^ + let hover2 = await doc.hover({ line: 0, character: 13 }) + expect(hover2).toEqual(null) + + // Re-create the CSS file + let didRestartAgain = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) + }) + await fs.writeFile( + path.resolve(root, 'app.css'), + css` + @import 'tailwindcss'; + `, + ) + await didRestartAgain + + expect(client.serverCapabilities).not.toEqual([]) + let ids3 = client.serverCapabilities.map((cap) => cap.id) + + await new Promise((resolve) => setTimeout(resolve, 500)) + + //
+ // ^ + let hover3 = await doc.hover({ line: 0, character: 13 }) + expect(hover3).toEqual(null) + + expect(ids1).not.toContainEqual(expect.toBeOneOf(ids2)) + expect(ids1).not.toContainEqual(expect.toBeOneOf(ids3)) + + expect(ids2).not.toContainEqual(expect.toBeOneOf(ids1)) + expect(ids2).not.toContainEqual(expect.toBeOneOf(ids3)) + + expect(ids3).not.toContainEqual(expect.toBeOneOf(ids1)) + expect(ids3).not.toContainEqual(expect.toBeOneOf(ids2)) + }, +}) + +defineTest({ + name: 'Creating a CSS config in an empty folder initalizes a project', + fs: { + 'app.css': css` + /* this file is not a Tailwind CSS config yet */ + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ root, log: true }), + }), + handle: async ({ root, client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + + expect(hover).toEqual(null) + + // Create a CSS config file + await fs.writeFile( + `${root}/app.css`, + css` + @import 'tailwindcss'; + + @theme { + --color-primary: #c0ffee; + } + `, + ) + + // Create a CSS config file + // Notify the server of the config change + let didRestart = Promise.race([ + new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Did not restart in time')), 5000), + ), + ]) + + await client.notifyChangedFiles({ + changed: [`${root}/app.css`], + }) + + await didRestart + + // TODO: Sending a shutdown request immediately after a restart + // gets lost + // await client.shutdown() + + //
+ // ^ + hover = await doc.hover({ line: 0, character: 13 }) + + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .text-primary { + color: var(--color-primary) /* #c0ffee */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 24 }, + }, + }) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/env/v4.test.js b/packages/tailwindcss-language-server/tests/env/v4.test.js index 76de7f15d..6104ef7ae 100644 --- a/packages/tailwindcss-language-server/tests/env/v4.test.js +++ b/packages/tailwindcss-language-server/tests/env/v4.test.js @@ -21,7 +21,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) @@ -49,7 +49,7 @@ defineTest({ }, }) - expect(completion?.items.length).toBe(12288) + expect(completion?.items.length).not.toBe(0) }, }) @@ -137,7 +137,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) @@ -188,7 +188,7 @@ defineTest({ 'package.json': json` { "dependencies": { - "tailwindcss": "4.0.1" + "tailwindcss": "4.1.1" } } `, @@ -205,7 +205,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.1', + version: '4.1.1', isDefaultVersion: false, }, }) @@ -233,7 +233,7 @@ defineTest({ }, }) - expect(completion?.items.length).toBe(12288) + expect(completion?.items.length).not.toBe(0) }, }) @@ -243,7 +243,7 @@ defineTest({ 'package.json': json` { "dependencies": { - "tailwindcss": "4.0.1" + "tailwindcss": "4.1.1" } } `, @@ -270,7 +270,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.1', + version: '4.1.1', isDefaultVersion: false, }, }) @@ -322,7 +322,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) @@ -354,7 +354,7 @@ defineTest({ 'package.json': json` { "dependencies": { - "tailwindcss": "4.0.1" + "tailwindcss": "4.1.1" } } `, @@ -605,6 +605,12 @@ defineTest({ }) defineTest({ + // This test sometimes takes a really long time on Windows because… Windows. + options: { + retry: 3, + timeout: 30_000, + }, + name: 'Plugins with a `#` in the name are loadable', fs: { 'app.css': css` @@ -650,6 +656,12 @@ defineTest({ }) defineTest({ + // This test sometimes takes a really long time on Windows because… Windows. + options: { + retry: 3, + timeout: 30_000, + }, + name: 'v3: Presets with a `#` in the name are loadable', fs: { 'package.json': json` @@ -707,6 +719,12 @@ defineTest({ }) defineTest({ + // This test sometimes takes a really long time on Windows because… Windows. + options: { + retry: 3, + timeout: 30_000, + }, + // This test *always* passes inside Vitest because our custom version of // `Module._resolveFilename` is not called. Our custom implementation is // using enhanced-resolve under the hood which is affected by the `#` @@ -737,7 +755,7 @@ defineTest({ presets: [require('some-pkg/config/tailwind.config.js').default] } `, - 'packages/some-pkg': symlinkTo('packages/some-pkg#c3f1e'), + 'packages/some-pkg': symlinkTo('packages/some-pkg#c3f1e', 'dir'), 'packages/some-pkg#c3f1e/package.json': json` { "name": "some-pkg", @@ -791,3 +809,89 @@ defineTest({ }) }, }) + +defineTest({ + name: 'regex literals do not break language boundaries', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'javascriptreact', + text: js` + export default function Page() { + let styles = "str".match(/ +
+
+ `, + }) + + let boundaries = getLanguageBoundaries(file.state, file.doc) + + expect(boundaries).toEqual([ + { + type: 'html', + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 2 }, + }, + }, + { + type: 'css', + range: { + start: { line: 1, character: 2 }, + end: { line: 5, character: 2 }, + }, + }, + { + type: 'html', + range: { + start: { line: 5, character: 2 }, + end: { line: 7, character: 6 }, + }, + }, + ]) +}) + +test('script tags in HTML are treated as a separate boundary', ({ expect }) => { + let file = createDocument({ + name: 'file.html', + lang: 'html', + content: html` +
+ +
+
+ `, + }) + + let boundaries = getLanguageBoundaries(file.state, file.doc) + + expect(boundaries).toEqual([ + { + type: 'html', + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 2 }, + }, + }, + { + type: 'js', + range: { + start: { line: 1, character: 2 }, + end: { line: 5, character: 2 }, + }, + }, + { + type: 'html', + range: { + start: { line: 5, character: 2 }, + end: { line: 7, character: 6 }, + }, + }, + ]) +}) + +test('Vue files detect