From 6edbbf108ac820f2e8cfb3efcbc0dc70c3b7d36d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 22:43:36 +0000 Subject: [PATCH] fix(bundle): read TOML with @preventive/lockfile's parser Adds @preventive/lockfile 1.0.0-alpha.1 and reads Cargo.toml, Cargo.lock and foundry.toml with its strict TOML parser (parseToml) in place of the reader loaders/toml.js carried since #179. The readers walk the parsed table tree: the dotted, inline and header spellings of a table are one table by construction. loaders/toml.js keeps readToml (the parser, with the file named in its errors), isTomlTable and the bracket helpers the Rust scanner shares. The parser refuses what those files are never written in, on top of what isn't TOML: local dates and times, a byte order mark, U+FFFD where a lenient decoder replaced bytes, a dotted key through a table a header made (Cargo's toml crate refuses it too). A foundry.toml profile's sub-tables are its values whichever way they are written, so a `no-collision` extends sees a `[profile.default.fuzz]` table as forge does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL --- doc/file-formats.md | 6 +- pnpm-lock.yaml | 9 + stasis/package.json | 1 + stasis/src/loaders/cargo.js | 143 +++++----- stasis/src/loaders/foundry.js | 44 ++-- stasis/src/loaders/toml.js | 480 ++-------------------------------- tests/bundle-cmd.test.js | 9 +- tests/rust-features.test.js | 8 +- tests/solidity-loader.test.js | 11 +- tests/toml.test.js | 176 +++---------- 10 files changed, 173 insertions(+), 714 deletions(-) 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) })