diff --git a/packages/tailwindcss-language-server/src/matching.ts b/packages/tailwindcss-language-server/src/matching.ts new file mode 100644 index 00000000..a373b116 --- /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/project-locator.ts b/packages/tailwindcss-language-server/src/project-locator.ts index 4e3c0453..99d68505 100644 --- a/packages/tailwindcss-language-server/src/project-locator.ts +++ b/packages/tailwindcss-language-server/src/project-locator.ts @@ -206,62 +206,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, @@ -545,13 +490,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) } } @@ -586,11 +532,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, } } @@ -603,8 +556,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) { @@ -657,12 +613,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 { // @@ -793,3 +753,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 21e7bb3b..0c54942c 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -80,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' @@ -286,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 @@ -307,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() } @@ -963,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, + ) } ////////////////////// diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index f2376154..3b99da5c 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -56,6 +56,7 @@ import { ProjectLocator, type ProjectConfig } from './project-locator' import type { TailwindCssSettings } from '@tailwindcss/language-service/src/util/state' import { createResolver, Resolver } from './resolver' import { analyzeStylesheet } from './version-guesser.js' +import { createPathMatcher, PathMatcher } from './matching.js' const TRIGGER_CHARACTERS = [ // class attributes @@ -104,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 { @@ -151,6 +154,7 @@ export class TW { private async _init(): Promise { clearRequireCache() + this.pathMatcher.clear() let folders = this.getWorkspaceFolders().map((folder) => normalizePath(folder.uri)) if (folders.length === 0) { @@ -321,6 +325,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 }) @@ -335,6 +340,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)) { @@ -960,44 +966,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 } } diff --git a/packages/tailwindcss-language-server/src/util/isExcluded.ts b/packages/tailwindcss-language-server/src/util/isExcluded.ts index df998e7f..63547304 100644 --- a/packages/tailwindcss-language-server/src/util/isExcluded.ts +++ b/packages/tailwindcss-language-server/src/util/isExcluded.ts @@ -20,6 +20,7 @@ export default async function isExcluded( 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/tests/env/multi-config-content.test.js b/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js index 44b4cb64..f9e7da2f 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/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 14775414..a80f579a 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -13,6 +13,8 @@ - Handle helper function lookups in nested parens ([#1354](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1354)) - Hide `@property` declarations from completion details ([#1356](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1356)) - Hide variant-provided declarations from completion details for a utility ([#1356](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1356)) +- Compute correct document selectors when a project is initialized ([#1335](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1335)) +- Fix matching of some content file paths on Windows ([#1335](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1335)) # 0.14.16