diff --git a/src/cssLanguageService.ts b/src/cssLanguageService.ts index ea4017cc..b94f0cab 100644 --- a/src/cssLanguageService.ts +++ b/src/cssLanguageService.ts @@ -63,7 +63,7 @@ export interface LanguageService { getFoldingRanges(document: TextDocument, context?: { rangeLimit?: number; }): FoldingRange[]; getSelectionRanges(document: TextDocument, positions: Position[], stylesheet: Stylesheet): SelectionRange[]; format(document: TextDocument, range: Range | undefined, options: CSSFormatConfiguration): TextEdit[]; - + clearCache(): void, } export function getDefaultCSSDataProvider(): ICSSDataProvider { @@ -104,7 +104,8 @@ function createFacade(parser: Parser, completion: CSSCompletion, hover: CSSHover prepareRename: navigation.prepareRename.bind(navigation), doRename: navigation.doRename.bind(navigation), getFoldingRanges, - getSelectionRanges + getSelectionRanges, + clearCache: navigation.clearCache.bind(navigation), }; } diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index 663f9125..65e483dc 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -6,10 +6,12 @@ import { AliasSettings, Color, ColorInformation, ColorPresentation, DocumentHighlight, DocumentHighlightKind, DocumentLink, Location, - Position, Range, SymbolInformation, SymbolKind, TextEdit, WorkspaceEdit, TextDocument, DocumentContext, FileSystemProvider, FileType, DocumentSymbol + Position, Range, SymbolInformation, SymbolKind, TextEdit, WorkspaceEdit, TextDocument, DocumentContext, FileSystemProvider, FileType, DocumentSymbol, + LanguageSettings } from '../cssLanguageTypes'; import * as l10n from '@vscode/l10n'; import * as nodes from '../parser/cssNodes'; +import { Utils, URI } from 'vscode-uri'; import { Symbols } from '../parser/cssSymbolScope'; import { getColorValue, @@ -22,17 +24,27 @@ import { } from '../languageFacts/facts'; import { startsWith } from '../utils/strings'; import { dirname, joinPath } from '../utils/resources'; +import { readFile } from 'node:fs/promises'; type UnresolvedLinkData = { link: DocumentLink, isRawLink: boolean }; type DocumentSymbolCollector = (name: string, kind: SymbolKind, symbolNodeOrRange: nodes.Node | Range, nameNodeOrRange: nodes.Node | Range | undefined, bodyNode: nodes.Node | undefined) => void; +interface VSCodeSettings { + [key: string]: any; + css: LanguageSettings; + scss: LanguageSettings; + less: LanguageSettings; +} + const startsWithSchemeRegex = /^\w+:\/\//; const startsWithData = /^data:/; export class CSSNavigation { protected defaultSettings?: AliasSettings; + private documentSettingsUriCache = new Map(); + private aliasCache = new Map(); constructor(protected fileSystemProvider: FileSystemProvider | undefined, private readonly resolveModuleReferences: boolean) { } @@ -408,6 +420,11 @@ export class CSSNavigation { }; } + public clearCache(): void { + this.aliasCache.clear(); + this.documentSettingsUriCache.clear(); + } + protected async resolveModuleReference(ref: string, documentUri: string, documentContext: DocumentContext): Promise { if (startsWith(documentUri, 'file://')) { const moduleName = getModuleNameFromPath(ref); @@ -458,27 +475,109 @@ export class CSSNavigation { // Try resolving the reference from the language configuration alias settings if (ref && !(await this.fileExists(ref))) { - const rootFolderUri = documentContext.resolveReference('/', documentUri); - if (settings && rootFolderUri) { - // Specific file reference - if (target in settings) { - return this.mapReference(joinPath(rootFolderUri, settings[target]), isRawLink); + const workspaceFolder = documentContext.resolveReference('/', documentUri); + + if (workspaceFolder) { + if (settings) { + // Single/Multi-root workspace support + const aliasMatch = await this.resolveAliasFromSettings(settings, target, workspaceFolder, isRawLink); + if (aliasMatch) { + return aliasMatch; + } } - // Reference folder - const firstSlash = target.indexOf('/'); - const prefix = `${target.substring(0, firstSlash)}/`; - if (prefix in settings) { - const aliasPath = (settings[prefix]).slice(0, -1); - let newPath = joinPath(rootFolderUri, aliasPath); - return this.mapReference(newPath = joinPath(newPath, target.substring(prefix.length - 1)), isRawLink); + + // settings === null; attempt directory tree traversal + const settingsUri = await this.getSettingsUri(workspaceFolder, documentUri); + if (settingsUri) { + const effectiveWorkspaceFolder = joinPath(settingsUri, '../../'); + const aliases = await this.getAliasesFromSettings(settingsUri, effectiveWorkspaceFolder, documentUri); + if (aliases) { + const aliasMatch = await this.resolveAliasFromSettings(aliases, target, effectiveWorkspaceFolder, isRawLink); + if (aliasMatch) { + return aliasMatch; + } + } } } - } + } // fall back. it might not exists return ref; } + private async resolveAliasFromSettings(settings: AliasSettings, target: string, workspaceFolder: string, isRawLink = false) { + // Specific file reference + if (target in settings) { + return this.mapReference(joinPath(workspaceFolder, settings[target]), isRawLink); + } + // Reference folder + const firstSlash = target.indexOf('/'); + const prefix = `${target.substring(0, firstSlash)}/`; + if (prefix in settings) { + const aliasPath = settings[prefix].slice(0, -1); + let newPath = joinPath(workspaceFolder, aliasPath); + return this.mapReference((newPath = joinPath(newPath, target.substring(prefix.length - 1))), isRawLink); + } + } + + private async getSettingsUri(workspaceFolder: string, documentUri: string): Promise { + if (this.documentSettingsUriCache.has(documentUri)) { + return this.documentSettingsUriCache.get(documentUri); + } + const settings = await this.findNearestSettings(workspaceFolder, documentUri); + this.documentSettingsUriCache.set(documentUri, settings?.fsPath); + return settings?.fsPath; + } + + private async getAliasesFromSettings(settingsUri: string, workspaceFolder: string, documentUri: string): Promise { + if (this.aliasCache.has(settingsUri)) { + return this.aliasCache.get(settingsUri); + } + + const settingsJSON = await this.parseSettingsFile(settingsUri); + if (settingsJSON) { + const documentExt = Utils.extname(URI.parse(documentUri)).slice(1); + const aliases = settingsJSON[`${documentExt}.importAliases`]; + this.aliasCache.set(settingsUri, aliases); + return aliases; + } + // Prevent repeated lookup + this.aliasCache.set(settingsUri, {}); + return {}; + } + + private async parseSettingsFile(settingsPath: string): Promise { + const candidate = URI.parse(settingsPath); + try { + const text = await readFile(candidate.fsPath, 'utf-8'); + const json = JSON.parse(text); + return json; + } catch (error) { + console.warn(`Failed to read ${candidate}:`, error); + } + return undefined; + } + + private async findNearestSettings(workspaceFolder: string, documentUri: string): Promise { + // Walks up from documentUri toward workspaceFolder. Stop at first .vscode/settings.json + const document = URI.parse(documentUri); + const root = URI.parse(workspaceFolder); + let current = document; + + while (current.path.startsWith(root.path)) { + const candidate = Utils.joinPath(current, '.vscode', 'settings.json'); + if (await this.fileExists(candidate.toString())) { + return candidate; + } + const parent = Utils.dirname(current); + if (parent.path === current.path) { + break; + } + current = parent; + } + return undefined; + } + protected async resolvePathToModule(_moduleName: string, documentFolderUri: string, rootFolderUri: string | undefined): Promise { // resolve the module relative to the document. We can't use `require` here as the code is webpacked. @@ -606,4 +705,4 @@ export function getModuleNameFromPath(path: string) { } // Otherwise get until first instance of '/' return path.substring(0, firstSlash); -} +} \ No newline at end of file diff --git a/src/test/css/navigation.test.ts b/src/test/css/navigation.test.ts index 3189a125..7010596f 100644 --- a/src/test/css/navigation.test.ts +++ b/src/test/css/navigation.test.ts @@ -378,14 +378,104 @@ suite('CSS - Navigation', () => { ]); }); - test('aliased @import links', async function () { - const settings = aliasSettings(); + test('aliased @import links (single-root)', async function () { const ls = getCSSLS(); - ls.configure(settings); + ls.configure({ + "importAliases": { + "@SassFile": "scss/file1.scss", + "@SassDir/": "scss/", + } + }); + + const testUri = getTestResource('scss/file1.scss'); + const testUri2 = getTestResource('scss/file2.module.scss'); + const workspaceFolder = getTestResource(''); + + await assertLinks(ls, '@import "@SassFile"', [{ range: newRange(8, 19), target: getTestResource('scss/file1.scss')}], 'scss', testUri, workspaceFolder); + + await assertLinks(ls, '@import "@SassDir/file2.module.scss"', [{ range: newRange(8, 36), target: getTestResource('scss/file2.module.scss')}], 'scss', testUri2, workspaceFolder); + }); + + test('aliased @import links (multi-root)', async function () { + const lsRoot1 = getCSSLS(); + const lsRoot2 = getCSSLS(); + + lsRoot1.configure({ + importAliases: { + "@SassFile": "assets/sass/main.scss" + } + }); + + lsRoot2.configure({ + importAliases: { + "@SassFile": "assets/sass/main.scss" + } + }); + + const testUriRoot1 = getTestResource('scss/root1/main.scss'); + const workspaceRoot1 = getTestResource('scss/root1'); + + const testUriRoot2 = getTestResource('scss/root2/main.scss'); + const workspaceRoot2 = getTestResource('scss/root2'); + + await assertLinks( + lsRoot1, + '@import "@SassFile"', + [{ range: newRange(8, 19), target: getTestResource('scss/root1/assets/sass/main.scss') }], + 'scss', + testUriRoot1, + workspaceRoot1 + ); + + await assertLinks( + lsRoot2, + '@import "@SassFile"', + [{ range: newRange(8, 19), target: getTestResource('scss/root2/assets/sass/main.scss') }], + 'scss', + testUriRoot2, + workspaceRoot2 + ); + }); - await assertLinks(ls, '@import "@SingleStylesheet"', [{ range: newRange(8, 27), target: "test://test/src/assets/styles.css"}]); + test('aliased @import links (mono-repo)', async function () { + const ls = getCSSLS(); + + // pkgs have actual '.vscode/settings.json' in linksTestFixtures/scss/pkg folders + ls.configure({ + importAliases: { + "@Shared": "./file1.scss" + } + }); + + const rootFolder = getTestResource('scss'); - await assertLinks(ls, '@import "@AssetsDir/styles.css"', [{ range: newRange(8, 31), target: "test://test/src/assets/styles.css"}]); + const pkg1Uri = getTestResource('scss/pkg1/main.scss'); + const pkg2Uri = getTestResource('scss/pkg2/main.scss'); + + // pkg1/2 should use their local alias and also resolve the shared one + await assertLinks( + ls, + '@import "@Shared"; @import "@Styles";', + [ + { range: newRange(8, 17), target: getTestResource('scss/file1.scss') }, + { range: newRange(27, 36), target: getTestResource('scss/pkg1/main.scss') } + ], + 'scss', + pkg1Uri, + rootFolder + ); + + await assertLinks( + ls, + '@import "@Shared"; @import "@Styles";', + [ + { range: newRange(8, 17), target: getTestResource('scss/file1.scss') }, + { range: newRange(27, 36), target: getTestResource('scss/pkg2/main.scss') } + ], + 'scss', + pkg2Uri, + rootFolder + ); }); test('links in rulesets', async () => { diff --git a/src/test/scss/scssNavigation.test.ts b/src/test/scss/scssNavigation.test.ts index f667e6d7..bec07c5d 100644 --- a/src/test/scss/scssNavigation.test.ts +++ b/src/test/scss/scssNavigation.test.ts @@ -32,7 +32,7 @@ async function assertDynamicLinks(docUri: string, input: string, expected: Docum const ls = getSCSSLS(); if (settings) { ls.configure(settings); - } + } const document = TextDocument.create(docUri, 'scss', 0, input); const stylesheet = ls.parseStylesheet(document); diff --git a/test/linksTestFixtures/scss/.vscode/settings.json b/test/linksTestFixtures/scss/.vscode/settings.json new file mode 100644 index 00000000..e8922cd4 --- /dev/null +++ b/test/linksTestFixtures/scss/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "scss.importAliases": { + "@Shared": "./file1.scss" + } +} \ No newline at end of file diff --git a/test/linksTestFixtures/scss/file1.scss b/test/linksTestFixtures/scss/file1.scss new file mode 100644 index 00000000..e69de29b diff --git a/test/linksTestFixtures/scss/file2.module.scss b/test/linksTestFixtures/scss/file2.module.scss new file mode 100644 index 00000000..e69de29b diff --git a/test/linksTestFixtures/scss/pkg1/.vscode/settings.json b/test/linksTestFixtures/scss/pkg1/.vscode/settings.json new file mode 100644 index 00000000..5c15cb8b --- /dev/null +++ b/test/linksTestFixtures/scss/pkg1/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "scss.importAliases": { + "@Styles": "./main.scss" + } +} \ No newline at end of file diff --git a/test/linksTestFixtures/scss/pkg1/main.scss b/test/linksTestFixtures/scss/pkg1/main.scss new file mode 100644 index 00000000..e69de29b diff --git a/test/linksTestFixtures/scss/pkg2/.vscode/settings.json b/test/linksTestFixtures/scss/pkg2/.vscode/settings.json new file mode 100644 index 00000000..5c15cb8b --- /dev/null +++ b/test/linksTestFixtures/scss/pkg2/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "scss.importAliases": { + "@Styles": "./main.scss" + } +} \ No newline at end of file diff --git a/test/linksTestFixtures/scss/pkg2/main.scss b/test/linksTestFixtures/scss/pkg2/main.scss new file mode 100644 index 00000000..e69de29b diff --git a/test/linksTestFixtures/scss/root1/main.scss b/test/linksTestFixtures/scss/root1/main.scss new file mode 100644 index 00000000..e69de29b diff --git a/test/linksTestFixtures/scss/root2/main.scss b/test/linksTestFixtures/scss/root2/main.scss new file mode 100644 index 00000000..e69de29b