import { CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH, CASHASSEMBLY_COMMENT_START_LENGTH, CASHASSEMBLY_EVALUATION_START_LENGTH, EMPTY_CASHASSEMBLY_EVALUATION, } from './defaults.ts'; import { CashAssemblyBlockCommentUnclosedError, CashAssemblyEvaluationUnclosedError, CashAssemblyQuotedLiteralUnclosedError } from './errors.ts'; /** * Skips a quoted CashAssembly string so a `)` inside it cannot end `$()`. * * CashAssembly UTF8 literals use `"..."` or `'...'`. Everything between the quotes is payload, * including parentheses. * For example `$(<"hello)world">)` must close after the literal, not at the `)` inside the quotes. * * @param {string} cashAssemblyText - Text that contains the quoted literal. * @param {number} openingQuoteIndex - Index of the opening `"` or `'`. * @returns {number} - Index of the character after the closing quote. * @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When the quoted literal never closes. */ const skipQuotedCashAssemblyLiteral = (cashAssemblyText: string, openingQuoteIndex: number): number => { const quoteCharacter = cashAssemblyText[openingQuoteIndex]; let currentIndex = openingQuoteIndex + 1; // CashAssembly puts payload `)` inside the quotes. Stop only at the matching quote. while (currentIndex < cashAssemblyText.length) { if (cashAssemblyText[currentIndex] === quoteCharacter) { return currentIndex + 1; } currentIndex += 1; } throw new CashAssemblyQuotedLiteralUnclosedError(openingQuoteIndex, quoteCharacter, cashAssemblyText); }; /** * This function skips a `//` comment so a `)` written in the comment cannot end `$()`. * A line comment runs from `//` to the newline, or to the end of the string when there is no newline. * A `)` in that span is comment text, not the closer of `$()`. * * @param {string} cashAssemblyText - Text that contains the line comment. * @param {number} commentStartIndex - Index of the first `/` of `//`. * @returns {number} - Index after the newline that ends the comment, or the end of the string if there is no newline. */ const skipSingleLineCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => { let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH; // CashAssembly line comments hide `)` until the newline. End of string also ends them. while (currentIndex < cashAssemblyText.length) { if (cashAssemblyText[currentIndex] === '\n') { return currentIndex + 1; } currentIndex += 1; } return currentIndex; }; /** * This function skips a block comment so a `)` written in the comment cannot end `$()`. * A `)` inside a block comment is comment text, not the closer of `$()`. * * @param {string} cashAssemblyText - Text that contains the block comment. * @param {number} commentStartIndex - Index of the first slash of the block comment opener. * @returns {number} - Index of the character after the comment closer. * @throws {@link CashAssemblyBlockCommentUnclosedError} - When the block comment never closes. */ const skipBlockCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => { let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH; // CashAssembly block comments hide `)` until the closer is found. while (currentIndex + 1 < cashAssemblyText.length) { if (cashAssemblyText[currentIndex] === '*' && cashAssemblyText[currentIndex + 1] === '/') { return currentIndex + CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH; } currentIndex += 1; } throw new CashAssemblyBlockCommentUnclosedError(commentStartIndex, cashAssemblyText); }; /** * Parenthesis matching with a depth count. * * Returns the index of the `)` that closes the `$()` starting at `evaluationStartIndex`. * Nested `$()` raise the depth so an inner closer cannot finish the outer evaluation. * * @param {string} cashAssemblyText - Text that contains the evaluation. * @param {number} evaluationStartIndex - Index of the `$` that opens this evaluation. * @returns {number} - Index of the matching closing parenthesis. * @throws {@link CashAssemblyEvaluationUnclosedError} - When the evaluation never closes. * @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal inside the evaluation never closes. * @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment inside the evaluation never closes. */ const findCashAssemblyEvaluationCloseIndex = (cashAssemblyText: string, evaluationStartIndex: number): number => { let currentIndex = evaluationStartIndex + CASHASSEMBLY_EVALUATION_START_LENGTH; let remainingOpenEvaluations = 1; // Scan the evaluation contents until the matching closer at depth zero. while (currentIndex < cashAssemblyText.length) { const currentCharacter = cashAssemblyText[currentIndex]; const nextCharacter = cashAssemblyText[currentIndex + 1]; // CashAssembly allows `)` inside `"..."` and `'...'`. Skip the literal so that closer is not structural. if (currentCharacter === '"' || currentCharacter === "'") { currentIndex = skipQuotedCashAssemblyLiteral(cashAssemblyText, currentIndex); continue; } // CashAssembly `//` comments may mention `)`. Skip them so that `)` is not treated as a closer. if (currentCharacter === '/' && nextCharacter === '/') { currentIndex = skipSingleLineCashAssemblyComment(cashAssemblyText, currentIndex); continue; } // CashAssembly block comments may mention `)`. Skip them so that `)` is not treated as a closer. if (currentCharacter === '/' && nextCharacter === '*') { currentIndex = skipBlockCashAssemblyComment(cashAssemblyText, currentIndex); continue; } // Nested `$()` must close before this evaluation can close. if (currentCharacter === '$' && nextCharacter === '(') { // Since all previous checks have passed then it means this is start of another evaluation, it we must // increment the remainingOpenEvaluations counter and move the currentIndex to the next character. remainingOpenEvaluations += 1; currentIndex += CASHASSEMBLY_EVALUATION_START_LENGTH; continue; } // A closer at depth one ends this evaluation. Inner closers only reduce nested depth. if (currentCharacter === ')') { // Since all previous checks have passed then it means this is a closer for the current evaluation, we must // decrement the remainingOpenEvaluations counter and check if it is now zero. If it is then we have found the // matching closer and we can return the currentIndex. remainingOpenEvaluations -= 1; if (remainingOpenEvaluations === 0) { return currentIndex; } } // Move the currentIndex to the next character. currentIndex += 1; } // The matching closer was never found. throw new CashAssemblyEvaluationUnclosedError(evaluationStartIndex, remainingOpenEvaluations, cashAssemblyText); }; /** * One `$()` found in text that is not nested inside another `$()`. */ type CashAssemblyEvaluationScanMatch = { /** * Complete `$()` text including the opening `$` and the matching closer. */ evaluationText: string; /** * Index of the `$` that opens this evaluation. */ startIndex: number; /** * Index of the matching closing parenthesis. */ closeIndex: number; }; /** * Scans text and returns every `$()` that starts in this string, including empty `$()`. * * Nested `$()` stay inside the outer evaluation. * * @param {string} cashAssemblyText - Text that may contain `$()`. * @returns {CashAssemblyEvaluationScanMatch[]} - Evaluations in left to right order. * @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes. * @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes. * @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes. */ export const scanCashAssemblyEvaluations = (cashAssemblyText: string): CashAssemblyEvaluationScanMatch[] => { const scannedEvaluations: CashAssemblyEvaluationScanMatch[] = []; let currentIndex = 0; // Find `$()` that starts in this string. Nested evaluations stay inside the outer evaluation. while (currentIndex < cashAssemblyText.length) { const currentCharacter = cashAssemblyText[currentIndex]; const nextCharacter = cashAssemblyText[currentIndex + 1]; // A `$` that is followed by `(`. if (currentCharacter === '$' && nextCharacter === '(') { // Count parentheses so quotes, comments, and nested `$()` cannot close this evaluation early. const closeIndex = findCashAssemblyEvaluationCloseIndex(cashAssemblyText, currentIndex); // Slice the complete `$()` so later steps do not re-scan these characters. const evaluationText = cashAssemblyText.slice(currentIndex, closeIndex + 1); // Store the outer `$()`. Nested evaluations are already inside evaluationText. scannedEvaluations.push({ closeIndex, evaluationText, startIndex: currentIndex, }); // Skip to after this `$()` so nested evaluations are not scanned as their own matches. currentIndex = closeIndex + 1; continue; } currentIndex += 1; } return scannedEvaluations; }; /** * This function returns each `$()` that starts in this string. * Empty `$()` is omitted. * * @param {string} cashAssemblyText - Text that may contain `$()`. * @returns {string[]} - Complete non empty evaluation strings in left to right order. * @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes. * @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes. * @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes. */ export const extractCashAssemblyEvaluations = (cashAssemblyText: string): string[] => { const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText); const evaluations: string[] = []; // Omit empty `$()` so callers that compile extracted text do not treat it as an evaluation. for (const scannedEvaluation of scannedEvaluations) { if (scannedEvaluation.evaluationText !== EMPTY_CASHASSEMBLY_EVALUATION) { evaluations.push(scannedEvaluation.evaluationText); } } return evaluations; }; /** * This function returns true when the value is exactly one CashAssembly `$()`. * Text with characters outside `$()`, empty `$()`, or a failed scan returns false. * * @param {unknown} expression - Value to test. * @returns {boolean} - True when the value is a string that is one complete evaluation and nothing else. */ export const isCashAssemblyExpression = (expression: unknown): boolean => { // Numbers and other types are never CashAssembly evaluations. if (typeof expression !== 'string') { return false; } try { const evaluations = extractCashAssemblyEvaluations(expression); // If there is only one evaluation extracted and the returned value matches to that of the expression passed in the parameter, // then return true. return evaluations.length === 1 && evaluations[0] === expression; } catch { // A failed scan is not a complete evaluation. return false; } };