273 lines
12 KiB
TypeScript
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;
|
|
}
|
|
};
|