From 18455b4bb8cac852863ec172f7e53672e3ce3e02 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Mon, 24 Jun 2024 11:08:56 +0200 Subject: [PATCH] feat: add support for Sass `pkg:` importers (#384) * 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 --- .gitignore | 1 + src/cssLanguageTypes.ts | 7 +- src/services/cssNavigation.ts | 16 ++- src/services/scssNavigation.ts | 103 +++++++++++++++- src/test/scss/scssNavigation.test.ts | 47 ++++++- src/test/testUtil/fsProvider.ts | 115 +++++++++--------- src/utils/strings.ts | 7 +- .../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 + 12 files changed, 272 insertions(+), 69 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/cssLanguageTypes.ts b/src/cssLanguageTypes.ts index cb5af3ee..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,6 +284,7 @@ export interface FileStat { export interface FileSystemProvider { stat(uri: DocumentUri): Promise; readDirectory?(uri: DocumentUri): Promise<[string, FileType][]>; + getContent?(uri: DocumentUri, encoding?: string): Promise; } export interface CSSFormatConfiguration { @@ -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; diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index d3d8ec85..8df4c072 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -432,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'); @@ -444,6 +444,7 @@ export class CSSNavigation { return undefined; } + protected async fileExists(uri: string): Promise { if (!this.fileSystemProvider) { return false; @@ -460,6 +461,17 @@ 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) { + return null; + } + } + } function getColorInformation(node: nodes.Node, document: TextDocument): ColorInformation | null { @@ -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 ''; 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/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..d3b300b8 100644 --- a/src/test/testUtil/fsProvider.ts +++ b/src/test/testUtil/fsProvider.ts @@ -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 }); } }; } \ 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, '.*'); +} + 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" +}