@meantjs/profile 0.0.0-stage → 0.2.0
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.
- package/CHANGELOG.md +60 -0
- package/LICENSE +202 -0
- package/README.md +277 -2
- package/bin/candid-profile.mjs +6 -0
- package/data/packs/erc20.profile.json +221 -0
- package/data/packs/icrc1.profile.json +275 -0
- package/data/packs/icrc2.profile.json +515 -0
- package/data/profile-v0.schema.json +1118 -0
- package/data/profile-v0.validate.mjs +12 -0
- package/package.json +77 -4
- package/src/abi-iface.mjs +112 -0
- package/src/abi-pack.mjs +114 -0
- package/src/abi.mjs +672 -0
- package/src/address.d.mts +30 -0
- package/src/address.mjs +62 -0
- package/src/analyze.mjs +344 -0
- package/src/browser.d.mts +212 -0
- package/src/browser.mjs +52 -0
- package/src/checks.mjs +154 -0
- package/src/cli.mjs +120 -0
- package/src/compile.mjs +304 -0
- package/src/doc-annotations.mjs +394 -0
- package/src/evm.d.mts +86 -0
- package/src/evm.mjs +28 -0
- package/src/idl.mjs +10 -0
- package/src/iface.mjs +123 -0
- package/src/index.d.mts +70 -0
- package/src/index.mjs +15 -0
- package/src/keccak.mjs +108 -0
- package/src/pack-match.mjs +19 -0
- package/src/packs.mjs +25 -0
- package/src/resolve.mjs +167 -0
- package/src/resolved.mjs +64 -0
- package/src/schema-core.mjs +45 -0
- package/src/schema.mjs +23 -0
- package/src/served.mjs +59 -0
- package/src/sha256.mjs +59 -0
- package/src/subtype.mjs +271 -0
- package/src/tags.mjs +90 -0
- package/src/util.mjs +16 -0
- package/src/validate.mjs +65 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doc annotations (spec section 9): the profile tags an author writes as doc comments in the `.did`, read by
|
|
3
|
+
* candid-core (doc attributes v1, `docs/doc-attributes.md` in the candid-core repository) and turned here into an
|
|
4
|
+
* `author` canister profile bound to the interface's `interface_id`.
|
|
5
|
+
*
|
|
6
|
+
* candid-core parses every `@name(key = value, ...)` doc line, refuses a malformed one and hands each out with the
|
|
7
|
+
* SourceInfo path of the item it documents; it gives no name a meaning. This module decides which names are profile
|
|
8
|
+
* tags and where each lands:
|
|
9
|
+
*
|
|
10
|
+
* method docs method tags at the method; value tags need `at` ("<relpath>" for the arguments,
|
|
11
|
+
* "-><relpath>" for the results); `describe` without `at` describes the method
|
|
12
|
+
* declaration docs `type:<Decl>`, plus `at` when given (a pathless address is the resolver's E_TYPE_NODE check)
|
|
13
|
+
* field docs `type:<Decl>.<path>` in a declaration, or a method address for a field of an inline actor
|
|
14
|
+
* type (the OQ-4 mapping, docs/proposals/w1-source-info.md), plus `at` when given
|
|
15
|
+
* prose `describe.help` of the item, except where it would change how a value is read (below)
|
|
16
|
+
*
|
|
17
|
+
* Only bare names that are profile tags are read. A dotted name (`@ui.hidden`) belongs to another tool and is left
|
|
18
|
+
* alone; so is any other bare name, with a warning when it is one or two letters from a tag (`@amout`).
|
|
19
|
+
*
|
|
20
|
+
* Prose never makes an integer typable. The tool layer reads an untagged integer with a `describe` as plain digits
|
|
21
|
+
* (D-044), so a comment such as "the amount in e8s" would turn a bare number into an agent input without a unit. Prose
|
|
22
|
+
* on an integer position is attached only when the same doc gives it a value tag that reads it (`amount`,
|
|
23
|
+
* `timestamp`, `duration`, `named`) or an explicit `@describe`; otherwise it is reported (I_DOC_PROSE).
|
|
24
|
+
*
|
|
25
|
+
* New diagnostic codes, proposed in docs/proposals/w1-doc-attributes.md: E_DOC_INPUT, E_DOC_SYNTAX, E_DOC_TARGET,
|
|
26
|
+
* E_DOC_VALUE, W_DOC_UNKNOWN_TAG, I_DOC_IGNORED, I_DOC_PROSE; candid-core's compile failures pass through as E_CANDID.
|
|
27
|
+
*/
|
|
28
|
+
import { parsePath } from "./address.mjs";
|
|
29
|
+
import { METHOD_TAGS, NUMERIC } from "./checks.mjs";
|
|
30
|
+
|
|
31
|
+
/** The doc attributes version this module reads. */
|
|
32
|
+
export const DOC_ATTRIBUTES_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
const TAGS = new Set([
|
|
35
|
+
"amount", "timestamp", "duration", "account", "subaccount", "bytes", "default", "describe", "text", "range", "ui",
|
|
36
|
+
"named", ...METHOD_TAGS,
|
|
37
|
+
]);
|
|
38
|
+
/** Value tags that decide how an integer is read: prose next to one of them cannot change the reading. */
|
|
39
|
+
const READING_TAGS = new Set(["amount", "timestamp", "duration", "named"]);
|
|
40
|
+
/** Arguments the schema holds as decimal strings (`int_string`): a number written there is kept as its digits. */
|
|
41
|
+
const STRING_INTEGERS = new Set(["range.min", "range.max"]);
|
|
42
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
43
|
+
// the schema's relpath, without the leading "->"
|
|
44
|
+
const RELPATH = /^(\((0|[1-9][0-9]*)\))?(\.?([A-Za-z_][A-Za-z0-9_]*|#(0|[1-9][0-9]{0,9}))|\[\])?((\.([A-Za-z_][A-Za-z0-9_]*|#(0|[1-9][0-9]{0,9})))|\[\])*$/;
|
|
45
|
+
// one step of a SourceInfo occurrence path after its origin (OQ-4)
|
|
46
|
+
const STEP = /\.(type|inner|function|fields|args|results|methods|init)(?:\[(0|[1-9][0-9]*)\])?/y;
|
|
47
|
+
|
|
48
|
+
class DocError extends Error {
|
|
49
|
+
constructor(code, message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An `author` canister profile from the doc comments of a `.did`.
|
|
57
|
+
*
|
|
58
|
+
* @param response what `didToDocAttributes(sources)` of `@candid-core/cli` returns: `{ ok: true, contract,
|
|
59
|
+
* source_info, doc_attributes }`, or `{ ok: false, diagnostics }`, which is passed through
|
|
60
|
+
* @param opts.name the profile's name (default "doc-annotations")
|
|
61
|
+
* @param opts.origin "author" (default) or "third_party"
|
|
62
|
+
* @param opts.language default "en"
|
|
63
|
+
* @param opts.canisterId bind the profile to one canister too
|
|
64
|
+
* @returns { ok, doc, diagnostics }: `doc` is the profile, or null when the comments hold no tag (or the response is
|
|
65
|
+
* not readable); `ok` is false when any diagnostic is an error. Run the profile through `resolveService` as
|
|
66
|
+
* usual: this module places tags, the resolver checks them.
|
|
67
|
+
*/
|
|
68
|
+
export function profileFromDocAttributes(response, opts = {}) {
|
|
69
|
+
const diagnostics = [];
|
|
70
|
+
const read = readResponse(response, diagnostics);
|
|
71
|
+
if (read === null) return { ok: false, doc: null, diagnostics };
|
|
72
|
+
const graph = new Graph(read.contract, read.sourceInfo);
|
|
73
|
+
const annotations = [];
|
|
74
|
+
for (const item of read.items) placeItem(graph, item, annotations, diagnostics);
|
|
75
|
+
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
76
|
+
if (annotations.length === 0) {
|
|
77
|
+
if (ok) diagnostics.push({ severity: "info", code: "I_DOC_IGNORED", at: read.entry, message: "the doc comments hold no profile tag, so there is no profile" });
|
|
78
|
+
return { ok, doc: null, diagnostics };
|
|
79
|
+
}
|
|
80
|
+
const binds = { interface_id: read.contract.identities.interface };
|
|
81
|
+
if (opts.canisterId !== undefined) binds.canister_id = opts.canisterId;
|
|
82
|
+
const doc = {
|
|
83
|
+
format: "candid-profile",
|
|
84
|
+
format_version: 0,
|
|
85
|
+
kind: "canister",
|
|
86
|
+
name: opts.name ?? "doc-annotations",
|
|
87
|
+
origin: opts.origin ?? "author",
|
|
88
|
+
language: opts.language ?? "en",
|
|
89
|
+
description: `From the doc comments of ${read.entry}.`,
|
|
90
|
+
binds,
|
|
91
|
+
annotations,
|
|
92
|
+
};
|
|
93
|
+
return { ok, doc, diagnostics };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readResponse(response, diagnostics) {
|
|
97
|
+
const fail = (message) => {
|
|
98
|
+
diagnostics.push({ severity: "error", code: "E_DOC_INPUT", at: "service.did", message });
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
if (response === null || typeof response !== "object" || Array.isArray(response)) return fail("not a didToDocAttributes response: not a JSON object");
|
|
102
|
+
if (response.ok === false) {
|
|
103
|
+
const list = Array.isArray(response.diagnostics) ? response.diagnostics : [];
|
|
104
|
+
for (const d of list) {
|
|
105
|
+
const syntax = typeof d?.code === "string" && (d.code.startsWith("doc_attribute_") || d.resource_limit?.resource === "source_nesting");
|
|
106
|
+
diagnostics.push({ severity: "error", code: syntax ? "E_DOC_SYNTAX" : "E_CANDID", at: d?.path ?? d?.span?.source_name ?? "service.did", message: `${d?.code}: ${d?.message}` });
|
|
107
|
+
}
|
|
108
|
+
if (list.length === 0) fail("the response failed and gives no diagnostics");
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const { contract, source_info: sourceInfo, doc_attributes: attrs } = response;
|
|
112
|
+
if (response.ok !== true || !contract || !sourceInfo || !attrs) return fail("not a didToDocAttributes response: it needs ok, contract, source_info and doc_attributes (is @candid-core/cli older than doc attributes?)");
|
|
113
|
+
if (attrs.doc_attributes_version !== DOC_ATTRIBUTES_VERSION) return fail(`doc_attributes_version is ${JSON.stringify(attrs.doc_attributes_version)}; this compiler reads version ${DOC_ATTRIBUTES_VERSION}`);
|
|
114
|
+
const id = contract.identities?.interface;
|
|
115
|
+
if (typeof id !== "string" || !/^candid-core:interface:v1:sha256:[0-9a-f]{64}$/.test(id)) return fail("the contract has no identities.interface");
|
|
116
|
+
if (sourceInfo.contract_id !== contract.identities.contract) return fail("source_info belongs to another contract");
|
|
117
|
+
if (!Array.isArray(attrs.items) || !Array.isArray(sourceInfo.methods) || !Array.isArray(sourceInfo.field_labels)) return fail("doc_attributes.items, source_info.methods and source_info.field_labels must be arrays");
|
|
118
|
+
const entry = sourceInfo.sources?.[0]?.name ?? "the .did";
|
|
119
|
+
return { contract, sourceInfo, items: attrs.items, entry };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The Contract and SourceInfo lookups placement needs. */
|
|
123
|
+
class Graph {
|
|
124
|
+
constructor(contract, sourceInfo) {
|
|
125
|
+
this.types = contract.types;
|
|
126
|
+
this.decls = new Map(contract.declarations.map((d) => [d.name, d.type]));
|
|
127
|
+
const actor = contract.actor;
|
|
128
|
+
this.service = actor?.kind === "service" ? actor.service : actor?.kind === "class" ? this.types[actor.class].service : null;
|
|
129
|
+
this.methods = new Map();
|
|
130
|
+
for (const m of this.service === null ? [] : this.types[this.service].methods) this.methods.set(m.name, this.types[m.function]);
|
|
131
|
+
// trap 1: SourceInfo's method index is not the Contract's; methods are found by their SourceInfo path
|
|
132
|
+
this.methodEntries = sourceInfo.methods;
|
|
133
|
+
this.fieldLabels = new Map(sourceInfo.field_labels.map((f) => [f.path, f]));
|
|
134
|
+
}
|
|
135
|
+
unwrapOpt(ref) {
|
|
136
|
+
while (this.types[ref]?.kind === "opt") ref = this.types[ref].inner;
|
|
137
|
+
return ref;
|
|
138
|
+
}
|
|
139
|
+
kindOf(ref) {
|
|
140
|
+
const n = this.types[this.unwrapOpt(ref)];
|
|
141
|
+
return n?.kind === "primitive" ? n.primitive : n?.kind;
|
|
142
|
+
}
|
|
143
|
+
/** The SourceInfo method entry an item path starts with: equal to it, or followed by a step. */
|
|
144
|
+
methodEntryOf(path) {
|
|
145
|
+
let best = null;
|
|
146
|
+
for (const m of this.methodEntries) {
|
|
147
|
+
if ((path === m.path || path.startsWith(`${m.path}.`)) && (best === null || m.path.length > best.path.length)) best = m;
|
|
148
|
+
}
|
|
149
|
+
return best;
|
|
150
|
+
}
|
|
151
|
+
/** The canister method an entry describes, or a DocError saying why it is not one. */
|
|
152
|
+
canisterMethod(entry) {
|
|
153
|
+
if (entry.service !== this.service) throw new DocError("E_DOC_TARGET", `${entry.name} is a method of a service type that is not this canister's (a callback or a reference), so a profile cannot address it`);
|
|
154
|
+
if (!IDENT.test(entry.name)) throw new DocError("E_DOC_TARGET", `method "${entry.name}" is not identifier-shaped; v0 addresses cannot name it (OQ-3)`);
|
|
155
|
+
return { name: entry.name, func: this.methods.get(entry.name) };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Where an item's docs land: { kind: "method", method, func } | { kind: "value", address, ref, method?, func? }
|
|
159
|
+
* | { kind: "actor" }. Throws a DocError for an item no address reaches.
|
|
160
|
+
*/
|
|
161
|
+
place(item) {
|
|
162
|
+
if (item.kind === "actor") return { kind: "actor" };
|
|
163
|
+
const entry = this.methodEntryOf(item.path);
|
|
164
|
+
if (entry !== null) {
|
|
165
|
+
const { name, func } = this.canisterMethod(entry);
|
|
166
|
+
if (item.path === entry.path) return { kind: "method", method: name, func };
|
|
167
|
+
const steps = readSteps(item.path.slice(entry.path.length));
|
|
168
|
+
const side = steps[0]?.step === "function" ? steps[1] : undefined;
|
|
169
|
+
if (side === undefined || (side.step !== "args" && side.step !== "results") || side.index === undefined) throw new DocError("E_DOC_TARGET", `cannot read the occurrence path ${item.path}`);
|
|
170
|
+
const tuple = side.step === "args" ? func.args : func.results;
|
|
171
|
+
const base = `${name}${side.step === "args" ? "" : "->"}(${side.index})`;
|
|
172
|
+
return { kind: "value", ...this.walk(base, tuple[side.index], steps.slice(2), item.path), method: name, func };
|
|
173
|
+
}
|
|
174
|
+
const decl = /^type:([A-Za-z_][A-Za-z0-9_]*)/.exec(item.path);
|
|
175
|
+
if (decl === null || !this.decls.has(decl[1])) throw new DocError("E_DOC_TARGET", `cannot read the occurrence path ${item.path}`);
|
|
176
|
+
const rest = item.path.slice(decl[0].length);
|
|
177
|
+
return { kind: "value", ...this.walk(`type:${decl[1]}`, this.decls.get(decl[1]), readSteps(rest), item.path) };
|
|
178
|
+
}
|
|
179
|
+
/** Follow occurrence steps from a ref, writing the address: `.label` per field, nothing for opt, `[]` for vec. */
|
|
180
|
+
walk(address, ref, steps, path) {
|
|
181
|
+
let prefix = path.slice(0, path.length - steps.reduce((n, s) => n + s.text.length, 0));
|
|
182
|
+
for (const s of steps) {
|
|
183
|
+
prefix += s.text;
|
|
184
|
+
const node = this.types[ref];
|
|
185
|
+
if (s.step === "type") continue;
|
|
186
|
+
if (s.step === "inner" && (node?.kind === "opt" || node?.kind === "vec")) {
|
|
187
|
+
if (node.kind === "vec") address += "[]";
|
|
188
|
+
ref = node.inner;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (s.step === "fields" && (node?.kind === "record" || node?.kind === "variant") && s.index < node.fields.length) {
|
|
192
|
+
const field = node.fields[s.index];
|
|
193
|
+
const entry = this.fieldLabels.get(prefix);
|
|
194
|
+
if (entry !== undefined && (entry.container !== ref || entry.id !== field.id)) throw new DocError("E_DOC_TARGET", `SourceInfo and the Contract disagree at ${prefix}`);
|
|
195
|
+
const name = entry?.label?.kind === "named" ? entry.label.name : null;
|
|
196
|
+
address += name !== null && IDENT.test(name) ? `.${name}` : `.#${field.id}`;
|
|
197
|
+
ref = field.type;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// trap 6: a position inside a func or service value has no v0 address
|
|
201
|
+
if (s.step === "args" || s.step === "results" || s.step === "methods" || s.step === "function") throw new DocError("E_DOC_TARGET", `${path} is inside a func or service value, which v0 addresses cannot reach`);
|
|
202
|
+
throw new DocError("E_DOC_TARGET", `cannot follow ${path} through the Contract`);
|
|
203
|
+
}
|
|
204
|
+
return { address, ref };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function readSteps(text) {
|
|
209
|
+
const steps = [];
|
|
210
|
+
let i = 0;
|
|
211
|
+
while (i < text.length) {
|
|
212
|
+
STEP.lastIndex = i;
|
|
213
|
+
const m = STEP.exec(text);
|
|
214
|
+
if (m === null) throw new DocError("E_DOC_TARGET", `cannot read the occurrence path step "${text.slice(i)}"`);
|
|
215
|
+
steps.push({ step: m[1], index: m[2] === undefined ? undefined : Number(m[2]), text: m[0] });
|
|
216
|
+
i = STEP.lastIndex;
|
|
217
|
+
}
|
|
218
|
+
return steps;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function placeItem(graph, item, annotations, diagnostics) {
|
|
222
|
+
// `at` is the item's address once it has one, else its SourceInfo path; `path` and `source` say where the comment is
|
|
223
|
+
let at = item.path;
|
|
224
|
+
const where = { path: item.path, ...(item.origin?.source ? { source: item.origin.source } : {}) };
|
|
225
|
+
const diag = (severity, code, line, name, message) => diagnostics.push({
|
|
226
|
+
severity, code, at, message: line === null ? message : `doc line ${line + 1}: @${name}: ${message}`, ...where,
|
|
227
|
+
});
|
|
228
|
+
let place;
|
|
229
|
+
try {
|
|
230
|
+
place = graph.place(item);
|
|
231
|
+
at = place.kind === "method" ? place.method : place.kind === "value" ? place.address : "actor";
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (!(e instanceof DocError)) throw e;
|
|
234
|
+
for (const a of item.attributes) {
|
|
235
|
+
if (TAGS.has(a.name)) diag("error", e.code, a.doc_line, a.name, e.message);
|
|
236
|
+
}
|
|
237
|
+
if (prose(item) !== null) diag("info", "I_DOC_PROSE", null, null, `the prose is not attached: ${e.message}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const own = []; // this item's annotations, for the prose rule
|
|
241
|
+
for (const a of item.attributes) {
|
|
242
|
+
if (!TAGS.has(a.name)) {
|
|
243
|
+
if (a.name.includes(".")) diag("info", "I_DOC_IGNORED", a.doc_line, a.name, "a dotted name belongs to another tool; not a profile tag");
|
|
244
|
+
else {
|
|
245
|
+
const near = nearestTag(a.name);
|
|
246
|
+
if (near !== null) diag("warning", "W_DOC_UNKNOWN_TAG", a.doc_line, a.name, `not a profile tag; did you mean @${near}?`);
|
|
247
|
+
else diag("info", "I_DOC_IGNORED", a.doc_line, a.name, "not a profile tag; left for other tools");
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const args = {};
|
|
253
|
+
let target;
|
|
254
|
+
for (const { key, value } of a.args) {
|
|
255
|
+
if (key === "at") {
|
|
256
|
+
if (value?.kind !== "text") throw new DocError("E_DOC_VALUE", "`at` is a path written as text, for example at = \"amount\" or at = \"->(0).Ok\"");
|
|
257
|
+
target = value.value;
|
|
258
|
+
} else args[key] = jsonValue(value, `${a.name}.${key}`);
|
|
259
|
+
}
|
|
260
|
+
const address = targetOf(graph, place, a.name, target);
|
|
261
|
+
const annotation = { at: address, tag: a.name, args };
|
|
262
|
+
annotations.push(annotation);
|
|
263
|
+
own.push({ annotation, explicitAt: target !== undefined });
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (!(e instanceof DocError)) throw e;
|
|
266
|
+
diag("error", e.code, a.doc_line, a.name, e.message);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const help = prose(item);
|
|
270
|
+
if (help === null) return;
|
|
271
|
+
const skip = (why) => diag("info", "I_DOC_PROSE", null, null, `the prose is not attached: ${why}`);
|
|
272
|
+
if (place.kind === "actor") return skip("no tag describes the service as a whole");
|
|
273
|
+
const self = place.kind === "method" ? place.method : place.address;
|
|
274
|
+
const describe = own.find((o) => o.annotation.tag === "describe" && !o.explicitAt);
|
|
275
|
+
if (describe !== undefined) {
|
|
276
|
+
if (describe.annotation.args.help !== undefined) return skip("@describe already gives help");
|
|
277
|
+
describe.annotation.args.help = help;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (place.kind === "value") {
|
|
281
|
+
const kind = graph.kindOf(place.ref);
|
|
282
|
+
if (item.kind === "declaration" && kind !== "record" && kind !== "variant") {
|
|
283
|
+
return skip(`type:${item.name} is a ${kind}, and only a record or a variant can be described as a whole (spec 3.2 rule 8); describe the fields or methods that use it`);
|
|
284
|
+
}
|
|
285
|
+
const read = own.some((o) => !o.explicitAt && READING_TAGS.has(o.annotation.tag));
|
|
286
|
+
if (NUMERIC.has(kind) && !read) {
|
|
287
|
+
return skip("as describe.help it would make this untagged integer an agent input of plain digits (D-044); add a value tag such as @amount, or an explicit @describe if plain digits are right");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
annotations.push({ at: self, tag: "describe", args: { help } });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The address an attribute lands on, or a DocError. */
|
|
294
|
+
function targetOf(graph, place, tag, target) {
|
|
295
|
+
const methodTag = METHOD_TAGS.has(tag);
|
|
296
|
+
if (place.kind === "actor") throw new DocError("E_DOC_TARGET", "no tag applies to the service as a whole; put it on a method, a declaration or a field");
|
|
297
|
+
if (place.kind === "method") {
|
|
298
|
+
if (methodTag) {
|
|
299
|
+
if (target !== undefined) throw new DocError("E_DOC_TARGET", "a method tag applies to the method itself and takes no `at`");
|
|
300
|
+
return place.method;
|
|
301
|
+
}
|
|
302
|
+
if (target === undefined) {
|
|
303
|
+
if (tag === "describe") return place.method;
|
|
304
|
+
throw new DocError("E_DOC_TARGET", "a value tag on a method needs `at`: at = \"<path>\" for an argument, at = \"-><path>\" for a result");
|
|
305
|
+
}
|
|
306
|
+
const results = target.startsWith("->");
|
|
307
|
+
const rel = results ? target.slice(2) : target;
|
|
308
|
+
if (rel === "" || !RELPATH.test(rel)) throw new DocError("E_DOC_TARGET", `at = ${JSON.stringify(target)} is not a relative path (spec 3.1 relpath)`);
|
|
309
|
+
const m = /^\((\d+)\)/.exec(rel);
|
|
310
|
+
const tuple = results ? place.func.results : place.func.args;
|
|
311
|
+
if (m === null && tuple.length !== 1) throw new DocError("E_DOC_TARGET", `${place.method} has ${tuple.length} ${results ? "results" : "arguments"}, so at must start with (i) (spec 3.2 rule 9)`);
|
|
312
|
+
return `${place.method}${results ? "->" : ""}${m === null ? "(0)" : m[0]}${tail(m === null ? rel : rel.slice(m[0].length))}`;
|
|
313
|
+
}
|
|
314
|
+
if (methodTag) throw new DocError("E_DOC_TARGET", "a method tag belongs on a method's doc comment");
|
|
315
|
+
if (target === undefined) return place.address;
|
|
316
|
+
if (target.startsWith("->") || target.startsWith("(") || !RELPATH.test(target)) {
|
|
317
|
+
throw new DocError("E_DOC_TARGET", `at = ${JSON.stringify(target)}: on a field or a declaration, at is a path inside it, such as "owner" or "[].amount"`);
|
|
318
|
+
}
|
|
319
|
+
return `${place.address}${tail(target)}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A relpath tail as address segments: "amount" -> ".amount", ".a[]" and "[]" stay. */
|
|
323
|
+
function tail(rel) {
|
|
324
|
+
if (rel === "") return "";
|
|
325
|
+
const text = rel.startsWith(".") || rel.startsWith("[") ? rel : `.${rel}`;
|
|
326
|
+
if (parsePath(text) === null) throw new DocError("E_DOC_TARGET", `cannot read the path ${JSON.stringify(rel)}`);
|
|
327
|
+
return text;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A doc value as the JSON a profile holds (spec section 4), or a DocError. */
|
|
331
|
+
function jsonValue(v, where) {
|
|
332
|
+
switch (v?.kind) {
|
|
333
|
+
case "text":
|
|
334
|
+
case "ident":
|
|
335
|
+
return v.value;
|
|
336
|
+
case "bool":
|
|
337
|
+
return v.value;
|
|
338
|
+
case "number": {
|
|
339
|
+
// no silent rounding (spec 5.3, D-006): a fraction is refused, an integer is kept exact
|
|
340
|
+
if (!/^-?(0|[1-9][0-9]*)$/.test(v.value)) throw new DocError("E_DOC_VALUE", `${v.value} is not a whole number; no tag argument takes a fraction`);
|
|
341
|
+
const n = BigInt(v.value);
|
|
342
|
+
if (STRING_INTEGERS.has(where)) return n.toString();
|
|
343
|
+
return n >= BigInt(Number.MIN_SAFE_INTEGER) && n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : n.toString();
|
|
344
|
+
}
|
|
345
|
+
case "call": {
|
|
346
|
+
if (v.name === "query") {
|
|
347
|
+
const [m] = v.args;
|
|
348
|
+
if (v.args.length !== 1 || (m.kind !== "ident" && m.kind !== "text") || !IDENT.test(m.value)) throw new DocError("E_DOC_VALUE", "query(...) takes one method name, for example query(decimals)");
|
|
349
|
+
return { from: `query:${m.value}` };
|
|
350
|
+
}
|
|
351
|
+
if (v.name === "now" || v.name === "caller") {
|
|
352
|
+
if (v.args.length !== 0) throw new DocError("E_DOC_VALUE", `${v.name}() takes no arguments`);
|
|
353
|
+
return { from: v.name };
|
|
354
|
+
}
|
|
355
|
+
throw new DocError("E_DOC_VALUE", `${v.name}(...) is not a dynamic value; the dynamic values are query(<method>), now() and caller() (spec 4)`);
|
|
356
|
+
}
|
|
357
|
+
case "list":
|
|
358
|
+
return v.items.map((x) => jsonValue(x, where));
|
|
359
|
+
case "record":
|
|
360
|
+
return Object.fromEntries(v.fields.map((f) => [f.key, jsonValue(f.value, `${where}.${f.key}`)]));
|
|
361
|
+
default:
|
|
362
|
+
throw new DocError("E_DOC_VALUE", `a value of kind ${JSON.stringify(v?.kind)} is not one this compiler reads`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** An item's prose as describe.help: lines of a paragraph joined by spaces, paragraphs by a blank line. */
|
|
367
|
+
function prose(item) {
|
|
368
|
+
const paragraphs = [];
|
|
369
|
+
let current = [];
|
|
370
|
+
for (const line of item.prose ?? []) {
|
|
371
|
+
if (line === "") {
|
|
372
|
+
if (current.length) paragraphs.push(current.join(" "));
|
|
373
|
+
current = [];
|
|
374
|
+
} else current.push(line);
|
|
375
|
+
}
|
|
376
|
+
if (current.length) paragraphs.push(current.join(" "));
|
|
377
|
+
return paragraphs.length ? paragraphs.join("\n\n") : null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The profile tag one or two edits from a name, if exactly one is. */
|
|
381
|
+
function nearestTag(name) {
|
|
382
|
+
const near = [...TAGS].filter((t) => editDistance(t, name) <= 2 && Math.min(t.length, name.length) > 2);
|
|
383
|
+
return near.length === 1 ? near[0] : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function editDistance(a, b) {
|
|
387
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
388
|
+
for (let i = 1; i <= a.length; i++) {
|
|
389
|
+
const row = [i];
|
|
390
|
+
for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
391
|
+
prev = row;
|
|
392
|
+
}
|
|
393
|
+
return prev[b.length];
|
|
394
|
+
}
|
package/src/evm.d.mts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Types for @meantjs/profile/evm: the EVM helpers of spec section 15 (experimental), with no dependency, for a
|
|
2
|
+
// browser bundle. Hand-written; the source is src/evm.mjs, src/abi.mjs and src/keccak.mjs.
|
|
3
|
+
// test/types.test.mjs keeps the declared names equal to the runtime exports.
|
|
4
|
+
|
|
5
|
+
/** An ABI type: `kind` is uint, int, address, bool, string, bytes, fixedbytes, array or tuple. */
|
|
6
|
+
export type AbiType =
|
|
7
|
+
| { kind: "uint" | "int"; bits: number }
|
|
8
|
+
| { kind: "address" | "bool" | "string" | "bytes" }
|
|
9
|
+
| { kind: "fixedbytes"; size: number }
|
|
10
|
+
| { kind: "array"; element: AbiType; length: number | null }
|
|
11
|
+
| { kind: "tuple"; components: AbiParam[] };
|
|
12
|
+
/** A parameter, output or tuple component; `name` is null when it has none. */
|
|
13
|
+
export interface AbiParam {
|
|
14
|
+
name: string | null;
|
|
15
|
+
type: AbiType;
|
|
16
|
+
}
|
|
17
|
+
/** A function read from an ABI. */
|
|
18
|
+
export interface AbiFunction {
|
|
19
|
+
name: string;
|
|
20
|
+
inputs: AbiParam[];
|
|
21
|
+
outputs: AbiParam[];
|
|
22
|
+
stateMutability: "pure" | "view" | "nonpayable" | "payable";
|
|
23
|
+
/** `transfer(address to,uint256 value)`: the ERC-7730 key, and a resolved form's `signature`. */
|
|
24
|
+
signature: string;
|
|
25
|
+
/** `transfer(address,uint256)`: what the selector hashes. */
|
|
26
|
+
typeSignature: string;
|
|
27
|
+
/** `0xa9059cbb` */
|
|
28
|
+
selector: string;
|
|
29
|
+
/** `function transfer(address to,uint256 value) returns (bool)` */
|
|
30
|
+
canonical: string;
|
|
31
|
+
/** Where it was read (`abi[3]`); parseSignature leaves it out. */
|
|
32
|
+
at?: string;
|
|
33
|
+
}
|
|
34
|
+
/** A decoded or encodable value: decimal strings (or bigints) for integers, EIP-55 text for addresses, Uint8Array for bytes. */
|
|
35
|
+
export type AbiValue = string | bigint | boolean | Uint8Array | AbiValue[] | { [key: string]: AbiValue };
|
|
36
|
+
export interface AbiLimits {
|
|
37
|
+
maxBytes?: number;
|
|
38
|
+
maxDepth?: number;
|
|
39
|
+
maxElements?: number;
|
|
40
|
+
}
|
|
41
|
+
/** Every failure of the ABI helpers: E_ABI (a fragment), E_ABI_ENCODE, E_ABI_DECODE, E_ADDRESS or E_ADDRESS_CHECKSUM. */
|
|
42
|
+
export declare class AbiError extends Error {
|
|
43
|
+
constructor(code: string, message: string);
|
|
44
|
+
name: "AbiError";
|
|
45
|
+
code: string;
|
|
46
|
+
}
|
|
47
|
+
export interface TypedData {
|
|
48
|
+
domain: Record<string, unknown>;
|
|
49
|
+
types: Record<string, { name: string; type: string }[]>;
|
|
50
|
+
primaryType: string;
|
|
51
|
+
message: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Keccak-256 (Ethereum's, not NIST SHA3-256) of bytes or of a string's UTF-8 bytes. */
|
|
55
|
+
export function keccak256(input: Uint8Array | string): Uint8Array;
|
|
56
|
+
/** Keccak-256 as 64 lowercase hex digits, without `0x`. */
|
|
57
|
+
export function keccak256Hex(input: Uint8Array | string): string;
|
|
58
|
+
/** RFC 8785 canonical JSON. */
|
|
59
|
+
export function jcs(value: unknown): string;
|
|
60
|
+
export const DEFAULT_ABI_LIMITS: Readonly<Required<AbiLimits>>;
|
|
61
|
+
/** Read an ABI (human-readable fragments or JSON ABI entries); only functions are read. Problems are listed, not thrown. */
|
|
62
|
+
export function parseAbi(entries: unknown, label?: string): { functions: AbiFunction[]; errors: { at: string; message: string }[] };
|
|
63
|
+
/** One function from its named signature, as a resolved form carries it. Throws AbiError. */
|
|
64
|
+
export function parseSignature(signature: string, where?: string): AbiFunction;
|
|
65
|
+
/** `candid-profile:evm-abi:v0:sha256:…`: SHA-256 over the JCS array of the canonical fragments, sorted. */
|
|
66
|
+
export function abiInterfaceId(functions: readonly AbiFunction[]): string;
|
|
67
|
+
export function canonicalFragment(f: Pick<AbiFunction, "name" | "inputs" | "outputs" | "stateMutability">): string;
|
|
68
|
+
export function namedSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
|
|
69
|
+
export function typeSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
|
|
70
|
+
export function selectorOf(f: Pick<AbiFunction, "name" | "inputs">): string;
|
|
71
|
+
/** Canonical type text; with `names`, tuple components keep their names. */
|
|
72
|
+
export function typeString(type: AbiType, names?: boolean): string;
|
|
73
|
+
/** A tuple component's key in values and resolved forms: its name, else `#` and its position. */
|
|
74
|
+
export function componentKey(param: AbiParam, index: number): string;
|
|
75
|
+
/** ABI-encode a parameter list's values, keyed like a tuple. Throws AbiError (E_ABI_ENCODE). */
|
|
76
|
+
export function encodeParams(params: readonly AbiParam[], values: Record<string, AbiValue>): Uint8Array;
|
|
77
|
+
/** The selector followed by the encoded parameters. */
|
|
78
|
+
export function encodeCall(f: AbiFunction, values: Record<string, AbiValue>): Uint8Array;
|
|
79
|
+
/** Decode strictly: the bytes must be the canonical encoding of the values read. Throws AbiError (E_ABI_DECODE). */
|
|
80
|
+
export function decodeParams(params: readonly AbiParam[], data: Uint8Array, limits?: AbiLimits): Record<string, AbiValue>;
|
|
81
|
+
/** The EIP-55 checksummed form of `0x` and 40 hex digits. */
|
|
82
|
+
export function toChecksumAddress(address: string): string;
|
|
83
|
+
/** Read an address: mixed case must match its EIP-55 checksum. Returns the checksummed form. */
|
|
84
|
+
export function readAddress(text: string): string;
|
|
85
|
+
/** The EIP-712 digest a signer signs, as `0x` and 64 hex digits. */
|
|
86
|
+
export function typedDataDigest(typed: TypedData): string;
|
package/src/evm.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@meantjs/profile/evm`: the EVM helpers of spec section 15 (D-054), browser-safe and without dependencies.
|
|
3
|
+
* Keccak-256, ABI fragments (reading, canonical text, selectors, the interface identity), a strict ABI codec, EIP-55
|
|
4
|
+
* addresses and EIP-712 digests. The consent package builds calldata display and the ERC-7730 export on them.
|
|
5
|
+
*
|
|
6
|
+
* test/evm.test.mjs checks the vectors; test/address-entry.test.mjs keeps this entry free of node:* imports.
|
|
7
|
+
*/
|
|
8
|
+
export { keccak256, keccak256Hex } from "./keccak.mjs";
|
|
9
|
+
export {
|
|
10
|
+
AbiError,
|
|
11
|
+
DEFAULT_ABI_LIMITS,
|
|
12
|
+
abiInterfaceId,
|
|
13
|
+
canonicalFragment,
|
|
14
|
+
componentKey,
|
|
15
|
+
decodeParams,
|
|
16
|
+
encodeCall,
|
|
17
|
+
encodeParams,
|
|
18
|
+
namedSignature,
|
|
19
|
+
parseAbi,
|
|
20
|
+
parseSignature,
|
|
21
|
+
readAddress,
|
|
22
|
+
selectorOf,
|
|
23
|
+
toChecksumAddress,
|
|
24
|
+
typeSignature,
|
|
25
|
+
typeString,
|
|
26
|
+
typedDataDigest,
|
|
27
|
+
} from "./abi.mjs";
|
|
28
|
+
export { jcs } from "./util.mjs";
|
package/src/idl.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Candid label ids. Pure and dependency-free: the browser-safe `@meantjs/profile/address` entry imports it.
|
|
2
|
+
|
|
3
|
+
export const U32_MAX = 4294967295;
|
|
4
|
+
|
|
5
|
+
/** Candid field/method id: the hash of the label (Candid spec, "Hash of field names"). */
|
|
6
|
+
export function idlHash(label) {
|
|
7
|
+
let h = 0;
|
|
8
|
+
for (const b of new TextEncoder().encode(label)) h = (Math.imul(h, 223) + b) >>> 0;
|
|
9
|
+
return h;
|
|
10
|
+
}
|
package/src/iface.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A compiled Candid interface: the candid-core Contract graph plus the field-name table, with the lookups the
|
|
3
|
+
* resolver needs (address resolution, opt-transparent walks, shape tests). Moved verbatim from scripts/validate.mjs;
|
|
4
|
+
* the only additions are `label` (used in subtype diagnostics) and `interfaceId`.
|
|
5
|
+
*/
|
|
6
|
+
import { idlHash } from "./util.mjs";
|
|
7
|
+
import { parseRelPath, methodKey, edgeKey } from "./address.mjs";
|
|
8
|
+
|
|
9
|
+
export class Iface {
|
|
10
|
+
/** @param envelope a candid-core ContractEnvelope; @param label how diagnostics name this side ("canister", "pack fragment") */
|
|
11
|
+
constructor(envelope, label = "interface") {
|
|
12
|
+
this.label = label;
|
|
13
|
+
this.contract = envelope.contract;
|
|
14
|
+
this.types = this.contract.types;
|
|
15
|
+
this.names = new Map();
|
|
16
|
+
for (const [c, id, name] of envelope.extensions?.["org.candid-core.field-names/v1"] ?? []) {
|
|
17
|
+
this.names.set(`${c}:${id}`, name);
|
|
18
|
+
}
|
|
19
|
+
this.decls = new Map(this.contract.declarations.map((d) => [d.name, d.type]));
|
|
20
|
+
// Interning: structurally equal declarations share one node. A type: address on one reaches the others (spec §3.2 rule 7).
|
|
21
|
+
this.aliases = new Map();
|
|
22
|
+
for (const d of this.contract.declarations) {
|
|
23
|
+
if (!this.aliases.has(d.type)) this.aliases.set(d.type, []);
|
|
24
|
+
this.aliases.get(d.type).push(d.name);
|
|
25
|
+
}
|
|
26
|
+
this.methods = new Map();
|
|
27
|
+
const actor = this.contract.actor;
|
|
28
|
+
const svcRef = actor?.kind === "service" ? actor.service : actor?.kind === "class" ? this.types[actor.class].service : null;
|
|
29
|
+
if (svcRef !== null && svcRef !== undefined) {
|
|
30
|
+
for (const m of this.types[svcRef].methods) {
|
|
31
|
+
const f = this.types[m.function];
|
|
32
|
+
this.methods.set(m.name, { mode: f.mode, args: f.args, results: f.results });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
get interfaceId() { return this.contract.identities.interface ?? null; }
|
|
37
|
+
node(ref) { return this.types[ref]; }
|
|
38
|
+
unwrapOpt(ref) {
|
|
39
|
+
let optional = false;
|
|
40
|
+
while (this.types[ref].kind === "opt") { optional = true; ref = this.types[ref].inner; }
|
|
41
|
+
return { ref, optional };
|
|
42
|
+
}
|
|
43
|
+
fieldName(container, id) { return this.names.get(`${container}:${id}`) ?? `#${id}`; }
|
|
44
|
+
isBlob(ref) { const n = this.types[ref]; return n.kind === "vec" && this.types[n.inner].kind === "primitive" && this.types[n.inner].primitive === "nat8"; }
|
|
45
|
+
prim(ref) { const n = this.types[ref]; return n.kind === "primitive" ? n.primitive : null; }
|
|
46
|
+
isAccount(ref) {
|
|
47
|
+
const n = this.types[ref];
|
|
48
|
+
if (n.kind !== "record" || n.fields.length !== 2) return false;
|
|
49
|
+
const owner = n.fields.find((f) => f.id === idlHash("owner"));
|
|
50
|
+
const sub = n.fields.find((f) => f.id === idlHash("subaccount"));
|
|
51
|
+
if (!owner || !sub || this.prim(owner.type) !== "principal") return false;
|
|
52
|
+
const s = this.types[sub.type];
|
|
53
|
+
return s.kind === "opt" && this.isBlob(s.inner);
|
|
54
|
+
}
|
|
55
|
+
describeType(ref) {
|
|
56
|
+
const { ref: r, optional } = this.unwrapOpt(ref);
|
|
57
|
+
const n = this.types[r];
|
|
58
|
+
const base = n.kind === "primitive" ? n.primitive : this.isBlob(r) ? "blob" : n.kind;
|
|
59
|
+
return optional ? `opt ${base}` : base;
|
|
60
|
+
}
|
|
61
|
+
/** Walk segments from a start ref. Returns {ref, parent} or {error}. */
|
|
62
|
+
walk(start, segs) {
|
|
63
|
+
let cur = start;
|
|
64
|
+
let parent = null;
|
|
65
|
+
for (const s of segs) {
|
|
66
|
+
cur = this.unwrapOpt(cur).ref;
|
|
67
|
+
const n = this.types[cur];
|
|
68
|
+
if (s.kind === "elem") {
|
|
69
|
+
if (n.kind !== "vec") return { error: `"[]" applied to ${this.describeType(cur)}` };
|
|
70
|
+
parent = { container: cur, kind: "vec" };
|
|
71
|
+
cur = n.inner;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (n.kind !== "record" && n.kind !== "variant") return { error: `".${s.label}" applied to ${this.describeType(cur)}` };
|
|
75
|
+
const f = n.fields.find((x) => x.id === s.id);
|
|
76
|
+
if (!f) return { error: `no ${n.kind === "record" ? "field" : "case"} "${s.label}" in ${n.kind}` };
|
|
77
|
+
parent = { container: cur, kind: n.kind, id: s.id };
|
|
78
|
+
cur = f.type;
|
|
79
|
+
}
|
|
80
|
+
return { ref: cur, parent };
|
|
81
|
+
}
|
|
82
|
+
/** Resolve an address. Returns {ref, parent, key} | {method, key} | {error}. */
|
|
83
|
+
resolve(addr) {
|
|
84
|
+
if (addr.kind === "type") {
|
|
85
|
+
const start = this.decls.get(addr.decl);
|
|
86
|
+
if (start === undefined) return { error: `no declaration "${addr.decl}"` };
|
|
87
|
+
const r = this.walk(start, addr.segs);
|
|
88
|
+
if (r.error) return r;
|
|
89
|
+
if (!r.parent) {
|
|
90
|
+
// Pathless: the whole declared type. Only records and variants, or a primitive alias would tag every such value (spec §3.2 rule 8).
|
|
91
|
+
const kind = this.types[this.unwrapOpt(r.ref).ref].kind;
|
|
92
|
+
if (kind !== "record" && kind !== "variant") {
|
|
93
|
+
const t = this.describeType(r.ref);
|
|
94
|
+
return { error: `pathless type: address on "${addr.decl}" (${t}) would tag every ${t} in the interface; use a field path or a method address`, code: "E_TYPE_NODE" };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { ...r, key: r.parent ? edgeKey(r.parent) : `n:${this.unwrapOpt(r.ref).ref}` };
|
|
98
|
+
}
|
|
99
|
+
const m = this.methods.get(addr.method);
|
|
100
|
+
if (!m) return { error: `no method "${addr.method}"` };
|
|
101
|
+
if (addr.kind === "method") return { method: m, key: `m:${addr.method}` };
|
|
102
|
+
const tuple = addr.kind === "arg" ? m.args : m.results;
|
|
103
|
+
if (addr.index >= tuple.length) return { error: `${addr.kind} index ${addr.index} out of range (${tuple.length})` };
|
|
104
|
+
const r = this.walk(tuple[addr.index], addr.segs);
|
|
105
|
+
if (r.error) return r;
|
|
106
|
+
return { ...r, key: methodKey(addr.method, addr.kind === "arg" ? "arg" : "res", addr.index, addr.segs) };
|
|
107
|
+
}
|
|
108
|
+
resolveRel(methodName, side, rel) {
|
|
109
|
+
const m = this.methods.get(methodName);
|
|
110
|
+
const p = parseRelPath(rel);
|
|
111
|
+
if (!p) return { error: `bad relative path "${rel}"` };
|
|
112
|
+
const tuple = side === "args" ? m.args : m.results;
|
|
113
|
+
let index = p.index;
|
|
114
|
+
if (index === null) {
|
|
115
|
+
if (tuple.length !== 1) return { error: `relative path "${rel}" must start with (i): ${side} tuple has ${tuple.length} elements` };
|
|
116
|
+
index = 0;
|
|
117
|
+
}
|
|
118
|
+
if (index >= tuple.length) return { error: `index ${index} out of range` };
|
|
119
|
+
const r = this.walk(tuple[index], p.segs);
|
|
120
|
+
if (r.error) return r;
|
|
121
|
+
return { ...r, key: methodKey(methodName, side === "args" ? "arg" : "res", index, p.segs) };
|
|
122
|
+
}
|
|
123
|
+
}
|