Skip to content
Open
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
5 changes: 3 additions & 2 deletions src/cssLanguageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
};
}

Expand Down
129 changes: 114 additions & 15 deletions src/services/cssNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string | undefined>();
private aliasCache = new Map<string, AliasSettings>();

constructor(protected fileSystemProvider: FileSystemProvider | undefined, private readonly resolveModuleReferences: boolean) {
}
Expand Down Expand Up @@ -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<string | undefined> {
if (startsWith(documentUri, 'file://')) {
const moduleName = getModuleNameFromPath(ref);
Expand Down Expand Up @@ -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<string | undefined> {
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<AliasSettings | undefined> {
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<VSCodeSettings | undefined> {
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<URI | undefined> {
// 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<string | undefined> {
// resolve the module relative to the document. We can't use `require` here as the code is webpacked.

Expand Down Expand Up @@ -606,4 +705,4 @@ export function getModuleNameFromPath(path: string) {
}
// Otherwise get until first instance of '/'
return path.substring(0, firstSlash);
}
}
100 changes: 95 additions & 5 deletions src/test/css/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/test/scss/scssNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions test/linksTestFixtures/scss/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"scss.importAliases": {
"@Shared": "./file1.scss"
}
}
Empty file.
Empty file.
5 changes: 5 additions & 0 deletions test/linksTestFixtures/scss/pkg1/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"scss.importAliases": {
"@Styles": "./main.scss"
}
}
Empty file.
5 changes: 5 additions & 0 deletions test/linksTestFixtures/scss/pkg2/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"scss.importAliases": {
"@Styles": "./main.scss"
}
}
Empty file.
Empty file.
Empty file.