Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for Sass pkg: importers #384

Merged
merged 5 commits into from
Jun 24, 2024
Merged
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
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
Loading