From b5c9115c3deb7673e25079b5a47a5afd369d13ca Mon Sep 17 00:00:00 2001 From: Jason Dent Date: Sun, 15 Dec 2024 15:13:21 +0100 Subject: [PATCH] feat: Support Windows UNC files. (#6671) --- package.json | 2 +- .../Controller/configLoader/configLoader.ts | 7 ++- .../src/lib/Settings/GlobalSettings.ts | 7 +-- .../cspell-lib/src/lib/Settings/resolveCwd.ts | 8 +-- .../src/lib/textValidation/docValidator.ts | 3 +- packages/cspell-lib/src/lib/util/Uri.ts | 5 +- .../cspell-lib/src/lib/util/resolveFile.ts | 13 +++-- packages/cspell-lib/src/lib/util/url.ts | 9 +-- packages/cspell-url/src/FileUrlBuilder.mts | 28 ++++++--- .../cspell-url/src/FileUrlBuilder.test.mts | 17 +++++- packages/cspell-url/src/fileUrl.mts | 43 +++++++++++++- packages/cspell-url/src/fileUrl.test.mts | 16 +++++- packages/cspell-url/src/index.mts | 2 + packages/cspell-url/src/url.mts | 24 ++++++-- packages/cspell-url/src/url.test.mts | 57 +++++++++++++------ packages/cspell/src/app/lint/lint.ts | 4 +- packages/cspell/src/app/util/fileHelper.ts | 4 +- packages/cspell/src/app/util/stdinUrl.ts | 6 +- packages/dynamic-import/package.json | 1 + .../dynamic-import/src/esm/dynamicImport.mts | 13 +++-- pnpm-lock.yaml | 41 ++++++------- 21 files changed, 215 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index 66c1744c30b..ba9ef1888aa 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "cspell": "bin.mjs", "cspell-tools": "cspell-tools.mjs" }, - "packageManager": "pnpm@9.4.0+sha512.f549b8a52c9d2b8536762f99c0722205efc5af913e77835dbccc3b0b0b2ca9e7dc8022b78062c17291c48e88749c70ce88eb5a74f1fa8c4bf5e18bb46c8bd83a", + "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c", "private": true, "scripts": { "bt": "pnpm run build && pnpm run test", diff --git a/packages/cspell-lib/src/lib/Settings/Controller/configLoader/configLoader.ts b/packages/cspell-lib/src/lib/Settings/Controller/configLoader/configLoader.ts index 0e24e78ed3c..b88cb33bdda 100644 --- a/packages/cspell-lib/src/lib/Settings/Controller/configLoader/configLoader.ts +++ b/packages/cspell-lib/src/lib/Settings/Controller/configLoader/configLoader.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import type { CSpellUserSettings, ImportFileRef, Source } from '@cspell/cspell-types'; import { CSpellConfigFile, CSpellConfigFileReaderWriter, ICSpellConfigFile, IO, TextFile } from 'cspell-config-lib'; @@ -21,6 +21,7 @@ import { addTrailingSlash, cwdURL, resolveFileWithURL, + toFileDirURL, toFilePathOrHref, toFileUrl, windowsDriveLetterToUpper, @@ -224,7 +225,7 @@ export class ConfigLoader implements IConfigLoader { pnpSettings?: PnPSettingsOptional, ): Promise { await this.onReady; - const ref = await this.resolveFilename(filename, relativeTo || pathToFileURL('./')); + const ref = await this.resolveFilename(filename, relativeTo || toFileDirURL('./')); const entry = this.importSettings(ref, pnpSettings || defaultPnPSettings, []); return entry.onReady; } @@ -233,7 +234,7 @@ export class ConfigLoader implements IConfigLoader { filenameOrURL: string | URL, relativeTo?: string | URL, ): Promise { - const ref = await this.resolveFilename(filenameOrURL.toString(), relativeTo || pathToFileURL('./')); + const ref = await this.resolveFilename(filenameOrURL.toString(), relativeTo || toFileDirURL('./')); const url = toFileURL(ref.filename); const href = url.href; if (ref.error) return new ImportError(`Failed to read config file: "${ref.filename}"`, ref.error); diff --git a/packages/cspell-lib/src/lib/Settings/GlobalSettings.ts b/packages/cspell-lib/src/lib/Settings/GlobalSettings.ts index 370ad82449c..b5b15cd6f1c 100644 --- a/packages/cspell-lib/src/lib/Settings/GlobalSettings.ts +++ b/packages/cspell-lib/src/lib/Settings/GlobalSettings.ts @@ -1,8 +1,7 @@ -import { pathToFileURL } from 'node:url'; - import type { CSpellSettings, CSpellSettingsWithSourceTrace } from '@cspell/cspell-types'; import type { CSpellConfigFile } from 'cspell-config-lib'; import { CSpellConfigFileInMemory, CSpellConfigFileJson } from 'cspell-config-lib'; +import { toFileURL } from 'cspell-io'; import { getSourceDirectoryUrl, toFilePathOrHref } from '../util/url.js'; import { GlobalConfigStore } from './cfgStore.js'; @@ -24,7 +23,7 @@ export async function getRawGlobalSettings(): Promise { export async function getGlobalConfig(): Promise { const name = 'CSpell Configstore'; const configPath = getGlobalConfigPath(); - let urlGlobal = configPath ? pathToFileURL(configPath) : new URL('global-config.json', getSourceDirectoryUrl()); + let urlGlobal = configPath ? toFileURL(configPath) : new URL('global-config.json', getSourceDirectoryUrl()); const source: CSpellSettingsWST['source'] = { name, @@ -39,7 +38,7 @@ export async function getGlobalConfig(): Promise { if (found && found.config && found.filename) { const cfg = found.config; - urlGlobal = pathToFileURL(found.filename); + urlGlobal = toFileURL(found.filename); // Only populate globalConf is there are values. if (cfg && Object.keys(cfg).length) { diff --git a/packages/cspell-lib/src/lib/Settings/resolveCwd.ts b/packages/cspell-lib/src/lib/Settings/resolveCwd.ts index 465d320f6ab..3fc2a4ae01c 100644 --- a/packages/cspell-lib/src/lib/Settings/resolveCwd.ts +++ b/packages/cspell-lib/src/lib/Settings/resolveCwd.ts @@ -1,4 +1,4 @@ -import { pathToFileURL } from 'node:url'; +import { toFileDirURL, toFileURL } from '@cspell/url'; export class CwdUrlResolver { #lastPath: string; @@ -8,7 +8,7 @@ export class CwdUrlResolver { constructor() { this.#cwd = process.cwd(); - this.#cwdUrl = pathToFileURL(this.#cwd); + this.#cwdUrl = toFileDirURL(this.#cwd); this.#lastPath = this.#cwd; this.#lastUrl = this.#cwdUrl; } @@ -17,12 +17,12 @@ export class CwdUrlResolver { if (path === this.#lastPath) return this.#lastUrl; if (path === this.#cwd) return this.#cwdUrl; this.#lastPath = path; - this.#lastUrl = pathToFileURL(path); + this.#lastUrl = toFileURL(path); return this.#lastUrl; } reset(cwd: string = process.cwd()) { this.#cwd = cwd; - this.#cwdUrl = pathToFileURL(this.#cwd); + this.#cwdUrl = toFileDirURL(this.#cwd); } } diff --git a/packages/cspell-lib/src/lib/textValidation/docValidator.ts b/packages/cspell-lib/src/lib/textValidation/docValidator.ts index 81ae6ab8b4a..532e80cbfe9 100644 --- a/packages/cspell-lib/src/lib/textValidation/docValidator.ts +++ b/packages/cspell-lib/src/lib/textValidation/docValidator.ts @@ -1,5 +1,4 @@ import assert from 'node:assert'; -import { pathToFileURL } from 'node:url'; import { opConcatMap, opMap, pipeSync } from '@cspell/cspell-pipe/sync'; import type { @@ -140,7 +139,7 @@ export class DocumentValidator { const { options, settings: rawSettings } = this; const resolveImportsRelativeTo = toFileURL( - options.resolveImportsRelativeTo || pathToFileURL('./virtual.settings.json'), + options.resolveImportsRelativeTo || toFileURL('./virtual.settings.json'), ); const settings = rawSettings.import?.length diff --git a/packages/cspell-lib/src/lib/util/Uri.ts b/packages/cspell-lib/src/lib/util/Uri.ts index 081276fd138..da1506dc602 100644 --- a/packages/cspell-lib/src/lib/util/Uri.ts +++ b/packages/cspell-lib/src/lib/util/Uri.ts @@ -1,7 +1,6 @@ import assert from 'node:assert'; -import { pathToFileURL } from 'node:url'; -import { toFilePathOrHref, toFileURL, toURL } from '@cspell/url'; +import { toFileDirURL, toFilePathOrHref, toFileURL, toURL } from '@cspell/url'; import { isUrlLike } from 'cspell-io'; import { URI, Utils } from 'vscode-uri'; @@ -26,7 +25,7 @@ export function toUri(uriOrFile: string | Uri | URL): UriInstance { const isWindows = process.platform === 'win32'; const hasDriveLetter = /^[a-zA-Z]:[\\/]/; -const rootUrl = pathToFileURL('/'); +const rootUrl = toFileDirURL('/'); export function uriToFilePath(uri: DocumentUri): string { let url = documentUriToURL(uri); diff --git a/packages/cspell-lib/src/lib/util/resolveFile.ts b/packages/cspell-lib/src/lib/util/resolveFile.ts index 541b4118fd6..8c3cad53a30 100644 --- a/packages/cspell-lib/src/lib/util/resolveFile.ts +++ b/packages/cspell-lib/src/lib/util/resolveFile.ts @@ -1,7 +1,6 @@ import { createRequire } from 'node:module'; import * as os from 'node:os'; import * as path from 'node:path'; -import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { resolveGlobal } from '@cspell/cspell-resolver'; @@ -18,6 +17,7 @@ import { isFileURL, isURLLike, resolveFileWithURL, + toFileDirURL, toFilePathOrHref, toFileUrl, toURL, @@ -42,6 +42,8 @@ export interface ResolveFileResult { const regExpStartsWidthNodeModules = /^node_modules[/\\]/; +const debugMode = false; + export class FileResolver { constructor( private fs: VFileSystem, @@ -174,12 +176,15 @@ export class FileResolver { tryCreateRequire = (filename: string | URL, relativeTo: string | URL): ResolveFileResult | undefined => { if (filename instanceof URL) return undefined; - const rel = !isURLLike(relativeTo) || isFileURL(relativeTo) ? relativeTo : pathToFileURL('./'); - const require = createRequire(rel); + const rel = !isURLLike(relativeTo) || isFileURL(relativeTo) ? relativeTo : toFileDirURL('./'); try { + const require = createRequire(rel); const r = require.resolve(filename); return { filename: r, relativeTo: rel.toString(), found: true, method: 'tryCreateRequire' }; - } catch { + } catch (error) { + if (debugMode) { + console.error('Error in tryCreateRequire: %o', { filename, rel, relativeTo, error: `${error}` }); + } return undefined; } }; diff --git a/packages/cspell-lib/src/lib/util/url.ts b/packages/cspell-lib/src/lib/util/url.ts index ed6f5378ea7..ac5d617e12e 100644 --- a/packages/cspell-lib/src/lib/util/url.ts +++ b/packages/cspell-lib/src/lib/util/url.ts @@ -1,7 +1,4 @@ -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; - -import { toFilePathOrHref, toFileURL } from '@cspell/url'; +import { toFileDirURL, toFilePathOrHref, toFileURL } from '@cspell/url'; import { srcDirectory } from '../pkg-info.mjs'; @@ -21,7 +18,7 @@ export { * @returns URL for the source directory */ export function getSourceDirectoryUrl(): URL { - const srcDirectoryURL = pathToFileURL(path.join(srcDirectory, '/')); + const srcDirectoryURL = toFileDirURL(srcDirectory); return srcDirectoryURL; } @@ -35,7 +32,7 @@ export function relativeTo(path: string, relativeTo?: URL | string): URL { } export function cwdURL(): URL { - return pathToFileURL('./'); + return toFileDirURL('./'); } export function toFileUrl(file: string | URL): URL { diff --git a/packages/cspell-url/src/FileUrlBuilder.mts b/packages/cspell-url/src/FileUrlBuilder.mts index 6a7959c8296..fddb299f577 100644 --- a/packages/cspell-url/src/FileUrlBuilder.mts +++ b/packages/cspell-url/src/FileUrlBuilder.mts @@ -2,7 +2,15 @@ import assert from 'node:assert'; import Path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { pathWindowsDriveLetterToUpper, regExpWindowsPathDriveLetter, toFilePathOrHref } from './fileUrl.mjs'; +import { + isFileURL, + isWindows, + isWindowsFileUrl, + isWindowsPathnameWithDriveLatter, + pathWindowsDriveLetterToUpper, + regExpWindowsPathDriveLetter, + toFilePathOrHref, +} from './fileUrl.mjs'; import { addTrailingSlash, isUrlLike, @@ -12,8 +20,6 @@ import { urlToUrlRelative, } from './url.mjs'; -export const isWindows = process.platform === 'win32'; - const isWindowsPathRegEx = regExpWindowsPathDriveLetter; const isWindowsPathname = regExpWindowsPath; @@ -127,18 +133,26 @@ export class FileUrlBuilder { */ #toFileURL(filenameOrUrl: string | URL, relativeTo?: string | URL): URL { if (typeof filenameOrUrl !== 'string') return filenameOrUrl; - if (isUrlLike(filenameOrUrl)) return new URL(filenameOrUrl); + if (isUrlLike(filenameOrUrl)) return normalizeWindowsUrl(new URL(filenameOrUrl)); relativeTo ??= this.cwd; isWindows && (filenameOrUrl = filenameOrUrl.replaceAll('\\', '/')); + if (this.isAbsolute(filenameOrUrl) && isFileURL(relativeTo)) { + const pathname = this.normalizeFilePathForUrl(filenameOrUrl); + if (isWindowsFileUrl(relativeTo) && !isWindowsPathnameWithDriveLatter(pathname)) { + const relFilePrefix = relativeTo.toString().slice(0, 10); + return normalizeWindowsUrl(new URL(relFilePrefix + pathname)); + } + return normalizeWindowsUrl(new URL('file://' + pathname)); + } if (isUrlLike(relativeTo)) { const pathname = this.normalizeFilePathForUrl(filenameOrUrl); - return new URL(pathname, relativeTo); + return normalizeWindowsUrl(new URL(pathname, relativeTo)); } // Resolve removes the trailing slash, so we need to add it back. const appendSlash = filenameOrUrl.endsWith('/') ? '/' : ''; const pathname = this.normalizeFilePathForUrl(this.path.resolve(relativeTo.toString(), filenameOrUrl)) + appendSlash; - return this.pathToFileURL(pathname, this.cwd); + return normalizeWindowsUrl(new URL('file://' + pathname)); } /** @@ -158,7 +172,7 @@ export class FileUrlBuilder { } #urlToFilePathOrHref(url: URL): string { - if (url.protocol !== ProtocolFile) return url.href; + if (url.protocol !== ProtocolFile || url.hostname) return url.href; const p = this.path === Path ? toFilePathOrHref(url) diff --git a/packages/cspell-url/src/FileUrlBuilder.test.mts b/packages/cspell-url/src/FileUrlBuilder.test.mts index 447db1e1690..b0b0245f7e6 100644 --- a/packages/cspell-url/src/FileUrlBuilder.test.mts +++ b/packages/cspell-url/src/FileUrlBuilder.test.mts @@ -1,5 +1,5 @@ import Path from 'node:path'; -import url from 'node:url'; +import url, { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, expect, test } from 'vitest'; @@ -27,6 +27,21 @@ describe('FileUrlBuilder', () => { expect(result).toEqual(expected); }); + test.each` + file | relativeTo | path | expected + ${'.'} | ${undefined} | ${undefined} | ${pathToFileURL('./').href} + ${'README.md'} | ${process.cwd()} | ${undefined} | ${pathToFileURL('README.md').href} + ${import.meta.url} | ${process.cwd()} | ${Path.win32} | ${import.meta.url} + ${'deeper/'} | ${'file:///E:/user/Test/project/'} | ${Path.win32} | ${'file:///E:/user/Test/project/deeper/'} + ${'file://host/E$/user/test/project/'} | ${undefined} | ${Path.win32} | ${'file://host/E$/user/test/project/'} + ${'../sibling'} | ${'file://host/E$/user/test/project/'} | ${Path.win32} | ${'file://host/E$/user/test/sibling'} + ${fileURLToPath(import.meta.url)} | ${'file://host/E$/user/test/project/'} | ${Path.win32} | ${import.meta.url} + `('toFileURL $file $relativeTo', ({ file, relativeTo, path, expected }) => { + const builder = new FileUrlBuilder({ path }); + const url = builder.toFileURL(file, relativeTo); + expect(url.href).toBe(expected); + }); + test.each` filePath | path | expected ${'.'} | ${undefined} | ${false} diff --git a/packages/cspell-url/src/fileUrl.mts b/packages/cspell-url/src/fileUrl.mts index 537dcfe5957..a00dd844562 100644 --- a/packages/cspell-url/src/fileUrl.mts +++ b/packages/cspell-url/src/fileUrl.mts @@ -1,7 +1,15 @@ -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { hasProtocol } from './url.mjs'; +export const isWindows = process.platform === 'win32'; + +const windowsUrlPathRegExp = /^\/[a-zA-Z]:\//; + +export function isWindowsPathnameWithDriveLatter(pathname: string): boolean { + return windowsUrlPathRegExp.test(pathname); +} + /** * @param url - URL or string to check if it is a file URL. * @returns true if the URL is a file URL. @@ -16,11 +24,29 @@ export function isFileURL(url: URL | string): boolean { * @returns path or href */ export function toFilePathOrHref(url: URL | string): string { - return isFileURL(url) ? toFilePath(url) : url.toString(); + return isFileURL(url) && url.toString().startsWith('file:///') ? toFilePath(url) : url.toString(); } function toFilePath(url: string | URL): string { - return pathWindowsDriveLetterToUpper(fileURLToPath(url)); + try { + // Fix drive letter if necessary. + if (isWindows) { + const u = new URL(url); + if (!isWindowsPathnameWithDriveLatter(u.pathname)) { + const cwdUrl = pathToFileURL(process.cwd()); + if (cwdUrl.hostname) { + return fileURLToPath(new URL(u.pathname, cwdUrl)); + } + const drive = cwdUrl.pathname.split('/')[1]; + u.pathname = `/${drive}${u.pathname}`; + return fileURLToPath(u); + } + } + return pathWindowsDriveLetterToUpper(fileURLToPath(url)); + } catch { + // console.error('Failed to convert URL to path', url); + return url.toString(); + } } export const regExpWindowsPathDriveLetter = /^([a-zA-Z]):[\\/]/; @@ -28,3 +54,14 @@ export const regExpWindowsPathDriveLetter = /^([a-zA-Z]):[\\/]/; export function pathWindowsDriveLetterToUpper(absoluteFilePath: string): string { return absoluteFilePath.replace(regExpWindowsPathDriveLetter, (s) => s.toUpperCase()); } + +const regExpWindowsFileUrl = /^file:\/\/\/[a-zA-Z]:\//; + +/** + * Test if a url is a file url with a windows path. It does check for UNC paths. + * @param url - the url + * @returns true if the url is a file url with a windows path with a drive letter. + */ +export function isWindowsFileUrl(url: URL | string): boolean { + return regExpWindowsFileUrl.test(url.toString()); +} diff --git a/packages/cspell-url/src/fileUrl.test.mts b/packages/cspell-url/src/fileUrl.test.mts index 7b72e0ae63f..2cb2fed13f8 100644 --- a/packages/cspell-url/src/fileUrl.test.mts +++ b/packages/cspell-url/src/fileUrl.test.mts @@ -5,7 +5,7 @@ import { describe, expect, test } from 'vitest'; import { urlBasename } from './dataUrl.mjs'; import { normalizeFilePathForUrl, toFileDirURL, toFileURL } from './defaultFileUrlBuilder.mjs'; -import { pathWindowsDriveLetterToUpper, toFilePathOrHref } from './fileUrl.mjs'; +import { isWindows, isWindowsFileUrl, pathWindowsDriveLetterToUpper, toFilePathOrHref } from './fileUrl.mjs'; import { FileUrlBuilder } from './FileUrlBuilder.mjs'; import { isUrlLike, normalizeWindowsUrl, toURL, urlParent } from './url.mjs'; @@ -129,9 +129,21 @@ describe('util', () => { ${'data:application/json'} | ${'data:application/json'} ${'stdin:file.txt'} | ${'stdin:file.txt'} ${'stdin:/path/to/dir'} | ${'stdin:/path/to/dir'} - `('windowsDriveLetterToUpper $path', ({ path, expected }) => { + `('pathWindowsDriveLetterToUpper $path', ({ path, expected }) => { expect(pathWindowsDriveLetterToUpper(path)).toEqual(expected); }); + + test.each` + url | expected + ${'d:\\user\\data\\file.md'} | ${false} + ${'file:///c:/user/data/file.md'} | ${true} + ${'data:application/json'} | ${false} + ${'stdin:file.txt'} | ${false} + ${'stdin:/path/to/dir'} | ${false} + ${import.meta.url} | ${isWindows} + `('isWindowsFileUrl $url', ({ url, expected }) => { + expect(isWindowsFileUrl(url)).toEqual(expected); + }); }); function u(path: string, relativeURL?: string | URL) { diff --git a/packages/cspell-url/src/index.mts b/packages/cspell-url/src/index.mts index 3190ff8932b..735f59951a2 100644 --- a/packages/cspell-url/src/index.mts +++ b/packages/cspell-url/src/index.mts @@ -6,10 +6,12 @@ export { FileUrlBuilder } from './FileUrlBuilder.mjs'; export { addTrailingSlash, basenameOfUrlPathname, + fixUncUrl, hasProtocol, isNotUrlLike, isURL, isUrlLike, + normalizeWindowsUrl, toURL, urlDirname, urlParent, diff --git a/packages/cspell-url/src/url.mts b/packages/cspell-url/src/url.mts index 79bbacd6d31..05522001185 100644 --- a/packages/cspell-url/src/url.mts +++ b/packages/cspell-url/src/url.mts @@ -178,6 +178,8 @@ export function urlToUrlRelative(urlFrom: URL, urlTo: URL): string { export const regExpWindowsPath = /^[\\/]([a-zA-Z]:[\\/])/; export const regExpEncodedColon = /%3[aA]/g; +const badUncLocalhostUrl = /^(\/+[a-zA-Z])\$/; + /** * Ensure that a windows file url is correctly formatted with a capitol letter for the drive. * @@ -187,14 +189,28 @@ export const regExpEncodedColon = /%3[aA]/g; export function normalizeWindowsUrl(url: URL | string): URL { url = typeof url === 'string' ? new URL(url) : url; if (url.protocol === 'file:') { - const pathname = url.pathname - .replaceAll(regExpEncodedColon, ':') - .replace(regExpWindowsPath, (d) => d.toUpperCase()); + let pathname = url.pathname.replaceAll('%3A', ':').replaceAll('%3a', ':').replaceAll('%24', '$'); + if (!url.host) { + pathname = pathname.replace(badUncLocalhostUrl, '$1:'); + } + pathname = pathname.replace(regExpWindowsPath, (d) => d.toUpperCase()); if (pathname !== url.pathname) { url = new URL(url); url.pathname = pathname; - return url; + return fixUncUrl(url); } } + return fixUncUrl(url); +} + +/** + * There is a bug is NodeJS that sometimes causes UNC paths converted to a URL to be prefixed with `file:////`. + * @param url - URL to check. + * @returns fixed URL if needed. + */ +export function fixUncUrl(url: URL): URL { + if (url.href.startsWith('file:////')) { + return new URL(url.href.replace(/^file:\/{4}/, 'file://')); + } return url; } diff --git a/packages/cspell-url/src/url.test.mts b/packages/cspell-url/src/url.test.mts index c0b61c90842..a0a359826be 100644 --- a/packages/cspell-url/src/url.test.mts +++ b/packages/cspell-url/src/url.test.mts @@ -20,6 +20,8 @@ describe('url', () => { ${'https://github.com/streetsidesoftware/cspell/raw/main/packages/cspell-io/samples/cities.txt'} | ${true} ${'https://github.com/streetsidesoftware/cspell/raw/main/packages/cspell-io/samples/cities.txt.gz'} | ${true} ${'vsls:/cspell.config.yaml'} | ${true} + ${'file://localhost/c$/Users/'} | ${true} + ${'file://synology/home/'} | ${true} `('isUrlLike $file', ({ file, expected }) => { expect(isUrlLike(file)).toBe(expected); }); @@ -33,7 +35,11 @@ describe('url', () => { ${'stdin:sample.py'} | ${'file:///'} | ${'stdin:sample.py'} ${'vsls:/cspell.config.yaml'} | ${'file:///'} | ${'vsls:/cspell.config.yaml'} ${'**/*.json'} | ${'file:///User/test/project/'} | ${'file:///User/test/project/**/*.json'} + ${'**/*.json'} | ${'file:///User/test/project/data.txt'} | ${'file:///User/test/project/**/*.json'} ${'**/*{.json,.jsonc,.yml}'} | ${'file:///User/test/project/'} | ${'file:///User/test/project/**/*%7B.json,.jsonc,.yml%7D'} + ${'**/*.json'} | ${'file://localhost/c$/Users/'} | ${'file:///C:/Users/**/*.json'} + ${'**/*.json'} | ${'file://strongman/c$/Users/'} | ${'file://strongman/c$/Users/**/*.json'} + ${'**/*.json'} | ${'file://synology/home/README.md'} | ${'file://synology/home/**/*.json'} `('toUrl $url $rootUrl', ({ url, rootUrl, expected }) => { expect(toURL(url, rootUrl)).toEqual(new URL(expected)); }); @@ -48,6 +54,8 @@ describe('url', () => { ${'https://github.com/streetsidesoftware/cspell/raw/main/packages/cspell-io/samples/cities.txt'} | ${'cities.txt'} ${'https://github.com/streetsidesoftware/cspell/raw/main/packages/cspell-io/samples/cities.txt.gz'} | ${'cities.txt.gz'} ${'https://github.com/streetsidesoftware/cspell/raw/main/packages/cspell-io/samples/code/'} | ${'code/'} + ${'file://localhost/c$/Users/README.md'} | ${'README.md'} + ${'file://synology/home/README.md'} | ${'README.md'} `('basename $file', async ({ file, expected }) => { expect(basenameOfUrlPathname(file)).toEqual(expected); }); @@ -62,6 +70,8 @@ describe('url', () => { ${'stdin:github.com/streetsidesoftware/samples/'} | ${'stdin:github.com/streetsidesoftware/'} ${'vsls:/cspell.config.yaml'} | ${'vsls:/'} ${'vsls:/path/file.txt'} | ${'vsls:/path/'} + ${'file://localhost/c$/Users/README.md'} | ${'file:///C:/Users/'} + ${'file://synology/home/README.md'} | ${'file://synology/home/'} `('urlParent $url', ({ url, expected }) => { expect(urlParent(url).href).toEqual(new URL(expected).href); }); @@ -75,6 +85,8 @@ describe('url', () => { ${'data:application/text'} | ${true} ${'https://github.com/streetsidesoftware/samples/cities.txt'} | ${true} ${'vs-code:///remote/file/sample.ts'} | ${true} + ${'file://localhost/c$/Users/'} | ${true} + ${'file://synology/home/'} | ${true} `('isUrlLike $url', ({ url, expected }) => { expect(isUrlLike(url)).toEqual(expected); }); @@ -89,23 +101,27 @@ describe('url', () => { ${'data:application/text'} | ${'data:application/text'} ${'https://github.com/streetsidesoftware/samples'} | ${'https://github.com/streetsidesoftware/samples/'} ${'vs-code:///remote/file/sample.ts'} | ${'vs-code:///remote/file/sample.ts/'} + ${'file://localhost/c$/Users'} | ${'file://C:/Users/'} + ${'file://synology/home'} | ${'file://synology/home/'} `('addTrailingSlash $url', ({ url, expected }) => { expect(addTrailingSlash(toURL(url))).toEqual(new URL(expected)); }); test.each` - urlFrom | urlTo | expected - ${'file:///'} | ${'file:///'} | ${''} - ${'file:///samples/code/'} | ${'file:///samples/code/src/file.cpp'} | ${'src/file.cpp'} - ${'file:///samples/code/package.json'} | ${'file:///samples/code/src/file.cpp'} | ${'src/file.cpp'} - ${'file:///samples/code/'} | ${'file:///samples/code/'} | ${''} - ${'file:///samples/code/'} | ${'file:///samples/code'} | ${'../code'} - ${'file:///samples/code'} | ${'file:///samples/code/'} | ${'code/'} - ${'stdin:sample'} | ${'stdin:sample'} | ${''} - ${'stdin:/sample'} | ${'stdin:/sample'} | ${''} - ${'data:application/text'} | ${'data:application/text'} | ${''} - ${'https://github.com/streetsidesoftware/samples'} | ${'https://github.com/streetsidesoftware/samples'} | ${''} - ${'vs-code:///remote/file/sample.ts'} | ${'vs-code:///remote/file/sample.ts'} | ${''} + urlFrom | urlTo | expected + ${'file:///'} | ${'file:///'} | ${''} + ${'file:///samples/code/'} | ${'file:///samples/code/src/file.cpp'} | ${'src/file.cpp'} + ${'file:///samples/code/package.json'} | ${'file:///samples/code/src/file.cpp'} | ${'src/file.cpp'} + ${'file:///samples/code/'} | ${'file:///samples/code/'} | ${''} + ${'file:///samples/code/'} | ${'file:///samples/code'} | ${'../code'} + ${'file:///samples/code'} | ${'file:///samples/code/'} | ${'code/'} + ${'stdin:sample'} | ${'stdin:sample'} | ${''} + ${'stdin:/sample'} | ${'stdin:/sample'} | ${''} + ${'data:application/text'} | ${'data:application/text'} | ${''} + ${'https://github.com/streetsidesoftware/samples'} | ${'https://github.com/streetsidesoftware/samples'} | ${''} + ${'vs-code:///remote/file/sample.ts'} | ${'vs-code:///remote/file/sample.ts'} | ${''} + ${'file://localhost/c$/Users/me/project/README.md'} | ${'file://localhost/c$/Users/me/code/README.md'} | ${'../code/README.md'} + ${'file://synology/home/project/README.md'} | ${'file://synology/home/code/README.md'} | ${'../code/README.md'} `('urlRelative $urlFrom $urlTo', ({ urlFrom, urlTo, expected }) => { expect(urlRelative(urlFrom, urlTo)).toEqual(expected); const rel = urlRelative(toURL(urlFrom), toURL(urlTo)); @@ -117,17 +133,21 @@ describe('url', () => { }); test.each` - url | expected - ${'file:///path/to/my/file.txt'} | ${'file.txt'} - ${'stdin:sample'} | ${''} + url | expected + ${'file:///path/to/my/file.txt'} | ${'file.txt'} + ${'stdin:sample'} | ${''} + ${'file://localhost/c$/Users/me/project/README.md'} | ${'README.md'} + ${'file://synology/home/project/README.md'} | ${'README.md'} `('urlFilename $url', ({ url, expected }) => { url = new URL(url); expect(urlFilename(url)).toBe(expected); }); test.each` - url | expected - ${'file:///path/to/my/file.txt'} | ${'file.txt'} + url | expected + ${'file:///path/to/my/file.txt'} | ${'file.txt'} + ${'file://localhost/c$/Users/me/project/README.md'} | ${'README.md'} + ${'file://synology/home/project/README.md'} | ${'README.md'} `('urlFilename & urlRemoveFilename $url', ({ url, expected }) => { url = new URL(url); expect(urlFilename(url)).toBe(expected); @@ -142,6 +162,9 @@ describe('url', () => { ${'file:///d:/path/to/my/file.txt'} | ${'file:///D:/path/to/my/file.txt'} ${'file:///d%3a/path/to/my/file.txt'} | ${'file:///D:/path/to/my/file.txt'} ${'file:///d%3A/path/to/my/file.txt'} | ${'file:///D:/path/to/my/file.txt'} + ${'file://localhost/c%24/Users/me/'} | ${'file:///C:/Users/me/'} + ${'file://synology/home/'} | ${'file://synology/home/'} + ${'file:////synology/home/'} | ${'file://synology/home/'} `('normalizeWindowsUrl $url', ({ url, expected }) => { url = new URL(url); expect(normalizeWindowsUrl(url).href).toBe(expected); diff --git a/packages/cspell/src/app/lint/lint.ts b/packages/cspell/src/app/lint/lint.ts index 29f8b9a0dbb..476a958fcc0 100644 --- a/packages/cspell/src/app/lint/lint.ts +++ b/packages/cspell/src/app/lint/lint.ts @@ -1,11 +1,11 @@ import * as path from 'node:path'; -import { pathToFileURL } from 'node:url'; import { format } from 'node:util'; import { isAsyncIterable, operators, opFilter, pipeAsync } from '@cspell/cspell-pipe'; import { opMap, pipe } from '@cspell/cspell-pipe/sync'; import type { CSpellSettings, Glob, Issue, RunResult, TextDocumentOffset, TextOffset } from '@cspell/cspell-types'; import { MessageTypes } from '@cspell/cspell-types'; +import { toFileURL } from '@cspell/url'; import chalk from 'chalk'; import { _debug as cspellDictionaryDebug } from 'cspell-dictionary'; import { findRepoRoot, GitIgnore } from 'cspell-gitignore'; @@ -591,7 +591,7 @@ async function determineFilesToCheck( } function isExcluded(filename: string, globMatcherExclude: GlobMatcher) { - if (cspellIsBinaryFile(pathToFileURL(filename))) { + if (cspellIsBinaryFile(toFileURL(filename))) { return true; } const { root } = cfg; diff --git a/packages/cspell/src/app/util/fileHelper.ts b/packages/cspell/src/app/util/fileHelper.ts index 45449da30d0..04038fd699a 100644 --- a/packages/cspell/src/app/util/fileHelper.ts +++ b/packages/cspell/src/app/util/fileHelper.ts @@ -1,6 +1,6 @@ import { promises as fsp } from 'node:fs'; import * as path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import { toFileDirURL, toFilePathOrHref, toFileURL } from '@cspell/url'; import type { BufferEncoding } from 'cspell-io'; @@ -134,7 +134,7 @@ export function resolveFilename(filename: string, cwd?: string): string { cwd = cwd || process.cwd(); if (filename === STDIN) return STDINUrlPrefix; if (filename.startsWith(FileUrlPrefix)) { - const url = new URL(filename.slice(FileUrlPrefix.length), pathToFileURL(cwd + path.sep)); + const url = new URL(filename.slice(FileUrlPrefix.length), toFileDirURL(cwd)); return fileURLToPath(url); } if (isStdinUrl(filename)) { diff --git a/packages/cspell/src/app/util/stdinUrl.ts b/packages/cspell/src/app/util/stdinUrl.ts index 374270954e5..d07a0f64c2c 100644 --- a/packages/cspell/src/app/util/stdinUrl.ts +++ b/packages/cspell/src/app/util/stdinUrl.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; -import Path from 'node:path'; -import { pathToFileURL } from 'node:url'; + +import { toFileURL } from '@cspell/url'; import { STDINProtocol } from './constants.js'; @@ -23,6 +23,6 @@ export function resolveStdinUrl(url: string, cwd: string): string { .slice(STDINProtocol.length) .replace(/^\/\//, '') .replace(/^\/([a-z]:)/i, '$1'); - const fileUrl = pathToFileURL(Path.resolve(cwd, path)); + const fileUrl = toFileURL(path, cwd); return fileUrl.toString().replace(/^file:/, STDINProtocol) + (path ? '' : '/'); } diff --git a/packages/dynamic-import/package.json b/packages/dynamic-import/package.json index 7a81ad2b6d7..b64bb54059b 100644 --- a/packages/dynamic-import/package.json +++ b/packages/dynamic-import/package.json @@ -59,6 +59,7 @@ "node": ">=18.0" }, "dependencies": { + "@cspell/url": "workspace:*", "import-meta-resolve": "^4.1.0" } } diff --git a/packages/dynamic-import/src/esm/dynamicImport.mts b/packages/dynamic-import/src/esm/dynamicImport.mts index 50ec7529d4c..96d8305dac7 100644 --- a/packages/dynamic-import/src/esm/dynamicImport.mts +++ b/packages/dynamic-import/src/esm/dynamicImport.mts @@ -1,7 +1,7 @@ import { statSync } from 'node:fs'; -import { sep as pathSep } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { resolve as resolvePath } from 'node:path'; +import { toFileDirURL, toFileURL } from '@cspell/url'; import { resolve } from 'import-meta-resolve'; const isWindowsPath = /^[a-z]:\\/i; @@ -55,7 +55,7 @@ export function importResolveModuleName(moduleName: string | URL, paths: (string typeof parent === 'string' ? parent.startsWith('file://') ? new URL(parent) - : pathToFileURL(parent + pathSep) + : dirToUrl(parent) : parent; const resolvedURL = new URL(resolve(modulesNameToImport.toString(), url.toString())); try { @@ -77,7 +77,7 @@ export function importResolveModuleName(moduleName: string | URL, paths: (string } function normalizeModuleName(moduleName: string | URL) { - return typeof moduleName === 'string' && isWindowsPath.test(moduleName) ? pathToFileURL(moduleName) : moduleName; + return typeof moduleName === 'string' && isWindowsPath.test(moduleName) ? toFileURL(moduleName) : moduleName; } interface NodeError extends Error { @@ -92,3 +92,8 @@ function toError(e: unknown): NodeError { function isError(e: unknown): e is NodeError { return e instanceof Error; } + +function dirToUrl(dir: string): URL { + const abs = resolvePath(dir); + return toFileDirURL(abs); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d54ccae64..5869e2652ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -899,6 +899,9 @@ importers: packages/dynamic-import: dependencies: + '@cspell/url': + specifier: workspace:* + version: link:../cspell-url import-meta-resolve: specifier: ^4.1.0 version: 4.1.0 @@ -1127,7 +1130,7 @@ importers: version: link:../../../packages/cspell-types ts-loader: specifier: ^9.5.1 - version: 9.5.1(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4)) + version: 9.5.1(typescript@5.7.2)(webpack@5.97.1) webpack: specifier: ^5.97.1 version: 5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4) @@ -12656,7 +12659,7 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@18.3.1)': dependencies: - '@types/react': 19.0.1 + '@types/react': 18.3.16 react: 18.3.1 '@docusaurus/remark-plugin-npm2yarn@3.6.3': @@ -14753,14 +14756,6 @@ snapshots: optionalDependencies: vite: 5.4.11(@types/node@18.19.68)(terser@5.37.0) - '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.2)(terser@5.37.0))': - dependencies: - '@vitest/spy': 2.1.8 - estree-walker: 3.0.3 - magic-string: 0.30.15 - optionalDependencies: - vite: 5.4.11(@types/node@22.10.2)(terser@5.37.0) - '@vitest/pretty-format@2.1.8': dependencies: tinyrainbow: 1.2.0 @@ -14862,17 +14857,17 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4))': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4))': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4))': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1) @@ -21697,25 +21692,25 @@ snapshots: temp-dir@3.0.0: {} - terser-webpack-plugin@5.3.11(@swc/core@1.7.26)(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4)): + terser-webpack-plugin@5.3.11(@swc/core@1.7.26)(webpack@5.97.1(@swc/core@1.7.26)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4) + webpack: 5.97.1(@swc/core@1.7.26) optionalDependencies: '@swc/core': 1.7.26 - terser-webpack-plugin@5.3.11(@swc/core@1.7.26)(webpack@5.97.1(@swc/core@1.7.26)): + terser-webpack-plugin@5.3.11(@swc/core@1.7.26)(webpack@5.97.1): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1(@swc/core@1.7.26) + webpack: 5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4) optionalDependencies: '@swc/core': 1.7.26 @@ -21817,7 +21812,7 @@ snapshots: tslib: 2.8.1 typescript: 5.7.2 - ts-loader@9.5.1(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4)): + ts-loader@9.5.1(typescript@5.7.2)(webpack@5.97.1): dependencies: chalk: 4.1.2 enhanced-resolve: 5.17.1 @@ -22317,7 +22312,7 @@ snapshots: vitest@2.1.8(@types/node@22.10.2)(terser@5.37.0): dependencies: '@vitest/expect': 2.1.8 - '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.2)(terser@5.37.0)) + '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@18.19.68)(terser@5.37.0)) '@vitest/pretty-format': 2.1.8 '@vitest/runner': 2.1.8 '@vitest/snapshot': 2.1.8 @@ -22399,9 +22394,9 @@ snapshots: webpack-cli@5.1.4(webpack@5.97.1): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4)) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4)) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4)) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.97.1) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.97.1) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack@5.97.1) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.6 @@ -22528,7 +22523,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.11(@swc/core@1.7.26)(webpack@5.97.1(@swc/core@1.7.26)(webpack-cli@5.1.4)) + terser-webpack-plugin: 5.3.11(@swc/core@1.7.26)(webpack@5.97.1) watchpack: 2.4.2 webpack-sources: 3.2.3 optionalDependencies: