Files
xo-cash-utils/source/cash-assembly/collect-evaluations.ts
T
2026-10-05 12:59:22 +00:00

258 lines
9.6 KiB
TypeScript

import { describeExpectedInput, parseScript } from '@bitauth/libauth';
import type { CashAssemblyScriptSegment } from '@bitauth/libauth';
import { CashAssemblyCompilationFailedError } from './errors.ts';
import type { CashAssemblyTemplateScripts } from './types.ts';
/**
* Shared state while scanning a parse tree and visiting nested template scripts.
*/
type CollectFromParseTreeContext = {
/**
* Script ids present in the template scripts map.
*/
knownScriptIdentifiers: ReadonlySet<string>;
/**
* Script ids whose sources were already visited.
*/
visitedScriptIdentifiers: ReadonlySet<string>;
/**
* Pending script ids queued to visit.
*/
scriptIdentifiersToVisit: string[];
/**
* WalletData variable names.
*/
variableNames: Set<string>;
};
/**
* Parameters for scanning one parsed Script node.
*/
type CollectFromParsedScriptParameters = CollectFromParseTreeContext & {
/**
* Parsed Script whose children are Push, Evaluation, Identifier, or literals.
*/
scriptSegment: CashAssemblyScriptSegment;
/**
* True when this Script is the value of a Push.
* Direct Identifier children are WalletData unless they are template script ids.
*/
isDirectPushContent: boolean;
};
/**
* Parameters for handling one Identifier node.
*/
type CollectFromParsedIdentifierParameters = CollectFromParseTreeContext & {
/**
* Identifier text from parseScript, which may include `.` and `_`.
*/
identifier: string;
/**
* True when this Identifier is a direct child of a Push Script.
*/
isDirectPushContent: boolean;
};
/**
* Enqueues a template script id so its source can be visited when it exists in the scripts map.
*
* @param {string} scriptIdentifier - Script id from an evaluation or nested script source.
* @param {ReadonlySet<string>} knownScriptIdentifiers - Script ids present in the template scripts map.
* @param {ReadonlySet<string>} visitedScriptIdentifiers - Script ids whose sources were already visited.
* @param {string[]} scriptIdentifiersToVisit - Pending script ids queued to visit.
*/
const enqueueReachableTemplateScriptIdentifier = (
scriptIdentifier: string,
knownScriptIdentifiers: ReadonlySet<string>,
visitedScriptIdentifiers: ReadonlySet<string>,
scriptIdentifiersToVisit: string[],
): void => {
// Return when this id is not a template script.
if (knownScriptIdentifiers.has(scriptIdentifier) === false) {
return;
}
// Skip ids whose sources were already visited.
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
return;
}
// Avoid duplicate queue entries when the same id is referenced more than once.
if (scriptIdentifiersToVisit.includes(scriptIdentifier) === true) {
return;
}
// Queue this id so its source is parsed after the current scan finishes.
scriptIdentifiersToVisit.push(scriptIdentifier);
};
/**
* Parses CashAssembly source with parseScript and throws when the parse fails.
*
* @param {string} cashAssemblyText - Evaluation or script source to parse.
* @returns {CashAssemblyScriptSegment} - Parsed Script root.
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the source.
*/
const parseCashAssemblySource = (cashAssemblyText: string): CashAssemblyScriptSegment => {
// Parse so Push and Identifier nodes are available for collection.
const parseResult = parseScript(cashAssemblyText);
// parseScript returns a union. status false is the failure arm and must not be read as a Script.
if (parseResult.status === false) {
// describeExpectedInput turns the expected input list into readable text for the typed error.
const expectedInputDescription = describeExpectedInput(parseResult.expected);
throw new CashAssemblyCompilationFailedError(`${expectedInputDescription} Line ${String(parseResult.index.line)}, column ${String(parseResult.index.column)}.`);
}
return parseResult.value;
};
/**
* Identifier visitor for the parseScript tree.
* Records WalletData or queues a nested script from one Identifier node.
*
* @param {CollectFromParsedIdentifierParameters} parameters - Identifier text and the parse tree context.
*/
const collectFromParsedIdentifier = (parameters: CollectFromParsedIdentifierParameters): void => {
const { identifier, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } = parameters;
// Script ids are visited whether they appear in a Push or beside opcodes.
if (knownScriptIdentifiers.has(identifier) === true) {
enqueueReachableTemplateScriptIdentifier(identifier, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit);
return;
}
// Direct Push names that are not script ids are WalletData, including dotted names.
if (isDirectPushContent === true) {
variableNames.add(identifier);
}
};
/**
* Visitor for one Script node in the parseScript tree.
* Recurses into Push and Evaluation children and dispatches Identifier nodes.
*
* @param {CollectFromParsedScriptParameters} parameters - Parsed Script, Push depth flag, and parse tree context.
*/
const collectFromParsedScript = (parameters: CollectFromParsedScriptParameters): void => {
const { scriptSegment, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } =
parameters;
for (const child of scriptSegment.value) {
if (child.name === 'Push') {
// Inner Script of a Push is where `<name>` WalletData lives, including nested angle brackets.
collectFromParsedScript({
scriptSegment: child.value,
isDirectPushContent: true,
knownScriptIdentifiers,
visitedScriptIdentifiers,
scriptIdentifiersToVisit,
variableNames,
});
continue;
}
if (child.name === 'Evaluation') {
// Evaluation contents are opcodes and nested Pushes, not WalletData by themselves.
collectFromParsedScript({
scriptSegment: child.value,
isDirectPushContent: false,
knownScriptIdentifiers,
visitedScriptIdentifiers,
scriptIdentifiersToVisit,
variableNames,
});
continue;
}
if (child.name === 'Identifier') {
collectFromParsedIdentifier({
identifier: child.value,
isDirectPushContent,
knownScriptIdentifiers,
visitedScriptIdentifiers,
scriptIdentifiersToVisit,
variableNames,
});
}
}
};
/**
* Collects WalletData names from the parseScript tree using the visitor pattern.
*
* parseScript returns a tree of Script, Push, Evaluation, and Identifier nodes. Nested Push and
* Evaluation nodes are entered immediately. A template script id is queued whether it appears inside a Push or next to
* opcodes. Queued script sources are visited after the current tree finishes, in the order they were
* first seen.
* A visited id is skipped on later encounters. Opcodes and literals are ignored. Unrelated scripts
* in the map are not visited. An Identifier inside a nested Push is collected from that inner Push,
* for example `<$(<someKey.public_key> OP_HASH160)>` collects `someKey.public_key`.
*
* @param {string} evaluation - CashAssembly evaluation or script id text to scan.
* @param {CashAssemblyTemplateScripts} [templateScripts] - Optional template scripts map used to visit nested script ids.
* @returns {string[]} - WalletData names from reachable Pushes. Script ids are omitted when
* `templateScripts` is provided.
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the starting text or a visited script source.
*/
export const collectVariablesUsingParseScript = (evaluation: string, templateScripts: CashAssemblyTemplateScripts = {}): string[] => {
// Create a set of the script identifiers from the template scripts map.
const knownScriptIdentifiers = new Set(Object.keys(templateScripts));
const visitedScriptIdentifiers = new Set<string>();
const scriptIdentifiersToVisit: string[] = [];
const variableNames = new Set<string>();
// Share by reference context.
const parseTreeContext: CollectFromParseTreeContext = {
knownScriptIdentifiers,
visitedScriptIdentifiers,
scriptIdentifiersToVisit,
variableNames,
};
// Parse the starting text so nested Pushes are visible before visiting nested script sources.
const startingScriptSegment = parseCashAssemblySource(evaluation);
// Scan the starting parse tree before visiting nested script sources.
collectFromParsedScript({
...parseTreeContext,
scriptSegment: startingScriptSegment,
isDirectPushContent: false,
});
for (const scriptIdentifier of scriptIdentifiersToVisit) {
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
continue;
}
visitedScriptIdentifiers.add(scriptIdentifier);
// Get the script definition from the template scripts map.
const scriptDefinition = templateScripts[scriptIdentifier];
const nestedScriptSegment = parseCashAssemblySource(scriptDefinition);
collectFromParsedScript({
...parseTreeContext,
scriptSegment: nestedScriptSegment,
isDirectPushContent: false,
});
}
return [ ...variableNames ];
};