Files
xo-cash-utils/source/cash-assembly/scan-evaluations.ts
T

273 lines
12 KiB
TypeScript

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;
}
};