diff --git a/doc/file-formats.md b/doc/file-formats.md index c08c73ba..b561f858 100644 --- a/doc/file-formats.md +++ b/doc/file-formats.md @@ -407,7 +407,11 @@ test code reaches for. Two kinds of cfg are decided: The crate a versioned dependency resolves to comes from `Cargo.lock`, so two vendored versions of one crate each get their own features and edges. A package the resolved build doesn't pull in has unknown features, and its gated - code is kept. + code is kept. The manifests and the lock are read with `@preventive/lockfile`'s + strict TOML parser (so is a `foundry.toml`): a `Cargo.toml` or `Cargo.lock` + that exists but isn't TOML stops the build, naming the file and line, and so + does TOML those files are never written in (a local date, a byte order mark, + U+FFFD where bytes weren't UTF-8). `all(…)`/`any(…)`/`not(…)` compose; a predicate that reduces to true (`not(test)`, an enabled feature) is as firm as no cfg, so a missing module behind it is fatal. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b59df7d1..f862f1c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: '@exodus/stasis-plugins': specifier: 1.0.0-beta.4 version: link:../stasis-plugins + '@preventive/lockfile': + specifier: 1.0.0-alpha.1 + version: 1.0.0-alpha.1 '@preventive/upstream': specifier: 1.0.0-alpha.0 version: 1.0.0-alpha.0 @@ -645,6 +648,10 @@ packages: cpu: [x64] os: [win32] + '@preventive/lockfile@1.0.0-alpha.1': + resolution: {integrity: sha512-enrcy7d1hUZXXYOM7LNdshTQeiHhJCBoLlEkmJRR9cLsL7bDt6b5wvNUweo3UFmvutgdxg/6oy5vz3ig6nLR8Q==, tarball: https://registry.npmjs.org/@preventive/lockfile/-/lockfile-1.0.0-alpha.1.tgz} + engines: {node: '>=24.14.0'} + '@preventive/upstream@1.0.0-alpha.0': resolution: {integrity: sha512-xqVwQ0QOzanNQy8LHOs7RxGMNggMhvUAAQIZ80FIiYZp7l4/9SqrdZnkO8JqZqDIRlqMOHxU5pyk3e/G1iv+dg==, tarball: https://registry.npmjs.org/@preventive/upstream/-/upstream-1.0.0-alpha.0.tgz} engines: {node: '>=24.14.0'} @@ -2842,6 +2849,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.52.0': optional: true + '@preventive/lockfile@1.0.0-alpha.1': {} + '@preventive/upstream@1.0.0-alpha.0': {} '@rollup/rollup-android-arm-eabi@4.62.4': diff --git a/stasis/package.json b/stasis/package.json index 8eb8aac3..ad5d4147 100644 --- a/stasis/package.json +++ b/stasis/package.json @@ -79,6 +79,7 @@ "dependencies": { "@exodus/stasis-core": "1.0.0-beta.4", "@exodus/stasis-plugins": "1.0.0-beta.4", + "@preventive/lockfile": "1.0.0-alpha.1", "@preventive/upstream": "1.0.0-alpha.0", "oxc-parser": "^0.94.0" }, diff --git a/stasis/src/loaders/cargo.js b/stasis/src/loaders/cargo.js index 1be7c1c1..5a0483fe 100644 --- a/stasis/src/loaders/cargo.js +++ b/stasis/src/loaders/cargo.js @@ -9,7 +9,7 @@ import { dirname, isAbsolute, join, posix, relative } from 'node:path' import { toPosix } from '@exodus/stasis-core/util' -import { tomlEntries } from './toml.js' +import { isTomlTable, readToml } from './toml.js' // `cargo vendor` copies registry crates in-tree under this dir. export const VENDOR_DIR = 'vendor' @@ -45,7 +45,7 @@ export function normalizeRel(dir, sub) { // --- Cargo.toml ----------------------------------------------------------------------- -const DEP_KINDS = { dependencies: 'normal', 'dev-dependencies': 'dev', 'build-dependencies': 'build' } +const DEP_KINDS = { __proto__: null, dependencies: 'normal', 'dev-dependencies': 'dev', 'build-dependencies': 'build' } // Entries of a `[features]` list beyond a plain feature name: `dep:key` and `key/feat` / `key?/feat`. const DEP_IMPLICATION_RE = /^dep:(.+)$/u const DEP_FEATURE_RE = /^([^/?]+)(\?)?\/(.+)$/u @@ -72,9 +72,9 @@ export function isTestTargetPath(pkgDir, fileRel) { // (kind, `path`/`version`/`package`/`workspace`, `optional`, `default-features`, `features`), // `[features]`, `[patch.*]` path overrides and the workspace tables members inherit from. // Dependency keys are normalized to the `use` spelling (`-` → `_`); feature names keep theirs. -// Each pair is read by the table path it lands at, so `[dependencies.foo] features = […]`, -// `[dependencies] foo.features = […]` and `foo = { features = […] }` are one thing. Throws a -// TomlError naming `file` on text that isn't TOML. +// It reads the parsed table tree, so `[dependencies.foo] features = […]`, `[dependencies] +// foo.features = […]` and `foo = { features = […] }` are one thing. Throws a TomlError naming +// `file` on text that isn't TOML. export function parseCargoManifest(text, file = null) { const manifest = { package: null, // { name, version, versionFromWorkspace, edition } @@ -100,65 +100,68 @@ export function parseCargoManifest(text, file = null) { } return map.get(key) } - // Apply one table (or one `[dependencies.foo]` line) to a dependency: identity fields on the - // record, request fields on the entry for `kind` (or on the record itself for a flat one). - const setDepFields = (dep, table, kind) => { + // Apply one dependency's spec to its record: identity fields on the record, request fields on + // the entry for `kind` (or on the record itself for a flat one). + const setDepFields = (dep, spec, kind) => { const request = kind === null ? dep : (dep.kinds.get(kind) ?? dep.kinds.set(kind, newRequest()).get(kind)) - if (typeof table === 'string') { - dep.version = table // `foo = "1.2"`: a registry dep + if (typeof spec === 'string') { + dep.version = spec // `foo = "1.2"`: a registry dep return } - if (typeof table !== 'object' || table === null) return - if (typeof table.version === 'string') dep.version = table.version - if (typeof table.path === 'string') dep.path = table.path - if (typeof table.package === 'string') dep.package = table.package - if (table.workspace === true) dep.workspace = true - if (table.optional === true) request.optional = true - const defaults = table['default-features'] ?? table.default_features + if (!isTomlTable(spec)) return + if (typeof spec.version === 'string') dep.version = spec.version + if (typeof spec.path === 'string') dep.path = spec.path + if (typeof spec.package === 'string') dep.package = spec.package + if (spec.workspace === true) dep.workspace = true + if (spec.optional === true) request.optional = true + const defaults = spec['default-features'] ?? spec.default_features if (defaults === true || defaults === false) request.defaultFeatures = defaults - if (Array.isArray(table.features)) request.features = [...new Set([...request.features, ...table.features.filter((f) => typeof f === 'string')])] - } - for (const { path, header, value } of tomlEntries(text, { file })) { - // A `[workspace]` table, however it is spelled out, makes this a workspace root. - if (path[0] === 'workspace') manifest.isWorkspace = true - if (header) continue - const [head, key, sub, extra] = path - if (head === 'package') { - manifest.package ??= { name: null, version: null, versionFromWorkspace: false, edition: null } - if (path.length === 2) { - if (key === 'name' && typeof value === 'string') manifest.package.name = value - else if (key === 'version' && typeof value === 'string') manifest.package.version = value - else if (key === 'version' && value?.workspace === true) manifest.package.versionFromWorkspace = true - else if (key === 'edition' && typeof value === 'string') manifest.package.edition = value - else if (key === 'resolver' && typeof value === 'string') manifest.resolver = value - } else if (path.length === 3 && key === 'version' && sub === 'workspace' && value === true) manifest.package.versionFromWorkspace = true - } else if (head === 'lib' && path.length === 2) { - if (key === 'name' && typeof value === 'string') manifest.lib.name = value - else if (key === 'path' && typeof value === 'string') manifest.lib.path = value - } else if (head === 'features' && path.length === 2) { - if (Array.isArray(value)) manifest.features.set(key, value.filter((v) => typeof v === 'string')) - } else if (head === 'patch') { - // `[patch.] crate = { path = "…" }` or `[patch..crate] path = "…"` - const patch = path.length === 3 ? value?.path : (path.length === 4 && extra === 'path' ? value : undefined) - if (typeof patch === 'string') manifest.patches.set(normName(sub), patch) - } else if (head === 'workspace' && path.length === 2 && key === 'resolver' && typeof value === 'string') { - manifest.resolver = value - } else if (head === 'workspace' && path.length === 3 && key === 'package' && sub === 'version' && typeof value === 'string') { - manifest.workspacePackage.version = value - } else { - // `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`, under `target.` or - // `workspace`: then the dep's name, then possibly one field of it. - const ws = head === 'workspace' - const segs = ws ? path.slice(1) : path - const kindAt = segs[0] === 'target' ? 2 : 0 - const kind = DEP_KINDS[segs[kindAt]] - const [depName, field, more] = segs.slice(kindAt + 1) - if (kind === undefined || depName === undefined || more !== undefined) continue - const dep = depOf(ws ? manifest.workspaceDeps : manifest.deps, depName, { flat: ws }) - setDepFields(dep, field === undefined ? value : { [field]: value }, ws ? null : kind) + if (Array.isArray(spec.features)) request.features = [...new Set([...request.features, ...spec.features.filter((f) => typeof f === 'string')])] + } + const doc = readToml(text, file) + const str = (v) => (typeof v === 'string' ? v : null) + const table = (v) => (isTomlTable(v) ? v : null) + const pkg = table(doc.package) + if (pkg !== null && typeof pkg.name === 'string') { + manifest.package = { + name: pkg.name, + version: str(pkg.version), + versionFromWorkspace: table(pkg.version)?.workspace === true, + edition: str(pkg.edition), } } - if (manifest.package && !manifest.package.name) manifest.package = null + const ws = table(doc.workspace) + // A `[workspace]` table, however it is spelled out, makes this a workspace root. + manifest.isWorkspace = 'workspace' in doc + manifest.resolver = str(ws?.resolver) ?? str(pkg?.resolver) + manifest.workspacePackage.version = str(table(ws?.package)?.version) + const lib = table(doc.lib) + manifest.lib = { name: str(lib?.name), path: str(lib?.path) } + for (const [name, list] of Object.entries(table(doc.features) ?? {})) { + if (Array.isArray(list)) manifest.features.set(name, list.filter((v) => typeof v === 'string')) + } + // `[patch.] crate = { path = "…" }`, however it is spelled out. + for (const registry of Object.values(table(doc.patch) ?? {})) { + for (const [crate, spec] of Object.entries(table(registry) ?? {})) { + const patch = str(table(spec)?.path) + if (patch !== null) manifest.patches.set(normName(crate), patch) + } + } + // `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`, under `target.` or + // `workspace`, in the order the manifest has them: each dependency a version string or a table. + const depTables = (scope, into, flat) => { + for (const [key, value] of Object.entries(scope)) { + if (key === 'target') { + for (const cfg of Object.values(table(value) ?? {})) depTables(table(cfg) ?? {}, into, flat) + continue + } + const kind = DEP_KINDS[key] + if (kind === undefined) continue + for (const [depName, spec] of Object.entries(table(value) ?? {})) setDepFields(depOf(into, depName, { flat }), spec, flat ? null : kind) + } + } + depTables(doc, manifest.deps, false) + if (ws !== null) depTables(ws, manifest.workspaceDeps, true) return manifest } @@ -170,26 +173,20 @@ export function parseCargoManifest(text, file = null) { // text that isn't TOML. export function parseCargoLock(text, file = null) { if (text === null) return null - const packages = [] - let cur = null // the [[package]] being read; null inside any other table // A dependency is `"name"`, or `"name version"` when several versions of it are locked. const dep = (s) => { const [name, version] = s.split(' ') return { name: normName(name), version: version ?? null } } - for (const { path, header, value } of tomlEntries(text, { file })) { - if (header) { - cur = path.length === 1 && path[0] === 'package' ? { name: null, version: null, deps: [] } : null - if (cur) packages.push(cur) - continue - } - if (cur === null || path.length !== 2) continue - const key = path[1] - if (key === 'name' && typeof value === 'string') cur.name = normName(value) - else if (key === 'version' && typeof value === 'string') cur.version = value - else if (key === 'dependencies' && Array.isArray(value)) { - for (const s of value) if (typeof s === 'string') cur.deps.push(dep(s)) - } + const packages = [] + const locked = readToml(text, file).package + for (const p of Array.isArray(locked) ? locked : []) { + if (!isTomlTable(p)) continue + packages.push({ + name: typeof p.name === 'string' ? normName(p.name) : null, + version: typeof p.version === 'string' ? p.version : null, + deps: Array.isArray(p.dependencies) ? p.dependencies.filter((d) => typeof d === 'string').map(dep) : [], + }) } const byId = new Map() const byName = new Map() diff --git a/stasis/src/loaders/foundry.js b/stasis/src/loaders/foundry.js index 4ed3d46e..49a361f0 100644 --- a/stasis/src/loaders/foundry.js +++ b/stasis/src/loaders/foundry.js @@ -18,7 +18,7 @@ import { posix, resolve } from 'node:path' import { toPosix } from '@exodus/stasis-core/util' import { isDir } from '../resolve-typescript.js' import { readFileOrNull } from './cargo.js' -import { tomlEntries } from './toml.js' +import { isTomlTable, readToml } from './toml.js' export const FOUNDRY_TOML = 'foundry.toml' export const REMAPPINGS_TXT = 'remappings.txt' @@ -407,37 +407,25 @@ const STANDALONE_SECTIONS = new Set([ // foundry.toml -> `{ profiles, topLevel }`. `profiles` is Map> (profile // names lowercased, keys snake_cased as forge does) from the `[profile.]` tables and the -// legacy top-level `[]` ones forge still reads, the former winning key by key; sub-tables -// other than `extends` are skipped. `topLevel` holds keys set outside any table (forge rejects -// those; a `--mapping` file may list its `remappings` there). Throws a TomlError naming `file` on -// text that isn't TOML, as forge refuses the file. +// legacy top-level `[]` ones forge still reads, the former winning key by key; a profile's +// sub-tables are its values like any other (`extends`, `fuzz`: forge compares them all for a +// `no-collision` extends). `topLevel` holds the values set outside any table (forge rejects those; +// a `--mapping` file may list its `remappings` there). Throws a TomlError naming `file` on text +// that isn't TOML, as forge refuses the file. function parseFoundryToml(text, file = null) { const current = new Map() const legacy = new Map() const topLevel = new Map() - const dictOf = (map, name) => map.get(name) ?? map.set(name, new Map()).get(name) - for (const { path, header, value } of tomlEntries(text, { file })) { - let map - let rest - if (path[0] === 'profile' && path.length >= 2) { - map = current - rest = path.slice(2) - } else if (path.length >= (header ? 1 : 2) && !STANDALONE_SECTIONS.has(path[0])) { - map = legacy - rest = path.slice(1) - } else { - if (!header && path.length === 1) topLevel.set(snakeCase(path[0]), value) - continue - } - const dict = dictOf(map, (map === current ? path[1] : path[0]).toLowerCase()) - if (header || rest.length === 0) continue - const k = snakeCase(rest[0]) - if (rest.length === 1) { - dict.set(k, value) - } else if (k === 'extends' && rest.length === 2) { - const ext = dict.get('extends') - dict.set('extends', { ...(ext && typeof ext === 'object' ? ext : {}), [rest[1]]: value }) - } + const read = (map, name, table) => { + const profile = name.toLowerCase() + const dict = map.get(profile) ?? map.set(profile, new Map()).get(profile) + for (const [key, value] of Object.entries(table)) dict.set(snakeCase(key), value) + } + for (const [key, value] of Object.entries(readToml(text, file))) { + if (!isTomlTable(value)) topLevel.set(snakeCase(key), value) + else if (key === 'profile') { + for (const [name, table] of Object.entries(value)) if (isTomlTable(table)) read(current, name, table) + } else if (!STANDALONE_SECTIONS.has(key)) read(legacy, key, value) } const profiles = new Map([...legacy].map(([name, dict]) => [name, new Map(dict)])) for (const [name, dict] of current) profiles.set(name, new Map([...(profiles.get(name) ?? []), ...dict])) diff --git a/stasis/src/loaders/toml.js b/stasis/src/loaders/toml.js index 9631a065..bb76b5ab 100644 --- a/stasis/src/loaders/toml.js +++ b/stasis/src/loaders/toml.js @@ -1,11 +1,12 @@ -// A TOML 1.0 reader for the build descriptions the loaders take -- Cargo.toml, Cargo.lock, -// foundry.toml -- as the document's `[header]`s and `key = value` pairs in order, each with the -// table path it lands at. It reads the whole grammar and refuses what isn't TOML: a bad escape, an -// unterminated string, text after a value, a missing comma, a bare word where a value goes, a key -// or table defined twice, a number with a leading zero ... each is a TomlError naming the file and -// line, never a guessed value. Values are JavaScript ones: strings, numbers (an integer past 2^53 -// is a BigInt), booleans, arrays and inline tables (as null-prototype objects); a date or time is -// checked and kept as its text. +// TOML for the build descriptions the loaders take -- Cargo.toml, Cargo.lock, foundry.toml -- read +// by @preventive/lockfile's strict TOML 1.0 parser into a tree of tables, and refused where it +// isn't TOML, or isn't the TOML those files are written in: a bad escape, an unterminated string, a +// key or table defined twice, a local date, U+FFFD where a lenient decoder replaced bytes ... each a +// TomlError naming the file and line, never a guessed value. + +import { TomlError, parseToml } from '@preventive/lockfile/toml.js' + +export { TomlError } // --- Bracket helpers (for the Rust scanner) ---------------------------------------------- @@ -47,456 +48,19 @@ export function splitTopLevel(text) { // --- The reader ----------------------------------------------------------------------------- -export class TomlError extends Error { - constructor(message, file, line) { - super(`${file === null ? `line ${line}` : `${file}:${line}`}: ${message}`) - this.name = 'TomlError' - this.file = file - this.line = line +// The table tree of TOML `text` (parseToml): tables are null-prototype objects, arrays arrays, and +// values strings, booleans, numbers (an integer past 2^53 a BigInt), TomlFloat and TomlDateTime. +// Throws a TomlError on anything else, naming `file` when given. +export function readToml(text, file = null) { + try { + return parseToml(text) + } catch (err) { + if (file === null || !(err instanceof TomlError)) throw err + const named = new TomlError(`${file}: ${err.message}`) + named.line = err.line + throw named } } -// Sticky patterns, matched at the reader's position. A scalar must end where a value can: at -// whitespace, a line end, `,`, `]`, `}`, a comment or the end of the text. -const END = String.raw`(?=[ \t\r\n,\]}#]|$)` -const WS_RE = /[ \t]*/uy -const NEWLINE_RE = /\r?\n/uy -const BARE_KEY_RE = /[\w-]+/uy -const BARE_KEY_FULL_RE = /^[\w-]+$/u -// The control characters TOML bans outside escapes (tab excepted); multi-line strings allow LF too. -const CONTROL = String.raw`\u0000-\u0008\u000A-\u001F\u007F` -const CONTROL_MULTILINE = String.raw`\u0000-\u0008\u000B-\u001F\u007F` -const COMMENT_RE = new RegExp(`#[^${CONTROL}]*`, 'uy') -const BAD_TOKEN_RE = /[^ \t\r\n,\]}#]*/uy -const BOOL_RE = new RegExp(String.raw`(?:true|false)${END}`, 'uy') -const NUMBER_RE = new RegExp(String.raw`([+-]?(?:0|[1-9](?:_?\d)*))(\.\d(?:_?\d)*)?([Ee][+-]?\d(?:_?\d)*)?${END}`, 'uy') -const RADIX_RE = new RegExp(String.raw`0(?:x([\dA-Fa-f](?:_?[\dA-Fa-f])*)|o([0-7](?:_?[0-7])*)|b([01](?:_?[01])*))${END}`, 'uy') -const SPECIAL_FLOAT_RE = new RegExp(String.raw`([+-]?)(inf|nan)${END}`, 'uy') -const TIME = String.raw`(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?` -const DATETIME_RE = new RegExp(String.raw`(\d{4})-(\d{2})-(\d{2})(?:[Tt ]${TIME}(?:[Zz]|[+-](\d{2}):(\d{2}))?)?${END}`, 'uy') -const LOCAL_TIME_RE = new RegExp(`${TIME}${END}`, 'uy') -// Runs of ordinary text inside each kind of string: not its quote, not an escape in the basic -// kinds, and no control character but tab (and LF in the multi-line kinds; CR is handled by hand -// so that only CRLF passes). -const PLAIN_BASIC_RE = new RegExp(String.raw`[^"\\${CONTROL}]+`, 'uy') -const PLAIN_LITERAL_RE = new RegExp(`[^'${CONTROL}]+`, 'uy') -const PLAIN_MULTILINE_BASIC_RE = new RegExp(String.raw`[^"\\${CONTROL_MULTILINE}]+`, 'uy') -const PLAIN_MULTILINE_LITERAL_RE = new RegExp(`[^'${CONTROL_MULTILINE}]+`, 'uy') -// `\` at the end of a line in a multi-line basic string: it and the whitespace up to the next -// non-blank character are dropped. -const LINE_ENDING_BACKSLASH_RE = /[ \t]*\r?\n[ \t\r\n]*/uy -const HEX_RE = /^[\dA-Fa-f]+$/u -const ESCAPES = new Map([['b', '\b'], ['t', '\t'], ['n', '\n'], ['f', '\f'], ['r', '\r'], ['"', '"'], ['\\', '\\']]) - -const isLineEnd = (ch) => ch === undefined || ch === '\n' || ch === '\r' - -function validDate(year, month, day) { - const t = new Date(0) - t.setUTCFullYear(Number(year), Number(month) - 1, Number(day)) - return t.getUTCMonth() === Number(month) - 1 && t.getUTCDate() === Number(day) -} -const validTime = (hour, minute, second, offsetHour = '0', offsetMinute = '0') => - Number(hour) <= 23 && Number(minute) <= 59 && Number(second) <= 59 && Number(offsetHour) <= 23 && Number(offsetMinute) <= 59 - -// The reader's picture of a table at `path`: `children` by key, where a leaf holds its `value` and -// an array of tables its `items`; `explicit` once a `[header]` defined it, `dotted` once a dotted -// key passed through it (neither may then be defined again by a header). -const tableNode = (path) => ({ path, children: new Map(), value: undefined, items: null, explicit: false, dotted: false }) -const isTable = (node) => node.explicit || node.dotted || node.children.size > 0 - -const dotted = (path) => path.map((seg) => (BARE_KEY_FULL_RE.test(seg) ? seg : JSON.stringify(seg))).join('.') - -// An inline table's contents as a plain (null-prototype) object. -function toObject(node) { - const out = Object.create(null) - for (const [key, child] of node.children) out[key] = child.value === undefined ? toObject(child) : child.value - return out -} - -class Reader { - constructor(text, file) { - this.text = text - this.file = file - this.pos = text.startsWith('') ? 1 : 0 // a byte-order mark is skipped, as cargo and forge do - this.root = tableNode([]) - this.lineStarts = [0] - for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) this.lineStarts.push(i + 1) - } - - // The 0-based line holding `pos`. - lineOf(pos) { - let lo = 0 - let hi = this.lineStarts.length - 1 - while (lo < hi) { - const mid = (lo + hi + 1) >> 1 - if (this.lineStarts[mid] <= pos) lo = mid - else hi = mid - 1 - } - return lo - } - - fail(message, at = this.pos) { - throw new TomlError(message, this.file, this.lineOf(at) + 1) - } - - peek(offset = 0) { - return this.text[this.pos + offset] - } - - eof() { - return this.pos >= this.text.length - } - - // The sticky `re` matched at the position (or null); `take` also moves past the match. - match(re) { - re.lastIndex = this.pos - return re.exec(this.text) - } - - take(re) { - const m = this.match(re) - if (m) this.pos += m[0].length - return m - } - - ws() { - this.take(WS_RE) - } - - // At the end of the text or of a line (LF or CRLF; a lone CR is a control character). - atLineEnd() { - const ch = this.peek() - return ch === undefined || ch === '\n' || (ch === '\r' && this.peek(1) === '\n') - } - - // A `# comment` up to its line end. - comment() { - this.take(COMMENT_RE) - if (!this.atLineEnd()) this.fail('control character in a comment') - } - - // Whitespace, comments and line ends: what may sit between entries and between array items. - blank() { - for (;;) { - this.ws() - const ch = this.peek() - if (ch === '#') this.comment() - else if (ch === '\n') this.pos++ - else if (ch === '\r' && this.peek(1) === '\n') this.pos += 2 - else return - } - } - - // After a header or a pair, only whitespace and a comment may follow on the line. - endOfLine(what) { - this.ws() - if (this.peek() === '#') this.comment() - else if (!this.atLineEnd()) this.fail(`unexpected text after ${what}`) - } - - // A key -- bare, quoted or dotted: `a.b."c.d"` → ['a', 'b', 'c.d'] -- and the whitespace after it. - key() { - const segments = [] - for (;;) { - const ch = this.peek() - if (ch === '"') segments.push(this.basicString()) - else if (ch === "'") segments.push(this.literalString()) - else { - const bare = this.take(BARE_KEY_RE) - if (!bare) this.fail('expected a key') - segments.push(bare[0]) - } - this.ws() - if (this.peek() !== '.') return segments - this.pos++ - this.ws() - } - } - - // `key = value`, defined under `table`. - pair(table) { - const at = this.pos - const segments = this.key() - if (this.peek() !== '=') this.fail('expected "=" after the key') - this.pos++ - this.ws() - const value = this.value([...table.path, ...segments]) - this.define(table, segments, value, at) - return { segments, value } - } - - // The value at the position; `path` is the key it is for (named in messages). - value(path) { - const ch = this.peek() - if (ch === '"') return this.text.startsWith('"""', this.pos) ? this.multilineString('"') : this.basicString() - if (ch === "'") return this.text.startsWith("'''", this.pos) ? this.multilineString("'") : this.literalString() - if (ch === '[') return this.array(path) - if (ch === '{') return this.inlineTable(path) - return this.scalar() - } - - // true/false, a number, or a date/time (kept as its text). - scalar() { - const at = this.pos - let m = this.take(BOOL_RE) - if (m) return m[0] === 'true' - m = this.take(NUMBER_RE) - if (m) { - const digits = m[0].replaceAll('_', '') - if (m[2] !== undefined || m[3] !== undefined) return Number(digits) - const int = Number(digits) - return Number.isSafeInteger(int) ? int : BigInt(digits) - } - m = this.take(RADIX_RE) - if (m) { - const big = BigInt(`0${m[0][1]}${(m[1] ?? m[2] ?? m[3]).replaceAll('_', '')}`) - return big <= Number.MAX_SAFE_INTEGER ? Number(big) : big - } - m = this.take(SPECIAL_FLOAT_RE) - if (m) return m[2] === 'nan' ? Number.NaN : (m[1] === '-' ? -Infinity : Infinity) - m = this.take(DATETIME_RE) - if (m) { - const [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = m - if (!validDate(year, month, day) || (hour !== undefined && !validTime(hour, minute, second, offsetHour, offsetMinute))) this.fail(`invalid date "${m[0]}"`, at) - return m[0] - } - m = this.take(LOCAL_TIME_RE) - if (m) { - if (!validTime(m[1], m[2], m[3])) this.fail(`invalid time "${m[0]}"`, at) - return m[0] - } - const bad = this.match(BAD_TOKEN_RE)[0] - return this.fail(bad === '' ? 'expected a value' : `invalid value "${bad}"`) - } - - // `"..."`: escapes decoded; it must close on its line. - basicString() { - const at = this.pos - this.pos++ - let out = '' - for (;;) { - const run = this.take(PLAIN_BASIC_RE) - if (run) out += run[0] - const ch = this.peek() - if (ch === '"') { - this.pos++ - return out - } - if (ch === '\\') { - this.pos++ - out += this.escape() - } else if (isLineEnd(ch)) this.fail('unterminated string', at) - else this.fail('control character in a string') - } - } - - // The escape after a `\`. - escape() { - const ch = this.peek() - if (ch === 'u' || ch === 'U') { - const len = ch === 'u' ? 4 : 8 - const hex = this.text.slice(this.pos + 1, this.pos + 1 + len) - if (hex.length !== len || !HEX_RE.test(hex)) this.fail(`invalid escape "\\${ch}${hex}"`) - const code = Number.parseInt(hex, 16) - if ((code >= 0xd8_00 && code <= 0xdf_ff) || code > 0x10_ff_ff) this.fail(`"\\${ch}${hex}" is not a Unicode scalar value`) - this.pos += 1 + len - return String.fromCodePoint(code) - } - const out = ch === undefined ? undefined : ESCAPES.get(ch) - if (out === undefined) this.fail(`invalid escape "\\${ch ?? ''}"`) - this.pos++ - return out - } - - // `'...'`: as written; it must close on its line. - literalString() { - const at = this.pos - this.pos++ - const run = this.take(PLAIN_LITERAL_RE) - const ch = this.peek() - if (ch === "'") { - this.pos++ - return run ? run[0] : '' - } - if (isLineEnd(ch)) this.fail('unterminated string', at) - return this.fail('control character in a string') - } - - // `"""…"""` / `'''…'''`: a line end right after the opening delimiter is dropped, a `\` ending a - // line of the basic kind drops the whitespace up to the next character, and up to two quotes - // right before the closing delimiter belong to the text. - multilineString(quote) { - const at = this.pos - const delim = quote.repeat(3) - const plain = quote === '"' ? PLAIN_MULTILINE_BASIC_RE : PLAIN_MULTILINE_LITERAL_RE - this.pos += 3 - this.take(NEWLINE_RE) - let out = '' - for (;;) { - const run = this.take(plain) - if (run) out += run[0] - if (this.text.startsWith(delim, this.pos)) { - this.pos += 3 - for (let extra = 0; extra < 2 && this.peek() === quote; extra++) { - out += quote - this.pos++ - } - return out - } - const ch = this.peek() - if (ch === quote) { - out += ch - this.pos++ - } else if (ch === '\\' && quote === '"') { - this.pos++ - if (!this.take(LINE_ENDING_BACKSLASH_RE)) out += this.escape() - } else if (ch === '\r' && this.peek(1) === '\n') { - out += '\r\n' - this.pos += 2 - } else if (ch === undefined) this.fail('unterminated string', at) - else this.fail('control character in a string') - } - } - - // `[ v, v, ]`: items may spread over lines, with comments; a trailing comma is fine. - array(path) { - const at = this.pos - this.pos++ - const items = [] - for (;;) { - this.blank() - if (this.peek() === ']') { - this.pos++ - return items - } - if (this.eof()) this.fail('unterminated array', at) - items.push(this.value(path)) - this.blank() - const ch = this.peek() - if (ch === ',') this.pos++ - else if (ch === ']') { - this.pos++ - return items - } else if (ch === undefined) this.fail('unterminated array', at) - else this.fail('expected a comma or "]" after the array item') - } - } - - // `{ k = v, k = v }` on one line; no trailing comma; keys may be dotted; none twice. - inlineTable(path) { - const at = this.pos - this.pos++ - const table = tableNode(path) - this.ws() - if (this.peek() === '}') { - this.pos++ - return toObject(table) - } - for (;;) { - if (this.atLineEnd()) this.fail('unterminated inline table', at) - this.pair(table) - this.ws() - const ch = this.peek() - if (ch === '}') { - this.pos++ - return toObject(table) - } - if (ch === ',') { - this.pos++ - this.ws() - if (this.peek() === '}') this.fail('trailing comma in an inline table') - } else if (isLineEnd(ch)) this.fail('unterminated inline table', at) - else this.fail('expected a comma or "}" after the inline table entry') - } - } - - // The child `key` of `node`, made if new. - child(node, key) { - let child = node.children.get(key) - if (!child) { - child = tableNode([...node.path, key]) - node.children.set(key, child) - } - return child - } - - // `segments = value` under `table`: the prefix segments are tables a dotted key may open or pass - // through (not a value, an array of tables, or a table a header defined); the last one is new. - define(table, segments, value, at) { - let node = table - for (const seg of segments.slice(0, -1)) { - const child = this.child(node, seg) - if (child.value !== undefined || child.items) this.fail(`"${dotted(child.path)}" is not a table`, at) - if (child.explicit) this.fail(`table [${dotted(child.path)}] is already defined by a header`, at) - child.dotted = true - node = child - } - const last = segments.at(-1) - if (node.children.has(last)) this.fail(`duplicate key "${dotted([...node.path, last])}"`, at) - this.child(node, last).value = value - } - - // `[segments]` / `[[segments]]` → the table its entries land in. The prefix segments may pass - // through tables of any kind (an array of tables by its last item); the last must be new, or - // for `[[x]]` the array of tables to append to. - header(segments, array, at) { - let node = this.root - for (const seg of segments.slice(0, -1)) { - const child = this.child(node, seg) - if (child.value !== undefined) this.fail(`"${dotted(child.path)}" is not a table`, at) - node = child.items ? child.items.at(-1) : child - } - const child = this.child(node, segments.at(-1)) - if (child.value !== undefined) this.fail(`"${dotted(child.path)}" is already defined as a value`, at) - if (array) { - if (!child.items && isTable(child)) this.fail(`"${dotted(child.path)}" is not an array of tables`, at) - child.items ??= [] - const item = tableNode(child.path) - item.explicit = true - child.items.push(item) - return item - } - if (child.items || child.explicit || child.dotted) this.fail(`duplicate table [${dotted(child.path)}]`, at) - child.explicit = true - return child - } - - // The document's entries in order, each with the physical lines (`first`..`last`, 0-based) it spans. - document() { - const entries = [] - let table = this.root - let tablePath = [] - for (;;) { - this.blank() - if (this.eof()) return entries - const at = this.pos - const first = this.lineOf(at) - if (this.peek() === '[') { - const array = this.peek(1) === '[' - this.pos += array ? 2 : 1 - this.ws() - const segments = this.key() - const close = array ? ']]' : ']' - if (!this.text.startsWith(close, this.pos)) this.fail(`expected "${close}" to close the table header`) - this.pos += close.length - table = this.header(segments, array, at) - tablePath = segments - this.endOfLine('the table header') - entries.push({ path: segments, header: true, first, last: first }) - } else { - const { segments, value } = this.pair(table) - const last = this.lineOf(this.pos - 1) - this.endOfLine('the value') - entries.push({ path: [...tablePath, ...segments], header: false, value, first, last }) - } - } - } -} - -// Every `[header]` and `key = value` of a TOML text in order: a header as `{ path, header: true }` -// (the table's segments), a pair as `{ path, header: false, value }` (the enclosing table's segments -// then the key's), each with the physical lines (`first`..`last`, 0-based) it spans. Throws a -// TomlError naming `file` and the line on anything that isn't TOML. -export function tomlEntries(text, { file = null } = {}) { - return new Reader(text, file).document() -} +// Whether a TOML value is a table (a TomlFloat or TomlDateTime is an object too). +export const isTomlTable = (value) => value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === null diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index 9fef8579..47213e98 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import { spawnSync } from 'node:child_process' -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -2749,8 +2749,9 @@ test('CLI: bundle (JS) fails loudly when the oxc-parser dependency is missing', // exited 0 with no warning at all. The setup error must propagate with its // install hint instead. Exercised against a copy of stasis whose node_modules // carries only the zero-dep @exodus/stasis-core (so the moved-module shims - // resolve and the bundle command loads) but no oxc-parser, so the lazy lookup - // (createRequire from src/scan.js) genuinely misses. + // resolve) and @preventive/lockfile (whose TOML parser the loaders import), so + // the bundle command loads, but no oxc-parser, so the lazy lookup (createRequire + // from src/scan.js) genuinely misses. const stasisCopy = join(tmp, 'stasis') mkdirSync(stasisCopy) for (const entry of ['bin', 'src']) cpSync(join(here, '..', 'stasis', entry), join(stasisCopy, entry), { recursive: true }) @@ -2761,6 +2762,8 @@ test('CLI: bundle (JS) fails loudly when the oxc-parser dependency is missing', mkdirSync(coreDest, { recursive: true }) for (const entry of ['bin', 'src']) cpSync(join(here, '..', 'stasis-core', entry), join(coreDest, entry), { recursive: true }) cpSync(join(here, '..', 'stasis-core', 'package.json'), join(coreDest, 'package.json')) + // pnpm links it from its store: the real directory is what gets copied. + cpSync(realpathSync(join(here, '..', 'stasis', 'node_modules', '@preventive', 'lockfile')), join(stasisCopy, 'node_modules', '@preventive', 'lockfile'), { recursive: true }) const proj = join(tmp, 'proj') mkdirSync(proj) jsProject(proj, { 'file.mjs': 'export * from "@noble/ciphers/_arx.js"\n' }) diff --git a/tests/rust-features.test.js b/tests/rust-features.test.js index e12cb89f..5b85578e 100644 --- a/tests/rust-features.test.js +++ b/tests/rust-features.test.js @@ -96,11 +96,11 @@ test('parseCargoManifest reads a pair by the table it lands in, whichever way th test('parseCargoManifest and parseCargoLock refuse text that is not TOML, naming the file and line', (t) => { t.assert.throws(() => parseCargoManifest('[package]\nname = "app"\nversion = 0.1.0\n', 'crates/app/Cargo.toml'), { - name: 'TomlError', message: 'crates/app/Cargo.toml:3: invalid value "0.1.0"', + name: 'TomlError', message: 'crates/app/Cargo.toml: expected a value, found "0.1.0" at line 3', }) - t.assert.throws(() => parseCargoManifest('[dependencies]\nserde = { version = "1", version = "2" }\n'), { message: 'line 2: duplicate key "dependencies.serde.version"' }) + t.assert.throws(() => parseCargoManifest('[dependencies]\nserde = { version = "1", version = "2" }\n'), { message: 'duplicate key "version" at line 2' }) t.assert.throws(() => parseCargoLock('version = 3\n\n[[package]]\nname = "app"\nversion = "0.1.0"\ndependencies = ["a" "b"]\n', 'Cargo.lock'), { - name: 'TomlError', message: 'Cargo.lock:6: expected a comma or "]" after the array item', + name: 'TomlError', message: 'Cargo.lock: expected "," or "]", found "\\"b\\"]" at line 6', }) // through the context, with the manifest's project-relative path const tmp = mkdtempSync(join(tmpdir(), 'stasis-toml-')) @@ -109,7 +109,7 @@ test('parseCargoManifest and parseCargoLock refuse text that is not TOML, naming writeFileSync(join(tmp, 'Cargo.toml'), '[workspace]\nmembers = ["crates/app"]\n') writeFileSync(join(tmp, 'crates', 'app', 'Cargo.toml'), '[package]\nname = "app"\nversion = "0.1.0\n') writeFileSync(join(tmp, 'crates', 'app', 'src', 'main.rs'), 'fn main() {}\n') - t.assert.throws(() => createCargoContext(tmp).packageInfo('crates/app/src/main.rs'), { name: 'TomlError', message: 'crates/app/Cargo.toml:3: unterminated string' }) + t.assert.throws(() => createCargoContext(tmp).packageInfo('crates/app/src/main.rs'), { name: 'TomlError', message: 'crates/app/Cargo.toml: unterminated string at line 3', line: 2 }) } finally { rmSync(tmp, { recursive: true, force: true }) } diff --git a/tests/solidity-loader.test.js b/tests/solidity-loader.test.js index fbe5e89f..7980ade0 100644 --- a/tests/solidity-loader.test.js +++ b/tests/solidity-loader.test.js @@ -523,6 +523,15 @@ test('foundryProject honours auto_detect_remappings = false and `extends`', with t.assert.deepEqual(foundryProject(dir, { env: {} }).files.toSorted(), ['base.toml', 'foundry.toml']) })) +test('foundryProject reads a profile\'s sub-tables however they are spelled: `extends` as a table, a `no-collision` over `fuzz`', withProject({ + 'foundry.toml': '[profile.default]\nremappings = ["@local/=lib/local/"]\n\n[profile.default.extends]\npath = "base.toml"\nstrategy = "no-collision"\n\n[profile.default.fuzz]\nruns = 1\n', + 'base.toml': '[profile.default]\nfuzz = { runs = 2 }\n', + 'lib/forge-std/src/Test.sol': '', +}, (t, dir) => { + // forge compares the profile's keys, sub-tables included: `fuzz` is set on both sides. + t.assert.throws(() => foundryProject(dir, { env: {} }), { message: /key collision in profile 'default' when extending base\.toml: fuzz$/u }) +})) + test('discoverSolidityConfig: --mapping takes exactly that file; no foundry.toml falls back to remappings.txt', withProject({ 'foundry.toml': '[profile.default]\n', 'mapping.txt': '@m/=lib/m/\nforge-std=lib/forge-std/src\nconsole.sol=lib/forge-std/src/console.sol\n', @@ -608,7 +617,7 @@ test('foundry.toml profiles: legacy [] tables, case-insensitive names, quo }) test('foundryTomlRemappings names the line of a foundry.toml that isn\'t TOML', (t) => { - t.assert.throws(() => foundryTomlRemappings('[profile.default]\nremappings = ["a/=b/"\n'), { name: 'TomlError', message: 'line 2: unterminated array' }) + t.assert.throws(() => foundryTomlRemappings('[profile.default]\nremappings = ["a/=b/"\n'), { name: 'TomlError', message: 'expected "," or "]", found the end of the text at line 3' }) }) test('resolveSolImport refuses a non-.sol target, one outside the root, and a dependency reaching the project', withProject({ diff --git a/tests/toml.test.js b/tests/toml.test.js index ce4d9593..309ea53a 100644 --- a/tests/toml.test.js +++ b/tests/toml.test.js @@ -1,158 +1,42 @@ import { test } from 'node:test' -import { TomlError, tomlEntries } from '../stasis/src/loaders/toml.js' +import { TomlError, isTomlTable, readToml } from '../stasis/src/loaders/toml.js' -// The value of `v = `. -const value = (text) => tomlEntries(`v = ${text}\n`)[0].value +// The parser is @preventive/lockfile's, tested there; these pin what the loaders rely on. -// `text` is refused with a TomlError whose message matches `message`, at `line` (1-based). -const refuses = (t, text, message, line = 1) => t.assert.throws(() => tomlEntries(text), { name: 'TomlError', message, line }) - -test('tomlEntries refuses what is not TOML, naming the file and line', (t) => { - // strings - refuses(t, 's = "\\uD800"', /"\\uD800" is not a Unicode scalar value/u) - refuses(t, 's = "\\U00110000"', /"\\U00110000" is not a Unicode scalar value/u) - refuses(t, 's = "a\\qb"', /invalid escape "\\q"/u) - refuses(t, 's = "a\\eb"', /invalid escape "\\e"/u) - refuses(t, 's = "\\u12"', /invalid escape "\\u12"/u) - refuses(t, 's = "abc', /unterminated string/u) - refuses(t, "s = 'abc", /unterminated string/u) - refuses(t, 's = """abc\n\n', /unterminated string/u) - refuses(t, 'x = 1\ns = "abc\n', /unterminated string/u, 2) - refuses(t, 's = "a\u0001b"', /control character in a string/u) - refuses(t, "s = 'a\rb'", /unterminated string/u) - // trailing text - refuses(t, 's = "a" junk', /unexpected text after the value/u) - refuses(t, 'n = 1 b = 2', /unexpected text after the value/u) - refuses(t, '[a] junk', /unexpected text after the table header/u) - refuses(t, '[a', /expected "\]" to close the table header/u) - refuses(t, '[[a]', /expected "\]\]" to close the table header/u) - // arrays and inline tables - refuses(t, 'a = ["a" "b"]', /expected a comma or "\]" after the array item/u) - refuses(t, 'a = ["a", b]', /invalid value "b"/u) - refuses(t, 'a = [1,,2]', /expected a value/u) - refuses(t, 'a = [\n 1,\n 2', /unterminated array/u) - refuses(t, 't = { a = 1, a = 2 }', /duplicate key "t\.a"/u) - refuses(t, 't = { a = "x"', /unterminated inline table/u) - refuses(t, 't = { a = 1, }', /trailing comma in an inline table/u) - refuses(t, 't = { a = 1\n}', /unterminated inline table/u) - refuses(t, 't = { a = 1 b = 2 }', /expected a comma or "\}" after the inline table entry/u) - // keys and tables defined twice - refuses(t, 'a = 1\na = 2', /duplicate key "a"/u, 2) - refuses(t, '[a]\nx = 1\n[a]\ny = 2', /duplicate table \[a\]/u, 3) - refuses(t, '[a]\n[a.b]\n[a]', /duplicate table \[a\]/u, 3) - refuses(t, 'a.b = 1\n[a]', /duplicate table \[a\]/u, 2) - refuses(t, '[a]\nb.c = 1\n[a.b]', /duplicate table \[a\.b\]/u, 3) - refuses(t, '[a.b]\n[a]\nb.c = 1', /table \[a\.b\] is already defined by a header/u, 3) - refuses(t, 'a = 1\na.b = 2', /"a" is not a table/u, 2) - refuses(t, 'a = { b = 1 }\na.c = 2', /"a" is not a table/u, 2) - refuses(t, 'a = {}\n[a.b]', /"a" is not a table/u, 2) - refuses(t, 'a = 1\n[a]', /"a" is already defined as a value/u, 2) - refuses(t, '[[a]]\n[a]', /duplicate table \[a\]/u, 2) - refuses(t, '[a]\n[[a]]', /"a" is not an array of tables/u, 2) - refuses(t, 'a = []\n[[a]]', /"a" is already defined as a value/u, 2) - // malformed keys and values - refuses(t, '= 1', /expected a key/u) - refuses(t, 'a', /expected "=" after the key/u) - refuses(t, 'a =', /expected a value/u) - refuses(t, '[]', /expected a key/u) - refuses(t, '[a.]', /expected a key/u) - refuses(t, 'n = 01', /invalid value "01"/u) - refuses(t, 'n = 1__0', /invalid value "1__0"/u) - refuses(t, 'n = 1_', /invalid value "1_"/u) - refuses(t, 'f = 1.', /invalid value "1\."/u) - refuses(t, 'f = .5', /invalid value "\.5"/u) - refuses(t, 'n = -0x1', /invalid value "-0x1"/u) - refuses(t, 'b = True', /invalid value "True"/u) - refuses(t, 'd = 1979-02-30', /invalid date "1979-02-30"/u) - refuses(t, 'd = 1979-05-27T25:00:00', /invalid date/u) - refuses(t, 'd = 07:60:00', /invalid time "07:60:00"/u) - refuses(t, 'a = 1 # c\u0001', /control character in a comment/u) - refuses(t, 'a = 1\rb = 2', /unexpected text after the value/u) - // the file and line are in the message and on the error - t.assert.throws(() => tomlEntries('[a]\n\nb = "x', { file: 'foundry.toml' }), (err) => { +test('readToml names the file and line of text that is not TOML', (t) => { + t.assert.throws(() => readToml('[a]\n\nb = "x', 'foundry.toml'), (err) => { t.assert.ok(err instanceof TomlError) - t.assert.equal(err.message, 'foundry.toml:3: unterminated string') - t.assert.deepEqual([err.file, err.line], ['foundry.toml', 3]) + t.assert.equal(err.message, 'foundry.toml: unterminated string at line 3') + t.assert.equal(err.line, 2) // counting from zero, as the parser's own error does return true }) - t.assert.throws(() => tomlEntries('b = "x'), { message: 'line 1: unterminated string' }) -}) - -test('tomlEntries reads TOML values as JavaScript ones', (t) => { - // numbers - t.assert.deepEqual(['3', '-17', '+0', '1_000', '0x1f', '0xDEAD_beef', '0o17', '0b1010', '6.626e-34', '1e3', '-2.5', '1_0.0_1e1_0'].map(value), [3, -17, 0, 1000, 31, 0xdeadbeef, 15, 10, 6.626e-34, 1000, -2.5, 10.01e10]) - t.assert.deepEqual(['inf', '+inf', '-inf'].map(value), [Infinity, Infinity, -Infinity]) - t.assert.ok(Number.isNaN(value('nan'))) - t.assert.equal(value('9223372036854775807'), 9223372036854775807n) // past 2^53: a BigInt keeps it exact - t.assert.equal(value('9007199254740991'), 9007199254740991) - t.assert.deepEqual(value('[1, "a", true]'), [1, 'a', true]) - // booleans - t.assert.deepEqual([value('true'), value('false')], [true, false]) - // strings - t.assert.equal(value('"a\\"b\\\\c\\td\\u00e9\\U0001F600"'), 'a"b\\c\td\u00e9\u{1F600}') - t.assert.equal(value("'C:\\path\\\"quoted\"'"), 'C:\\path\\"quoted"') - t.assert.equal(value('"""\nRoses are red\nViolets are blue"""'), 'Roses are red\nViolets are blue') - t.assert.equal(value('"""The quick brown \\\n\n fox jumps over \\\n the lazy dog."""'), 'The quick brown fox jumps over the lazy dog.') - t.assert.equal(value('"""Here are fifteen quotation marks: ""\\"""\\"""\\"""\\"""\\"."""'), 'Here are fifteen quotation marks: """"""""""""""".') - t.assert.equal(value('"""a""""'), 'a"') - t.assert.equal(value("'''\n'a' and ''b''\n'''"), "'a' and ''b''\n") - t.assert.equal(value("''''a''''"), "'a'") - // arrays: over lines, with comments and a trailing comma - t.assert.deepEqual(value('[\n "a", # first\n # between\n "b-c",\n \'d\',\n]'), ['a', 'b-c', 'd']) - t.assert.deepEqual(value('[]'), []) - t.assert.deepEqual(value('[[1, 2], [{ x = 1 }, { x = 2 }]]'), [[1, 2], [{ x: 1 }, { x: 2 }]]) - // inline tables, dotted keys inside them - t.assert.deepEqual(value('{ version = "1", features = ["x", "y"], optional = true, default-features = false }'), { - version: '1', features: ['x', 'y'], optional: true, 'default-features': false, - }) - t.assert.deepEqual(value('{ a.b = 1, a.c = 2, "d.e" = { f = 3 } }'), { a: { b: 1, c: 2 }, 'd.e': { f: 3 } }) - t.assert.deepEqual(value('{}'), {}) - t.assert.equal(Object.getPrototypeOf(value('{ __proto__ = 1 }')), null) // a key is only a key - // dates and times, checked, kept as their text - t.assert.deepEqual(['1979-05-27', '1979-05-27T07:32:00Z', '1979-05-27 00:32:00.999-07:00', '07:32:00', '2000-02-29'].map(value), [ - '1979-05-27', '1979-05-27T07:32:00Z', '1979-05-27 00:32:00.999-07:00', '07:32:00', '2000-02-29', - ]) + t.assert.throws(() => readToml('b = "x'), { name: 'TomlError', message: 'unterminated string at line 1' }) }) -test('tomlEntries yields headers and pairs with their table paths and line spans', (t) => { - const text = [ - '# a comment', 'top = 1', '', '[a] # trailing', - 'x.y = "z"', 'list = [', ' 1,', ' 2,', ']', - '[[arr]]', 'name = "one"', '[[arr]]', 'name = "two"', '[arr.sub]', 'k = 2', - '[profile."ci.fast"]', 'remappings = ["@f/=lib/\\u0066/"]', '[a.deeper]', 'w = true', '', - ].join('\n') - t.assert.deepEqual(tomlEntries(text), [ - { path: ['top'], header: false, value: 1, first: 1, last: 1 }, - { path: ['a'], header: true, first: 3, last: 3 }, - { path: ['a', 'x', 'y'], header: false, value: 'z', first: 4, last: 4 }, - { path: ['a', 'list'], header: false, value: [1, 2], first: 5, last: 8 }, - { path: ['arr'], header: true, first: 9, last: 9 }, - { path: ['arr', 'name'], header: false, value: 'one', first: 10, last: 10 }, - { path: ['arr'], header: true, first: 11, last: 11 }, - { path: ['arr', 'name'], header: false, value: 'two', first: 12, last: 12 }, - { path: ['arr', 'sub'], header: true, first: 13, last: 13 }, - { path: ['arr', 'sub', 'k'], header: false, value: 2, first: 14, last: 14 }, - { path: ['profile', 'ci.fast'], header: true, first: 15, last: 15 }, - { path: ['profile', 'ci.fast', 'remappings'], header: false, value: ['@f/=lib/f/'], first: 16, last: 16 }, - { path: ['a', 'deeper'], header: true, first: 17, last: 17 }, - { path: ['a', 'deeper', 'w'], header: false, value: true, first: 18, last: 18 }, - ]) - // CRLF line ends, a leading BOM, and an empty document - t.assert.deepEqual(tomlEntries('\uFEFFa = 1\r\n[t]\r\nb = "x" # c\r\n').map((e) => e.path.join('.')), ['a', 't', 't.b']) - t.assert.deepEqual(tomlEntries(''), []) - t.assert.deepEqual(tomlEntries('# only a comment\n\n'), []) +test('readToml refuses what the build descriptions the loaders read may not hold', (t) => { + const refuses = (text, message) => t.assert.throws(() => readToml(text, 'Cargo.toml'), { name: 'TomlError', message }) + refuses('a = 1\na = 2\n', /^Cargo\.toml: duplicate key "a" at line 2$/u) + refuses('[a]\nx = 1\n[a]\n', /^Cargo\.toml: .* at line 3$/u) + refuses('d = 1979-05-27\n', /^Cargo\.toml: .* at line 1$/u) // a local date: in no file the loaders read + refuses('a = 1\n', /^Cargo\.toml: a byte order mark is not read at line 1$/u) + refuses('a = "�"\n', /^Cargo\.toml: U\+FFFD is not supported/u) // where a lenient decoder replaced bytes }) -test('tomlEntries applies TOML\'s rules on what may be defined again', (t) => { - // a super-table may follow its sub-tables; dotted keys may open sub-tables a later header extends - t.assert.equal(tomlEntries('[a.b]\nx = 1\n[a]\ny = 2\n').length, 4) - t.assert.equal(tomlEntries('[fruit]\napple.color = "red"\napple.taste.sweet = true\n[fruit.apple.texture]\nsmooth = true\n').length, 5) - t.assert.equal(tomlEntries('a.b = 1\n[a.c]\nd = 2\n').length, 3) - // each `[[x]]` item is its own table: the same sub-tables and keys under each - t.assert.equal(tomlEntries('[[a]]\nx = 1\n[a.b]\ny = 2\n[[a]]\nx = 3\n[a.b]\ny = 4\n').length, 8) - // a table opened implicitly by a header can't become an array of tables, and vice versa - refuses(t, '[a.b]\n[[a]]', /"a" is not an array of tables/u, 2) - refuses(t, '[[a]]\n[a.b]\n[[a.b]]', /"a\.b" is not an array of tables/u, 3) - refuses(t, '[a.b.c]\n[a]\nb.d = 1\n[a.b]', /duplicate table \[a\.b\]/u, 4) +test('readToml gives the table tree the loaders walk', (t) => { + const doc = readToml([ + '[package]', 'name = "app"', 'version.workspace = true', '', + '[dependencies]', 'serde = { version = "1", features = ["derive"] }', '', + '[dependencies.log]', 'version = "0.4"', '', + '[[package.metadata.list]]', 'n = 1', '[[package.metadata.list]]', 'n = 9223372036854775807', '', + '[profile]', 'f = 1.0', '__proto__ = "a key like any other"', '', + ].join('\n')) + t.assert.ok(isTomlTable(doc) && isTomlTable(doc.package) && isTomlTable(doc.package.version)) + t.assert.equal(Object.getPrototypeOf(doc.dependencies.serde), null) + // however a table is spelled out, it is one table + t.assert.deepEqual(JSON.parse(JSON.stringify(doc.dependencies)), { serde: { version: '1', features: ['derive'] }, log: { version: '0.4' } }) + t.assert.deepEqual(doc.package.metadata.list.map((x) => x.n), [1, 9223372036854775807n]) // past 2^53: a BigInt + t.assert.deepEqual([doc.profile.f.text, Number(doc.profile.f), isTomlTable(doc.profile.f)], ['1.0', 1, false]) // a float is an object, not a table + t.assert.equal(doc.profile.__proto__, 'a key like any other') + t.assert.equal(isTomlTable([]), false) })