Skip to content

Commit 062bfdb

Browse files
JungzlGPT-5.4
andauthored
fix(language-server,vscode): honor workspace roots for unocss config discovery (#5145)
Co-authored-by: GPT-5.4 <gpt-5.4@users.noreply.github.com>
1 parent f1094ed commit 062bfdb

File tree

6 files changed

+479
-47
lines changed

6 files changed

+479
-47
lines changed

packages-integrations/language-server/src/core/context.ts

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export class ContextManager {
3030
presets: [presetWind3()],
3131
}
3232

33+
private readonly workspaceRoots: string[]
34+
3335
public events = createNanoEvents<{
3436
reload: () => void
3537
unload: (context: UnocssPluginContext<UserConfig<any>>) => void
@@ -41,7 +43,9 @@ export class ContextManager {
4143
constructor(
4244
public cwd: string,
4345
private connection: Connection,
46+
workspaceRoots: string[] = [cwd],
4447
) {
48+
this.workspaceRoots = Array.from(new Set(workspaceRoots.length ? workspaceRoots : [cwd]))
4549
this.ready = this.reload()
4650
}
4751

@@ -61,31 +65,34 @@ export class ContextManager {
6165
return Array.from(new Set(this.contextsMap.values())).filter(notNull)
6266
}
6367

68+
async setRoots(roots: string[]) {
69+
const nextRoots = Array.from(new Set(roots.length ? roots : this.workspaceRoots))
70+
const currentRoots = Array.from(this.contextsMap.keys())
71+
72+
if (
73+
currentRoots.length === nextRoots.length
74+
&& nextRoots.every(root => currentRoots.includes(root))
75+
) {
76+
return
77+
}
78+
79+
await Promise.all(currentRoots
80+
.filter(root => !nextRoots.includes(root))
81+
.map(root => this.unloadContext(root)))
82+
83+
this.resetCaches()
84+
await this.loadRoots(nextRoots.filter(root => !currentRoots.includes(root)))
85+
86+
this.events.emit('reload')
87+
}
88+
6489
async reload() {
6590
const dirs = Array.from(this.contextsMap.keys())
91+
const rootsToLoad = dirs.length ? dirs : this.workspaceRoots
6692
await Promise.all(dirs.map(dir => this.unloadContext(dir)))
6793

68-
this.fileContextCache.clear()
69-
this.configExistsCache.clear()
70-
71-
if (dirs.length) {
72-
await Promise.all(dirs.map(async (dir) => {
73-
try {
74-
await this.loadContextInDirectory(dir)
75-
}
76-
catch (e: any) {
77-
this.warn(`⚠️ Failed to reload context for ${dir}: ${String(e.stack ?? e)}`)
78-
}
79-
}))
80-
}
81-
else {
82-
try {
83-
await this.loadContextInDirectory(this.cwd)
84-
}
85-
catch (e: any) {
86-
this.warn(`⚠️ Failed to reload context for ${this.cwd}: ${String(e.stack ?? e)}`)
87-
}
88-
}
94+
this.resetCaches()
95+
await this.loadRoots(rootsToLoad)
8996

9097
this.events.emit('reload')
9198
}
@@ -121,6 +128,22 @@ export class ContextManager {
121128
}
122129
}
123130

131+
private resetCaches() {
132+
this.fileContextCache.clear()
133+
this.configExistsCache.clear()
134+
}
135+
136+
private async loadRoots(roots: string[]) {
137+
await Promise.all(roots.map(async (dir) => {
138+
try {
139+
await this.loadContextInDirectory(dir)
140+
}
141+
catch (e: any) {
142+
this.warn(`⚠️ Failed to reload context for ${dir}: ${String(e.stack ?? e)}`)
143+
}
144+
}))
145+
}
146+
124147
async loadContextInDirectory(dir: string): Promise<UnoContext> {
125148
// Return cached context
126149
const cached = this.contextsMap.get(dir)
@@ -339,12 +362,16 @@ export class ContextManager {
339362
if (cached !== undefined)
340363
return cached || undefined
341364

365+
const searchBoundary = this.getWorkspaceBoundary(startDir)
366+
if (!searchBoundary)
367+
return undefined
368+
342369
const root = path.parse(startDir).root
343370
const searchedDirs: string[] = []
344371
let dir = startDir
345372

346373
// Search upwards until we find a config file or hit the boundary
347-
while (dir !== root && (isSubdir(this.cwd, dir) || dir === this.cwd)) {
374+
while (dir !== root && (isSubdir(searchBoundary, dir) || dir === searchBoundary)) {
348375
searchedDirs.push(dir)
349376

350377
// Check if this directory is already cached
@@ -369,6 +396,12 @@ export class ContextManager {
369396
return undefined
370397
}
371398

399+
private getWorkspaceBoundary(dir: string): string | undefined {
400+
return [...this.workspaceRoots, ...this.contextsMap.keys()]
401+
.filter(root => root === dir || isSubdir(root, dir))
402+
.sort((a, b) => b.length - a.length)[0]
403+
}
404+
372405
private async hasConfigFiles(dir: string): Promise<boolean> {
373406
try {
374407
const files = await readdir(dir)

packages-integrations/language-server/src/server.ts

Lines changed: 100 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,99 @@ import { registerReferences } from './capabilities/references'
2121
import { clearAllCache, clearDocumentCache, getMatchedPositionsFromDoc } from './core/cache'
2222
import { ContextManager } from './core/context'
2323
import { defaultSettings } from './types'
24+
import { resolveWorkspaceRoots } from './utils/roots'
2425

2526
const connection = createConnection(ProposedFeatures.all)
2627
const documents = new TextDocuments(TextDocument)
2728

29+
interface UnocssSettingsInput extends Partial<Omit<ServerSettings, 'autocompleteMatchType' | 'autocompleteStrict' | 'autocompleteMaxItems'>> {
30+
autocomplete?: {
31+
matchType?: ServerSettings['autocompleteMatchType']
32+
strict?: boolean
33+
maxItems?: number
34+
}
35+
}
36+
37+
interface WorkspaceInitializationOptions {
38+
workspaceFileDir?: string
39+
workspaceFolderPaths?: string[]
40+
}
41+
42+
interface WorkspaceContextState {
43+
rootPath?: string
44+
fileDir?: string
45+
folderPaths: string[]
46+
}
47+
2848
let settings: ServerSettings = { ...defaultSettings }
2949
let contextManager: ContextManager
3050
let hasWatchedFilesCapability = false
3151
let serverInitialized = false
3252
let watcherDisposable: Disposable | undefined
53+
const workspaceContext: WorkspaceContextState = {
54+
folderPaths: [],
55+
}
56+
57+
function mergeSettings(unocssSettings: UnocssSettingsInput = {}) {
58+
settings = {
59+
...defaultSettings,
60+
...unocssSettings,
61+
autocompleteMatchType: unocssSettings?.autocomplete?.matchType ?? defaultSettings.autocompleteMatchType,
62+
autocompleteStrict: unocssSettings?.autocomplete?.strict ?? defaultSettings.autocompleteStrict,
63+
autocompleteMaxItems: unocssSettings?.autocomplete?.maxItems ?? defaultSettings.autocompleteMaxItems,
64+
}
65+
}
66+
67+
function resolveConfiguredRoots(root: string | string[] | undefined): string[] {
68+
return resolveWorkspaceRoots(root, {
69+
workspaceRootPath: workspaceContext.rootPath,
70+
workspaceFileDir: workspaceContext.fileDir,
71+
workspaceFolderPaths: workspaceContext.folderPaths,
72+
})
73+
}
74+
75+
async function applyConfiguredRoots() {
76+
if (!contextManager)
77+
return
78+
79+
await contextManager.setRoots(resolveConfiguredRoots(settings.root))
80+
}
81+
82+
function updateWorkspaceContext(
83+
rootUri: string,
84+
workspaceFolders: { uri: string }[] | null | undefined,
85+
initializationOptions: WorkspaceInitializationOptions | undefined,
86+
) {
87+
workspaceContext.rootPath = uriToFsPath(rootUri)
88+
workspaceContext.fileDir = initializationOptions?.workspaceFileDir
89+
workspaceContext.folderPaths = initializationOptions?.workspaceFolderPaths
90+
|| workspaceFolders?.map(folder => uriToFsPath(folder.uri))
91+
|| []
92+
}
93+
94+
function createContextManager() {
95+
if (!workspaceContext.rootPath)
96+
return
97+
98+
contextManager = new ContextManager(
99+
workspaceContext.rootPath,
100+
connection,
101+
workspaceContext.folderPaths.length ? workspaceContext.folderPaths : [workspaceContext.rootPath],
102+
)
103+
104+
contextManager.events.on('contextReload', (ctx) => {
105+
resetAutoCompleteCache(ctx)
106+
})
107+
contextManager.events.on('contextUnload', (ctx) => {
108+
resetAutoCompleteCache(ctx)
109+
})
110+
contextManager.events.on('unload', (ctx) => {
111+
resetAutoCompleteCache(ctx)
112+
})
113+
contextManager.events.on('contextLoaded', () => {
114+
void updateConfigWatchers()
115+
})
116+
}
33117

34118
async function updateConfigWatchers(): Promise<void> {
35119
if (!hasWatchedFilesCapability || !serverInitialized || !contextManager)
@@ -78,26 +162,14 @@ function getContextManager() {
78162

79163
connection.onInitialize((params) => {
80164
hasWatchedFilesCapability = !!params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration
165+
const initializationOptions = params.initializationOptions as WorkspaceInitializationOptions | undefined
81166

82167
const workspaceFolders = params.workspaceFolders
83168
const rootUri = workspaceFolders?.[0]?.uri || params.rootUri
84169

85170
if (rootUri) {
86-
const rootPath = uriToFsPath(rootUri)
87-
contextManager = new ContextManager(rootPath, connection)
88-
89-
contextManager.events.on('contextReload', (ctx) => {
90-
resetAutoCompleteCache(ctx)
91-
})
92-
contextManager.events.on('contextUnload', (ctx) => {
93-
resetAutoCompleteCache(ctx)
94-
})
95-
contextManager.events.on('unload', (ctx) => {
96-
resetAutoCompleteCache(ctx)
97-
})
98-
contextManager.events.on('contextLoaded', () => {
99-
void updateConfigWatchers()
100-
})
171+
updateWorkspaceContext(rootUri, workspaceFolders, initializationOptions)
172+
createContextManager()
101173
}
102174

103175
// Register all LSP capabilities
@@ -121,9 +193,16 @@ connection.onInitialize((params) => {
121193
})
122194

123195
connection.onInitialized(async () => {
124-
serverInitialized = true
125-
if (contextManager)
196+
try {
197+
mergeSettings(await connection.workspace.getConfiguration('unocss'))
198+
}
199+
catch {}
200+
201+
if (contextManager) {
126202
await contextManager.ready
203+
await applyConfiguredRoots()
204+
}
205+
serverInitialized = true
127206
connection.console.log('✅ UnoCSS Language Server initialized')
128207
await updateConfigWatchers()
129208
})
@@ -143,16 +222,12 @@ connection.onDidChangeWatchedFiles((_change) => {
143222
}, 500)
144223
})
145224

146-
connection.onDidChangeConfiguration((change) => {
225+
connection.onDidChangeConfiguration(async (change) => {
147226
const unocssSettings = change.settings?.unocss
148227
if (unocssSettings) {
149-
settings = {
150-
...defaultSettings,
151-
...unocssSettings,
152-
autocompleteMatchType: unocssSettings?.autocomplete?.matchType ?? defaultSettings.autocompleteMatchType,
153-
autocompleteStrict: unocssSettings?.autocomplete?.strict ?? defaultSettings.autocompleteStrict,
154-
autocompleteMaxItems: unocssSettings?.autocomplete?.maxItems ?? defaultSettings.autocompleteMaxItems,
155-
}
228+
mergeSettings(unocssSettings)
229+
await applyConfiguredRoots()
230+
await updateConfigWatchers()
156231
resetAutoCompleteCache()
157232
}
158233
})
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { existsSync } from 'node:fs'
2+
import path from 'node:path'
3+
import { toArray } from '@unocss/core'
4+
5+
export interface WorkspaceRootResolutionOptions {
6+
workspaceRootPath?: string
7+
workspaceFileDir?: string
8+
workspaceFolderPaths?: string[]
9+
}
10+
11+
export function resolveWorkspaceRoots(
12+
root: string | string[] | undefined,
13+
options: WorkspaceRootResolutionOptions,
14+
): string[] {
15+
const workspaceFolderPaths = normalizePaths(options.workspaceFolderPaths || [])
16+
const fallbackRoots = workspaceFolderPaths.length
17+
? workspaceFolderPaths
18+
: normalizePaths([options.workspaceRootPath])
19+
20+
const configuredRoots = toArray(root).filter(Boolean)
21+
22+
if (!configuredRoots.length)
23+
return fallbackRoots
24+
25+
const basePaths = normalizePaths([
26+
options.workspaceFileDir,
27+
...workspaceFolderPaths,
28+
options.workspaceRootPath,
29+
])
30+
31+
return normalizePaths(configuredRoots.flatMap(value => resolveConfiguredRoot(value, basePaths)))
32+
}
33+
34+
function resolveConfiguredRoot(value: string, basePaths: string[]): string[] {
35+
if (!value)
36+
return []
37+
38+
if (isAbsolutePath(value))
39+
return [path.normalize(value)]
40+
41+
const normalizedValue = path.normalize(value)
42+
const matchingWorkspaceRoots = basePaths.filter((base) => {
43+
const normalizedBase = path.normalize(base)
44+
return normalizedBase === normalizedValue || normalizedBase.endsWith(`${path.sep}${normalizedValue}`)
45+
})
46+
47+
if (matchingWorkspaceRoots.length)
48+
return matchingWorkspaceRoots
49+
50+
const resolvedCandidates = basePaths.map(base => path.resolve(base, value))
51+
const existingCandidates = resolvedCandidates.filter(candidate => existsSync(candidate))
52+
53+
return existingCandidates.length
54+
? existingCandidates
55+
: resolvedCandidates.slice(0, 1)
56+
}
57+
58+
function normalizePaths(paths: Array<string | undefined>): string[] {
59+
return Array.from(new Set(paths.filter(Boolean).map(value => path.normalize(value!))))
60+
}
61+
62+
function isAbsolutePath(value: string): boolean {
63+
return path.isAbsolute(value)
64+
|| path.win32.isAbsolute(value)
65+
}

0 commit comments

Comments
 (0)