Skip to content

Commit 9c2a9d5

Browse files
Speed up project selector matching (#1381)
When a request comes in to the language server for a document we have to determine what project that document belongs to. This is because your workspace can have many tailwind config files and potentially across different Tailwind CSS versions. The process for this happens in two stages: 1. Initialization computes a list of selectors for a project. These selectors contain glob patterns that match a filepath to one or more projects. In addition to the glob pattern is a priority which is a "lowest match wins" scale. So things like user configured patterns in settings are the most important, then your config files, then content from the `content` array in v3 or detected sources in v4, approximate path matches based on folder, etc… 2. When we get a request for hovers, completions, etc… we check every project against the list of selectors to see if it matches and at what priority. This involves compiling glob patterns. The *lowest* priority match wins. If multiple projects match a document at the same priority then first match wins. If a project contains a selector that discards a particular document then that project is removed from consideration before continuing. Now for the problem, we were re-compiling globs *over and over and over again*. Normally, for a small project this isn't an issue but automatic content detection means that a large number of paths can be returned. And if you have lots of projects? Welp… this adds up. What's more is that when VSCode needed to compute document symbols requests were made to our language server (… i don't know why actually) which then incurred a perf hit caused by these globs being repeatedly recompiled… and since this is effectively a single threaded operation it delays any waiting promises. So this PR does two things: - It moves the sorting that was happening for every request for every project to happen *during project initialization*. If selectors are computed or recomputed they are sorted then. We do this sorting to move the negative matchers to the front so we can disqualify files from matching a project quicker. - We cache the compiled matchers. This is especially important if you have several v4 projects and many of them list the same paths. In a realworld project this lowered the time to show suggestions from emmet from **4 SECONDS** (omg) to about 15-20ms on an M3 Max machine. aside: There was also sometimes a delay in time even getting completion requests due to the single-threaded nature of JS. That could also end up being multiple seconds. So in reality the time could be from range from 4–8s depending on when you made the request.
1 parent f9496db commit 9c2a9d5

File tree

4 files changed

+48
-34
lines changed

4 files changed

+48
-34
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import picomatch from 'picomatch'
2+
import { DefaultMap } from './util/default-map'
3+
4+
export interface PathMatcher {
5+
anyMatches(pattern: string, paths: string[]): boolean
6+
clear(): void
7+
}
8+
9+
export function createPathMatcher(): PathMatcher {
10+
let matchers = new DefaultMap<string, picomatch.Matcher>((pattern) => {
11+
// Escape picomatch special characters so they're matched literally
12+
pattern = pattern.replace(/[\[\]{}()]/g, (m) => `\\${m}`)
13+
14+
return picomatch(pattern, { dot: true })
15+
})
16+
17+
return {
18+
anyMatches: (pattern, paths) => {
19+
let check = matchers.get(pattern)
20+
return paths.some((path) => check(path))
21+
},
22+
clear: () => matchers.clear(),
23+
}
24+
}

packages/tailwindcss-language-server/src/project-locator.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,5 +815,12 @@ export async function calculateDocumentSelectors(
815815
documentSelectors.findIndex(({ pattern: p }) => p === pattern) === index,
816816
)
817817

818+
// Move all the negated patterns to the front
819+
selectors = selectors.sort((a, z) => {
820+
if (a.pattern.startsWith('!') && !z.pattern.startsWith('!')) return -1
821+
if (!a.pattern.startsWith('!') && z.pattern.startsWith('!')) return 1
822+
return 0
823+
})
824+
818825
return selectors
819826
}

packages/tailwindcss-language-server/src/tw.ts

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { ProjectLocator, type ProjectConfig } from './project-locator'
5656
import type { TailwindCssSettings } from '@tailwindcss/language-service/src/util/state'
5757
import { createResolver, Resolver } from './resolver'
5858
import { analyzeStylesheet } from './version-guesser.js'
59+
import { createPathMatcher, PathMatcher } from './matching.js'
5960

6061
const TRIGGER_CHARACTERS = [
6162
// class attributes
@@ -104,12 +105,14 @@ export class TW {
104105
private watched: string[] = []
105106

106107
private settingsCache: SettingsCache
108+
private pathMatcher: PathMatcher
107109

108110
constructor(private connection: Connection) {
109111
this.documentService = new DocumentService(this.connection)
110112
this.projects = new Map()
111113
this.projectCounter = 0
112114
this.settingsCache = createSettingsCache(connection)
115+
this.pathMatcher = createPathMatcher()
113116
}
114117

115118
async init(): Promise<void> {
@@ -151,6 +154,7 @@ export class TW {
151154
private async _init(): Promise<void> {
152155
clearRequireCache()
153156

157+
this.pathMatcher.clear()
154158
let folders = this.getWorkspaceFolders().map((folder) => normalizePath(folder.uri))
155159

156160
if (folders.length === 0) {
@@ -325,6 +329,7 @@ export class TW {
325329
let needsRestart = false
326330
let needsSoftRestart = false
327331

332+
// TODO: This should use the server-level path matcher
328333
let isPackageMatcher = picomatch(`**/${PACKAGE_LOCK_GLOB}`, { dot: true })
329334
let isCssMatcher = picomatch(`**/${CSS_GLOB}`, { dot: true })
330335
let isConfigMatcher = picomatch(`**/${CONFIG_GLOB}`, { dot: true })
@@ -339,6 +344,7 @@ export class TW {
339344
normalizedFilename = normalizeDriveLetter(normalizedFilename)
340345

341346
for (let ignorePattern of ignore) {
347+
// TODO: This should use the server-level path matcher
342348
let isIgnored = picomatch(ignorePattern, { dot: true })
343349

344350
if (isIgnored(normalizedFilename)) {
@@ -984,44 +990,20 @@ export class TW {
984990
continue
985991
}
986992

987-
let documentSelector = project
988-
.documentSelector()
989-
.concat()
990-
// move all the negated patterns to the front
991-
.sort((a, z) => {
992-
if (a.pattern.startsWith('!') && !z.pattern.startsWith('!')) {
993-
return -1
994-
}
995-
if (!a.pattern.startsWith('!') && z.pattern.startsWith('!')) {
996-
return 1
997-
}
998-
return 0
999-
})
1000-
1001-
for (let selector of documentSelector) {
1002-
let pattern = selector.pattern.replace(/[\[\]{}()]/g, (m) => `\\${m}`)
1003-
1004-
if (pattern.startsWith('!')) {
1005-
if (picomatch(pattern.slice(1), { dot: true })(fsPath)) {
1006-
break
1007-
}
1008-
1009-
if (picomatch(pattern.slice(1), { dot: true })(normalPath)) {
1010-
break
1011-
}
1012-
}
1013-
1014-
if (picomatch(pattern, { dot: true })(fsPath) && selector.priority < matchedPriority) {
1015-
matchedProject = project
1016-
matchedPriority = selector.priority
1017-
1018-
continue
993+
for (let selector of project.documentSelector()) {
994+
if (
995+
selector.pattern.startsWith('!') &&
996+
this.pathMatcher.anyMatches(selector.pattern.slice(1), [fsPath, normalPath])
997+
) {
998+
break
1019999
}
10201000

1021-
if (picomatch(pattern, { dot: true })(normalPath) && selector.priority < matchedPriority) {
1001+
if (
1002+
selector.priority < matchedPriority &&
1003+
this.pathMatcher.anyMatches(selector.pattern, [fsPath, normalPath])
1004+
) {
10221005
matchedProject = project
10231006
matchedPriority = selector.priority
1024-
10251007
continue
10261008
}
10271009
}

packages/tailwindcss-language-server/src/util/isExcluded.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default async function isExcluded(
2020
pattern = normalizePath(pattern)
2121
pattern = normalizeDriveLetter(pattern)
2222

23+
// TODO: This should use the server-level path matcher
2324
if (picomatch(pattern)(file)) {
2425
return true
2526
}

0 commit comments

Comments
 (0)