From 4208cf5f0a30d07288cd72a0c0aea7f35da5236e Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 16 Feb 2024 20:19:29 +0100 Subject: [PATCH 1/5] chore: add getContent to fsProvider Matches the RequestService passed in from the language server extension. --- src/cssLanguageTypes.ts | 1 + src/services/cssNavigation.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/cssLanguageTypes.ts b/src/cssLanguageTypes.ts index cb5af3ee..4092146f 100644 --- a/src/cssLanguageTypes.ts +++ b/src/cssLanguageTypes.ts @@ -284,6 +284,7 @@ export interface FileStat { export interface FileSystemProvider { stat(uri: DocumentUri): Promise; readDirectory?(uri: DocumentUri): Promise<[string, FileType][]>; + getContent?(uri: DocumentUri, encoding?: BufferEncoding): Promise; } export interface CSSFormatConfiguration { diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index d3d8ec85..16acc9d0 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -460,6 +460,18 @@ export class CSSNavigation { } } + protected async getContent(uri: string): Promise { + if (!this.fileSystemProvider || !this.fileSystemProvider.getContent) { + return null; + } + try { + return await this.fileSystemProvider.getContent(uri); + } catch (err) { + console.error(err); + return null; + } + } + } function getColorInformation(node: nodes.Node, document: TextDocument): ColorInformation | null { From 476e97b1b6d03983e6302331ab597a1bed5f0cd2 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 16 Feb 2024 18:43:42 +0100 Subject: [PATCH 2/5] feat: add support for Sass pkg import navigation --- .gitignore | 1 + src/services/cssNavigation.ts | 86 +++++++++++++++++++ src/test/scss/scssNavigation.test.ts | 47 +++++++++- src/test/testUtil/fsProvider.ts | 17 +++- .../node_modules/@foo/baz/package.json | 13 +++ .../node_modules/bar-pattern/package.json | 13 +++ .../node_modules/bar/package.json | 13 +++ .../node_modules/root-sass/package.json | 3 + .../node_modules/root-style/package.json | 3 + 9 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 test/linksTestFixtures/node_modules/@foo/baz/package.json create mode 100644 test/linksTestFixtures/node_modules/bar-pattern/package.json create mode 100644 test/linksTestFixtures/node_modules/bar/package.json create mode 100644 test/linksTestFixtures/node_modules/root-sass/package.json create mode 100644 test/linksTestFixtures/node_modules/root-style/package.json diff --git a/.gitignore b/.gitignore index 2d9f632f..ef9b945f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ lib/ node_modules/ +!src/test/scss/linkFixture/pkgImport/node_modules/ coverage/ .nyc_output/ npm-debug.log \ No newline at end of file diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index 16acc9d0..3dd21f48 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -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:', ''); + 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> + } = 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) diff --git a/src/test/scss/scssNavigation.test.ts b/src/test/scss/scssNavigation.test.ts index 922fafb2..2b307f43 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); @@ -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', () => { diff --git a/src/test/testUtil/fsProvider.ts b/src/test/testUtil/fsProvider.ts index 88e0705d..8cedd6e0 100644 --- a/src/test/testUtil/fsProvider.ts +++ b/src/test/testUtil/fsProvider.ts @@ -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 { @@ -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); + }); + }); } }; } \ No newline at end of file diff --git a/test/linksTestFixtures/node_modules/@foo/baz/package.json b/test/linksTestFixtures/node_modules/@foo/baz/package.json new file mode 100644 index 00000000..b4f30b11 --- /dev/null +++ b/test/linksTestFixtures/node_modules/@foo/baz/package.json @@ -0,0 +1,13 @@ +{ + "exports": { + ".": { + "sass": "./styles/index.scss" + }, + "./colors.scss": { + "sass": "./styles/colors.scss" + }, + "./button": { + "sass": "./styles/button.scss" + } + } +} diff --git a/test/linksTestFixtures/node_modules/bar-pattern/package.json b/test/linksTestFixtures/node_modules/bar-pattern/package.json new file mode 100644 index 00000000..876ae8b3 --- /dev/null +++ b/test/linksTestFixtures/node_modules/bar-pattern/package.json @@ -0,0 +1,13 @@ +{ + "exports": { + ".": { + "sass": "./styles/index.scss" + }, + "./*.scss": { + "sass": "./styles/*.scss" + }, + "./theme/*": { + "sass": "./styles/theme/*.scss" + } + } +} diff --git a/test/linksTestFixtures/node_modules/bar/package.json b/test/linksTestFixtures/node_modules/bar/package.json new file mode 100644 index 00000000..b4f30b11 --- /dev/null +++ b/test/linksTestFixtures/node_modules/bar/package.json @@ -0,0 +1,13 @@ +{ + "exports": { + ".": { + "sass": "./styles/index.scss" + }, + "./colors.scss": { + "sass": "./styles/colors.scss" + }, + "./button": { + "sass": "./styles/button.scss" + } + } +} diff --git a/test/linksTestFixtures/node_modules/root-sass/package.json b/test/linksTestFixtures/node_modules/root-sass/package.json new file mode 100644 index 00000000..9451bbf8 --- /dev/null +++ b/test/linksTestFixtures/node_modules/root-sass/package.json @@ -0,0 +1,3 @@ +{ + "sass": "./styles/index.scss" +} diff --git a/test/linksTestFixtures/node_modules/root-style/package.json b/test/linksTestFixtures/node_modules/root-style/package.json new file mode 100644 index 00000000..80b361f2 --- /dev/null +++ b/test/linksTestFixtures/node_modules/root-style/package.json @@ -0,0 +1,3 @@ +{ + "style": "./styles/index.scss" +} From 825c1a0f7cd21b96a495c82a903c146e43eeb01e Mon Sep 17 00:00:00 2001 From: William Killerud Date: Wed, 28 Feb 2024 19:25:57 +0100 Subject: [PATCH 3/5] refactor: extract to method --- src/services/cssNavigation.ts | 165 +++++++++++++++++----------------- 1 file changed, 85 insertions(+), 80 deletions(-) diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index 3dd21f48..ff17358a 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -396,86 +396,7 @@ export class CSSNavigation { // 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:', ''); - 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> - } = 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; - } - } - } - } - } + return this.resolvePkgModulePath(target, documentUri, documentContext); } const ref = await this.mapReference(documentContext.resolveReference(target, documentUri), isRawLink); @@ -530,6 +451,90 @@ export class CSSNavigation { return undefined; } + private async resolvePkgModulePath(target: string, documentUri: string, documentContext: DocumentContext): Promise { + 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) { + 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> + } = 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; + } + } + } + } + } + return undefined; + } + protected async fileExists(uri: string): Promise { if (!this.fileSystemProvider) { return false; From 62c58c355c963f77201c271a87186cf9dd8f3a1a Mon Sep 17 00:00:00 2001 From: William Killerud Date: Wed, 28 Feb 2024 19:26:06 +0100 Subject: [PATCH 4/5] chore: remove console --- src/services/cssNavigation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index ff17358a..89edfe70 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -558,7 +558,6 @@ export class CSSNavigation { try { return await this.fileSystemProvider.getContent(uri); } catch (err) { - console.error(err); return null; } } From ade0b030e69546b93e946e2e1a23bf21eb08ed6e Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Mon, 24 Jun 2024 10:47:10 +0200 Subject: [PATCH 5/5] polish --- src/cssLanguageTypes.ts | 8 +- src/services/cssNavigation.ts | 94 +---------------------- src/services/scssNavigation.ts | 103 ++++++++++++++++++++++++- src/test/testUtil/fsProvider.ts | 128 ++++++++++++++------------------ src/utils/strings.ts | 7 +- 5 files changed, 167 insertions(+), 173 deletions(-) diff --git a/src/cssLanguageTypes.ts b/src/cssLanguageTypes.ts index 4092146f..72cdfd3d 100644 --- a/src/cssLanguageTypes.ts +++ b/src/cssLanguageTypes.ts @@ -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. */ @@ -284,7 +284,7 @@ export interface FileStat { export interface FileSystemProvider { stat(uri: DocumentUri): Promise; readDirectory?(uri: DocumentUri): Promise<[string, FileType][]>; - getContent?(uri: DocumentUri, encoding?: BufferEncoding): Promise; + getContent?(uri: DocumentUri, encoding?: string): Promise; } export interface CSSFormatConfiguration { @@ -306,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; diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index 89edfe70..8df4c072 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -392,13 +392,6 @@ 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:')) { - return this.resolvePkgModulePath(target, documentUri, documentContext); - } - const ref = await this.mapReference(documentContext.resolveReference(target, documentUri), isRawLink); // Following [less-loader](https://github.com/webpack-contrib/less-loader#imports) @@ -439,7 +432,7 @@ export class CSSNavigation { return ref; } - private async resolvePathToModule(_moduleName: string, documentFolderUri: string, rootFolderUri: string | undefined): Promise { + 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. const packPath = joinPath(documentFolderUri, 'node_modules', _moduleName, 'package.json'); @@ -451,89 +444,6 @@ export class CSSNavigation { return undefined; } - private async resolvePkgModulePath(target: string, documentUri: string, documentContext: DocumentContext): Promise { - 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) { - 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> - } = 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; - } - } - } - } - } - return undefined; - } protected async fileExists(uri: string): Promise { if (!this.fileSystemProvider) { @@ -633,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 ''; diff --git a/src/services/scssNavigation.ts b/src/services/scssNavigation.ts index fb9e57f8..96d85479 100644 --- a/src/services/scssNavigation.ts +++ b/src/services/scssNavigation.ts @@ -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) { @@ -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 { + 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> + }; + 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[] { diff --git a/src/test/testUtil/fsProvider.ts b/src/test/testUtil/fsProvider.ts index 8cedd6e0..d3b300b8 100644 --- a/src/test/testUtil/fsProvider.ts +++ b/src/test/testUtil/fsProvider.ts @@ -5,88 +5,68 @@ import { FileSystemProvider, FileType } from "../../cssLanguageTypes"; import { URI } from 'vscode-uri'; -import { stat as fsStat, readdir, readFile } 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 - }); - }); - }); - }, - 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; + 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; } - 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]; - } - })); - }); - }); + } }, - 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; + 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]; } - readFile(location.fsPath, encoding, (err, data) => { - if (err) { - return e(err); - } - c(data); - }); }); + }, + 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 }); } }; } \ No newline at end of file diff --git a/src/utils/strings.ts b/src/utils/strings.ts index 0d178071..a979dbee 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -104,4 +104,9 @@ export function repeat(value: string, count: number) { count = count >>> 1; } return s; -} \ No newline at end of file +} + +export function convertSimple2RegExpPattern(pattern: string): string { + return pattern.replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g, '\\$&').replace(/[\*]/g, '.*'); +} +