diff --git a/packages/oakscript-engine/src/transpiler/PineParser.ts b/packages/oakscript-engine/src/transpiler/PineParser.ts index 0d13b5e..a947053 100644 --- a/packages/oakscript-engine/src/transpiler/PineParser.ts +++ b/packages/oakscript-engine/src/transpiler/PineParser.ts @@ -8,6 +8,12 @@ export interface ASTNode { type: string; value?: string | number | boolean; children?: ASTNode[]; + /** Variable name for for loops, for-in loops, and tuple destructuring */ + name?: string; + /** Operator symbol (e.g., ':=' for reassignment) */ + operator?: string; + /** Step value expression for for loops with 'by' clause */ + step?: ASTNode; location?: { line: number; column: number; @@ -100,6 +106,40 @@ export class PineParser { return this.parseVariableDeclaration(); } + // Parse if statement + if (this.matchKeyword('if')) { + return this.parseIfStatement(); + } + + // Parse for loop + if (this.matchKeyword('for')) { + return this.parseForLoop(); + } + + // Parse while loop + if (this.matchKeyword('while')) { + return this.parseWhileLoop(); + } + + // Parse break statement + if (this.matchKeyword('break')) { + return { type: 'BreakStatement' }; + } + + // Parse continue statement + if (this.matchKeyword('continue')) { + return { type: 'ContinueStatement' }; + } + + // Parse tuple destructuring: [a, b, c] = ... + if (this.peek() === '[') { + const tuple = this.parseTupleDestructuring(); + if (tuple) { + return tuple; + } + // parseTupleDestructuring restores position internally if parsing fails + } + // Parse expressions/assignments return this.parseExpressionStatement(); } @@ -162,14 +202,500 @@ export class PineParser { }; } + private parseIfStatement(): ASTNode { + // 'if' keyword already matched + this.skipWhitespace(); + const condition = this.parseExpression(); + + // Skip to end of line + this.skipToEndOfLine(); + + // Parse if body + const body = this.parseIndentedBlock(); + + // Check for else if / else + this.skipWhitespace(); + let alternate: ASTNode | undefined; + + if (this.matchKeyword('else')) { + this.skipWhitespace(); + if (this.matchKeyword('if')) { + // else if - recursively parse another if statement + alternate = this.parseIfStatement(); + } else { + // else block + this.skipToEndOfLine(); + const elseBody = this.parseIndentedBlock(); + alternate = { + type: 'Block', + children: elseBody, + }; + } + } + + return { + type: 'IfStatement', + children: [ + condition, + { type: 'Block', children: body }, + ...(alternate ? [alternate] : []), + ], + }; + } + + private parseForLoop(): ASTNode { + // 'for' keyword already matched + this.skipWhitespace(); + + // Check for for-in loop: for item in array OR for [index, item] in array + if (this.peek() === '[') { + return this.parseForInLoopWithDestructuring(); + } + + const varName = this.parseIdentifier(); + this.skipWhitespace(); + + // Check if it's a for-in loop + if (this.matchKeyword('in')) { + return this.parseForInLoopContinued(varName); + } + + // Standard for loop: for i = start to end [by step] + if (this.peek() !== '=') { + this.errors.push({ + message: 'Expected "=" or "in" in for loop', + line: this.line, + column: this.column, + }); + } + this.advance(); // skip '=' + this.skipWhitespace(); + + const start = this.parseExpression(); + this.skipWhitespace(); + + if (!this.matchKeyword('to')) { + this.errors.push({ + message: 'Expected "to" in for loop', + line: this.line, + column: this.column, + }); + } + this.skipWhitespace(); + + const end = this.parseExpression(); + this.skipWhitespace(); + + // Check for optional 'by' step + let step: ASTNode | undefined; + if (this.matchKeyword('by')) { + this.skipWhitespace(); + step = this.parseExpression(); + } + + this.skipToEndOfLine(); + const body = this.parseIndentedBlock(); + + return { + type: 'ForLoop', + name: varName, // loop variable name + step: step, + children: [ + start, // children[0] = start + end, // children[1] = end + { type: 'Block', children: body }, // children[2] = body + ], + }; + } + + private parseForInLoopWithDestructuring(): ASTNode { + // Parse [index, item] destructuring + this.advance(); // skip '[' + const vars: string[] = []; + while (this.peek() !== ']' && this.position < this.source.length) { + this.skipWhitespace(); + const varName = this.parseIdentifier(); + if (varName) vars.push(varName); + this.skipWhitespace(); + if (this.peek() === ',') { + this.advance(); + } + } + if (this.peek() === ']') { + this.advance(); + } + this.skipWhitespace(); + + if (!this.matchKeyword('in')) { + this.errors.push({ + message: 'Expected "in" in for loop', + line: this.line, + column: this.column, + }); + } + this.skipWhitespace(); + + const iterable = this.parseExpression(); + this.skipToEndOfLine(); + const body = this.parseIndentedBlock(); + + return { + type: 'ForInLoop', + name: vars.join(','), // comma-separated variable names + children: [ + iterable, + { type: 'Block', children: body }, + ], + }; + } + + private parseForInLoopContinued(varName: string): ASTNode { + // 'in' keyword already matched + this.skipWhitespace(); + const iterable = this.parseExpression(); + this.skipToEndOfLine(); + const body = this.parseIndentedBlock(); + + return { + type: 'ForInLoop', + name: varName, + children: [ + iterable, + { type: 'Block', children: body }, + ], + }; + } + + private parseWhileLoop(): ASTNode { + // 'while' keyword already matched + this.skipWhitespace(); + const condition = this.parseExpression(); + this.skipToEndOfLine(); + const body = this.parseIndentedBlock(); + + return { + type: 'WhileLoop', + children: [ + condition, + { type: 'Block', children: body }, + ], + }; + } + + private parseSwitchExpression(): ASTNode { + // 'switch' keyword already consumed by parseIdentifier + this.skipWhitespace(); + + // Optional switch expression (might be empty for condition-based switch) + let switchExpr: ASTNode | undefined; + if (this.peek() !== '\n' && this.position < this.source.length) { + // Use parseTernary to avoid treating => as assignment + switchExpr = this.parseTernary(); + } + + // Skip to end of current line + this.skipToEndOfLine(); + + // Parse switch cases + const cases: ASTNode[] = []; + + while (this.position < this.source.length) { + // Get the indentation at the start of the current line + const currentIndent = this.getLineIndent(); + + // If no indent or empty line, check if we should continue + if (currentIndent === -1) { + this.skipLine(); + continue; + } + + // Switch cases should be indented; if not, we're done with the switch + if (currentIndent === 0) { + break; + } + + // Skip indentation whitespace + this.consumeIndent(); + + // Check for default case: => result + if (this.peek() === '=' && this.peekNext() === '>') { + this.advance(); // skip '=' + this.advance(); // skip '>' + this.skipWhitespace(); + const result = this.parseExpression(); + cases.push({ + type: 'SwitchDefault', + children: [result], + }); + this.skipToEndOfLine(); + continue; + } + + // Parse case value - use parseTernary to avoid consuming => + const caseValue = this.parseTernary(); + this.skipWhitespace(); + + // Expect '=>' + if (this.peek() === '=' && this.peekNext() === '>') { + this.advance(); + this.advance(); + } else { + // Not a valid case line, stop parsing + break; + } + this.skipWhitespace(); + + // Parse result expression + const result = this.parseExpression(); + cases.push({ + type: 'SwitchCase', + children: [caseValue, result], + }); + this.skipToEndOfLine(); + } + + return { + type: 'SwitchExpression', + children: [ + ...(switchExpr ? [switchExpr] : []), + ...cases, + ], + }; + } + + private parseTupleDestructuring(): ASTNode | null { + // '[' already checked, save position before '[' for potential backtrack + const savedPosition = this.position; + this.advance(); // skip '[' + const vars: string[] = []; + + while (this.peek() !== ']' && this.position < this.source.length) { + this.skipWhitespace(); + if (!this.isAlpha(this.peek())) { + // Not an identifier, restore and return null + this.position = savedPosition; + return null; + } + const varName = this.parseIdentifier(); + if (varName) vars.push(varName); + this.skipWhitespace(); + if (this.peek() === ',') { + this.advance(); + } + } + + if (this.peek() !== ']') { + this.position = savedPosition; + return null; + } + this.advance(); // skip ']' + this.skipWhitespace(); + + // Must have '=' after tuple + if (this.peek() !== '=') { + this.position = savedPosition; + return null; + } + this.advance(); // skip '=' + this.skipWhitespace(); + + const initializer = this.parseExpression(); + + return { + type: 'TupleDestructuring', + name: vars.join(','), + children: [initializer], + }; + } + + private parseIndentedBlock(): ASTNode[] { + const statements: ASTNode[] = []; + + // If we're at end of source, return empty + if (this.position >= this.source.length) { + return statements; + } + + // Calculate the expected indentation for block content + // After a newline, the block content should be indented + const blockIndent = this.getLineIndent(); + + // Block content should be indented (>0) + if (blockIndent <= 0) { + return statements; + } + + while (this.position < this.source.length) { + // Check the indentation of the current position + const currentIndent = this.getLineIndent(); + + // Block ends when indentation decreases below block indent level + if (currentIndent < blockIndent && currentIndent !== -1) { + break; + } + + // Skip lines that are less indented (shouldn't happen) or empty (-1) + if (currentIndent === -1) { + this.skipLine(); + continue; + } + + // Consume the indentation whitespace + this.consumeIndent(); + + const stmt = this.parseStatement(); + if (stmt) { + statements.push(stmt); + } + + // If we've consumed to end of line or there's a newline, advance past it + this.skipLineRemainder(); + } + + return statements; + } + + private getLineIndent(): number { + // Save position + let pos = this.position; + + // If we're not at the start of a line, find where the line starts + // (we may have already consumed the newline) + + // Count leading whitespace from current position + let indent = 0; + while (pos < this.source.length) { + const char = this.source[pos]; + if (char === ' ') { + indent++; + pos++; + } else if (char === '\t') { + indent += 4; + pos++; + } else if (char === '\n' || char === '\r') { + // Empty line - return -1 to indicate skip + return -1; + } else { + break; + } + } + + if (pos >= this.source.length) { + return -1; + } + + return indent; + } + + private consumeIndent(): void { + while (this.position < this.source.length) { + const char = this.peek(); + if (char === ' ' || char === '\t') { + this.advance(); + } else { + break; + } + } + } + + private skipLine(): void { + while (this.position < this.source.length && this.peek() !== '\n') { + this.advance(); + } + if (this.peek() === '\n') { + this.advance(); + } + } + + private skipLineRemainder(): void { + // Skip whitespace and potential newline at end + while (this.position < this.source.length) { + const char = this.peek(); + if (char === ' ' || char === '\t' || char === '\r') { + this.advance(); + } else if (char === '\n') { + this.advance(); + break; + } else { + break; + } + } + } + + private skipToEndOfLine(): void { + while (this.position < this.source.length && this.peek() !== '\n') { + this.advance(); + } + if (this.peek() === '\n') { + this.advance(); + } + } + + private skipWhitespaceAndNewlines(): void { + while (this.position < this.source.length) { + const char = this.peek(); + if (char === ' ' || char === '\t' || char === '\r' || char === '\n') { + this.advance(); + } else { + break; + } + } + } + + private measureIndent(): number { + // Find the start of the current line + let lineStart = this.position; + while (lineStart > 0 && this.source[lineStart - 1] !== '\n') { + lineStart--; + } + + // Count spaces/tabs from line start + let indent = 0; + let pos = lineStart; + while (pos < this.source.length) { + const char = this.source[pos]; + if (char === ' ') { + indent++; + pos++; + } else if (char === '\t') { + indent += 4; // Treat tabs as 4 spaces + pos++; + } else { + break; + } + } + + // Return -1 if the line is empty or only whitespace + if (pos >= this.source.length || this.source[pos] === '\n') { + return -1; + } + + return indent; + } + private parseExpression(): ASTNode { return this.parseAssignment(); } private parseAssignment(): ASTNode { - const left = this.parseBinary(); + const left = this.parseTernary(); this.skipWhitespace(); + + // Handle ':=' reassignment operator + if (this.peek() === ':' && this.peekNext() === '=') { + this.advance(); // skip ':' + this.advance(); // skip '=' + this.skipWhitespace(); + const right = this.parseAssignment(); + return { + type: 'Reassignment', + operator: ':=', + children: [left, right], + }; + } + + // Handle '=' assignment if (this.peek() === '=' && this.peekNext() !== '=') { this.advance(); // skip '=' this.skipWhitespace(); @@ -183,6 +709,29 @@ export class PineParser { return left; } + private parseTernary(): ASTNode { + const condition = this.parseBinary(); + + this.skipWhitespace(); + if (this.peek() === '?') { + this.advance(); // skip '?' + this.skipWhitespace(); + const consequent = this.parseAssignment(); + this.skipWhitespace(); + if (this.peek() === ':') { + this.advance(); // skip ':' + this.skipWhitespace(); + const alternate = this.parseAssignment(); + return { + type: 'TernaryExpression', + children: [condition, consequent, alternate], + }; + } + } + + return condition; + } + private parseBinary(): ASTNode { let left = this.parseUnary(); @@ -240,34 +789,46 @@ export class PineParser { if (this.peek() === ')') { this.advance(); } - return expr; + return this.maybeParseHistoryAccess(expr); } // Identifier or function call if (this.isAlpha(this.peek())) { + // Save position for potential keyword lookahead + const startPos = this.position; const name = this.parseIdentifier(); + + // Check if this is the 'switch' keyword + if (name === 'switch') { + return this.parseSwitchExpression(); + } + this.skipWhitespace(); // Check for function call if (this.peek() === '(') { this.advance(); const args = this.parseArguments(); - return { + const callNode: ASTNode = { type: 'FunctionCall', value: name, children: args, }; + return this.maybeParseHistoryAccess(callNode); } // Check for member access if (this.peek() === '.') { - return this.parseMemberAccess(name); + const memberExpr = this.parseMemberAccess(name); + return this.maybeParseHistoryAccess(memberExpr); } - return { + // Check for history access on simple identifier + const identNode: ASTNode = { type: 'Identifier', value: name, }; + return this.maybeParseHistoryAccess(identNode); } // Skip to end of line on error @@ -278,6 +839,26 @@ export class PineParser { }; } + private maybeParseHistoryAccess(base: ASTNode): ASTNode { + this.skipWhitespace(); + if (this.peek() === '[') { + this.advance(); // skip '[' + this.skipWhitespace(); + const offset = this.parseExpression(); + this.skipWhitespace(); + if (this.peek() === ']') { + this.advance(); // skip ']' + } + const historyNode: ASTNode = { + type: 'HistoryAccess', + children: [base, offset], + }; + // Check for chained history access (e.g., close[1][2] - unlikely but handle it) + return this.maybeParseHistoryAccess(historyNode); + } + return base; + } + private parseMemberAccess(object: string): ASTNode { const parts = [object]; @@ -293,17 +874,19 @@ export class PineParser { if (this.peek() === '(') { this.advance(); const args = this.parseArguments(); - return { + const callNode: ASTNode = { type: 'FunctionCall', value: parts.join('.'), children: args, }; + return this.maybeParseHistoryAccess(callNode); } - return { + const memberNode: ASTNode = { type: 'MemberExpression', value: parts.join('.'), }; + return this.maybeParseHistoryAccess(memberNode); } private parseArguments(): ASTNode[] { @@ -384,7 +967,8 @@ export class PineParser { } const oneChar = this.peek(); - if (['+', '-', '*', '/', '%', '<', '>', '?', ':'].includes(oneChar)) { + // Note: '?' and ':' are handled by ternary expression parsing, not here + if (['+', '-', '*', '/', '%', '<', '>'].includes(oneChar)) { this.advance(); return oneChar; } diff --git a/packages/oakscript-engine/src/transpiler/PineToTS.ts b/packages/oakscript-engine/src/transpiler/PineToTS.ts index f2a8504..9224dd5 100644 --- a/packages/oakscript-engine/src/transpiler/PineToTS.ts +++ b/packages/oakscript-engine/src/transpiler/PineToTS.ts @@ -58,6 +58,9 @@ export function transpileWithResult(source: string, options: TranspileOptions = * TypeScript code generator */ class CodeGenerator { + /** Number of spaces for each indentation level */ + private static readonly INDENT_SIZE = 2; + private options: TranspileOptions; private output: string[] = []; private indent: number = 0; @@ -184,6 +187,38 @@ class CodeGenerator { this.generateAssignment(node); break; + case 'Reassignment': + this.generateReassignment(node); + break; + + case 'IfStatement': + this.generateIfStatement(node); + break; + + case 'ForLoop': + this.generateForLoop(node); + break; + + case 'ForInLoop': + this.generateForInLoop(node); + break; + + case 'WhileLoop': + this.generateWhileLoop(node); + break; + + case 'TupleDestructuring': + this.generateTupleDestructuring(node); + break; + + case 'BreakStatement': + this.emit('break;'); + break; + + case 'ContinueStatement': + this.emit('continue;'); + break; + default: const expr = this.generateExpression(node); if (expr) { @@ -226,6 +261,251 @@ class CodeGenerator { } } + private generateReassignment(node: ASTNode): void { + if (!node.children || node.children.length < 2) return; + + const left = node.children[0]!; + const right = node.children[1]!; + const leftExpr = this.generateExpression(left); + const rightExpr = this.generateExpression(right); + + this.emit(`${leftExpr} = ${rightExpr};`); + } + + private generateIfStatement(node: ASTNode): void { + if (!node.children || node.children.length < 2) return; + + const condition = node.children[0]!; + const body = node.children[1]!; + const alternate = node.children[2]; + + const condExpr = this.generateExpression(condition); + this.emit(`if (${condExpr}) {`); + this.indent++; + + if (body.type === 'Block' && body.children) { + for (const stmt of body.children) { + this.generateStatement(stmt); + } + } + + this.indent--; + + if (alternate) { + if (alternate.type === 'IfStatement') { + // else if - inline the else if + this.emit('} else ' + this.generateIfStatementInline(alternate)); + } else { + this.emit('} else {'); + this.indent++; + if (alternate.type === 'Block' && alternate.children) { + for (const stmt of alternate.children) { + this.generateStatement(stmt); + } + } + this.indent--; + this.emit('}'); + } + } else { + this.emit('}'); + } + } + + private generateIfStatementInline(node: ASTNode): string { + if (!node.children || node.children.length < 2) return ''; + + const condition = node.children[0]!; + const body = node.children[1]!; + const alternate = node.children[2]; + + const condExpr = this.generateExpression(condition); + const lines: string[] = []; + const indent = this.getIndentString(); + lines.push(`if (${condExpr}) {`); + + if (body.type === 'Block' && body.children) { + for (const stmt of body.children) { + const stmtCode = this.generateStatementToString(stmt); + if (stmtCode) { + lines.push(indent + stmtCode); + } + } + } + + if (alternate) { + if (alternate.type === 'IfStatement') { + lines.push('} else ' + this.generateIfStatementInline(alternate)); + } else { + lines.push('} else {'); + if (alternate.type === 'Block' && alternate.children) { + for (const stmt of alternate.children) { + const stmtCode = this.generateStatementToString(stmt); + if (stmtCode) { + lines.push(indent + stmtCode); + } + } + } + lines.push('}'); + } + } else { + lines.push('}'); + } + + return lines.join('\n' + indent.repeat(this.indent)); + } + + private generateStatementToString(node: ASTNode): string { + if (!node) return ''; + + switch (node.type) { + case 'Comment': + return `// ${String(node.value || '').replace(/^\/\/\s*/, '')}`; + + case 'VariableDeclaration': { + const name = String(node.value || 'unknown'); + const tsName = this.sanitizeIdentifier(name); + this.variables.set(name, tsName); + if (node.children && node.children.length > 0) { + const init = this.generateExpression(node.children[0]!); + return `const ${tsName} = ${init};`; + } else { + return `let ${tsName};`; + } + } + + case 'ExpressionStatement': + if (node.children && node.children.length > 0) { + const expr = this.generateExpression(node.children[0]!); + if (expr) { + return `${expr};`; + } + } + return ''; + + case 'Assignment': { + if (!node.children || node.children.length < 2) return ''; + const left = node.children[0]!; + const right = node.children[1]!; + if (left.type === 'Identifier') { + const name = String(left.value || 'unknown'); + const tsName = this.sanitizeIdentifier(name); + if (!this.variables.has(name)) { + this.variables.set(name, tsName); + return `const ${tsName} = ${this.generateExpression(right)};`; + } else { + return `${tsName} = ${this.generateExpression(right)};`; + } + } + return ''; + } + + case 'Reassignment': { + if (!node.children || node.children.length < 2) return ''; + const leftExpr = this.generateExpression(node.children[0]!); + const rightExpr = this.generateExpression(node.children[1]!); + return `${leftExpr} = ${rightExpr};`; + } + + case 'BreakStatement': + return 'break;'; + + case 'ContinueStatement': + return 'continue;'; + + default: + const expr = this.generateExpression(node); + if (expr) { + return `${expr};`; + } + return ''; + } + } + + private generateForLoop(node: ASTNode): void { + if (!node.children || node.children.length < 3) return; + + const varName = node.name || 'i'; + const start = node.children[0]!; + const end = node.children[1]!; + const body = node.children[2]!; + const step = node.step; + + const startExpr = this.generateExpression(start); + const endExpr = this.generateExpression(end); + const stepExpr = step ? this.generateExpression(step) : '1'; + + this.emit(`for (let ${varName} = ${startExpr}; ${varName} <= ${endExpr}; ${varName} += ${stepExpr}) {`); + this.indent++; + + if (body.type === 'Block' && body.children) { + for (const stmt of body.children) { + this.generateStatement(stmt); + } + } + + this.indent--; + this.emit('}'); + } + + private generateForInLoop(node: ASTNode): void { + if (!node.children || node.children.length < 2) return; + + const varName = node.name || 'item'; + const iterable = node.children[0]!; + const body = node.children[1]!; + + const iterableExpr = this.generateExpression(iterable); + + // Check if we have destructured variables (e.g., [index, item]) + if (varName.includes(',')) { + const vars = varName.split(','); + this.emit(`for (const [${vars.join(', ')}] of ${iterableExpr}.entries()) {`); + } else { + this.emit(`for (const ${varName} of ${iterableExpr}) {`); + } + + this.indent++; + + if (body.type === 'Block' && body.children) { + for (const stmt of body.children) { + this.generateStatement(stmt); + } + } + + this.indent--; + this.emit('}'); + } + + private generateWhileLoop(node: ASTNode): void { + if (!node.children || node.children.length < 2) return; + + const condition = node.children[0]!; + const body = node.children[1]!; + + const condExpr = this.generateExpression(condition); + this.emit(`while (${condExpr}) {`); + this.indent++; + + if (body.type === 'Block' && body.children) { + for (const stmt of body.children) { + this.generateStatement(stmt); + } + } + + this.indent--; + this.emit('}'); + } + + private generateTupleDestructuring(node: ASTNode): void { + if (!node.children || node.children.length < 1) return; + + const varNames = node.name || ''; + const initializer = node.children[0]!; + const initExpr = this.generateExpression(initializer); + + this.emit(`const [${varNames}] = ${initExpr};`); + } + private generateExpression(node: ASTNode): string { if (!node) return ''; @@ -259,11 +539,109 @@ class CodeGenerator { } return ''; + case 'Reassignment': + if (node.children && node.children.length >= 2) { + const left = this.generateExpression(node.children[0]!); + const right = this.generateExpression(node.children[1]!); + return `${left} = ${right}`; + } + return ''; + + case 'TernaryExpression': + return this.generateTernaryExpression(node); + + case 'HistoryAccess': + return this.generateHistoryAccess(node); + + case 'SwitchExpression': + return this.generateSwitchExpression(node); + default: return ''; } } + private generateTernaryExpression(node: ASTNode): string { + if (!node.children || node.children.length < 3) return ''; + + const condition = this.generateExpression(node.children[0]!); + const consequent = this.generateExpression(node.children[1]!); + const alternate = this.generateExpression(node.children[2]!); + + return `(${condition} ? ${consequent} : ${alternate})`; + } + + private generateHistoryAccess(node: ASTNode): string { + if (!node.children || node.children.length < 2) return ''; + + const base = this.generateExpression(node.children[0]!); + const offset = this.generateExpression(node.children[1]!); + + return `${base}.get(${offset})`; + } + + private generateSwitchExpression(node: ASTNode): string { + if (!node.children || node.children.length === 0) return ''; + + // Separate the switch expression from cases + const children = node.children; + let switchExpr: ASTNode | undefined; + let cases: ASTNode[] = []; + + // First child might be the switch expression or a case + if (children[0] && children[0].type !== 'SwitchCase' && children[0].type !== 'SwitchDefault') { + switchExpr = children[0]; + cases = children.slice(1); + } else { + cases = children; + } + + // Generate IIFE-wrapped switch + const lines: string[] = []; + const indent = this.getIndentString(); + lines.push('(() => {'); + + if (switchExpr) { + const switchValue = this.generateExpression(switchExpr); + lines.push(`${indent}switch (${switchValue}) {`); + + for (const caseNode of cases) { + if (caseNode.type === 'SwitchCase' && caseNode.children && caseNode.children.length >= 2) { + const caseValue = this.generateExpression(caseNode.children[0]!); + const result = this.generateExpression(caseNode.children[1]!); + lines.push(`${indent}${indent}case ${caseValue}: return ${result};`); + } else if (caseNode.type === 'SwitchDefault' && caseNode.children && caseNode.children.length >= 1) { + const result = this.generateExpression(caseNode.children[0]!); + lines.push(`${indent}${indent}default: return ${result};`); + } + } + + lines.push(`${indent}}`); + } else { + // Condition-based switch (like if-else chain) + let isFirst = true; + for (const caseNode of cases) { + if (caseNode.type === 'SwitchCase' && caseNode.children && caseNode.children.length >= 2) { + const condition = this.generateExpression(caseNode.children[0]!); + const result = this.generateExpression(caseNode.children[1]!); + if (isFirst) { + lines.push(`${indent}if (${condition}) return ${result};`); + isFirst = false; + } else { + lines.push(`${indent}else if (${condition}) return ${result};`); + } + } else if (caseNode.type === 'SwitchDefault' && caseNode.children && caseNode.children.length >= 1) { + const result = this.generateExpression(caseNode.children[0]!); + lines.push(`${indent}else return ${result};`); + } + } + } + + lines.push('})()'); + + return lines.join('\n' + indent.repeat(this.indent)); + } + private generateFunctionCall(node: ASTNode): string { const name = String(node.value || ''); const args = (node.children || []).map(c => this.generateExpression(c)).join(', '); @@ -411,8 +789,12 @@ class CodeGenerator { .replace(/^_|_$/g, '') || 'unnamed'; } + private getIndentString(): string { + return ' '.repeat(CodeGenerator.INDENT_SIZE); + } + private emit(line: string): void { - const indentation = ' '.repeat(this.indent); + const indentation = this.getIndentString().repeat(this.indent); this.output.push(indentation + line); } } diff --git a/packages/oakscript-engine/tests/transpiler-phase1.test.ts b/packages/oakscript-engine/tests/transpiler-phase1.test.ts new file mode 100644 index 0000000..6d123b0 --- /dev/null +++ b/packages/oakscript-engine/tests/transpiler-phase1.test.ts @@ -0,0 +1,405 @@ +import { describe, it, expect } from 'vitest'; +import { PineParser } from '../src/transpiler/PineParser'; +import { transpile } from '../src/transpiler/PineToTS'; + +describe('Phase 1: Core Language Features', () => { + describe('Reassignment Operator `:=`', () => { + it('should parse `:=` reassignment', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('counter := counter + 1'); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('ExpressionStatement'); + expect(ast.children![0]!.children![0]!.type).toBe('Reassignment'); + }); + + it('should transpile `:=` to assignment', () => { + const source = `indicator("Test") +var counter = 0 +counter := counter + 1`; + + const result = transpile(source); + + expect(result).toContain('const counter = 0'); + expect(result).toContain('counter = (counter + 1)'); // Expression has parentheses + }); + }); + + describe('Ternary Operator `?:`', () => { + it('should parse ternary expression', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('x = condition ? 1 : 2'); + + expect(errors).toHaveLength(0); + }); + + it('should transpile ternary expression', () => { + const source = `indicator("Test") +color = close > open ? 1 : 0`; + + const result = transpile(source); + + expect(result).toContain('close.gt(open) ? 1 : 0'); + }); + }); + + describe('History Operator `[n]`', () => { + it('should parse history access', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('prevClose = close[1]'); + + expect(errors).toHaveLength(0); + const assignment = ast.children![0]!.children![0]!; + expect(assignment.type).toBe('Assignment'); + const rightSide = assignment.children![1]!; + expect(rightSide.type).toBe('HistoryAccess'); + }); + + it('should transpile history access to `.get()`', () => { + const source = `indicator("Test") +prevClose = close[1] +highFive = high[5]`; + + const result = transpile(source); + + expect(result).toContain('close.get(1)'); + expect(result).toContain('high.get(5)'); + }); + + it('should handle dynamic history index', () => { + const source = `indicator("Test") +idx = 3 +value = close[idx]`; + + const result = transpile(source); + + expect(result).toContain('close.get(idx)'); + }); + }); + + describe('If/Else Statements', () => { + it('should parse simple if statement', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`if close > open + x := 1`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('IfStatement'); + }); + + it('should parse if/else statement', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`if close > open + x := 1 +else + x := 0`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('IfStatement'); + expect(ast.children![0]!.children!.length).toBe(3); // condition, if-body, else-body + }); + + it('should transpile if/else to TypeScript', () => { + const source = `indicator("Test") +var x = 0 +if close > open + x := 1 +else + x := 0`; + + const result = transpile(source); + + expect(result).toContain('if (close.gt(open)) {'); + expect(result).toContain('x = 1'); + expect(result).toContain('} else {'); + expect(result).toContain('x = 0'); + }); + + it('should parse if/else if/else chain', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`if condition1 + x := 1 +else if condition2 + x := 2 +else + x := 0`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('IfStatement'); + }); + }); + + describe('For Loops', () => { + it('should parse standard for loop', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`for i = 0 to 10 + sum := sum + i`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('ForLoop'); + expect(ast.children![0]!.name).toBe('i'); + }); + + it('should parse for loop with step', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`for i = 0 to 10 by 2 + sum := sum + i`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('ForLoop'); + expect(ast.children![0]!.step).toBeDefined(); + }); + + it('should transpile standard for loop', () => { + const source = `indicator("Test") +var sum = 0 +for i = 0 to 10 + sum := sum + i`; + + const result = transpile(source); + + expect(result).toContain('for (let i = 0; i <= 10; i += 1)'); + expect(result).toContain('sum = (sum + i)'); // Expression has parentheses + }); + + it('should transpile for loop with step', () => { + const source = `indicator("Test") +var sum = 0 +for i = 0 to 10 by 2 + sum := sum + i`; + + const result = transpile(source); + + expect(result).toContain('for (let i = 0; i <= 10; i += 2)'); + }); + + it('should parse for-in loop', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`for item in myArray + total := total + item`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('ForInLoop'); + expect(ast.children![0]!.name).toBe('item'); + }); + + it('should transpile for-in loop', () => { + const source = `indicator("Test") +var total = 0 +for item in myArray + total := total + item`; + + const result = transpile(source); + + expect(result).toContain('for (const item of myArray)'); + }); + + it('should parse for-in loop with index destructuring', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`for [index, item] in myArray + total := total + item`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('ForInLoop'); + expect(ast.children![0]!.name).toBe('index,item'); + }); + + it('should transpile for-in loop with index destructuring', () => { + const source = `indicator("Test") +var total = 0 +for [index, item] in myArray + total := total + item`; + + const result = transpile(source); + + expect(result).toContain('for (const [index, item] of myArray.entries())'); + }); + }); + + describe('While Loops', () => { + it('should parse while loop', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`while condition + x := x + 1`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('WhileLoop'); + }); + + it('should transpile while loop', () => { + const source = `indicator("Test") +var x = 0 +while x < 10 + x := x + 1`; + + const result = transpile(source); + + expect(result).toContain('while ((x < 10))'); + expect(result).toContain('x = (x + 1)'); // The transpiler adds parentheses around expressions + }); + }); + + describe('Switch Expressions', () => { + it('should parse switch expression', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`switch maType + "SMA" => 1 + "EMA" => 2 + => 0`); + + expect(errors).toHaveLength(0); + // Switch is wrapped in ExpressionStatement + expect(ast.children![0]!.type).toBe('ExpressionStatement'); + expect(ast.children![0]!.children![0]!.type).toBe('SwitchExpression'); + }); + + it('should transpile switch expression', () => { + const source = `indicator("Test") +maType = "SMA" +ma = switch maType + "SMA" => 1 + "EMA" => 2 + => 0`; + + const result = transpile(source); + + expect(result).toContain('switch (maType)'); + expect(result).toContain('case "SMA": return 1'); + expect(result).toContain('case "EMA": return 2'); + expect(result).toContain('default: return 0'); + }); + }); + + describe('Tuple Destructuring', () => { + it('should parse tuple destructuring', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('[a, b, c] = someFunction()'); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('TupleDestructuring'); + expect(ast.children![0]!.name).toBe('a,b,c'); + }); + + it('should transpile tuple destructuring', () => { + const source = `indicator("Test") +[macdLine, signalLine, hist] = ta.macd(close, 12, 26, 9)`; + + const result = transpile(source); + + // The transpiler outputs without spaces after commas + expect(result).toContain('const [macdLine,signalLine,hist] = ta.macd(close, 12, 26, 9)'); + }); + }); + + describe('Break and Continue Statements', () => { + it('should parse break statement', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`for i = 0 to 10 + if condition + break`); + + expect(errors).toHaveLength(0); + }); + + it('should parse continue statement', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`for i = 0 to 10 + if condition + continue`); + + expect(errors).toHaveLength(0); + }); + + it('should transpile break statement', () => { + const source = `indicator("Test") +for i = 0 to 10 + if i > 5 + break`; + + const result = transpile(source); + + expect(result).toContain('break;'); + }); + + it('should transpile continue statement', () => { + const source = `indicator("Test") +for i = 0 to 10 + if i < 5 + continue`; + + const result = transpile(source); + + expect(result).toContain('continue;'); + }); + }); + + describe('Integration Tests', () => { + it('should handle simple RSI indicator with if/else', () => { + const source = `indicator("If/Else Test") +var color barColor = 0 + +if close > open + barColor := 1 +else if close < open + barColor := 2 +else + barColor := 0 + +plot(close)`; + + const result = transpile(source); + + expect(result).toContain('if (close.gt(open))'); + expect(result).toContain('barColor = 1'); + expect(result).toContain('else if (close.lt(open))'); + expect(result).toContain('barColor = 2'); + expect(result).toContain('barColor = 0'); + }); + + it('should handle for loop with history access', () => { + const source = `indicator("For Loop Test") +length = 14 +var sum = 0 + +for i = 0 to length + sum := sum + close[i] + +customSMA = sum / length +plot(customSMA)`; + + const result = transpile(source); + + expect(result).toContain('for (let i = 0; i <= length; i += 1)'); + expect(result).toContain('close.get(i)'); + expect(result).toContain('sum / length'); + }); + + it('should handle tuple destructuring with MACD', () => { + const source = `indicator("Tuple Test") +[macdLine, signalLine, hist] = ta.macd(close, 12, 26, 9) +plot(macdLine)`; + + const result = transpile(source); + + // The transpiler outputs without spaces after commas + expect(result).toContain('const [macdLine,signalLine,hist] = ta.macd(close, 12, 26, 9)'); + }); + + it('should handle nested control structures', () => { + const source = `indicator("Nested Test") +var total = 0 + +for i = 0 to 10 + if i > 5 + total := total + 1 + else + total := total + 2`; + + const result = transpile(source); + + expect(result).toContain('for (let i = 0; i <= 10; i += 1)'); + expect(result).toContain('if ((i > 5))'); + expect(result).toContain('} else {'); + }); + }); +});