diff --git a/.npmrc b/.npmrc index c1ca392..1b8d617 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ package-lock = false +@jsr:registry=https://npm.jsr.io diff --git a/packages/compat/tests/fixup-rules.js b/packages/compat/tests/fixup-rules.js index 7132d21..d23b976 100644 --- a/packages/compat/tests/fixup-rules.js +++ b/packages/compat/tests/fixup-rules.js @@ -7,6 +7,7 @@ //----------------------------------------------------------------------------- import assert from "node:assert"; +import path from "node:path"; import { fixupRule, fixupPluginRules, @@ -133,7 +134,7 @@ describe("@eslint/backcompat", () => { const linter = new Linter(); const code = "var foo = () => 123; function bar() { return 123; }"; const messages = linter.verify(code, config, { - filename: "test.js", + filename: path.resolve("test.js"), }); assert.deepStrictEqual( @@ -203,7 +204,7 @@ describe("@eslint/backcompat", () => { const code = "var foo = () => 123; function bar() { return 123; }"; const messages = linter.verify(code, config, { - filename: "test.js", + filename: path.resolve("test.js"), }); assert.deepStrictEqual( @@ -283,7 +284,7 @@ describe("@eslint/backcompat", () => { const code = "var foo = () => 123; function bar() { for (const x of y) { foo(); } }"; const messages = linter.verify(code, config, { - filename: "test.js", + filename: path.resolve("test.js"), }); assert.deepStrictEqual( @@ -507,7 +508,7 @@ describe("@eslint/backcompat", () => { }, }, { - filename: "test.js", + filename: path.resolve("test.js"), }, ); @@ -641,7 +642,7 @@ describe("@eslint/backcompat", () => { const code = "var foo = () => 123; function bar() { return 123; }"; const messages = linter.verify(code, fixupConfigRules(config), { - filename: "test.js", + filename: path.resolve("test.js"), }); assert.deepStrictEqual( diff --git a/packages/config-array/fix-std__path-imports.js b/packages/config-array/fix-std__path-imports.js new file mode 100644 index 0000000..9966e0b --- /dev/null +++ b/packages/config-array/fix-std__path-imports.js @@ -0,0 +1,26 @@ +/* + * Replace import specifiers in "dist" modules to use the bundled versions of "@jsr/std__path". + * + * In "dist/cjs/index.cjs": + * - '@jsr/std__path/posix' → './std__path/posix.cjs' + * - '@jsr/std__path/windows' → './std__path/windows.cjs' + * + * In "dist/esm/index.js": + * - '@jsr/std__path/posix' → './std__path/posix.js' + * - '@jsr/std__path/windows' → './std__path/windows.js' + */ + +import { readFile, writeFile } from "node:fs/promises"; + +async function replaceInFile(file, search, replacement) { + let text = await readFile(file, "utf-8"); + text = text.replace(search, replacement); + await writeFile(file, text); +} + +const SEARCH_REGEXP = /'@jsr\/std__path\/(.+?)'/gu; + +await Promise.all([ + replaceInFile("dist/cjs/index.cjs", SEARCH_REGEXP, "'./std__path/$1.cjs'"), + replaceInFile("dist/esm/index.js", SEARCH_REGEXP, "'./std__path/$1.js'"), +]); diff --git a/packages/config-array/jsr.json b/packages/config-array/jsr.json index 5e4bd32..c2b2fdd 100644 --- a/packages/config-array/jsr.json +++ b/packages/config-array/jsr.json @@ -8,6 +8,8 @@ "dist/esm/index.d.ts", "dist/esm/types.ts", "dist/esm/types.d.ts", + "dist/esm/std__path/posix.js", + "dist/esm/std__path/windows.js", "README.md", "jsr.json", "LICENSE" diff --git a/packages/config-array/package.json b/packages/config-array/package.json index 2dfdd5e..246ce5e 100644 --- a/packages/config-array/package.json +++ b/packages/config-array/package.json @@ -33,7 +33,8 @@ "scripts": { "build:dedupe-types": "node ../../tools/dedupe-types.js dist/cjs/index.cjs dist/esm/index.js", "build:cts": "node -e \"fs.copyFileSync('dist/esm/index.d.ts', 'dist/cjs/index.d.cts')\"", - "build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json && npm run build:cts", + "build:std__path": "rollup -c rollup.std__path-config.js && node fix-std__path-imports", + "build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json && npm run build:cts && npm run build:std__path", "test:jsr": "npx jsr@latest publish --dry-run", "pretest": "npm run build", "test": "mocha tests/", @@ -51,6 +52,7 @@ "minimatch": "^3.1.2" }, "devDependencies": { + "@jsr/std__path": "^1.0.4", "@types/minimatch": "^3.0.5", "c8": "^9.1.0", "mocha": "^10.4.0", diff --git a/packages/config-array/rollup.std__path-config.js b/packages/config-array/rollup.std__path-config.js new file mode 100644 index 0000000..533d7a0 --- /dev/null +++ b/packages/config-array/rollup.std__path-config.js @@ -0,0 +1,28 @@ +export default [ + { + input: "../../node_modules/@jsr/std__path/posix/mod.js", + output: [ + { + file: "./dist/cjs/std__path/posix.cjs", + format: "cjs", + }, + { + file: "./dist/esm/std__path/posix.js", + format: "esm", + }, + ], + }, + { + input: "../../node_modules/@jsr/std__path/windows/mod.js", + output: [ + { + file: "./dist/cjs/std__path/windows.cjs", + format: "cjs", + }, + { + file: "./dist/esm/std__path/windows.js", + format: "esm", + }, + ], + }, +]; diff --git a/packages/config-array/src/config-array.js b/packages/config-array/src/config-array.js index 51a525b..61bf991 100644 --- a/packages/config-array/src/config-array.js +++ b/packages/config-array/src/config-array.js @@ -7,7 +7,8 @@ // Imports //------------------------------------------------------------------------------ -import path from "node:path"; +import * as posixPath from "@jsr/std__path/posix"; +import * as windowsPath from "@jsr/std__path/windows"; import minimatch from "minimatch"; import createDebug from "debug"; @@ -89,6 +90,9 @@ const CONFIG_WITH_STATUS_UNCONFIGURED = Object.freeze({ status: "unconfigured", }); +// Match two leading dots followed by a slash or the end of input. +const EXTERNAL_PATH_REGEX = /^\.\.(\/|$)/u; + /** * Wrapper error for config validation errors that adds a name to the front of the * error message. @@ -348,15 +352,11 @@ function normalizeSync(items, context, extraConfigTypes) { * matcher. * @param {Array boolean)>} ignores The ignore patterns to check. * @param {string} filePath The absolute path of the file to check. - * @param {string} relativeFilePath The relative path of the file to check. + * @param {string} relativeFilePath The path of the file to check relative to the base path, + * using slash ("/") as a separator. * @returns {boolean} True if the path should be ignored and false if not. */ function shouldIgnorePath(ignores, filePath, relativeFilePath) { - // all files outside of the basePath are ignored - if (relativeFilePath.startsWith("..")) { - return true; - } - return ignores.reduce((ignored, matcher) => { if (!ignored) { if (typeof matcher === "function") { @@ -387,19 +387,13 @@ function shouldIgnorePath(ignores, filePath, relativeFilePath) { * Determines if a given file path is matched by a config based on * `ignores` only. * @param {string} filePath The absolute file path to check. - * @param {string} basePath The base path for the config. + * @param {string} relativeFilePath The path of the file to check relative to the base path, + * using slash ("/") as a separator. * @param {Object} config The config object to check. * @returns {boolean} True if the file path is matched by the config, * false if not. */ -function pathMatchesIgnores(filePath, basePath, config) { - /* - * For both files and ignores, functions are passed the absolute - * file path while strings are compared against the relative - * file path. - */ - const relativeFilePath = path.relative(basePath, filePath); - +function pathMatchesIgnores(filePath, relativeFilePath, config) { return ( Object.keys(config).filter(key => !META_FIELDS.has(key)).length > 1 && !shouldIgnorePath(config.ignores, filePath, relativeFilePath) @@ -412,19 +406,12 @@ function pathMatchesIgnores(filePath, basePath, config) { * is present then we match the globs in `files` and exclude any globs in * `ignores`. * @param {string} filePath The absolute file path to check. - * @param {string} basePath The base path for the config. + * @param {string} relativeFilePath The path of the file to check relative to the base path. * @param {Object} config The config object to check. * @returns {boolean} True if the file path is matched by the config, * false if not. */ -function pathMatches(filePath, basePath, config) { - /* - * For both files and ignores, functions are passed the absolute - * file path while strings are compared against the relative - * file path. - */ - const relativeFilePath = path.relative(basePath, filePath); - +function pathMatches(filePath, relativeFilePath, config) { // match both strings and functions function match(pattern) { if (isString(pattern)) { @@ -499,6 +486,28 @@ function assertExtraConfigTypes(extraConfigTypes) { } } +/** + * Returns path-handling implementations for Unix or Windows, depending on a given absolute path. + * @param {string} path The absolute path to check. + * @returns {typeof import("@jsr/std__path")} Path-handling implementations for the specified path. + * @throws An error is thrown if the specified argument is not an absolute path. + */ +function getPathImpl(path) { + // Posix absolute paths always start with a slash. + if (path.startsWith("/")) { + return posixPath; + } + + // Windows absolute paths start with a letter followed by a colon and at least one backslash, + // or with two backslashes in the case of UNC paths. + // Forward slashed are automatically normalized to backslashes. + if (/^(?:[A-Za-z]:[/\\]|[/\\]{2})/u.test(path)) { + return windowsPath; + } + + throw new Error(`Expected an absolute path but received "${path}"`); +} + //------------------------------------------------------------------------------ // Public Interface //------------------------------------------------------------------------------ @@ -524,7 +533,7 @@ export class ConfigArray extends Array { * @param {Iterable|Function|Object} configs An iterable yielding config * objects, or a config function, or a config object. * @param {Object} options The options for the ConfigArray. - * @param {string} [options.basePath=""] The path of the config file + * @param {string} [options.basePath=""] The absolute path of the config file directory. * @param {boolean} [options.normalized=false] Flag indicating if the * configs have already been normalized. * @param {Object} [options.schema] The additional schema @@ -599,6 +608,12 @@ export class ConfigArray extends Array { } else { this.push(configs); } + + // On Windows, `path.relative()` returns an absolute path when given two paths on different drives. + // The namespaced base path is useful to make sure that calculated relative paths are always relative. + // On Unix, it is identical to the base path. + this.namespacedBasePath = + basePath && getPathImpl(this.basePath).toNamespacedPath(basePath); } /** @@ -802,11 +817,21 @@ export class ConfigArray extends Array { return cache.get(filePath); } + // Select path-handling implementations depending on the specified file path. + const path = getPathImpl(filePath); + // check to see if the file is outside the base path - const relativeFilePath = path.relative(this.basePath, filePath); + const namespacedFilePath = path.toNamespacedPath(filePath); + + // If base path is not specified, relative paths cannot be built. + const relativeFilePath = ( + this.namespacedBasePath + ? path.relative(this.namespacedBasePath, namespacedFilePath) + : namespacedFilePath + ).replaceAll(path.SEPARATOR, "/"); - if (relativeFilePath.startsWith("..")) { + if (EXTERNAL_PATH_REGEX.test(relativeFilePath)) { debug(`No config for file ${filePath} outside of base path`); // cache and return result @@ -842,12 +867,12 @@ export class ConfigArray extends Array { this.forEach((config, index) => { if (!config.files) { if (!config.ignores) { - debug(`Anonymous universal config found for ${filePath}`); + debug(`Universal config found for ${filePath}`); matchingConfigIndices.push(index); return; } - if (pathMatchesIgnores(filePath, this.basePath, config)) { + if (pathMatchesIgnores(filePath, relativeFilePath, config)) { debug( `Matching config found for ${filePath} (based on ignores: ${config.ignores})`, ); @@ -883,7 +908,7 @@ export class ConfigArray extends Array { // check that the config matches without the non-universal files first if ( nonUniversalFiles.length && - pathMatches(filePath, this.basePath, { + pathMatches(filePath, relativeFilePath, { files: nonUniversalFiles, ignores: config.ignores, }) @@ -897,7 +922,7 @@ export class ConfigArray extends Array { // if there wasn't a match then check if it matches with universal files if ( universalFiles.length && - pathMatches(filePath, this.basePath, { + pathMatches(filePath, relativeFilePath, { files: universalFiles, ignores: config.ignores, }) @@ -912,7 +937,7 @@ export class ConfigArray extends Array { } // the normal case - if (pathMatches(filePath, this.basePath, config)) { + if (pathMatches(filePath, relativeFilePath, config)) { debug(`Matching config found for ${filePath}`); matchingConfigIndices.push(index); matchFound = true; @@ -1020,16 +1045,27 @@ export class ConfigArray extends Array { isDirectoryIgnored(directoryPath) { assertNormalized(this); - const relativeDirectoryPath = path - .relative(this.basePath, directoryPath) - .replace(/\\/gu, "/"); + // Select path-handling implementations depending on the specified directory path. + const path = getPathImpl(directoryPath); + + const namespacedDirectoryPath = path.toNamespacedPath(directoryPath); + + // If base path is not specified, relative paths cannot be built. + const relativeDirectoryPath = ( + this.namespacedBasePath + ? path.relative( + this.namespacedBasePath, + namespacedDirectoryPath, + ) + : namespacedDirectoryPath + ).replaceAll(path.SEPARATOR, "/"); // basePath directory can never be ignored if (relativeDirectoryPath === "") { return false; } - if (relativeDirectoryPath.startsWith("..")) { + if (EXTERNAL_PATH_REGEX.test(relativeDirectoryPath)) { return true; } diff --git a/packages/config-array/tests/config-array.test.js b/packages/config-array/tests/config-array.test.js index 11a0961..847e291 100644 --- a/packages/config-array/tests/config-array.test.js +++ b/packages/config-array/tests/config-array.test.js @@ -10,13 +10,14 @@ import { ConfigArray, ConfigArraySymbol } from "../src/config-array.js"; import path from "node:path"; import assert from "node:assert"; +import { fileURLToPath } from "node:url"; //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- // calculate base path using import.meta -const basePath = path.dirname(new URL(import.meta.url).pathname); +const basePath = fileURLToPath(new URL(".", import.meta.url)); const schema = { language: { @@ -494,6 +495,12 @@ describe("ConfigArray", () => { configs.normalizeSync(); }, "Config Error: Config (unnamed): Unexpected null config."); }); + + it("should throw an error when basePath is a relative path", async () => { + assert.throws(() => { + void new ConfigArray([{}], { basePath: "foo/bar" }); + }, /Expected an absolute path/u); + }); }); describe("ConfigArray members", () => { @@ -645,6 +652,12 @@ describe("ConfigArray", () => { }, /normalized/u); }); + it("should throw an error when passed a relative path", () => { + assert.throws(() => { + configs.getConfigWithStatus("./foo/bar.js"); + }, /Expected an absolute path/u); + }); + describe("should return expected results", () => { it("for a file outside the base path", () => { const filename = path.resolve(basePath, "../foo.js"); @@ -755,6 +768,12 @@ describe("ConfigArray", () => { }, /normalized/u); }); + it("should throw an error when passed a relative path", () => { + assert.throws(() => { + configs.getConfig("foo/bar.js"); + }, /Expected an absolute path/u); + }); + it("should calculate correct config when passed JS filename", () => { const filename = path.resolve(basePath, "foo.js"); const config = configs.getConfig(filename); @@ -1239,6 +1258,12 @@ describe("ConfigArray", () => { }, /normalized/u); }); + it("should throw an error when passed a relative path", () => { + assert.throws(() => { + configs.getConfigStatus("foo.js"); + }, /Expected an absolute path/u); + }); + it('should return "matched" when passed JS filename', () => { const filename = path.resolve(basePath, "foo.js"); @@ -1248,6 +1273,15 @@ describe("ConfigArray", () => { ); }); + it('should return "matched" when passed JS filename that starts with ".."', () => { + const filename = path.resolve(basePath, "..foo.js"); + + assert.strictEqual( + configs.getConfigStatus(filename), + "matched", + ); + }); + it('should return "external" when passed JS filename in parent directory', () => { const filename = path.resolve(basePath, "../foo.js"); @@ -2036,6 +2070,150 @@ describe("ConfigArray", () => { ); }); }); + + describe("Windows paths", () => { + it('should return "matched" for a file in the base directory with different capitalization', () => { + configs = new ConfigArray([{ files: ["**/*.js"] }], { + basePath: "C:\\DIR", + }); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus("c:\\dir\\subdir\\file.js"), + "matched", + ); + }); + + it('should return "external" for a file on a different drive when a base path is specified', () => { + configs = new ConfigArray([{ files: ["**/*.js"] }], { + basePath: "C:\\dir", + }); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus("D:\\dir\\file.js"), + "external", + ); + }); + + it('should return "matched" for files on different drives when no base path is specified', () => { + configs = new ConfigArray([{ files: ["**/*.js"] }]); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus("X:\\dir1\\file.js"), + "matched", + ); + assert.strictEqual( + configs.getConfigStatus("Y:\\dir2\\file.js"), + "matched", + ); + }); + + it('should return "external" for a file with a UNC path on a different drive', () => { + configs = new ConfigArray([{ files: ["**/*.js"] }], { + basePath: "C:\\dir", + }); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus("\\\\NAS\\Share\\file.js"), + "external", + ); + }); + + it('should return "matched" for a file with a UNC path in the base directory', () => { + configs = new ConfigArray([{ files: ["**/*.js"] }], { + basePath: "\\\\NAS\\Share", + }); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus("\\\\NAS\\Share\\dir\\file.js"), + "matched", + ); + }); + + it('should return "matched" for a file with a namespaced path in the base directory', () => { + configs = new ConfigArray([{ files: ["**/*.js"] }], { + basePath: "C:\\dir", + }); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus("\\\\?\\c:\\dir\\file.js"), + "matched", + ); + }); + + it('should return "matched" for a file with a namespaced UNC path in the base directory', () => { + configs = new ConfigArray([{ files: ["**/*.js"] }], { + basePath: "\\\\NAS\\Share", + }); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus( + "\\\\?\\UNC\\NAS\\Share\\file.js", + ), + "matched", + ); + }); + + it('should return "ignored" for a file with a namespaced path in a directory matched by a global ignore pattern', () => { + configs = new ConfigArray( + [{ files: ["**/*.js"] }, { ignores: ["dist"] }], + { basePath: "C:\\dir" }, + ); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus( + "\\\\?\\C:\\dir\\dist\\file.js", + ), + "ignored", + ); + }); + + it('should return "unconfigured" for a file with a namespaced path matched by a non-global ignore pattern', () => { + configs = new ConfigArray( + [ + { + files: ["**/*.js"], + ignores: ["dist/**"], + }, + ], + { basePath: "C:\\dir" }, + ); + + configs.normalizeSync(); + + assert.strictEqual( + configs.getConfigStatus( + "\\\\?\\C:\\dir\\dist\\file.js", + ), + "unconfigured", + ); + }); + + it("should throw an error when passed a relative path with a drive letter", () => { + configs = new ConfigArray([], { basePath: "C:\\dir" }); + + configs.normalizeSync(); + + assert.throws(() => { + configs.getConfigStatus("C:file.js"); + }, /Expected an absolute path/u); + }); + }); }); describe("isIgnored()", () => { @@ -2045,6 +2223,13 @@ describe("ConfigArray", () => { unnormalizedConfigs.isIgnored(filename); }, /normalized/u); }); + + it("should throw an error when passed a relative path", () => { + assert.throws(() => { + configs.isIgnored("foo.js"); + }, /Expected an absolute path/u); + }); + it("should return false when passed JS filename", () => { const filename = path.resolve(basePath, "foo.js"); assert.strictEqual(configs.isIgnored(filename), false); @@ -2116,6 +2301,12 @@ describe("ConfigArray", () => { }, /normalized/u); }); + it("should throw an error when passed a relative path", () => { + assert.throws(() => { + configs.isFileIgnored("foo.js"); + }, /Expected an absolute path/u); + }); + it("should return false when passed JS filename", () => { const filename = path.resolve(basePath, "foo.js"); @@ -3010,10 +3201,20 @@ describe("ConfigArray", () => { }, ); assert.throws(() => { - configs.isDirectoryIgnored("foo/bar"); + configs.isDirectoryIgnored("/foo/bar"); }, /normalized/u); }); + it("should throw an error when a relative path is specified", () => { + configs = new ConfigArray([]); + + configs.normalizeSync(); + + assert.throws(() => { + configs.isDirectoryIgnored("foo/bar"); + }, /Expected an absolute path/u); + }); + it("should return true when the directory is outside of the basePath", () => { configs = new ConfigArray( [ @@ -3036,6 +3237,49 @@ describe("ConfigArray", () => { ); }); + it("should return true when the directory is the parent of the base path", () => { + configs = new ConfigArray( + [ + { + files: ["**/*.js"], + }, + ], + { + basePath, + }, + ); + + configs.normalizeSync(); + + assert.strictEqual( + configs.isDirectoryIgnored(path.join(basePath, "..")), + true, + ); + assert.strictEqual( + configs.isDirectoryIgnored(path.join(basePath, "../")), + true, + ); + }); + + it("should return false when no basePath is specified", () => { + configs = new ConfigArray([ + { + ignores: ["**/bar"], + }, + ]); + + configs.normalizeSync(); + + assert.strictEqual( + configs.isDirectoryIgnored("/foo/bar/baz"), + true, + ); + assert.strictEqual( + configs.isDirectoryIgnored("C:\\foo\\bar\\baz"), + true, + ); + }); + it("should return true when the parent directory of a directory is ignored", () => { configs = new ConfigArray( [