Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion doc/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions stasis/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
143 changes: 70 additions & 73 deletions stasis/src/loaders/cargo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand All @@ -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.<registry>] crate = { path = "…" }` or `[patch.<registry>.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.<cfg>` 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.<registry>] 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.<cfg>` 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
}

Expand All @@ -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()
Expand Down
44 changes: 16 additions & 28 deletions stasis/src/loaders/foundry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -407,37 +407,25 @@ const STANDALONE_SECTIONS = new Set([

// foundry.toml -> `{ profiles, topLevel }`. `profiles` is Map<profile, Map<key, value>> (profile
// names lowercased, keys snake_cased as forge does) from the `[profile.<name>]` tables and the
// legacy top-level `[<name>]` 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 `[<name>]` 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]))
Expand Down
Loading
Loading