Skip to content

Commit

Permalink
feat: add support for Sass pkg: importers (#384)
Browse files Browse the repository at this point in the history
* chore: add getContent to fsProvider

Matches the RequestService passed in from the language server
extension.

* feat: add support for Sass pkg import navigation

* refactor: extract to method

* chore: remove console

* polish

---------

Co-authored-by: Martin Aeschlimann <[email protected]>
  • Loading branch information
wkillerud and aeschli authored Jun 24, 2024
1 parent 73c70a8 commit 18455b4
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 69 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
lib/
node_modules/
!src/test/scss/linkFixture/pkgImport/node_modules/
coverage/
.nyc_output/
npm-debug.log
7 changes: 4 additions & 3 deletions src/cssLanguageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export namespace ClientCapabilities {

export interface LanguageServiceOptions {
/**
* Unless set to false, the default CSS data provider will be used
* Unless set to false, the default CSS data provider will be used
* along with the providers from customDataProviders.
* Defaults to true.
*/
Expand Down Expand Up @@ -284,6 +284,7 @@ export interface FileStat {
export interface FileSystemProvider {
stat(uri: DocumentUri): Promise<FileStat>;
readDirectory?(uri: DocumentUri): Promise<[string, FileType][]>;
getContent?(uri: DocumentUri, encoding?: string): Promise<string>;
}

export interface CSSFormatConfiguration {
Expand All @@ -305,11 +306,11 @@ export interface CSSFormatConfiguration {
preserveNewLines?: boolean;
/** maximum number of line breaks to be preserved in one chunk. Default: unlimited */
maxPreserveNewLines?: number;
/** maximum amount of characters per line (0/undefined = disabled). Default: disabled. */
/** maximum amount of characters per line (0/undefined = disabled). Default: disabled. */
wrapLineLength?: number;
/** add indenting whitespace to empty lines. Default: false */
indentEmptyLines?: boolean;

/** @deprecated Use newlineBetweenSelectors instead*/
selectorSeparatorNewline?: boolean;

Expand Down
16 changes: 14 additions & 2 deletions src/services/cssNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export class CSSNavigation {
return ref;
}

private async resolvePathToModule(_moduleName: string, documentFolderUri: string, rootFolderUri: string | undefined): Promise<string | 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.

const packPath = joinPath(documentFolderUri, 'node_modules', _moduleName, 'package.json');
Expand All @@ -444,6 +444,7 @@ export class CSSNavigation {
return undefined;
}


protected async fileExists(uri: string): Promise<boolean> {
if (!this.fileSystemProvider) {
return false;
Expand All @@ -460,6 +461,17 @@ export class CSSNavigation {
}
}

protected async getContent(uri: string): Promise<string | null> {
if (!this.fileSystemProvider || !this.fileSystemProvider.getContent) {
return null;
}
try {
return await this.fileSystemProvider.getContent(uri);
} catch (err) {
return null;
}
}

}

function getColorInformation(node: nodes.Node, document: TextDocument): ColorInformation | null {
Expand Down Expand Up @@ -531,7 +543,7 @@ function toTwoDigitHex(n: number): string {
return r.length !== 2 ? '0' + r : r;
}

function getModuleNameFromPath(path: string) {
export function getModuleNameFromPath(path: string) {
const firstSlash = path.indexOf('/');
if (firstSlash === -1) {
return '';
Expand Down
103 changes: 101 additions & 2 deletions src/services/scssNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
*--------------------------------------------------------------------------------------------*/
'use strict';

import { CSSNavigation } from './cssNavigation';
import { CSSNavigation, getModuleNameFromPath } from './cssNavigation';
import { FileSystemProvider, DocumentContext, FileType, DocumentUri } from '../cssLanguageTypes';
import * as nodes from '../parser/cssNodes';
import { URI, Utils } from 'vscode-uri';
import { startsWith } from '../utils/strings';
import { convertSimple2RegExpPattern, startsWith } from '../utils/strings';
import { dirname, joinPath } from '../utils/resources';

export class SCSSNavigation extends CSSNavigation {
constructor(fileSystemProvider: FileSystemProvider | undefined) {
Expand Down Expand Up @@ -39,8 +40,106 @@ export class SCSSNavigation extends CSSNavigation {
if (startsWith(target, 'sass:')) {
return undefined; // sass library
}
// Following the [sass package importer](https://github.com/sass/sass/blob/f6832f974c61e35c42ff08b3640ff155071a02dd/js-api-doc/importer.d.ts#L349),
// look for the `exports` field of the module and any `sass`, `style` or `default` that matches the import.
// If it's only `pkg:module`, also look for `sass` and `style` on the root of package.json.
if (target.startsWith('pkg:')) {
return this.resolvePkgModulePath(target, documentUri, documentContext);
}
return super.resolveReference(target, documentUri, documentContext, isRawLink);
}

private async resolvePkgModulePath(target: string, documentUri: string, documentContext: DocumentContext): Promise<string | undefined> {
const bareTarget = target.replace('pkg:', '');
const moduleName = bareTarget.includes('/') ? getModuleNameFromPath(bareTarget) : bareTarget;
const rootFolderUri = documentContext.resolveReference('/', documentUri);
const documentFolderUri = dirname(documentUri);
const modulePath = await this.resolvePathToModule(moduleName, documentFolderUri, rootFolderUri);
if (!modulePath) {
return undefined;
}
// Since submodule exports import strings don't match the file system,
// we need the contents of `package.json` to look up the correct path.
let packageJsonContent = await this.getContent(joinPath(modulePath, 'package.json'));
if (!packageJsonContent) {
return undefined;
}
let packageJson: {
style?: string;
sass?: string;
exports?: Record<string, string | Record<string, string>>
};
try {
packageJson = JSON.parse(packageJsonContent);
} catch (e) {
// problems parsing package.json
return undefined;
}

const subpath = bareTarget.substring(moduleName.length + 1);
if (packageJson.exports) {
if (!subpath) {
const dotExport = packageJson.exports['.'];
// look for the default/index export
// @ts-expect-error If ['.'] is a string this just produces undefined
const entry = dotExport && (dotExport['sass'] || dotExport['style'] || dotExport['default']);
// the 'default' entry can be whatever, typically .js – confirm it looks like `scss`
if (entry && entry.endsWith('.scss')) {
const entryPath = joinPath(modulePath, entry);
return entryPath;
}
} else {
// The import string may be with or without .scss.
// Likewise the exports entry. Look up both paths.
// However, they need to be relative (start with ./).
const lookupSubpath = subpath.endsWith('.scss') ? `./${subpath.replace('.scss', '')}` : `./${subpath}`;
const lookupSubpathScss = subpath.endsWith('.scss') ? `./${subpath}` : `./${subpath}.scss`;
const subpathObject = packageJson.exports[lookupSubpathScss] || packageJson.exports[lookupSubpath];
if (subpathObject) {
// @ts-expect-error If subpathObject is a string this just produces undefined
const entry = subpathObject['sass'] || subpathObject['styles'] || subpathObject['default'];
// the 'default' entry can be whatever, typically .js – confirm it looks like `scss`
if (entry && entry.endsWith('.scss')) {
const entryPath = joinPath(modulePath, entry);
return entryPath;
}
} else {
// We have a subpath, but found no matches on direct lookup.
// It may be a [subpath pattern](https://nodejs.org/api/packages.html#subpath-patterns).
for (const [maybePattern, subpathObject] of Object.entries(packageJson.exports)) {
if (!maybePattern.includes("*")) {
continue;
}
// Patterns may also be without `.scss` on the left side, so compare without on both sides
const re = new RegExp(convertSimple2RegExpPattern(maybePattern.replace('.scss', '')).replace(/\.\*/g, '(.*)'));
const match = re.exec(lookupSubpath);
if (match) {
// @ts-expect-error If subpathObject is a string this just produces undefined
const entry = subpathObject['sass'] || subpathObject['styles'] || subpathObject['default'];
// the 'default' entry can be whatever, typically .js – confirm it looks like `scss`
if (entry && entry.endsWith('.scss')) {
// The right-hand side of a subpath pattern is also a pattern.
// Replace the pattern with the match from our regexp capture group above.
const expandedPattern = entry.replace('*', match[1]);
const entryPath = joinPath(modulePath, expandedPattern);
return entryPath;
}
}
}
}
}
} else if (!subpath && (packageJson.sass || packageJson.style)) {
// Fall back to a direct lookup on `sass` and `style` on package root
const entry = packageJson.sass || packageJson.style;
if (entry) {
const entryPath = joinPath(modulePath, entry);
return entryPath;
}
}
return undefined;

}

}

function toPathVariations(target: string): DocumentUri[] {
Expand Down
47 changes: 46 additions & 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 Expand Up @@ -283,6 +283,51 @@ suite('SCSS - Navigation', () => {
);
});

test('SCSS node package resolving', async () => {
let ls = getSCSSLS();
let testUri = getTestResource('about.scss');
let workspaceFolder = getTestResource('');
await assertLinks(ls, `@use "pkg:bar"`,
[{ range: newRange(5, 14), target: getTestResource('node_modules/bar/styles/index.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:bar/colors"`,
[{ range: newRange(5, 21), target: getTestResource('node_modules/bar/styles/colors.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:bar/colors.scss"`,
[{ range: newRange(5, 26), target: getTestResource('node_modules/bar/styles/colors.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:@foo/baz"`,
[{ range: newRange(5, 19), target: getTestResource('node_modules/@foo/baz/styles/index.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:@foo/baz/colors"`,
[{ range: newRange(5, 26), target: getTestResource('node_modules/@foo/baz/styles/colors.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:@foo/baz/colors.scss"`,
[{ range: newRange(5, 31), target: getTestResource('node_modules/@foo/baz/styles/colors.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:@foo/baz/button"`,
[{ range: newRange(5, 26), target: getTestResource('node_modules/@foo/baz/styles/button.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:@foo/baz/button.scss"`,
[{ range: newRange(5, 31), target: getTestResource('node_modules/@foo/baz/styles/button.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:root-sass"`,
[{ range: newRange(5, 20), target: getTestResource('node_modules/root-sass/styles/index.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:root-style"`,
[{ range: newRange(5, 21), target: getTestResource('node_modules/root-style/styles/index.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:bar-pattern/anything"`,
[{ range: newRange(5, 31), target: getTestResource('node_modules/bar-pattern/styles/anything.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:bar-pattern/anything.scss"`,
[{ range: newRange(5, 36), target: getTestResource('node_modules/bar-pattern/styles/anything.scss')}], 'scss', testUri, workspaceFolder
);
await assertLinks(ls, `@use "pkg:bar-pattern/theme/dark.scss"`,
[{ range: newRange(5, 38), target: getTestResource('node_modules/bar-pattern/styles/theme/dark.scss')}], 'scss', testUri, workspaceFolder
);
});

});

suite('Symbols', () => {
Expand Down
115 changes: 55 additions & 60 deletions src/test/testUtil/fsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,73 +5,68 @@

import { FileSystemProvider, FileType } from "../../cssLanguageTypes";
import { URI } from 'vscode-uri';
import { stat as fsStat, readdir } from 'fs';
import { promises as fs } from 'fs';

export function getFsProvider(): FileSystemProvider {
return {
stat(documentUriString: string) {
return new Promise((c, e) => {
const documentUri = URI.parse(documentUriString);
if (documentUri.scheme !== 'file') {
e(new Error('Protocol not supported: ' + documentUri.scheme));
return;
async stat(documentUriString: string) {
const documentUri = URI.parse(documentUriString);
if (documentUri.scheme !== 'file') {
throw new Error('Protocol not supported: ' + documentUri.scheme);
}
try {
const stats = await fs.stat(documentUri.fsPath);
let type = FileType.Unknown;
if (stats.isFile()) {
type = FileType.File;
} else if (stats.isDirectory()) {
type = FileType.Directory;
} else if (stats.isSymbolicLink()) {
type = FileType.SymbolicLink;
}
fsStat(documentUri.fsPath, (err, stats) => {
if (err) {
if (err.code === 'ENOENT') {
return c({
type: FileType.Unknown,
ctime: -1,
mtime: -1,
size: -1
});
} else {
return e(err);
}
}

let type = FileType.Unknown;
if (stats.isFile()) {
type = FileType.File;
} else if (stats.isDirectory()) {
type = FileType.Directory;
} else if (stats.isSymbolicLink()) {
type = FileType.SymbolicLink;
}

c({
type,
ctime: stats.ctime.getTime(),
mtime: stats.mtime.getTime(),
size: stats.size
});
});
});
return {
type,
ctime: stats.ctime.getTime(),
mtime: stats.mtime.getTime(),
size: stats.size
};
} catch (err: any) {
if (err.code === 'ENOENT') {
return {
type: FileType.Unknown,
ctime: -1,
mtime: -1,
size: -1
};
} else {
throw err;
}
}
},
readDirectory(locationString: string) {
return new Promise((c, e) => {
const location = URI.parse(locationString);
if (location.scheme !== 'file') {
e(new Error('Protocol not supported: ' + location.scheme));
return;
async readDirectory(locationString: string) {
const location = URI.parse(locationString);
if (location.scheme !== 'file') {
throw new Error('Protocol not supported: ' + location.scheme);
}
const children = await fs.readdir(location.fsPath, { withFileTypes: true });
return children.map(stat => {
if (stat.isSymbolicLink()) {
return [stat.name, FileType.SymbolicLink];
} else if (stat.isDirectory()) {
return [stat.name, FileType.Directory];
} else if (stat.isFile()) {
return [stat.name, FileType.File];
} else {
return [stat.name, FileType.Unknown];
}
readdir(location.fsPath, { withFileTypes: true }, (err, children) => {
if (err) {
return e(err);
}
c(children.map(stat => {
if (stat.isSymbolicLink()) {
return [stat.name, FileType.SymbolicLink];
} else if (stat.isDirectory()) {
return [stat.name, FileType.Directory];
} else if (stat.isFile()) {
return [stat.name, FileType.File];
} else {
return [stat.name, FileType.Unknown];
}
}));
});
});
},
async getContent(locationString, encoding = "utf-8") {
const location = URI.parse(locationString);
if (location.scheme !== 'file') {
throw new Error('Protocol not supported: ' + location.scheme);
}
return await fs.readFile(location.fsPath, { encoding: encoding as BufferEncoding });
}
};
}
Loading

0 comments on commit 18455b4

Please sign in to comment.