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, '.*'); +} +