UNPKG

npm

Version:

a package manager for JavaScript

439 lines (411 loc) • 17.2 kB
// @ts-check 'use strict' // Reproduce safeRe[COMPARATORTRIM] replacement boundaries, not version // validation. Each end-index finder returns an exclusive end, or its start // on no match. // Positions count from zero. An end of 3 means characters 0, 1, and 2 were read; // range.slice(start, end) gives the matched text, not a converted number. const { MAX_LENGTH, MAX_SAFE_BUILD_LENGTH, WHITESPACE_CHARACTERS, } = require('./constants') /** * Remembers the last two rows of digits so their characters need not be reread. * Positions start at -1 until a row has been found. * * @typedef {object} DigitRunCache * @property {number} start The first character of the most recently found row. * @property {number} end The position after that row's last character. * @property {number} previousStart The first character of the previous row. * @property {number} previousEnd The position after the previous row. */ // One entry per UTF-16 code unit keeps membership checks constant-time. const whitespace = new Uint8Array(0x10000) for (const char of WHITESPACE_CHARACTERS) { whitespace[char.charCodeAt(0)] = 1 } /** * Checks whether a character is whitespace, like a space, tab, or newline. * Looking past the end of the string returns false. * * @param {string|undefined} char One character, or undefined past the string's end. * @returns {boolean} Whether the character is whitespace. * @example isWhitespace(' ') // true * @example isWhitespace('1') // false */ const isWhitespace = char => char !== undefined && whitespace[char.charCodeAt(0)] === 1 /** * Checks for v, =, or whitespace before the version number. * These are historical version prefixes, not range operators: * < and > are handled separately, as are tilde and caret ranges. * * @param {string|undefined} char One character to check. * @returns {boolean} Whether the character can belong to a version prefix. * @example isVersionPrefix('=') // true * @example isVersionPrefix('>') // false */ const isVersionPrefix = char => char === 'v' || char === '=' || isWhitespace(char) /** * Checks whether a character is one of the digits 0 through 9. * * @param {string|undefined} char One character to check. * @returns {boolean} Whether the character is an ASCII digit. * @example isDigit('7') // true * @example isDigit('.') // false */ const isDigit = char => char !== undefined && char >= '0' && char <= '9' /** * Checks whether a character is an English letter, uppercase or lowercase. * * @param {string|undefined} char One character to check. * @returns {boolean} Whether the character is an ASCII letter. * @example isLetter('b') // true * @example isLetter('7') // false */ const isLetter = char => char !== undefined && ( (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') ) /** * Checks for a letter, digit, or hyphen: the characters allowed inside * prerelease and build names such as beta-1. * * @param {string|undefined} char One character to check. * @returns {boolean} Whether the character can be part of an identifier. * @example isIdentifierCharacter('-') // true * @example isIdentifierCharacter('.') // false; dots separate identifiers */ const isIdentifierCharacter = char => isDigit(char) || isLetter(char) || char === '-' /** * Checks whether a version component can begin here: with a digit or * a wildcard such as x, X, or *. * * @param {string|undefined} char One character to check. * @returns {boolean} Whether a number or wildcard can start with this character. * @example isVersionStart('x') // true * @example isVersionStart('v') // false; v belongs to the prefix */ const isVersionStart = char => isDigit(char) || char === 'x' || char === 'X' || char === '*' /** * Finds the end of a row of digits, but returns no more than limit digits. * Remembers the last two rows so looking at them again does not reread them. * * @param {string} range The text to read; it is not changed. * @param {number} start The position of the first character to check. * @param {number} limit The most digit characters the returned match can include. * @param {DigitRunCache} digitRuns Shared cache of digit-run positions, updated in place. * @returns {number} The position after the matched digits, or start if none match. * @example findDigitsEndIndex('123abc', 0, 10, digitRuns) // 3, pointing at a * @example findDigitsEndIndex('123abc', 0, 2, digitRuns) // 2, pointing at the unread 3 */ const findDigitsEndIndex = (range, start, limit, digitRuns) => { if (!isDigit(range[start])) { return start } if (start >= digitRuns.start && start < digitRuns.end) { return Math.min(start + limit, digitRuns.end) } if (start >= digitRuns.previousStart && start < digitRuns.previousEnd) { return Math.min(start + limit, digitRuns.previousEnd) } let end = start + 1 while (isDigit(range[end])) { end++ } // A failed full version can rewind from its second run to its first. digitRuns.previousStart = digitRuns.start digitRuns.previousEnd = digitRuns.end digitRuns.start = start digitRuns.end = end return Math.min(start + limit, end) } /** * Reads a number using the old regex's length and leading-zero rules. * Strict mode reads just the first zero in 012; loose mode can read all three. * * @param {string} range The text to read; it is not changed. * @param {number} start The position where the number should begin. * @param {boolean} loose Whether to allow leading zeros. * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed. * @returns {number} The position after the matched digits, not their numeric value. * @example findVersionNumberEndIndex('012', 0, false, digitRuns) // 1 * @example findVersionNumberEndIndex('012', 0, true, digitRuns) // 3 */ const findVersionNumberEndIndex = (range, start, loose, digitRuns) => { if (!loose && range[start] === '0') { return start + 1 } // Strict syntax allows an initial nonzero digit plus MAX_LENGTH digits. return findDigitsEndIndex(range, start, loose ? MAX_LENGTH : MAX_LENGTH + 1, digitRuns) } /** * Reads one piece of a prerelease name, such as beta or 1 in beta.1. * Tries the letter-or-hyphen form first, then the number-only form. * * @param {string} range The text to read; it is not changed. * @param {number} start The position where the identifier should begin. * @param {boolean} loose Whether numeric identifiers may have leading zeros. * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed. * @returns {number} The position after the identifier, or start if none matches. * @example findPrereleaseIdentifierEndIndex('beta.1', 0, false, digitRuns) // 4, at the dot * @example findPrereleaseIdentifierEndIndex('001beta', 0, false, digitRuns) // 7 */ const findPrereleaseIdentifierEndIndex = (range, start, loose, digitRuns) => { const digitsEnd = findDigitsEndIndex(range, start, MAX_LENGTH, digitRuns) // The legacy regex tries a nonnumeric identifier before a numeric one. if (isLetter(range[digitsEnd]) || range[digitsEnd] === '-') { let end = digitsEnd + 1 const limit = end + MAX_SAFE_BUILD_LENGTH while (end < limit && isIdentifierCharacter(range[end])) { end++ } return end } return findVersionNumberEndIndex(range, start, loose, digitRuns) } /** * Reads a prerelease suffix such as -beta.1, one dot-separated piece at a time. * Loose mode also allows the name without its opening hyphen. * * @param {string} range The text to read; it is not changed. * @param {number} start The position where the suffix should begin, including -. * @param {boolean} loose Whether the opening hyphen is optional and zeros allowed. * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed. * @returns {number} The position after the matched suffix, or start if absent. * @example findPrereleaseEndIndex('-beta.1+sha', 0, false, digitRuns) // 7, at the plus * @example findPrereleaseEndIndex('beta', 0, false, digitRuns) // 0; no opening hyphen */ const findPrereleaseEndIndex = (range, start, loose, digitRuns) => { const hyphen = range[start] === '-' if (!loose && !hyphen) { return start } const identifierStart = start + (hyphen ? 1 : 0) let end = findPrereleaseIdentifierEndIndex(range, identifierStart, loose, digitRuns) if (end === identifierStart) { if (!loose || !hyphen) { return start } // In loose syntax, a lone hyphen can itself be the identifier. end = findPrereleaseIdentifierEndIndex(range, start, loose, digitRuns) } while (range[end] === '.') { const next = findPrereleaseIdentifierEndIndex(range, end + 1, loose, digitRuns) if (next === end + 1) { break } end = next } return end } /** * Reads one build-name piece, such as sha or 123 in +sha.123. * Stops at a disallowed character or the old regex's length limit. * * @param {string} range The text to read; it is not changed. * @param {number} start The position where the identifier should begin. * @returns {number} The position after the identifier, or start if none matches. * @example findBuildIdentifierEndIndex('sha.123', 0) // 3, at the dot * @example findBuildIdentifierEndIndex('001', 0) // 3; leading zeros are allowed */ const findBuildIdentifierEndIndex = (range, start) => { let end = start while (end - start < MAX_SAFE_BUILD_LENGTH && isIdentifierCharacter(range[end])) { end++ } return end } /** * Reads optional build metadata such as +sha.123. * Leaves the position unchanged if there is no plus sign followed by a name. * * @param {string} range The text to read; it is not changed. * @param {number} start The position where the opening + should be. * @returns {number} The position after the matched metadata, or start if absent. * @example findBuildEndIndex('+sha.123 <2', 0) // 8, at the space * @example findBuildEndIndex('+', 0) // 0; a plus sign alone is not a match */ const findBuildEndIndex = (range, start) => { if (range[start] !== '+') { return start } let end = findBuildIdentifierEndIndex(range, start + 1) if (end === start + 1) { return start } while (range[end] === '.') { const next = findBuildIdentifierEndIndex(range, end + 1) if (next === end + 1) { break } end = next } return end } /** * Reads three numbers separated by dots, then any prerelease and build suffixes. * Uses loose rules, so forms such as 01.02.03beta can match here. * * @param {string} range The text to read; it is not changed. * @param {number} start The position of the first number, after any prefix. * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed. * @returns {number} The position after the version match, or start if none matches. * @example findLooseFullVersionEndIndex('1.2.3-beta+sha <2', 0, digitRuns) // 14 * @example findLooseFullVersionEndIndex('1.2', 0, digitRuns) // 0; needs three numbers */ const findLooseFullVersionEndIndex = (range, start, digitRuns) => { let end = start for (let component = 0; component < 3; component++) { const next = findVersionNumberEndIndex(range, end, true, digitRuns) if (next === end) { return start } end = next if (component < 2) { if (range[end] !== '.') { return start } end++ } } const prereleaseEnd = findPrereleaseEndIndex(range, end, true, digitRuns) return findBuildEndIndex(range, prereleaseEnd) } /** * Reads one component of a partial or wildcard version, such as 2 or x in 2.x. * Numbers use strict leading-zero rules; wildcards take up one character. * * @param {string} range The text to read; it is not changed. * @param {number} start The position where the component should begin. * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed. * @returns {number} The position after the component, or start if none matches. * @example findPartialVersionComponentEndIndex('2.x', 0, digitRuns) // 1, at the dot * @example findPartialVersionComponentEndIndex('2.x', 2, digitRuns) // 3, after x */ const findPartialVersionComponentEndIndex = (range, start, digitRuns) => { if (isDigit(range[start])) { return findVersionNumberEndIndex(range, start, false, digitRuns) } return isVersionStart(range[start]) ? start + 1 : start } /** * Reads up to three components, allowing forms such as 1, 1.2, or 1.x. * After the third component, also reads any strict prerelease and build suffixes. * * @param {string} range The text to read; it is not changed. * @param {number} start The position of a number or wildcard, after any prefix. * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed. * @returns {number} The position after the matched version text. * @example findPartialVersionEndIndex('1.x <2', 0, digitRuns) // 3, at the space * @example findPartialVersionEndIndex('1.', 0, digitRuns) // 1; the trailing dot is unread */ const findPartialVersionEndIndex = (range, start, digitRuns) => { let end = findPartialVersionComponentEndIndex(range, start, digitRuns) for (let component = 0; component < 2; component++) { if (range[end] !== '.') { return end } const next = findPartialVersionComponentEndIndex(range, end + 1, digitRuns) if (next === end + 1) { return end } end = next } const prereleaseEnd = findPrereleaseEndIndex(range, end, false, digitRuns) return findBuildEndIndex(range, prereleaseEnd) } /** * Finds where a version match ends, without deciding whether the range is valid. * Tries a loose full version first, then a partial or wildcard version, just * like the old regex's LOOSEPLAIN and XRANGEPLAIN alternatives. * * @param {string} range The text to read; it is not changed. * @param {number} start The position of a number or wildcard, after any prefix. * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed. * @returns {number} The position where scanning the next candidate can resume. * @example findVersionEndIndex('1.2.3 <2', 0, digitRuns) // 5, at the space * @example findVersionEndIndex('1.x <2', 0, digitRuns) // 3, at the space */ const findVersionEndIndex = (range, start, digitRuns) => { const fullVersionEnd = findLooseFullVersionEndIndex(range, start, digitRuns) return fullVersionEnd !== start ? fullVersionEnd : findPartialVersionEndIndex(range, start, digitRuns) } /** * Finds the one whitespace character the old replacement would remove. * Keeps leading whitespace, skips an optional operator, and checks immediately * after it. Returns -1 if there is nothing to remove. * * @param {string} range The text containing a candidate already found by the caller. * @param {number} matchStart The candidate's position, including leading whitespace. * @returns {number} The position of the removable whitespace, or -1 if absent. * @example findRemovableWhitespaceIndex(' > 1', 0) // 2; keep the space at position 0 * @example findRemovableWhitespaceIndex('>1', 0) // -1 */ const findRemovableWhitespaceIndex = (range, matchStart) => { let position = matchStart if (isWhitespace(range[position])) { position++ } if (range[position] === '<' || range[position] === '>') { position++ } if (range[position] === '=') { position++ } return isWhitespace(range[position]) ? position : -1 } /** * Joins an operator to its version, turning > 1.2.3 into >1.2.3. * Copies everything else according to the old regex's matching rules. * Moves forward through prefixes and whole versions to avoid repeatedly * searching the same characters on long inputs. * * @param {string} range The range text to process; the original string is unchanged. * @returns {string} The range with matched operator whitespace removed. * @example trimComparatorWhitespace('> 1.2.3 < 2') // '>1.2.3 <2' * @example trimComparatorWhitespace('> invalid') // '> invalid' */ const trimComparatorWhitespace = range => { let result = '' let copyFrom = 0 let cursor = 0 const digitRuns = { start: -1, end: -1, previousStart: -1, previousEnd: -1 } while (cursor < range.length) { const matchStart = cursor if (range[cursor] === '<' || range[cursor] === '>') { cursor++ } else if (!isVersionPrefix(range[cursor]) && !isVersionStart(range[cursor])) { cursor++ continue } // Consume a v/=/whitespace run once, even when no version follows it. while (cursor < range.length && isVersionPrefix(range[cursor])) { cursor++ } if (!isVersionStart(range[cursor])) { continue } const whitespacePosition = findRemovableWhitespaceIndex(range, matchStart) if (whitespacePosition !== -1) { result += range.slice(copyFrom, whitespacePosition) copyFrom = whitespacePosition + 1 } // Skip the entire match, so a prerelease's trailing v cannot be // reconsidered as the prefix of another version. cursor = findVersionEndIndex(range, cursor, digitRuns) } return result + range.slice(copyFrom) } module.exports = trimComparatorWhitespace