Change implementation strategy of cashassembly, add scripts, comments support and tests
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user