@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,125 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { isTomlTable, readToml } from '../loaders/toml.js'
4
+
5
+ // What `soldeer install` (0.12.0) writes beside the dependencies folder after installing it
6
+ // (soldeer-core's update_config_libs and edit_remappings with RemappingsAction::Update): the
7
+ // remappings.txt it would write is written into the tree, which then serves it in place of the
8
+ // project's; a foundry.toml it would edit is refused, as is what this can't tell it would leave as
9
+ // it is. `view` is the project's root by paths from `/`, `root` its real path, for messages.
10
+
11
+ const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u
12
+ // A requirement Soldeer takes as exact: a bare version (its `^` made `=`), or one written `=`.
13
+ const EXACT = /^=?\s*(\S+)$/u
14
+ const withoutBuild = (version) => version.replace(/\+.*$/u, '')
15
+
16
+ // A file's text as Soldeer reads it (a byte order mark kept), or undefined.
17
+ function textOf(view, path) {
18
+ try {
19
+ return view.stat(path).type === 'file' ? view.readFile(path).toString('utf8') : undefined
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
25
+ // Rust's `Path::components` of a remapping's target, `.` kept only at the start.
26
+ function components(path) {
27
+ const out = path.startsWith('/') ? ['/'] : []
28
+ for (const [i, part] of path.split('/').entries()) {
29
+ if (part !== '' && (part !== '.' || i === 0)) out.push(part)
30
+ }
31
+ return out
32
+ }
33
+
34
+ // Whether the folder `name` of a remapping's target (its dependencies folder and the one in it) is
35
+ // `dep`'s, as soldeer's path_matches tells, or null where only Rust's semver could tell.
36
+ function folderMatches(dep, name) {
37
+ if (!name.startsWith(`${dep.name}-`)) return false
38
+ const version = name.slice(dep.name.length + 1)
39
+ if (version === dep.version) return true
40
+ if (!SEMVER.test(version)) return version === dep.req
41
+ const exact = EXACT.exec(dep.req.trim())?.[1]
42
+ if (exact !== undefined && SEMVER.test(exact)) return withoutBuild(exact) === withoutBuild(version)
43
+ return null
44
+ }
45
+
46
+ // The remappings Soldeer's update leaves, of `existing` (`[name, target]` pairs) and the config's
47
+ // dependencies, sorted as it sorts them (by their UTF-8 bytes).
48
+ function updatedRemappings(existing, deps, { regenerate, where }) {
49
+ const items = deps.map((dep) => ({ dep, line: `${dep.remapped}=dependencies/${dep.folder}/` }))
50
+ let lines
51
+ if (regenerate || existing.length === 0) {
52
+ lines = items.map(({ line }) => line)
53
+ } else {
54
+ lines = []
55
+ let rest = existing
56
+ for (const { dep, line } of items) {
57
+ let found = false
58
+ rest = rest.filter(([name, target]) => {
59
+ const path = components(target).slice(0, 2)
60
+ const folder = path.at(-1)
61
+ const matches = folder === undefined || ['/', '.', '..'].includes(folder) ? false : folderMatches(dep, folder)
62
+ if (matches === null) throw new Error(`${where}: "${name}=${target}" names ${folder}, which \`soldeer install\` may rewrite to dependencies/${dep.folder}`)
63
+ if (!matches) return true
64
+ lines.push(`${name}=${target.replaceAll(path.join('/').replace(/^\/\//u, '/'), `dependencies/${dep.folder}`)}`)
65
+ found = true
66
+ return false
67
+ })
68
+ if (!found) lines.push(line)
69
+ }
70
+ lines.push(...rest.map(([name, target]) => `${name}=${target}`))
71
+ }
72
+ return lines.toSorted((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))
73
+ }
74
+
75
+ // `[name, target]` of each remapping in `lines`, as Soldeer reads them: split at the first `=`, any
76
+ // line without one passed over.
77
+ const pairs = (lines) => lines.flatMap((line) => {
78
+ const at = line.indexOf('=')
79
+ return at === -1 ? [] : [[line.slice(0, at), line.slice(at + 1)]]
80
+ })
81
+
82
+ export function settleSoldeer(view, vfs, root) {
83
+ const foundryText = textOf(view, '/foundry.toml')
84
+ const foundry = foundryText === undefined ? undefined : readToml(foundryText)
85
+ const configFile = isTomlTable(foundry?.dependencies) ? 'foundry.toml' : 'soldeer.toml'
86
+ const config = configFile === 'foundry.toml' ? foundry : readToml(textOf(view, '/soldeer.toml') ?? '')
87
+ const where = (file) => join(root, file)
88
+
89
+ if (configFile === 'foundry.toml') {
90
+ const libs = foundry.profile?.default?.libs
91
+ if (!Array.isArray(libs) || !libs.includes('dependencies')) {
92
+ throw new Error(`${where('foundry.toml')}: [profile.default] libs holds no "dependencies", which \`soldeer install\` adds`)
93
+ }
94
+ }
95
+
96
+ const settings = { remappings_generate: true, remappings_regenerate: false, remappings_version: true, remappings_prefix: '', remappings_location: 'txt', ...config.soldeer }
97
+ if (!settings.remappings_generate) return
98
+ const locked = new Map(readToml(textOf(view, '/soldeer.lock') ?? '').dependencies?.map((entry) => [entry.name, entry.version]) ?? [])
99
+ const deps = Object.entries(config.dependencies ?? {}).map(([name, value]) => {
100
+ const req = typeof value === 'string' ? value : value.version
101
+ const version = locked.get(name)
102
+ const remapped = `${settings.remappings_prefix}${name}${settings.remappings_version ? `-${req.replaceAll('=', '')}` : ''}/`
103
+ return { name, req, version, folder: `${name}-${version}`, remapped }
104
+ })
105
+
106
+ const remappingsTxt = textOf(view, '/remappings.txt')
107
+ if (configFile === 'foundry.toml' && settings.remappings_location === 'config') {
108
+ if (remappingsTxt !== undefined) throw new Error(`${where('remappings.txt')}: \`soldeer install\` removes it, as remappings_location is "config"`)
109
+ for (const [name, profile] of Object.entries(isTomlTable(foundry.profile) ? foundry.profile : {})) {
110
+ const current = Array.isArray(profile?.remappings) ? profile.remappings : undefined
111
+ if (current === undefined && name !== 'default') continue
112
+ const updated = updatedRemappings(pairs((current ?? []).filter((r) => typeof r === 'string')), deps, { regenerate: settings.remappings_regenerate, where: where('foundry.toml') })
113
+ if (current === undefined || updated.length !== current.length || updated.some((r, i) => r !== current[i])) {
114
+ throw new Error(`${where('foundry.toml')}: [profile.${name}] remappings are not as \`soldeer install\` leaves them`)
115
+ }
116
+ }
117
+ return
118
+ }
119
+
120
+ const lines = (settings.remappings_regenerate ? '' : remappingsTxt ?? '').split('\n')
121
+ if (lines.at(-1) === '') lines.pop()
122
+ const existing = pairs(lines.map((line) => line.replace(/\r$/u, '')))
123
+ const text = updatedRemappings(existing, deps, { regenerate: settings.remappings_regenerate, where: where('remappings.txt') }).map((line) => `${line}\n`).join('')
124
+ if (text !== remappingsTxt) vfs.writeFile('/remappings.txt', text, { mode: remappingsTxt === undefined ? 0o644 : view.lstat('/remappings.txt').mode })
125
+ }