UNPKG

@optique/core

Version:

Type-safe combinatorial command-line interface parser

325 lines (323 loc) 10.4 kB
import { message, text } from "./message.js"; //#region src/valueparser.ts /** * A predicate function that checks if an object is a {@link ValueParser}. * @param object The object to check. * @return `true` if the object is a {@link ValueParser}, `false` otherwise. */ function isValueParser(object) { return typeof object === "object" && object != null && "metavar" in object && typeof object.metavar === "string" && "parse" in object && typeof object.parse === "function" && "format" in object && typeof object.format === "function"; } /** * Creates a {@link ValueParser} that accepts one of multiple * string values, so-called enumerated values. * * This parser validates that the input string matches one of * the specified values. If the input does not match any of the values, * it returns an error message indicating the valid options. * @param values An array of valid string values that this parser can accept. * @param options Configuration options for the choice parser. * @returns A {@link ValueParser} that checks if the input matches one of the * specified values. */ function choice(values, options = {}) { const normalizedValues = options.caseInsensitive ? values.map((v) => v.toLowerCase()) : values; return { metavar: options.metavar ?? "TYPE", parse(input) { const normalizedInput = options.caseInsensitive ? input.toLowerCase() : input; const index = normalizedValues.indexOf(normalizedInput); if (index < 0) return { success: false, error: message`Expected one of ${values.join(", ")}, but got ${input}.` }; return { success: true, value: values[index] }; }, format(value) { return value; } }; } /** * Creates a {@link ValueParser} for strings. * * This parser validates that the input is a string and optionally checks * if it matches a specified regular expression pattern. * @param options Configuration options for the string parser. * @returns A {@link ValueParser} that parses strings according to the * specified options. */ function string(options = {}) { return { metavar: options.metavar ?? "STRING", parse(input) { if (options.pattern != null && !options.pattern.test(input)) return { success: false, error: message`Expected a string matching pattern ${text(options.pattern.source)}, but got ${input}.` }; return { success: true, value: input }; }, format(value) { return value; } }; } /** * Creates a ValueParser for parsing integer values from strings. * * This function provides two modes of operation: * * - Regular mode: Returns JavaScript numbers * (safe up to `Number.MAX_SAFE_INTEGER`) * - `bigint` mode: Returns `bigint` values for arbitrarily large integers * * The parser validates that the input is a valid integer and optionally * enforces minimum and maximum value constraints. * * @example * ```typescript * // Create a parser for regular integers * const portParser = integer({ min: 1, max: 0xffff }); * * // Create a parser for BigInt values * const bigIntParser = integer({ type: "bigint", min: 0n }); * * // Use the parser * const result = portParser.parse("8080"); * if (result.success) { * console.log(`Port: ${result.value}`); * } else { * console.error(result.error); * } * ``` * * @param options Configuration options specifying the type and constraints. * @returns A {@link ValueParser} that converts string input to the specified * integer type. */ function integer(options) { if (options?.type === "bigint") return { metavar: options.metavar ?? "INTEGER", parse(input) { let value; try { value = BigInt(input); } catch (e) { if (e instanceof SyntaxError) return { success: false, error: message`Expected a valid integer, but got ${input}.` }; throw e; } if (options.min != null && value < options.min) return { success: false, error: message`Expected a value greater than or equal to ${text(options.min.toLocaleString("en"))}, but got ${input}.` }; else if (options.max != null && value > options.max) return { success: false, error: message`Expected a value less than or equal to ${text(options.max.toLocaleString("en"))}, but got ${input}.` }; return { success: true, value }; }, format(value) { return value.toString(); } }; return { metavar: options?.metavar ?? "INTEGER", parse(input) { if (!input.match(/^\d+$/)) return { success: false, error: message`Expected a valid integer, but got ${input}.` }; const value = Number.parseInt(input); if (options?.min != null && value < options.min) return { success: false, error: message`Expected a value greater than or equal to ${text(options.min.toLocaleString("en"))}, but got ${input}.` }; else if (options?.max != null && value > options.max) return { success: false, error: message`Expected a value less than or equal to ${text(options.max.toLocaleString("en"))}, but got ${input}.` }; return { success: true, value }; }, format(value) { return value.toString(); } }; } /** * Creates a {@link ValueParser} for floating-point numbers. * * This parser validates that the input is a valid floating-point number * and optionally enforces minimum and maximum value constraints. * @param options Configuration options for the float parser. * @returns A {@link ValueParser} that parses strings into floating-point * numbers. */ function float(options = {}) { const floatRegex = /^[+-]?(?:(?:\d+\.?\d*)|(?:\d*\.\d+))(?:[eE][+-]?\d+)?$/; return { metavar: options.metavar ?? "NUMBER", parse(input) { let value; const lowerInput = input.toLowerCase(); if (lowerInput === "nan" && options.allowNaN) value = NaN; else if ((lowerInput === "infinity" || lowerInput === "+infinity") && options.allowInfinity) value = Infinity; else if (lowerInput === "-infinity" && options.allowInfinity) value = -Infinity; else if (floatRegex.test(input)) { value = Number(input); if (Number.isNaN(value)) return { success: false, error: message`Expected a valid number, but got ${input}.` }; } else return { success: false, error: message`Expected a valid number, but got ${input}.` }; if (options.min != null && value < options.min) return { success: false, error: message`Expected a value greater than or equal to ${text(options.min.toLocaleString("en"))}, but got ${input}.` }; else if (options.max != null && value > options.max) return { success: false, error: message`Expected a value less than or equal to ${text(options.max.toLocaleString("en"))}, but got ${input}.` }; return { success: true, value }; }, format(value) { return value.toString(); } }; } /** * Creates a {@link ValueParser} for URL values. * * This parser validates that the input is a well-formed URL and optionally * restricts the allowed protocols. The parsed result is a JavaScript `URL` * object. * @param options Configuration options for the URL parser. * @returns A {@link ValueParser} that converts string input to `URL` objects. */ function url(options = {}) { const allowedProtocols = options.allowedProtocols?.map((p) => p.toLowerCase()); return { metavar: options.metavar ?? "URL", parse(input) { if (!URL.canParse(input)) return { success: false, error: message`Invalid URL: ${input}.` }; const url$1 = new URL(input); if (allowedProtocols != null && !allowedProtocols.includes(url$1.protocol)) return { success: false, error: message`URL protocol ${url$1.protocol} is not allowed. Allowed protocols: ${allowedProtocols.join(", ")}.` }; return { success: true, value: url$1 }; }, format(value) { return value.href; } }; } /** * Creates a {@link ValueParser} for locale values. * * This parser validates that the input is a well-formed locale identifier * according to the Unicode Locale Identifier standard (BCP 47). * The parsed result is a JavaScript `Intl.Locale` object. * @param options Configuration options for the locale parser. * @returns A {@link ValueParser} that converts string input to `Intl.Locale` * objects. */ function locale(options = {}) { return { metavar: options.metavar ?? "LOCALE", parse(input) { let locale$1; try { locale$1 = new Intl.Locale(input); } catch (e) { if (e instanceof RangeError) return { success: false, error: message`Invalid locale: ${input}.` }; throw e; } return { success: true, value: locale$1 }; }, format(value) { return value.baseName; } }; } /** * Creates a {@link ValueParser} for UUID values. * * This parser validates that the input is a well-formed UUID string in the * standard format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` where each `x` * is a hexadecimal digit. The parser can optionally restrict to specific * UUID versions. * * @param options Configuration options for the UUID parser. * @returns A {@link ValueParser} that converts string input to {@link Uuid} * strings. */ function uuid(options = {}) { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return { metavar: options.metavar ?? "UUID", parse(input) { if (!uuidRegex.test(input)) return { success: false, error: message`Expected a valid UUID in format ${"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}, but got ${input}.` }; if (options.allowedVersions != null && options.allowedVersions.length > 0) { const versionChar = input.charAt(14); const version = parseInt(versionChar, 16); if (!options.allowedVersions.includes(version)) { let expectedVersions = message``; let i = 0; for (const v of options.allowedVersions) { expectedVersions = i < 1 ? message`${expectedVersions}${v.toLocaleString("en")}` : i + 1 >= options.allowedVersions.length ? message`${expectedVersions}, or ${v.toLocaleString("en")}` : message`${expectedVersions}, ${v.toLocaleString("en")}`; i++; } return { success: false, error: message`Expected UUID version ${expectedVersions}, but got version ${version.toLocaleString("en")}.` }; } } return { success: true, value: input }; }, format(value) { return value; } }; } //#endregion export { choice, float, integer, isValueParser, locale, string, url, uuid };