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 2 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
1 change: 1 addition & 0 deletions src/cssLanguageTypes.ts
Original file line number Diff line number Diff line change
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?: BufferEncoding): Promise<string>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matches the RequestService on the language server side

}

export interface CSSFormatConfiguration {
Expand Down
98 changes: 98 additions & 0 deletions src/services/cssNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,92 @@ export class CSSNavigation {
return this.mapReference(await this.resolveModuleReference(target, documentUri, documentContext), isRawLink);
}

// 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:')) {
const bareTarget = target.replace('pkg:', '');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be extracted in a method ?

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) {
const packageJsonPath = `${modulePath}/package.json`;
if (packageJsonPath) {
// 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(packageJsonPath);
if (packageJsonContent) {
const packageJson: {
style?: string;
sass?: string;
exports: Record<string, string | Record<string, string>>
} = JSON.parse(packageJsonContent);

const subpath = bareTarget.substring(moduleName.length + 1);
if (packageJson.exports) {
if (!subpath) {
// look for the default/index export
// @ts-expect-error If ['.'] is a string this just produces undefined
const entry = packageJson.exports['.']['sass'] || packageJson.exports['.']['style'] || packageJson.exports['.']['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(maybePattern.replace('./', '\\.\/').replace('.scss', '').replace('*', '(.+)'));
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;
}
}
}
}
}
}

const ref = await this.mapReference(documentContext.resolveReference(target, documentUri), isRawLink);

// Following [less-loader](https://github.com/webpack-contrib/less-loader#imports)
Expand Down Expand Up @@ -460,6 +546,18 @@ 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) {
console.error(err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not print to console

return null;
}
}

}

function getColorInformation(node: nodes.Node, document: TextDocument): ColorInformation | null {
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
17 changes: 16 additions & 1 deletion src/test/testUtil/fsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

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

export function getFsProvider(): FileSystemProvider {
return {
Expand Down Expand Up @@ -72,6 +72,21 @@ export function getFsProvider(): FileSystemProvider {
}));
});
});
},
getContent(locationString, encoding = "utf-8") {
return new Promise((c, e) => {
const location = URI.parse(locationString);
if (location.scheme !== 'file') {
e(new Error('Protocol not supported: ' + location.scheme));
return;
}
readFile(location.fsPath, encoding, (err, data) => {
if (err) {
return e(err);
}
c(data);
});
});
}
};
}
13 changes: 13 additions & 0 deletions test/linksTestFixtures/node_modules/@foo/baz/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions test/linksTestFixtures/node_modules/bar-pattern/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions test/linksTestFixtures/node_modules/bar/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/linksTestFixtures/node_modules/root-sass/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/linksTestFixtures/node_modules/root-style/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.