@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,923 @@
1
+ // Foundry configuration for the Solidity loader: foundry.toml profiles (with `extends`) and the
2
+ // remappings `forge build` hands solc -- the `FOUNDRY_REMAPPINGS`/`DAPP_REMAPPINGS` env var,
3
+ // remappings.txt, the profile's `remappings`, those of every dependency that is itself a Foundry
4
+ // project, and the ones forge auto-detects under the `libs` dirs (global, plus per-dependency
5
+ // contexts for an alias two dependencies map differently). A port of foundry v1.8.3's
6
+ // `RemappingsProvider` (crates/config/src/providers/remappings.rs) and foundry-compilers'
7
+ // `Remapping::find_many_with_context` (artifacts/solc/src/remappings/find.rs), including the
8
+ // `forge build` pass that drops aliases of the project's own src/test/script dirs.
9
+ //
10
+ // Paths are absolute POSIX strings internally, compared the way Rust compares `Path`s (by
11
+ // component: a trailing `/` doesn't count), and returned relative to the project root. Not read:
12
+ // the global ~/.foundry/foundry.toml, `FOUNDRY_CONFIG`, and the FOUNDRY_*/DAPP_* overrides of
13
+ // other keys (`FOUNDRY_PROFILE` and the remapping env vars are). The project is read through a
14
+ // `host` (@exodus/stasis-core/host), the disk's by default.
15
+
16
+ import { posix, resolve } from 'node:path'
17
+
18
+ import { diskHost } from '@exodus/stasis-core/host'
19
+ import { toPosix } from '@exodus/stasis-core/util'
20
+ import { isDir } from '../resolve-typescript.js'
21
+ import { projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull } from './solidity-ownership.js'
22
+ import { isTomlTable, readToml } from './toml.js'
23
+
24
+ export const FOUNDRY_TOML = 'foundry.toml'
25
+ export const REMAPPINGS_TXT = 'remappings.txt'
26
+
27
+ // --- Path helpers (Rust `Path` semantics on POSIX strings) --------------------------------
28
+
29
+ const isAbs = (p) => p.startsWith('/')
30
+ const normalComps = (p) => p.split('/').filter((c) => c !== '' && c !== '.')
31
+ const compCount = (p) => normalComps(p).length + (isAbs(p) ? 1 : 0)
32
+ const fileName = (p) => normalComps(p).at(-1) ?? null
33
+ const pathKey = (p) => (isAbs(p) ? '/' : '') + normalComps(p).join('/')
34
+ const pathEq = (a, b) => pathKey(a) === pathKey(b)
35
+ // `Path::ends_with` with a relative suffix: the last components match.
36
+ const pathEndsWith = (p, suffix) => {
37
+ const a = normalComps(p)
38
+ const b = normalComps(suffix)
39
+ return b.length <= a.length && b.every((c, i) => a[a.length - b.length + i] === c)
40
+ }
41
+ // `Path::strip_prefix`: the remaining components (no trailing `/`), or null.
42
+ function stripPrefix(p, base) {
43
+ if (isAbs(p) !== isAbs(base)) return null
44
+ const a = normalComps(p)
45
+ const b = normalComps(base)
46
+ if (b.length > a.length || !b.every((c, i) => a[i] === c)) return null
47
+ return a.slice(b.length).join('/')
48
+ }
49
+ const pathStartsWith = (p, base) => stripPrefix(p, base) !== null
50
+ // `PathBuf::join`: an absolute `p` replaces; otherwise appended with one separator.
51
+ const rustJoin = (base, p) => (isAbs(p) ? p : base.endsWith('/') ? `${base}${p}` : `${base}/${p}`)
52
+ const parentOf = (p) => {
53
+ const c = normalComps(p)
54
+ if (c.length === 0) return null
55
+ return (isAbs(p) ? '/' : '') + c.slice(0, -1).join('/')
56
+ }
57
+ const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
58
+ // `Ord for Path`: component by component.
59
+ function cmpPath(a, b) {
60
+ if (isAbs(a) !== isAbs(b)) return isAbs(a) ? -1 : 1
61
+ const x = normalComps(a)
62
+ const y = normalComps(b)
63
+ for (let i = 0; i < Math.min(x.length, y.length); i++) {
64
+ const c = cmpStr(x[i], y[i])
65
+ if (c !== 0) return c
66
+ }
67
+ return x.length - y.length
68
+ }
69
+
70
+ function canonicalize(p, host) {
71
+ const real = realpathOrNull(p, host)
72
+ return real === null ? null : toPosix(real)
73
+ }
74
+
75
+ // How messages name a file of the project at `root` (absolute POSIX, read through `host`): from the
76
+ // root, by its lexical or its canonical path, else as given.
77
+ export function shownFrom(root, host = diskHost, canonicalRoot = canonicalize(root, host) ?? root) {
78
+ return (abs) => stripPrefix(abs, root) || stripPrefix(abs, canonicalRoot) || abs
79
+ }
80
+
81
+ const isSymlinkPath = (p, host) => {
82
+ try {
83
+ return host.readlink(p) !== null
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ // A dir's entries with their (symlink-followed) kind, in the filesystem's own order where the host
90
+ // has one (readdirUnsorted): forge walks `read_dir` unsorted, and where two packages share a lib
91
+ // window the first one listed decides a remapping. An entry whose symlink doesn't resolve is dropped.
92
+ function listDir(dir, host) {
93
+ let entries
94
+ try {
95
+ entries = host.readdirUnsorted ? host.readdirUnsorted(dir) : host.readdir(dir)
96
+ } catch {
97
+ return []
98
+ }
99
+ const out = []
100
+ for (const e of entries) {
101
+ const path = rustJoin(dir, e.name)
102
+ const kind = e.isSymbolicLink() ? host.stat(path) : e
103
+ if (kind === null) continue
104
+ out.push({ path, name: e.name, isFile: kind.isFile(), isDir: kind.isDirectory(), isSymlink: e.isSymbolicLink() })
105
+ }
106
+ return out
107
+ }
108
+
109
+ // The auto-detection's view of a dir: hidden entries skipped.
110
+ const readDir = (dir, host) => listDir(dir, host).filter((e) => !e.name.startsWith('.'))
111
+
112
+ // --- Remapping values ----------------------------------------------------------------------
113
+
114
+ // Rust's `str::trim`: Unicode White_Space only, so a byte-order mark (U+FEFF, which JS's `trim`
115
+ // takes) stays, as it does for forge.
116
+ const rustTrim = (s) => s.replaceAll(/^\p{White_Space}+|\p{White_Space}+$/gu, '')
117
+
118
+ // `[context:]name=path`, as forge (`Remapping::from_str`) and solc split it: at the first `=`, then
119
+ // the first `:` before it. An empty context is global; an empty name or path is invalid (null),
120
+ // but for solc (`emptyPath`) only an empty name is: `x/=` maps `x/A.sol` to `A.sol`.
121
+ function parseRemapping(entry, { emptyPath = false } = {}) {
122
+ const eq = entry.indexOf('=')
123
+ if (eq === -1) return null
124
+ let name = entry.slice(0, eq)
125
+ const path = entry.slice(eq + 1)
126
+ let context = null
127
+ const colon = name.indexOf(':')
128
+ if (colon !== -1) {
129
+ context = name.slice(0, colon)
130
+ name = name.slice(colon + 1)
131
+ }
132
+ if (rustTrim(name) === '' || (!emptyPath && rustTrim(path) === '')) return null
133
+ if (context !== null && rustTrim(context) === '') context = null
134
+ return { context, name, path }
135
+ }
136
+
137
+ // What an invalid remapping should have been; errors name where one is, never its text, which may
138
+ // be anything (a file named as a mapping by mistake, a secret included).
139
+ const REMAPPING_FORM = 'expected [context:]prefix=target'
140
+
141
+ // A remappings.txt / env var body: one remapping per non-blank (trimmed) line. A line that isn't
142
+ // one throws, naming `label` (the file or variable) and the line, as forge and solc refuse the
143
+ // file. `emptyPath`: see parseRemapping.
144
+ export function parseRemappingLines(text, { label = 'remappings', emptyPath = false } = {}) {
145
+ const out = []
146
+ text.split('\n').forEach((raw, i) => {
147
+ const line = rustTrim(raw)
148
+ if (line === '') return
149
+ const r = parseRemapping(line, { emptyPath })
150
+ if (r === null) throw new Error(`${label}:${i + 1}: invalid remapping, ${REMAPPING_FORM}`)
151
+ out.push(r)
152
+ })
153
+ return out
154
+ }
155
+
156
+ // A foundry.toml's `remappings` value, parsed. One forge rejects -- not an array of strings, or an
157
+ // entry that isn't `[context:]name=path` -- throws, naming `file` when given and the entry (from 1).
158
+ function configRemappings(value, file) {
159
+ const where = `${file === null ? '' : `${file}: `}\`remappings\``
160
+ if (!Array.isArray(value)) throw new Error(`${where} is not an array of strings`)
161
+ return value.map((entry, i) => {
162
+ if (typeof entry !== 'string') throw new Error(`${where} entry ${i + 1} is not a string`)
163
+ const r = parseRemapping(entry)
164
+ if (r === null) throw new Error(`${where} entry ${i + 1}: invalid remapping, ${REMAPPING_FORM}`)
165
+ return r
166
+ })
167
+ }
168
+
169
+ // A config forge refuses for its settings (a missing or nested `extends`, colliding keys) or that a
170
+ // dependency may not read (a link out of it): forge skips such a dependency's config, and so does
171
+ // loadNestedConfig. Anything else wrong with a config -- text that isn't TOML, an invalid
172
+ // remapping -- is another error, and fatal.
173
+ class ConfigRefused extends Error {}
174
+
175
+ // Forge's trailing `/` on a remapping's name and path, unless they end in `/` or `.sol`.
176
+ const withSlash = (s) => (s.endsWith('/') || s.endsWith('.sol') ? s : `${s}/`)
177
+
178
+ // A remapping as forge hands it to solc, in the loader's `{ context, prefix, target }` shape:
179
+ // `forge-std=lib/forge-std/src` is `forge-std/=lib/forge-std/src/`.
180
+ export const toSolcRemapping = (r) => ({ context: r.context, prefix: withSlash(r.name), target: withSlash(r.path) })
181
+
182
+ // The profile forge selects: FOUNDRY_PROFILE, else `default`. Profile names are case-insensitive
183
+ // (figment's `Profile`), so they are compared lowercased.
184
+ export const foundryProfile = (env) => (env.FOUNDRY_PROFILE || 'default').toLowerCase()
185
+
186
+ // `RelativeRemappingPathBuf::with_root`.
187
+ function withRoot(parent, path) {
188
+ const rest = stripPrefix(path, parent)
189
+ if (rest !== null) return { parent, path: rest }
190
+ if (isAbs(path)) return { parent: null, path }
191
+ return { parent, path }
192
+ }
193
+
194
+ // `RelativeRemapping::new(remapping, root)`.
195
+ function toRelative(r, root) {
196
+ return {
197
+ context: r.context === null ? null : withRoot(root, r.context).path,
198
+ name: r.name,
199
+ path: withRoot(root, r.path),
200
+ }
201
+ }
202
+
203
+ // `From<RelativeRemapping> for Remapping`: the path joined back onto its parent, and a trailing
204
+ // `/` on name and path unless they end in `/` or `.sol`.
205
+ function fromRelative(rr) {
206
+ const { parent, path } = rr.path
207
+ const joined = isAbs(path) || parent === null ? path : rustJoin(parent, path)
208
+ return { context: rr.context, name: withSlash(rr.name), path: withSlash(joined) }
209
+ }
210
+
211
+ // `relative_remapping_preserving_context_boundary`: relative to `root`, keeping a context's
212
+ // trailing `/` (it bounds the directory the context names).
213
+ function relativePreservingBoundary(r, root) {
214
+ const rr = toRelative(r, root)
215
+ if (r.context?.endsWith('/') && rr.context !== null) rr.context = withTrailing(rr.context)
216
+ return rr
217
+ }
218
+
219
+ // `Display for RelativeRemapping`.
220
+ function displayRelative(rr) {
221
+ const s = `${rr.context === null ? '' : `${rr.context}:`}${rr.name}=${rr.path.path}`
222
+ return withSlash(s)
223
+ }
224
+
225
+ // `RelativeRemapping` equality (paths by component).
226
+ const relKey = (rr) => `${rr.context}\0${rr.name}\0${rr.path.parent === null ? '\u0001' : pathKey(rr.path.parent)}\0${pathKey(rr.path.path)}`
227
+
228
+ // `Remappings`: a list that only takes an alias not already claimed (in the same context) by an
229
+ // equal or shorter one, and never an alias of the project's own src/test/script dirs.
230
+ class Remappings {
231
+ constructor(remappings = [], projectPaths = []) {
232
+ this.remappings = remappings
233
+ this.projectPaths = projectPaths
234
+ }
235
+
236
+ push(r) {
237
+ if (r.name.endsWith('.sol') && !r.path.endsWith('.sol')) return
238
+ const conflicting = this.remappings.some((e) => {
239
+ if (r.name.endsWith('.sol')) return e.name === r.name && e.context === r.context && e.path === r.path
240
+ return r.name.startsWith(withTrailing(e.name)) && e.context === r.context
241
+ })
242
+ if (conflicting) return
243
+ if (this.projectPaths.some((p) => p.toLowerCase() === r.name.toLowerCase())) return
244
+ this.remappings.push(r)
245
+ }
246
+
247
+ // First of each (context, name).
248
+ intoInner() {
249
+ const seen = new Set()
250
+ return this.remappings.filter((r) => {
251
+ const key = `${r.context}\0${r.name}`
252
+ if (seen.has(key)) return false
253
+ seen.add(key)
254
+ return true
255
+ })
256
+ }
257
+ }
258
+
259
+ const withTrailing = (s) => (s.endsWith('/') ? s : `${s}/`)
260
+ const trimSlashes = (s) => s.replace(/\/+$/u, '')
261
+
262
+ // `remapping_name_is_prefix`: `prefix` names `name` or a parent namespace of it.
263
+ function nameIsPrefix(prefix, name) {
264
+ const p = trimSlashes(prefix)
265
+ const n = trimSlashes(name)
266
+ return p === n || (n.startsWith(p) && n.slice(p.length).startsWith('/'))
267
+ }
268
+
269
+ // --- Auto-detection (foundry-compilers find.rs) ---------------------------------------------
270
+
271
+ const SRC_DIR = 'src'
272
+ const JS_SRC_DIR = 'contracts'
273
+ const isSourceDir = (p) => [SRC_DIR, JS_SRC_DIR].includes(fileName(p))
274
+ const isLibName = (name) => name === 'lib' || name === 'node_modules'
275
+ const isLibDir = (p) => isLibName(fileName(p))
276
+ const noRecurse = (name) => name === 'tests' || name === 'test' || name === 'demo'
277
+
278
+ function dirDistance(root, current) {
279
+ const rest = stripPrefix(current, root)
280
+ return rest === null ? 0 : normalComps(rest).length
281
+ }
282
+
283
+ // The window a dir under the lib dir `root` belongs to: `root`'s child on the way to `current`.
284
+ // (Upstream loops `while !is_lib_dir(next) || !next.ends_with("contracts")`, which always holds on
285
+ // the first component.)
286
+ function nextNestedWindow(root, current) {
287
+ if (!isLibDir(root)) return root
288
+ const first = normalComps(stripPrefix(current, root) ?? '')[0]
289
+ return first === undefined ? root : rustJoin(root, first)
290
+ }
291
+
292
+ function lastNestedSourceDir(root, dir) {
293
+ if (isSourceDir(dir)) return dir
294
+ let p = dir
295
+ for (let parent = parentOf(p); parent !== null; parent = parentOf(p)) {
296
+ if (pathEq(parent, root)) return root
297
+ if (isSourceDir(parent)) return parent
298
+ p = parent
299
+ }
300
+ return root
301
+ }
302
+
303
+ const endsWithJsSource = (c) => fileName(c.sourceDir) === JS_SRC_DIR || pathEndsWith(c.sourceDir, 'contracts/src')
304
+
305
+ function mergeOnSameLevel(candidates, currentDir, level, windowStart, insideNodeModules) {
306
+ // A single `src` candidate wins outright.
307
+ const srcs = candidates.filter((c) => fileName(c.sourceDir) === SRC_DIR)
308
+ if (srcs.length === 1) {
309
+ candidates.splice(0, candidates.length, srcs[0])
310
+ return
311
+ }
312
+ // Else the current dir absorbs the candidates of its level (`current/{auth,tokens}/*.sol`).
313
+ for (let i = candidates.length - 1; i >= 0; i--) {
314
+ if (candidates[i].level === level) candidates.splice(i, 1)
315
+ }
316
+ const sourceDir = insideNodeModules ? windowStart : currentDir
317
+ // `<dep>/src/lib/` mistaken for a package of its own.
318
+ if (level > 0 && pathEq(sourceDir, windowStart) && (isSourceDir(sourceDir) || isLibDir(sourceDir))) return
319
+ candidates.push({ windowStart, sourceDir, level })
320
+ }
321
+
322
+ // Candidates below `currentDir`: a window opens at each `lib`/`node_modules` barrier, and a dir
323
+ // holding `.sol` files is a source dir of the window it lies in. Symlinked dirs are followed,
324
+ // except back into the current traversal path.
325
+ function findRemappingCandidates(currentDir, open, level, insideNodeModules, visited, host) {
326
+ let isCandidate = false
327
+ let current
328
+ const search = []
329
+ for (const e of readDir(currentDir, host)) {
330
+ if (!isCandidate && e.isFile && e.name.endsWith('.sol')) {
331
+ isCandidate = true
332
+ } else if (e.isDir) {
333
+ let seen = visited
334
+ if (e.isSymlink) {
335
+ const target = canonicalize(e.path, host)
336
+ if (target !== null) {
337
+ current ??= canonicalize(currentDir, host)
338
+ if (visited.has(target) || (current !== null && pathStartsWith(current, target))) continue
339
+ seen = new Set(visited).add(target)
340
+ }
341
+ }
342
+ if (!noRecurse(e.name)) search.push([e, seen])
343
+ }
344
+ }
345
+
346
+ const candidates = []
347
+ for (const [{ path, name }, seen] of search) {
348
+ candidates.push(...(isLibName(name)
349
+ ? findRemappingCandidates(path, path, level + 1, insideNodeModules, seen, host)
350
+ : findRemappingCandidates(path, open, level, insideNodeModules, seen, host)))
351
+ }
352
+
353
+ const windowStart = nextNestedWindow(open, currentDir)
354
+ if (isCandidate || candidates.filter((c) => c.level === level && pathEq(c.windowStart, windowStart)).length > 1) {
355
+ mergeOnSameLevel(candidates, currentDir, level, windowStart, insideNodeModules)
356
+ } else {
357
+ // A single nested candidate: `current/nested/contracts/c.sol` maps to `current`.
358
+ const c = candidates.find((x) => x.level === level)
359
+ if (c) {
360
+ const distance = dirDistance(c.windowStart, c.sourceDir)
361
+ if (distance > 1 && endsWithJsSource(c)) c.sourceDir = windowStart
362
+ else if (!isSourceDir(c.sourceDir) && !pathEq(c.sourceDir, c.windowStart)) c.sourceDir = lastNestedSourceDir(open, c.sourceDir)
363
+ }
364
+ }
365
+ return candidates
366
+ }
367
+
368
+ // Prefer the shorter path, then one ending in `src`.
369
+ function insertPrioritized(map, key, path) {
370
+ const existing = map.get(key)
371
+ if (existing === undefined || compCount(existing) > compCount(path) || (fileName(path) === SRC_DIR && fileName(existing) !== SRC_DIR)) {
372
+ map.set(key, path)
373
+ }
374
+ }
375
+
376
+ // The dependency owning a nested package window: the path before the last `lib`/`node_modules`
377
+ // component below `root` (null at the top level).
378
+ function dependencyOwner(root, windowStart) {
379
+ const rest = stripPrefix(windowStart, root)
380
+ if (rest === null) return null
381
+ const parts = normalComps(rest)
382
+ const barrier = parts.findLastIndex(isLibName)
383
+ return barrier > 0 ? parts.slice(0, barrier).reduce((p, c) => rustJoin(p, c), root) : null
384
+ }
385
+
386
+ const byContextDepth = (a, b) => {
387
+ const x = a.context ?? ''
388
+ const y = b.context ?? ''
389
+ return compCount(y) - compCount(x) || cmpStr(x, y)
390
+ }
391
+
392
+ // A map's entries, sorted by key with `cmp`.
393
+ const sortedEntries = (map, cmp) => [...map].toSorted(([a], [b]) => cmp(a, b))
394
+
395
+ // `Remapping::find_many_with_context(dir)`: `{ global, contextual }` remappings for the packages
396
+ // under a lib dir, `contextual` keyed by the dependency whose own lib dir holds the package.
397
+ export function findRemappingsWithContext(dir, host = diskHost) {
398
+ const insideNodeModules = fileName(dir) === 'node_modules'
399
+ const candidates = readDir(dir, host)
400
+ .filter((e) => e.isDir)
401
+ .flatMap((e) => findRemappingCandidates(e.path, e.path, 0, insideNodeModules, new Set(), host))
402
+ .toSorted((a, b) => cmpPath(a.sourceDir, b.sourceDir))
403
+
404
+ const global = new Map()
405
+ const contextual = new Map()
406
+ for (const c of candidates) {
407
+ const name = fileName(c.windowStart)
408
+ if (name === null) continue
409
+ const key = `${name}/`
410
+ const owner = dependencyOwner(dir, c.windowStart)
411
+ if (owner !== null) {
412
+ if (!contextual.has(owner)) contextual.set(owner, new Map())
413
+ insertPrioritized(contextual.get(owner), key, c.sourceDir)
414
+ }
415
+ insertPrioritized(global, key, c.sourceDir)
416
+ }
417
+ return {
418
+ global: sortedEntries(global, cmpStr).map(([name, path]) => ({ context: null, name, path: `${path}/` })),
419
+ contextual: sortedEntries(contextual, cmpPath)
420
+ .flatMap(([owner, map]) => sortedEntries(map, cmpStr).map(([name, path]) => ({ context: `${owner}/`, name, path: `${path}/` })))
421
+ .toSorted(byContextDepth),
422
+ }
423
+ }
424
+
425
+ // --- foundry.toml ---------------------------------------------------------------------------
426
+
427
+ const snakeCase = (k) => k.replaceAll(/([a-z0-9])([A-Z])/gu, '$1_$2').replaceAll('-', '_').toLowerCase()
428
+
429
+ // Top-level tables that are sections of their own, not (legacy) profiles (`Config::STANDALONE_SECTIONS`).
430
+ const STANDALONE_SECTIONS = new Set([
431
+ 'profile', 'external', 'rpc_endpoints', 'etherscan', 'fmt', 'lint', 'doc', 'fuzz', 'invariant', 'symbolic',
432
+ 'coverage', 'mutation', 'tracing', 'labels', 'dependencies', 'soldeer', 'vyper', 'bind_json',
433
+ ])
434
+
435
+ // foundry.toml -> `{ profiles, topLevel }`. `profiles` is Map<profile, Map<key, value>> (profile
436
+ // names lowercased, keys snake_cased as forge does) from the `[profile.<name>]` tables and the
437
+ // legacy top-level `[<name>]` ones forge still reads (not for `extends`), the former winning key
438
+ // by key; a profile's sub-tables are its values like any other (`extends`, `fuzz`: forge compares
439
+ // them all for a `no-collision` extends). `topLevel` holds the values set outside any table (forge
440
+ // rejects those; a `--mapping` file may list its `remappings` there). Throws a TomlError naming
441
+ // `file` on text that isn't TOML, as forge refuses the file.
442
+ function parseFoundryToml(text, file = null) {
443
+ const current = new Map()
444
+ const legacy = new Map()
445
+ const topLevel = new Map()
446
+ const read = (map, name, table) => {
447
+ const profile = name.toLowerCase()
448
+ const dict = map.get(profile) ?? map.set(profile, new Map()).get(profile)
449
+ for (const [key, value] of Object.entries(table)) {
450
+ const k = snakeCase(key)
451
+ if (k !== 'extends' || map === current) dict.set(k, value) // forge reads `extends` from `[profile.<name>]` only
452
+ }
453
+ }
454
+ for (const [key, value] of Object.entries(readToml(text, file))) {
455
+ if (!isTomlTable(value)) topLevel.set(snakeCase(key), value)
456
+ else if (key === 'profile') {
457
+ for (const [name, table] of Object.entries(value)) if (isTomlTable(table)) read(current, name, table)
458
+ } else if (!STANDALONE_SECTIONS.has(key)) read(legacy, key, value)
459
+ }
460
+ const profiles = new Map([...legacy].map(([name, dict]) => [name, new Map(dict)]))
461
+ for (const [name, dict] of current) profiles.set(name, new Map([...(profiles.get(name) ?? []), ...dict]))
462
+ return { profiles, topLevel }
463
+ }
464
+
465
+ // Figment's merge of an `extends` base under the local file: local keys win, and with the
466
+ // default `extend-arrays` strategy an array set on both sides is the base's followed by the local's.
467
+ function mergeExtended(base, local, strategy) {
468
+ const out = new Map([...base].map(([p, dict]) => [p, new Map(dict)]))
469
+ for (const [p, dict] of local) {
470
+ if (!out.has(p)) out.set(p, new Map())
471
+ const merged = out.get(p)
472
+ for (const [k, v] of dict) {
473
+ const prev = merged.get(k)
474
+ merged.set(k, strategy === 'extend-arrays' && Array.isArray(prev) && Array.isArray(v) ? [...prev, ...v] : v)
475
+ }
476
+ }
477
+ return out
478
+ }
479
+
480
+ // forge's `Extends`: a path, or `{ path, strategy? }`.
481
+ const EXTEND_STRATEGIES = new Set(['extend-arrays', 'replace-arrays', 'no-collision'])
482
+ const isExtends = (v) => typeof v === 'string'
483
+ || (isTomlTable(v) && typeof v.path === 'string' && (v.strategy === undefined || EXTEND_STRATEGIES.has(v.strategy)))
484
+
485
+ // A foundry.toml's profiles, with the selected profile's `extends` base merged in (forge's
486
+ // `TomlFileProvider`). `files` lists what was read; `topLevel` is the file's own (see
487
+ // parseFoundryToml). Throws where forge refuses the config, and where `refused` (a dependency's
488
+ // config: see findNestedFoundryRemappings) gives a reason not to read the file or its base: a
489
+ // dependency's config may not read the project's files. Messages name files `show(file)`.
490
+ function readFoundryProfiles(file, profile, { refused = () => null, show, host }) {
491
+ const name = show(file)
492
+ const refusal = refused(file)
493
+ if (refusal) throw new ConfigRefused(`${name}: refusing to read it: ${refusal}`)
494
+ const text = readUtf8OrNull(file, name, host)
495
+ if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] }
496
+ let { profiles, topLevel } = parseFoundryToml(text, name)
497
+ const files = [file]
498
+ const ext = profiles.get(profile)?.get('extends')
499
+ if (ext !== undefined) {
500
+ if (!isExtends(ext)) throw new Error(`${name}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`)
501
+ const { path: extPath, strategy = 'extend-arrays' } = typeof ext === 'string' ? { path: ext } : ext
502
+ // Joined as forge joins it, not normalized: the read resolves a `..` after a symlink the way
503
+ // forge's does (from where the link leads), not textually.
504
+ const baseFile = rustJoin(posix.dirname(file), extPath)
505
+ const baseRefusal = refused(baseFile)
506
+ if (baseRefusal) throw new ConfigRefused(`${name}: refusing to extend ${extPath}: ${baseRefusal}`)
507
+ const baseName = show(baseFile)
508
+ const baseText = readUtf8OrNull(baseFile, baseName, host)
509
+ if (baseText === null) throw new ConfigRefused(`${name}: the inherited config file does not exist: ${extPath}`)
510
+ const base = parseFoundryToml(baseText, baseName).profiles
511
+ if (base.get(profile)?.has('extends')) {
512
+ throw new ConfigRefused(`${name}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`)
513
+ }
514
+ if (strategy === 'no-collision') {
515
+ const collisions = [...(profiles.get(profile)?.keys() ?? [])].filter((k) => k !== 'extends' && base.get(profile)?.has(k))
516
+ if (collisions.length > 0) throw new ConfigRefused(`${name}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`)
517
+ }
518
+ profiles = mergeExtended(base, profiles, strategy)
519
+ files.push(baseFile)
520
+ }
521
+ return { profiles, topLevel, files }
522
+ }
523
+
524
+ // `[profile.default]` overlaid with the selected profile (a missing selected profile falls back to
525
+ // the default, as forge does for dependency configs).
526
+ function selectProfile(profiles, profile) {
527
+ const dict = new Map(profiles.get('default') ?? [])
528
+ if (profile !== 'default') for (const [k, v] of profiles.get(profile) ?? []) dict.set(k, v)
529
+ return dict
530
+ }
531
+
532
+ // The `remappings` a foundry.toml's profiles set for `profile` (`[profile.default]` overlaid by
533
+ // it), else the file's top-level `remappings` (a mapping file written for stasis), as written; an
534
+ // invalid one throws (configRemappings, naming `file`).
535
+ function profileRemappings({ profiles, topLevel }, profile, file = null) {
536
+ const value = selectProfile(profiles, profile).get('remappings') ?? topLevel.get('remappings')
537
+ return value === undefined ? [] : configRemappings(value, file)
538
+ }
539
+
540
+ // A foundry.toml text's own remappings for `profile` (see profileRemappings).
541
+ export function foundryTomlRemappings(text, profile = 'default') {
542
+ return profileRemappings(parseFoundryToml(text), profile)
543
+ }
544
+
545
+ // The same for a foundry.toml file, with its `extends` base: what `--mapping=foundry.toml` takes.
546
+ // `files` lists what was read; `profiled` whether the selected `profile` is one of the file's.
547
+ // Messages name files `show(file)`.
548
+ export function readFoundryTomlRemappings(file, profile, { show, host = diskHost }) {
549
+ const abs = toPosix(resolve(file))
550
+ const read = readFoundryProfiles(abs, profile, { show, host })
551
+ return { remappings: profileRemappings(read, profile, show(abs)), files: read.files, profiled: hasProfile(read.profiles, profile) }
552
+ }
553
+
554
+ // Whether the selected `profile` is one of `profiles` (not the default, which always applies).
555
+ const hasProfile = (profiles, profile) => profile !== 'default' && profiles.has(profile)
556
+
557
+ // hasProfile, for the root foundry.toml: one that isn't there is warned about (forge uses
558
+ // `[profile.default]` for it).
559
+ function profileApplies(profiles, profile) {
560
+ if (profile !== 'default' && !profiles.has(profile)) {
561
+ console.warn(`[loader.solidity] FOUNDRY_PROFILE=${profile} is not a profile in foundry.toml; using [profile.default]`)
562
+ }
563
+ return hasProfile(profiles, profile)
564
+ }
565
+
566
+ // `ProjectPathsConfig::find_source_dir`: `src` unless only `contracts` exists.
567
+ const findSourceDir = (root, host) => (isDir(rustJoin(root, 'src'), host) || !isDir(rustJoin(root, JS_SRC_DIR), host) ? 'src' : JS_SRC_DIR)
568
+
569
+ // `DappHardhatDirProvider`: `lib` and/or `node_modules`, whichever exist (`lib` when neither).
570
+ function detectLibs(root, host) {
571
+ const nm = isDir(rustJoin(root, 'node_modules'), host)
572
+ const lib = isDir(rustJoin(root, 'lib'), host)
573
+ if (!nm) return ['lib']
574
+ return lib ? ['lib', 'node_modules'] : ['node_modules']
575
+ }
576
+
577
+ // The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults
578
+ // filled in the way forge fills them. `remappings` are the profile's own, unnormalized; an invalid
579
+ // one throws (configRemappings). `refused`, `show` (from the root by default), `host`: see
580
+ // readFoundryProfiles.
581
+ function loadFoundryConfig(root, profile, { refused, host, show = shownFrom(root, host) }) {
582
+ const file = rustJoin(root, FOUNDRY_TOML)
583
+ const name = show(file)
584
+ const { profiles, files } = readFoundryProfiles(file, profile, { refused, show, host })
585
+ const dict = selectProfile(profiles, profile)
586
+ // A setting of the wrong type throws, as forge refuses the config: no quiet default.
587
+ const setting = (key, ok, what) => {
588
+ const value = dict.get(key)
589
+ if (value !== undefined && !ok(value)) throw new Error(`${name}: \`${key}\` must be ${what}`)
590
+ return value
591
+ }
592
+ const isString = (v) => typeof v === 'string'
593
+ return {
594
+ profiles,
595
+ files,
596
+ src: setting('src', isString, 'a string') ?? findSourceDir(root, host),
597
+ test: setting('test', isString, 'a string') ?? 'test',
598
+ script: setting('script', isString, 'a string') ?? 'script',
599
+ libs: setting('libs', (v) => Array.isArray(v) && v.every(isString), 'an array of strings') ?? detectLibs(root, host),
600
+ remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), name) : [],
601
+ autoDetect: setting('auto_detect_remappings', (v) => typeof v === 'boolean', 'a boolean') !== false,
602
+ }
603
+ }
604
+
605
+ // --- The remappings provider ----------------------------------------------------------------
606
+
607
+ // `foundry_toml_dir_entries`: `dir` and its direct subdirs (symlinks resolved) that hold a
608
+ // foundry.toml, as `{ canonical, path, isSymlink }`.
609
+ function foundryTomlDirEntries(dir, host) {
610
+ const out = []
611
+ const consider = (path, isSymlink) => {
612
+ if (host.stat(rustJoin(path, FOUNDRY_TOML)) === null) return
613
+ const canonical = canonicalize(path, host)
614
+ if (canonical !== null) out.push({ canonical, path, isSymlink })
615
+ }
616
+ consider(dir, isSymlinkPath(dir, host))
617
+ for (const e of listDir(dir, host)) if (e.isDir) consider(e.path, e.isSymlink)
618
+ return out
619
+ }
620
+
621
+ const cmpEntry = (a, b) => cmpPath(a.canonical, b.canonical) || cmpPath(a.path, b.path)
622
+
623
+ function rebaseNested(r, canonical, lexical) {
624
+ const rebase = (v) => {
625
+ const rest = stripPrefix(v, canonical)
626
+ return rest === null ? v : rustJoin(lexical, rest)
627
+ }
628
+ const out = { ...r, path: rebase(r.path) }
629
+ if (r.context !== null) {
630
+ const boundary = r.context.endsWith('/')
631
+ let context
632
+ if (isAbs(r.context)) {
633
+ context = rebase(r.context)
634
+ } else {
635
+ const parts = []
636
+ for (const c of normalComps(rustJoin(lexical, r.context))) {
637
+ if (c === '..') parts.pop()
638
+ else parts.push(c)
639
+ }
640
+ context = `/${parts.join('/')}`
641
+ }
642
+ out.context = boundary ? withTrailing(context) : context
643
+ }
644
+ return out
645
+ }
646
+
647
+ // A dependency's config as forge's `load_nested_config` reads it: remappings rebased onto its
648
+ // canonical root, its remappings.txt, its src and libs. Null when forge would reject the config,
649
+ // or when `refused` refuses it or its `extends` base (warned: ConfigRefused); a remappings.txt it
650
+ // refuses is skipped (warned). One that isn't TOML or holds an invalid remapping throws: forge
651
+ // refuses a bad remappings.txt line too, and skips a foundry.toml it can't read, which here is an
652
+ // error rather than a config quietly left out. `refused`, `show`: see readFoundryProfiles.
653
+ function loadNestedConfig(canonical, profile, { refused, show, host }) {
654
+ let config
655
+ try {
656
+ config = loadFoundryConfig(canonical, profile, { refused, show, host })
657
+ } catch (err) {
658
+ if (!(err instanceof ConfigRefused)) throw err
659
+ console.warn(`[loader.solidity] Skipping a dependency's config: ${err.message}`)
660
+ return null
661
+ }
662
+ const txt = rustJoin(canonical, REMAPPINGS_TXT)
663
+ const txtName = show(txt)
664
+ const refusal = refused(txt) // (null when nothing is there)
665
+ if (refusal) console.warn(`[loader.solidity] Skipping a dependency's ${txtName}: ${refusal}`)
666
+ const text = refusal ? null : readUtf8OrNull(txt, txtName, host)
667
+ return {
668
+ src: config.src,
669
+ libs: config.libs,
670
+ files: [...config.files, ...(text === null ? [] : [txt])],
671
+ // `sanitized()` roots them, then `Remapping::from` makes the path absolute and slash-terminated.
672
+ remappings: config.remappings.map((r) => fromRelative(relativePreservingBoundary(fromRelative({ ...r, path: { parent: null, path: r.path } }), canonical))),
673
+ fileRemappings: text === null ? [] : parseRemappingLines(text, { label: txtName }),
674
+ }
675
+ }
676
+
677
+ // `find_nested_foundry_remappings`: `[lexicalLibPath, remapping, isPackageEntry]` for every
678
+ // dependency (transitively, through each one's own libs) that is a Foundry project. A dependency's
679
+ // config reads only its own files and other dependencies' (by real path: `ownership`, see
680
+ // solidityOwnership), as forge would find them from its lexical path.
681
+ function findNestedFoundryRemappings(root, libPaths, profile, files, ownership, host) {
682
+ const canonicalRoot = canonicalize(root, host) ?? root
683
+ const shown = shownFrom(root, host, canonicalRoot)
684
+ // A dependency's file (a path from its canonical dir) under its lexical path: where the bundle
685
+ // sees it, and how messages name it.
686
+ const lexical = (entry, file) => rustJoin(entry.path, stripPrefix(file, entry.canonical) ?? file)
687
+ // Why the config of the dependency at `entry` may not read `file` (a path from its canonical
688
+ // dir), or null: judged by the path from the root, the lexical one or else the canonical one (an
689
+ // absolute lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a
690
+ // dependency outside the root reads nothing, and one a dependency's `libs` named must be a
691
+ // dependency itself (not the project's own dir passed off as one). Nothing there (the OS agrees:
692
+ // solidityOwnership) is left for the read to find missing.
693
+ const refused = (entry) => (file) => {
694
+ const dir = stripPrefix(entry.path, root) ?? stripPrefix(entry.canonical, canonicalRoot)
695
+ if (dir === null) return 'the dependency lies outside the project root'
696
+ if (entry.viaDependency && !ownership.of(dir).dependency) return `${dir}, which a dependency's \`libs\` names, isn't a dependency`
697
+ // `file` as joined under the dependency's dir (an `extends` path unnormalized, for the walk to
698
+ // resolve as the read does); one not under it (an absolute path elsewhere) lies outside it.
699
+ if (!file.startsWith(`${entry.canonical}/`)) return 'it lies outside the dependency'
700
+ const o = ownership.of(`${dir}/${file.slice(entry.canonical.length + 1)}`)
701
+ if (o.reason) return o.reason
702
+ if (o.outside) return `it resolves to ${o.real}, outside the project root`
703
+ if (o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical)) return null
704
+ return `it resolves to the project's own ${o.real}`
705
+ }
706
+ const show = (entry) => (file) => shown(lexical(entry, file))
707
+ // A BTreeSet popped in (canonical, path) order.
708
+ const pending = new Map()
709
+ const addPending = (e) => pending.set(`${e.canonical}\0${e.path}`, e)
710
+ for (const lib of libPaths) for (const e of foundryTomlDirEntries(rustJoin(root, lib), host)) addPending(e)
711
+ const seen = new Set([canonicalRoot])
712
+ const configs = new Map()
713
+ const out = []
714
+ while (pending.size > 0) {
715
+ let key
716
+ let entry
717
+ for (const [k, e] of pending) {
718
+ if (entry === undefined || cmpEntry(e, entry) < 0) [key, entry] = [k, e]
719
+ }
720
+ pending.delete(key)
721
+ if (entry.canonical === canonicalRoot) continue
722
+ if (!configs.has(entry.canonical)) {
723
+ const config = loadNestedConfig(entry.canonical, profile, { refused: refused(entry), show: show(entry), host })
724
+ configs.set(entry.canonical, config)
725
+ for (const f of config?.files ?? []) files.add(lexical(entry, f))
726
+ }
727
+ const config = configs.get(entry.canonical)
728
+ if (!config) continue
729
+ for (const r of config.remappings) out.push([entry.path, rebaseNested(r, entry.canonical, entry.path), false])
730
+ for (const r of config.fileRemappings) out.push([entry.path, fromRelative(toRelative(r, entry.path)), false])
731
+ if (!entry.isSymlink && !seen.has(entry.canonical)) {
732
+ seen.add(entry.canonical)
733
+ for (const lib of config.libs) {
734
+ for (const e of foundryTomlDirEntries(rustJoin(entry.path, lib), host)) if (!e.isSymlink) addPending({ ...e, viaDependency: true })
735
+ }
736
+ }
737
+ // A custom (or missing) source dir isn't auto-detected: forge synthesizes `<dep>/=<dep>/<src>/`.
738
+ const standard = ['src', 'contracts', 'lib'].some((s) => pathEq(s, config.src))
739
+ const name = fileName(entry.path)
740
+ if ((!standard || !isDir(rustJoin(entry.canonical, config.src), host)) && name !== null) {
741
+ out.push([entry.path, { context: null, name: `${name}/`, path: withTrailing(rustJoin(entry.path, config.src)) }, true])
742
+ }
743
+ }
744
+ return out
745
+ }
746
+
747
+ // `configured_auto_remapping`: an auto-detected alias a dependency's config redirects (its
748
+ // synthesized `<dep>/<src>/` entry) takes that target.
749
+ function configuredAutoRemapping(r, packageEntries) {
750
+ let best = null
751
+ for (const [lib, configured] of packageEntries) {
752
+ if (configured.name !== r.name) continue
753
+ if (r.context !== null && (pathEq(lib, r.context) || !pathStartsWith(lib, r.context))) continue
754
+ let rank
755
+ if (pathStartsWith(r.path, lib)) rank = [0, Number.MAX_SAFE_INTEGER - compCount(lib)]
756
+ else if (r.context !== null && pathStartsWith(lib, r.context) && pathStartsWith(lib, r.path)) rank = [1, compCount(lib)]
757
+ else continue
758
+ // `min_by` rank, then lib path: the first minimum wins.
759
+ const order = best === null ? -1 : (rank[0] - best.rank[0] || rank[1] - best.rank[1] || cmpPath(lib, best.lib))
760
+ if (order < 0) best = { rank, lib, configured }
761
+ }
762
+ return best === null ? r : { ...r, path: best.configured.path }
763
+ }
764
+
765
+ // `contextual_overlays`: null when an applicable authoritative alias already covers the
766
+ // refinement; else the authoritative aliases below it, re-scoped to the refinement's context.
767
+ function contextualOverlays(authoritative, refinement) {
768
+ const applicable = authoritative.filter((m) => m.context === null || (refinement.context !== null && pathStartsWith(refinement.context, m.context)))
769
+ if (applicable.some((m) => nameIsPrefix(m.name, refinement.name))) return null
770
+ return applicable
771
+ .filter((m) => nameIsPrefix(refinement.name, m.name))
772
+ .toSorted((a, b) => b.name.length - a.name.length)
773
+ .map((m) => ({ ...m, context: refinement.context }))
774
+ }
775
+
776
+ // `expand_scoped_contextual_remapping`: a contextual `@scope/=<..>/node_modules/@scope/` becomes
777
+ // one remapping per package in the scope.
778
+ function expandScopedContextual(r, host) {
779
+ const scope = trimSlashes(r.name)
780
+ if (!scope.startsWith('@') || fileName(r.path) !== scope || fileName(parentOf(r.path) ?? '') !== 'node_modules') return [r]
781
+ const packages = listDir(r.path, host)
782
+ .filter((e) => e.isDir)
783
+ .map((e) => ({ context: r.context, name: `${scope}/${e.name}/`, path: `${e.path}/` }))
784
+ .toSorted((a, b) => cmpStr(a.name, b.name))
785
+ return packages.length === 0 ? [r] : packages
786
+ }
787
+
788
+ // Forge's closest-path choice per alias: fewer components, then `src`, then path order.
789
+ function insertClosest(m, key, path) {
790
+ const existing = m.get(key)
791
+ const srcRank = (p) => (fileName(p) === SRC_DIR ? 0 : 1)
792
+ if (existing === undefined || (compCount(path) - compCount(existing) || srcRank(path) - srcRank(existing) || cmpPath(path, existing)) < 0) {
793
+ m.set(key, path)
794
+ }
795
+ }
796
+
797
+ // A refinement with the authoritative aliases it overlays, or nothing when one already covers it.
798
+ const withOverlays = (authoritative, r) => {
799
+ const overlays = contextualOverlays(authoritative, r)
800
+ return overlays ? [...overlays, r] : []
801
+ }
802
+
803
+ // `RemappingsProvider::get_remappings`: the remappings in the order forge settles them.
804
+ function providerRemappings(root, { userRemappings, libs, autoDetect, profile, files, ownership, host }) {
805
+ const authoritativeUser = userRemappings.map((r) => (r.context === null ? r : { ...r, context: rustJoin(root, r.context) }))
806
+ const all = new Remappings([...userRemappings])
807
+ if (!autoDetect) return all.intoInner()
808
+
809
+ const nested = findNestedFoundryRemappings(root, libs, profile, files, ownership, host)
810
+ const auto = { global: [], contextual: [] }
811
+ for (const lib of libs) {
812
+ const found = findRemappingsWithContext(rustJoin(root, lib), host)
813
+ auto.global.push(...found.global)
814
+ auto.contextual.push(...found.contextual)
815
+ }
816
+
817
+ const packageEntries = nested.filter(([, , pkg]) => pkg)
818
+ const safeAlias = (r) => !['lib/', 'src/', 'contracts/'].includes(r.name)
819
+ const global = auto.global.map((r) => configuredAutoRemapping(r, packageEntries)).filter(safeAlias)
820
+ const contextual = auto.contextual.map((r) => configuredAutoRemapping(r, packageEntries)).filter(safeAlias)
821
+ const detected = [...global, ...contextual]
822
+
823
+ const targetsByAlias = new Map()
824
+ for (const r of detected) {
825
+ if (!targetsByAlias.has(r.name)) targetsByAlias.set(r.name, new Set())
826
+ targetsByAlias.get(r.name).add(pathKey(r.path))
827
+ }
828
+ const ambiguous = new Set([...targetsByAlias].filter(([, t]) => t.size > 1).map(([name]) => name))
829
+
830
+ const explicitContextual = nested.filter(([, r]) => r.context !== null).flatMap(([, r]) => withOverlays(authoritativeUser, r))
831
+ const authoritative = [...authoritativeUser, ...explicitContextual]
832
+ // Forge's per-(context, alias) closest paths; only global remappings ever reach it.
833
+ const closest = new Map()
834
+ const contextualRemappings = []
835
+ for (const [lib, r, isPackageEntry] of nested) {
836
+ if (r.context !== null) continue
837
+ // A dependency refining an auto-detected package root to its source dir: scope the refinement
838
+ // to that dependency so root imports keep the broader mapping.
839
+ const refines = !isPackageEntry && detected.some((a) => a.name === r.name && !pathEq(r.path, a.path)
840
+ && ((a.context !== null && pathEq(a.context, lib)) || (a.context === null && pathStartsWith(r.path, a.path))))
841
+ if (refines) contextualRemappings.push(...withOverlays(authoritative, { ...r, context: `${lib}/` }))
842
+ insertClosest(closest, r.name, r.path)
843
+ }
844
+ for (const r of contextual.filter((c) => ambiguous.has(c.name)).flatMap((c) => expandScopedContextual(c, host))) {
845
+ contextualRemappings.push(...withOverlays(authoritative, r))
846
+ }
847
+ for (const r of global) insertClosest(closest, r.name, r.path)
848
+
849
+ const explicit = new Set(all.remappings.map((r) => relKey(relativePreservingBoundary(r, root))))
850
+ for (const c of [...explicitContextual.toSorted(byContextDepth), ...contextualRemappings.toSorted(byContextDepth)]) {
851
+ if (!explicit.has(relKey(relativePreservingBoundary(c, root)))) all.push(c)
852
+ }
853
+ for (const [name, path] of sortedEntries(closest, cmpStr)) all.push({ context: null, name, path })
854
+ return all.intoInner()
855
+ }
856
+
857
+ // The lib dirs `forge build` uses for the Foundry project at `baseDir`, `{ libs, profiled, files }`:
858
+ // the selected profile's `libs` (`profiled` when that profile is the file's), else the detected
859
+ // ones; also those, warned, when forge would reject the foundry.toml's settings (ConfigRefused; with
860
+ // a pinned mapping file, nothing else is read from it). `files` lists the config files read (the
861
+ // foundry.toml, its `extends` base). A foundry.toml that isn't TOML or holds an invalid remapping
862
+ // throws.
863
+ export function foundryLibs(baseDir, { env = process.env, host = diskHost } = {}) {
864
+ const root = toPosix(resolve(baseDir))
865
+ const profile = foundryProfile(env)
866
+ try {
867
+ const config = loadFoundryConfig(root, profile, { host })
868
+ return { libs: config.libs, profiled: profileApplies(config.profiles, profile), files: config.files }
869
+ } catch (err) {
870
+ if (!(err instanceof ConfigRefused)) throw err
871
+ console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`)
872
+ return { libs: detectLibs(root, host), profiled: false, files: [rustJoin(root, FOUNDRY_TOML)] }
873
+ }
874
+ }
875
+
876
+ // The source directory of the Foundry project at `baseDir`: its default profile's `src`, else
877
+ // forge's (`src` unless only `contracts` exists).
878
+ export function foundrySourceDir(baseDir, { host = diskHost } = {}) {
879
+ return loadFoundryConfig(toPosix(resolve(baseDir)), 'default', { host }).src
880
+ }
881
+
882
+ // The Foundry project at `baseDir`: what `forge build` would use. `remappings` are
883
+ // `{ context, prefix, target }` relative to the root, in forge's order; `libs` the lib dirs;
884
+ // `files` the config files read (project-relative, `../` when outside the project); `envUsed` the
885
+ // environment variables that shaped them; `ownership` its files' owners (see solidityOwnership),
886
+ // which also confines what a dependency's config reads. `env` supplies FOUNDRY_PROFILE and
887
+ // FOUNDRY_REMAPPINGS / DAPP_REMAPPINGS.
888
+ export function foundryProject(baseDir, { env = process.env, host = diskHost } = {}) {
889
+ const root = toPosix(resolve(baseDir))
890
+ const profile = foundryProfile(env)
891
+ const config = loadFoundryConfig(root, profile, { host })
892
+ const profiled = profileApplies(config.profiles, profile)
893
+ const ownership = projectOwnership(baseDir, config.libs, { soldeer: true, host })
894
+ const files = new Set(config.files)
895
+
896
+ const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null
897
+ const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], { label: envName })
898
+ const txtFile = rustJoin(root, REMAPPINGS_TXT)
899
+ const txt = readUtf8OrNull(txtFile, REMAPPINGS_TXT, host)
900
+ if (txt !== null) files.add(txtFile)
901
+ const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: REMAPPINGS_TXT })), ...config.remappings]
902
+
903
+ const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership, host })
904
+ .map((r) => displayRelative(relativePreservingBoundary(r, root)))
905
+
906
+ // `forge build` re-reads them as config remappings, dropping aliases of its own input dirs.
907
+ const build = new Remappings([], [config.src, config.test, config.script].map((p) => `${p}/`))
908
+ for (const s of provided) {
909
+ const r = parseRemapping(s)
910
+ if (r) build.push(r)
911
+ }
912
+ // ...and hands them to solc as the config's `RelativeRemapping`s: slash-terminated.
913
+ const remappings = build.intoInner()
914
+ .map((r) => parseRemapping(displayRelative(relativePreservingBoundary(r, root))))
915
+ .filter(Boolean)
916
+ .map(toSolcRemapping)
917
+
918
+ // One read from outside the root (an `extends = "../base.toml"`) stays `../`, for --manifests to
919
+ // refuse (it can't be carried).
920
+ const relFiles = [...files].map((f) => projectRelative(root, f, host))
921
+ const envUsed = [...(profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])]
922
+ return { remappings, libs: config.libs, files: relFiles, envUsed, ownership }
923
+ }