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; /** * Script ids whose sources were already visited. */ visitedScriptIdentifiers: ReadonlySet; /** * Pending script ids queued to visit. */ scriptIdentifiersToVisit: string[]; /** * WalletData variable names. */ variableNames: Set; }; /** * 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} knownScriptIdentifiers - Script ids present in the template scripts map. * @param {ReadonlySet} visitedScriptIdentifiers - Script ids whose sources were already visited. * @param {string[]} scriptIdentifiersToVisit - Pending script ids queued to visit. */ const enqueueReachableTemplateScriptIdentifier = ( scriptIdentifier: string, knownScriptIdentifiers: ReadonlySet, visitedScriptIdentifiers: ReadonlySet, 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 `` 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 `<$( OP_HASH160)>` collects `ownerKey.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(); const scriptIdentifiersToVisit: string[] = []; const variableNames = new Set(); // 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 ]; };