Skip to content

Speed up project selector matching #1381

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/tailwindcss-language-server/src/matching.ts
Original file line number Diff line number Diff line change
@@ -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<string, picomatch.Matcher>((pattern) => {
Copy link
Member

@RobinMalfait RobinMalfait May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DefaultMap coming in clutch and saving our butts once again

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FOR REAL

// 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(),
}
}
7 changes: 7 additions & 0 deletions packages/tailwindcss-language-server/src/project-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,5 +815,12 @@ export async function calculateDocumentSelectors(
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
}
50 changes: 16 additions & 34 deletions packages/tailwindcss-language-server/src/tw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -151,6 +154,7 @@ export class TW {
private async _init(): Promise<void> {
clearRequireCache()

this.pathMatcher.clear()
let folders = this.getWorkspaceFolders().map((folder) => normalizePath(folder.uri))

if (folders.length === 0) {
Expand Down Expand Up @@ -325,6 +329,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 })
Expand All @@ -339,6 +344,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)) {
Expand Down Expand Up @@ -984,44 +990,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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down