diff --git a/bids-validator/build.ts b/bids-validator/build.ts index e5fc80ef4..8dc51041e 100755 --- a/bids-validator/build.ts +++ b/bids-validator/build.ts @@ -52,6 +52,7 @@ const result = await esbuild.build({ plugins: [httpPlugin], allowOverwrite: true, sourcemap: flags.minify ? false : 'inline', + packages: 'external', }) if (result.warnings.length > 0) { diff --git a/bids-validator/src/deps/ignore.ts b/bids-validator/src/deps/ignore.ts deleted file mode 100644 index a70906317..000000000 --- a/bids-validator/src/deps/ignore.ts +++ /dev/null @@ -1,581 +0,0 @@ -// @ts-nocheck This is an NPM module we depend on forked for Deno -// See https://github.com/kaelzhang/node-ignore/blob/master/index.js and following license text -/** - Copyright (c) 2013 Kael Zhang , contributors - http://kael.me/ - - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files (the - "Software"), to deal in the Software without restriction, including - without limitation the rights to use, copy, modify, merge, publish, - distribute, sublicense, and/or sell copies of the Software, and to - permit persons to whom the Software is furnished to do so, subject to - the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE - LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -// A simple implementation of make-array -function makeArray(subject) { - return Array.isArray(subject) ? subject : [subject] -} - -const EMPTY = '' -const SPACE = ' ' -const ESCAPE = '\\' -const REGEX_TEST_BLANK_LINE = /^\s+$/ -const REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/ -const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/ -const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/ -const REGEX_SPLITALL_CRLF = /\r?\n/g -// /foo, -// ./foo, -// ../foo, -// . -// .. -const REGEX_TEST_INVALID_PATH = /^\.*\/|^\.+$/ - -const SLASH = '/' - -// Do not use ternary expression here, since "istanbul ignore next" is buggy -let TMP_KEY_IGNORE = 'node-ignore' -/* istanbul ignore else */ -if (typeof Symbol !== 'undefined') { - TMP_KEY_IGNORE = Symbol.for('node-ignore') -} -const KEY_IGNORE = TMP_KEY_IGNORE - -const define = (object, key, value) => - Object.defineProperty(object, key, { value }) - -const REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g - -const RETURN_FALSE = () => false - -// Sanitize the range of a regular expression -// The cases are complicated, see test cases for details -const sanitizeRange = (range) => - range.replace(REGEX_REGEXP_RANGE, (match, from, to) => - from.charCodeAt(0) <= to.charCodeAt(0) - ? match - : // Invalid range (out of order) which is ok for gitignore rules but - // fatal for JavaScript regular expression, so eliminate it. - EMPTY, - ) - -// See fixtures #59 -const cleanRangeBackSlash = (slashes) => { - const { length } = slashes - return slashes.slice(0, length - (length % 2)) -} - -// > If the pattern ends with a slash, -// > it is removed for the purpose of the following description, -// > but it would only find a match with a directory. -// > In other words, foo/ will match a directory foo and paths underneath it, -// > but will not match a regular file or a symbolic link foo -// > (this is consistent with the way how pathspec works in general in Git). -// '`foo/`' will not match regular file '`foo`' or symbolic link '`foo`' -// -> ignore-rules will not deal with it, because it costs extra `fs.stat` call -// you could use option `mark: true` with `glob` - -// '`foo/`' should not continue with the '`..`' -const REPLACERS = [ - // > Trailing spaces are ignored unless they are quoted with backslash ("\") - [ - // (a\ ) -> (a ) - // (a ) -> (a) - // (a \ ) -> (a ) - /\\?\s+$/, - (match) => (match.indexOf('\\') === 0 ? SPACE : EMPTY), - ], - - // replace (\ ) with ' ' - [/\\\s/g, () => SPACE], - - // Escape metacharacters - // which is written down by users but means special for regular expressions. - - // > There are 12 characters with special meanings: - // > - the backslash \, - // > - the caret ^, - // > - the dollar sign $, - // > - the period or dot ., - // > - the vertical bar or pipe symbol |, - // > - the question mark ?, - // > - the asterisk or star *, - // > - the plus sign +, - // > - the opening parenthesis (, - // > - the closing parenthesis ), - // > - and the opening square bracket [, - // > - the opening curly brace {, - // > These special characters are often called "metacharacters". - [/[\\$.|*+(){^]/g, (match) => `\\${match}`], - - [ - // > a question mark (?) matches a single character - /(?!\\)\?/g, - () => '[^/]', - ], - - // leading slash - [ - // > A leading slash matches the beginning of the pathname. - // > For example, "/*.c" matches "cat-file.c" but not "mozilla-sha1/sha1.c". - // A leading slash matches the beginning of the pathname - /^\//, - () => '^', - ], - - // replace special metacharacter slash after the leading slash - [/\//g, () => '\\/'], - - [ - // > A leading "**" followed by a slash means match in all directories. - // > For example, "**/foo" matches file or directory "foo" anywhere, - // > the same as pattern "foo". - // > "**/foo/bar" matches file or directory "bar" anywhere that is directly - // > under directory "foo". - // Notice that the '*'s have been replaced as '\\*' - /^\^*\\\*\\\*\\\//, - - // '**/foo' <-> 'foo' - () => '^(?:.*\\/)?', - ], - - // starting - [ - // there will be no leading '/' - // (which has been replaced by section "leading slash") - // If starts with '**', adding a '^' to the regular expression also works - /^(?=[^^])/, - function startingReplacer() { - // If has a slash `/` at the beginning or middle - return !/\/(?!$)/.test(this) - ? // > Prior to 2.22.1 - // > If the pattern does not contain a slash /, - // > Git treats it as a shell glob pattern - // Actually, if there is only a trailing slash, - // git also treats it as a shell glob pattern - - // After 2.22.1 (compatible but clearer) - // > If there is a separator at the beginning or middle (or both) - // > of the pattern, then the pattern is relative to the directory - // > level of the particular .gitignore file itself. - // > Otherwise the pattern may also match at any level below - // > the .gitignore level. - '(?:^|\\/)' - : // > Otherwise, Git treats the pattern as a shell glob suitable for - // > consumption by fnmatch(3) - '^' - }, - ], - - // two globstars - [ - // Use lookahead assertions so that we could match more than one `'/**'` - /\\\/\\\*\\\*(?=\\\/|$)/g, - - // Zero, one or several directories - // should not use '*', or it will be replaced by the next replacer - - // Check if it is not the last `'/**'` - (_, index, str) => - index + 6 < str.length - ? // case: /**/ - // > A slash followed by two consecutive asterisks then a slash matches - // > zero or more directories. - // > For example, "a/**/b" matches "a/b", "a/x/b", "a/x/y/b" and so on. - // '/**/' - '(?:\\/[^\\/]+)*' - : // case: /** - // > A trailing `"/**"` matches everything inside. - - // #21: everything inside but it should not include the current folder - '\\/.+', - ], - - // normal intermediate wildcards - [ - // Never replace escaped '*' - // ignore rule '\*' will match the path '*' - - // 'abc.*/' -> go - // 'abc.*' -> skip this rule, - // coz trailing single wildcard will be handed by [trailing wildcard] - /(^|[^\\]+)(\\\*)+(?=.+)/g, - - // '*.js' matches '.js' - // '*.js' doesn't match 'abc' - (_, p1, p2) => { - // 1. - // > An asterisk "*" matches anything except a slash. - // 2. - // > Other consecutive asterisks are considered regular asterisks - // > and will match according to the previous rules. - const unescaped = p2.replace(/\\\*/g, '[^\\/]*') - return p1 + unescaped - }, - ], - - [ - // unescape, revert step 3 except for back slash - // For example, if a user escape a '\\*', - // after step 3, the result will be '\\\\\\*' - /\\\\\\(?=[$.|*+(){^])/g, - () => ESCAPE, - ], - - [ - // '\\\\' -> '\\' - /\\\\/g, - () => ESCAPE, - ], - - [ - // > The range notation, e.g. [a-zA-Z], - // > can be used to match one of the characters in a range. - - // `\` is escaped by step 3 - /(\\)?\[([^\]/]*?)(\\*)($|\])/g, - (match, leadEscape, range, endEscape, close) => - leadEscape === ESCAPE - ? // '\\[bar]' -> '\\\\[bar\\]' - `\\[${range}${cleanRangeBackSlash(endEscape)}${close}` - : close === ']' - ? endEscape.length % 2 === 0 - ? // A normal case, and it is a range notation - // '[bar]' - // '[bar\\\\]' - `[${sanitizeRange(range)}${endEscape}]` - : // Invalid range notaton - // '[bar\\]' -> '[bar\\\\]' - '[]' - : '[]', - ], - - // ending - [ - // 'js' will not match 'js.' - // 'ab' will not match 'abc' - /(?:[^*])$/, - - // WTF! - // https://git-scm.com/docs/gitignore - // changes in [2.22.1](https://git-scm.com/docs/gitignore/2.22.1) - // which re-fixes #24, #38 - - // > If there is a separator at the end of the pattern then the pattern - // > will only match directories, otherwise the pattern can match both - // > files and directories. - - // 'js*' will not match 'a.js' - // 'js/' will not match 'a.js' - // 'js' will match 'a.js' and 'a.js/' - (match) => - /\/$/.test(match) - ? // foo/ will not match 'foo' - `${match}$` - : // foo matches 'foo' and 'foo/' - `${match}(?=$|\\/$)`, - ], - - // trailing wildcard - [ - /(\^|\\\/)?\\\*$/, - (_, p1) => { - const prefix = p1 - ? // '\^': - // '/*' does not match EMPTY - // '/*' does not match everything - - // '\\\/': - // 'abc/*' does not match 'abc/' - `${p1}[^/]+` - : // 'a*' matches 'a' - // 'a*' matches 'aa' - '[^/]*' - - return `${prefix}(?=$|\\/$)` - }, - ], -] - -// A simple cache, because an ignore rule only has only one certain meaning -const regexCache = Object.create(null) - -// @param {pattern} -const makeRegex = (pattern, ignoreCase) => { - let source = regexCache[pattern] - - if (!source) { - source = REPLACERS.reduce( - (prev, current) => prev.replace(current[0], current[1].bind(pattern)), - pattern, - ) - regexCache[pattern] = source - } - - return ignoreCase ? new RegExp(source, 'i') : new RegExp(source) -} - -const isString = (subject) => typeof subject === 'string' - -// > A blank line matches no files, so it can serve as a separator for readability. -const checkPattern = (pattern) => - pattern && - isString(pattern) && - !REGEX_TEST_BLANK_LINE.test(pattern) && - !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) && - // > A line starting with # serves as a comment. - pattern.indexOf('#') !== 0 - -const splitPattern = (pattern) => pattern.split(REGEX_SPLITALL_CRLF) - -class IgnoreRule { - constructor(origin, pattern, negative, regex) { - this.origin = origin - this.pattern = pattern - this.negative = negative - this.regex = regex - } -} - -const createRule = (pattern, ignoreCase) => { - const origin = pattern - let negative = false - - // > An optional prefix "!" which negates the pattern; - if (pattern.indexOf('!') === 0) { - negative = true - pattern = pattern.substr(1) - } - - pattern = pattern - // > Put a backslash ("\") in front of the first "!" for patterns that - // > begin with a literal "!", for example, `"\!important!.txt"`. - .replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, '!') - // > Put a backslash ("\") in front of the first hash for patterns that - // > begin with a hash. - .replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, '#') - - const regex = makeRegex(pattern, ignoreCase) - - return new IgnoreRule(origin, pattern, negative, regex) -} - -const throwError = (message, Ctor) => { - throw new Ctor(message) -} - -const checkPath = (path, originalPath, doThrow) => { - if (!isString(path)) { - return doThrow( - `path must be a string, but got \`${originalPath}\``, - TypeError, - ) - } - - // We don't know if we should ignore EMPTY, so throw - if (!path) { - return doThrow(`path must not be empty`, TypeError) - } - - // Check if it is a relative path - if (checkPath.isNotRelative(path)) { - const r = '`path.relative()`d' - return doThrow( - `path should be a ${r} string, but got "${originalPath}"`, - RangeError, - ) - } - - return true -} - -const isNotRelative = (path) => REGEX_TEST_INVALID_PATH.test(path) - -checkPath.isNotRelative = isNotRelative -checkPath.convert = (p) => p - -export class Ignore { - constructor({ - ignorecase = true, - ignoreCase = ignorecase, - allowRelativePaths = false, - } = {}) { - define(this, KEY_IGNORE, true) - - this._rules = [] - this._ignoreCase = ignoreCase - this._allowRelativePaths = allowRelativePaths - this._initCache() - } - - _initCache() { - this._ignoreCache = Object.create(null) - this._testCache = Object.create(null) - } - - _addPattern(pattern) { - // #32 - if (pattern && pattern[KEY_IGNORE]) { - this._rules = this._rules.concat(pattern._rules) - this._added = true - return - } - - if (checkPattern(pattern)) { - const rule = createRule(pattern, this._ignoreCase) - this._added = true - this._rules.push(rule) - } - } - - // @param {Array | string | Ignore} pattern - add(pattern) { - this._added = false - - makeArray(isString(pattern) ? splitPattern(pattern) : pattern).forEach( - this._addPattern, - this, - ) - - // Some rules have just added to the ignore, - // making the behavior changed. - if (this._added) { - this._initCache() - } - - return this - } - - // legacy - addPattern(pattern) { - return this.add(pattern) - } - - // | ignored : unignored - // negative | 0:0 | 0:1 | 1:0 | 1:1 - // -------- | ------- | ------- | ------- | -------- - // 0 | TEST | TEST | SKIP | X - // 1 | TESTIF | SKIP | TEST | X - - // - SKIP: always skip - // - TEST: always test - // - TESTIF: only test if checkUnignored - // - X: that never happen - - // @param {boolean} whether should check if the path is unignored, - // setting `checkUnignored` to `false` could reduce additional - // path matching. - - // @returns {TestResult} true if a file is ignored - _testOne(path, checkUnignored) { - let ignored = false - let unignored = false - - this._rules.forEach((rule) => { - const { negative } = rule - if ( - (unignored === negative && ignored !== unignored) || - (negative && !ignored && !unignored && !checkUnignored) - ) { - return - } - - const matched = rule.regex.test(path) - - if (matched) { - ignored = !negative - unignored = negative - } - }) - - return { - ignored, - unignored, - } - } - - // @returns {TestResult} - _test(originalPath, cache, checkUnignored, slices) { - const path = - originalPath && - // Supports nullable path - checkPath.convert(originalPath) - - checkPath( - path, - originalPath, - this._allowRelativePaths ? RETURN_FALSE : throwError, - ) - - return this._t(path, cache, checkUnignored, slices) - } - - _t(path, cache, checkUnignored, slices) { - if (path in cache) { - return cache[path] - } - - if (!slices) { - // path/to/a.js - // ['path', 'to', 'a.js'] - slices = path.split(SLASH) - } - - slices.pop() - - // If the path has no parent directory, just test it - if (!slices.length) { - return (cache[path] = this._testOne(path, checkUnignored)) - } - - const parent = this._t( - slices.join(SLASH) + SLASH, - cache, - checkUnignored, - slices, - ) - - // If the path contains a parent directory, check the parent first - return (cache[path] = parent.ignored - ? // > It is not possible to re-include a file if a parent directory of - // > that file is excluded. - parent - : this._testOne(path, checkUnignored)) - } - - ignores(path) { - return this._test(path, this._ignoreCache, false).ignored - } - - createFilter() { - return (path) => !this.ignores(path) - } - - filter(paths) { - return makeArray(paths).filter(this.createFilter()) - } - - // @returns {TestResult} - test(path) { - return this._test(path, this._testCache, true) - } -} - -export const ignore = (options) => new Ignore(options) - -const isPathValid = (path) => - checkPath(path && checkPath.convert(path), path, RETURN_FALSE) - -ignore.isPathValid = isPathValid diff --git a/bids-validator/src/files/ignore.test.ts b/bids-validator/src/files/ignore.test.ts index 151f0b428..58a5090c7 100644 --- a/bids-validator/src/files/ignore.test.ts +++ b/bids-validator/src/files/ignore.test.ts @@ -11,8 +11,9 @@ Deno.test('Deno implementation of FileIgnoreRules', async (t) => { '/participants.tsv', '/.git/HEAD', '/sub-01/anat/non-bidsy-file.xyz', + '/explicit/full/path.nii', ] - const rules = ['.git', '**/*.xyz'] + const rules = ['.git', '**/*.xyz', 'explicit/full/path.nii'] const ignore = new FileIgnoreRules(rules) const filtered = files.filter((path) => !ignore.test(path)) assertEquals(filtered, [ diff --git a/bids-validator/src/files/ignore.ts b/bids-validator/src/files/ignore.ts index 1dc3b0c97..dbed3bb09 100644 --- a/bids-validator/src/files/ignore.ts +++ b/bids-validator/src/files/ignore.ts @@ -1,5 +1,6 @@ import { BIDSFile } from '../types/file.ts' -import { ignore, Ignore } from '../deps/ignore.ts' +import { default as ignore } from 'npm:ignore@5.2.4' +import type { Ignore } from 'npm:ignore@5.2.4' export async function readBidsIgnore(file: BIDSFile) { const value = await file.text() @@ -30,7 +31,8 @@ export class FileIgnoreRules { #ignore: Ignore constructor(config: string[]) { - this.#ignore = ignore({ allowRelativePaths: true }) + // @ts-expect-error + this.#ignore = ignore() this.#ignore.add(defaultIgnores) this.#ignore.add(config) } @@ -41,6 +43,7 @@ export class FileIgnoreRules { /** Test if a dataset relative path should be ignored given configured rules */ test(path: string): boolean { - return this.#ignore.ignores(path) + // Paths come in with a leading slash, but ignore expects paths relative to root + return this.#ignore.ignores(path.slice(1, path.length)) } }