From ab1e490353910e8240981eadf0183c364e44fe94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:11:09 +0000 Subject: [PATCH 1/3] Initial plan From 6e89aa5bef679c873440efad24ec579a38dad069 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:23:39 +0000 Subject: [PATCH 2/3] Add Phase 3: User-Defined Types and Methods support to transpiler Co-authored-by: deepentropy <8287111+deepentropy@users.noreply.github.com> --- .../src/transpiler/PineParser.ts | 293 ++++++++- .../src/transpiler/PineToTS.ts | 338 +++++++++- .../oakscript-engine/src/transpiler/types.ts | 56 ++ .../tests/transpiler-phase3.test.ts | 578 ++++++++++++++++++ 4 files changed, 1259 insertions(+), 6 deletions(-) create mode 100644 packages/oakscript-engine/tests/transpiler-phase3.test.ts diff --git a/packages/oakscript-engine/src/transpiler/PineParser.ts b/packages/oakscript-engine/src/transpiler/PineParser.ts index 1a3c368..ecf0293 100644 --- a/packages/oakscript-engine/src/transpiler/PineParser.ts +++ b/packages/oakscript-engine/src/transpiler/PineParser.ts @@ -14,6 +14,14 @@ export interface ASTNode { operator?: string; /** Step value expression for for loops with 'by' clause */ step?: ASTNode; + /** Whether a type or method is exported */ + exported?: boolean; + /** Field type for FieldDeclaration nodes */ + fieldType?: string; + /** Bound type for method declarations (first parameter type) */ + boundType?: string; + /** Parameters for method declarations */ + params?: ASTNode[]; location?: { line: number; column: number; @@ -101,6 +109,28 @@ export class PineParser { return this.parseIndicatorDeclaration(); } + // Parse export keyword (for types, methods, functions) + if (this.matchKeyword('export')) { + this.skipWhitespace(); + if (this.matchKeyword('type')) { + return this.parseTypeDeclaration(true); + } + if (this.matchKeyword('method')) { + return this.parseMethodDeclaration(true); + } + // export function or other exports - fall through to expression parsing + } + + // Parse type declaration + if (this.matchKeyword('type')) { + return this.parseTypeDeclaration(false); + } + + // Parse method declaration + if (this.matchKeyword('method')) { + return this.parseMethodDeclaration(false); + } + // Parse variable declarations if (this.matchKeyword('var') || this.matchKeyword('varip')) { return this.parseVariableDeclaration(); @@ -175,6 +205,188 @@ export class PineParser { }; } + private parseTypeDeclaration(exported: boolean): ASTNode { + // 'type' keyword already matched + this.skipWhitespace(); + const typeName = this.parseIdentifier(); + this.skipToEndOfLine(); + + // Parse field declarations in indented block + const fields = this.parseTypeFields(); + + return { + type: 'TypeDeclaration', + value: typeName, + exported: exported, + children: fields, + }; + } + + private parseTypeFields(): ASTNode[] { + const fields: ASTNode[] = []; + + while (this.position < this.source.length) { + const currentIndent = this.getLineIndent(); + + // Block ends when indentation decreases to 0 or empty line + if (currentIndent <= 0 && currentIndent !== -1) { + break; + } + + // Skip empty lines + if (currentIndent === -1) { + this.skipLine(); + continue; + } + + // Consume the indentation whitespace + this.consumeIndent(); + + // Parse field: type fieldName [= defaultValue] + const fieldType = this.parseTypeIdentifier(); + this.skipWhitespace(); + const fieldName = this.parseIdentifier(); + this.skipWhitespace(); + + let defaultValue: ASTNode | undefined; + if (this.peek() === '=') { + this.advance(); // skip '=' + this.skipWhitespace(); + defaultValue = this.parseExpression(); + } + + fields.push({ + type: 'FieldDeclaration', + value: fieldName, + fieldType: fieldType, + children: defaultValue ? [defaultValue] : [], + }); + + this.skipLineRemainder(); + } + + return fields; + } + + private parseTypeIdentifier(): string { + // Parse type identifier which can be: + // - Simple: int, float, bool, string, color + // - Qualified: chart.point, line, label + // - Generic: array + let typeId = this.parseIdentifier(); + + // Handle qualified types (e.g., chart.point) + while (this.peek() === '.') { + this.advance(); + typeId += '.' + this.parseIdentifier(); + } + + // Handle generic types (e.g., array) + if (this.peek() === '<') { + this.advance(); // skip '<' + const innerType = this.parseTypeIdentifier(); + typeId += '<' + innerType + '>'; + if (this.peek() === '>') { + this.advance(); // skip '>' + } + } + + return typeId; + } + + private parseMethodDeclaration(exported: boolean): ASTNode { + // 'method' keyword already matched + this.skipWhitespace(); + const methodName = this.parseIdentifier(); + this.skipWhitespace(); + + // Parse parameters + if (this.peek() !== '(') { + this.errors.push({ + message: 'Expected "(" after method name', + line: this.line, + column: this.column, + }); + } + this.advance(); // skip '(' + + // Parse parameter list - first param should be "TypeName this" + const params: ASTNode[] = []; + let boundType = ''; + let isFirstParam = true; + + while (this.peek() !== ')' && this.position < this.source.length) { + this.skipWhitespace(); + if (this.peek() === ')') break; + + // Parse parameter: TypeName paramName [= default] + const paramType = this.parseTypeIdentifier(); + this.skipWhitespace(); + const paramName = this.parseIdentifier(); + + if (isFirstParam && paramName === 'this') { + boundType = paramType; + } else { + this.skipWhitespace(); + let defaultValue: ASTNode | undefined; + if (this.peek() === '=') { + this.advance(); + this.skipWhitespace(); + defaultValue = this.parseExpression(); + } + + params.push({ + type: 'Parameter', + value: paramName, + fieldType: paramType, + children: defaultValue ? [defaultValue] : [], + }); + } + + isFirstParam = false; + this.skipWhitespace(); + if (this.peek() === ',') { + this.advance(); + } + } + + if (this.peek() === ')') { + this.advance(); // skip ')' + } + + this.skipWhitespace(); + + // Check for => (arrow function body) + let body: ASTNode | undefined; + if (this.peek() === '=' && this.peekNext() === '>') { + this.advance(); // skip '=' + this.advance(); // skip '>' + this.skipWhitespace(); + + // Check if single expression or multi-line body + if (this.peek() === '\n') { + this.advance(); + const bodyStatements = this.parseIndentedBlock(); + body = { + type: 'Block', + children: bodyStatements, + }; + } else { + // Single expression body + body = this.parseExpression(); + } + } + + return { + type: 'MethodDeclaration', + value: methodName, + exported: exported, + boundType: boundType, + params: params, + children: body ? [body] : [], + }; + } + private parseVariableDeclaration(): ASTNode { this.skipWhitespace(); const name = this.parseIdentifier(); @@ -819,13 +1031,13 @@ export class PineParser { value: name, children: args, }; - return this.maybeParseHistoryAccess(callNode); + return this.maybeParseChainedAccess(callNode); } // Check for member access if (this.peek() === '.') { const memberExpr = this.parseMemberAccess(name); - return this.maybeParseHistoryAccess(memberExpr); + return memberExpr; // maybeParseChainedAccess is already called in parseMemberAccess } // Check for history access on simple identifier @@ -833,7 +1045,7 @@ export class PineParser { type: 'Identifier', value: name, }; - return this.maybeParseHistoryAccess(identNode); + return this.maybeParseChainedAccess(identNode); } // Skip to end of line on error @@ -902,23 +1114,94 @@ export class PineParser { this.skipWhitespace(); + // Handle generic type syntax (e.g., array.new) + let genericType: string | undefined; + if (this.peek() === '<') { + this.advance(); // skip '<' + genericType = this.parseTypeIdentifier(); + if (this.peek() === '>') { + this.advance(); // skip '>' + } + } + + this.skipWhitespace(); + // Check for function call if (this.peek() === '(') { this.advance(); const args = this.parseArguments(); + + // Check if this is array.new() call FIRST (before TypeInstantiation) + if (parts.join('.') === 'array.new' && genericType) { + const callNode: ASTNode = { + type: 'GenericFunctionCall', + value: 'array.new', + name: genericType, // Store generic type in name field + children: args, + }; + return this.maybeParseChainedAccess(callNode); + } + + // Check if this is Type.new() call (type instantiation) + // But NOT for built-in namespaces like 'array' + if (parts.length === 2 && parts[1] === 'new' && parts[0] !== 'array') { + const callNode: ASTNode = { + type: 'TypeInstantiation', + value: parts[0], // Type name + children: args, + }; + // Continue parsing for chained member access (e.g., Type.new().method()) + return this.maybeParseChainedAccess(callNode); + } + const callNode: ASTNode = { type: 'FunctionCall', value: parts.join('.'), children: args, }; - return this.maybeParseHistoryAccess(callNode); + return this.maybeParseChainedAccess(callNode); } const memberNode: ASTNode = { type: 'MemberExpression', value: parts.join('.'), }; - return this.maybeParseHistoryAccess(memberNode); + return this.maybeParseChainedAccess(memberNode); + } + + private maybeParseChainedAccess(base: ASTNode): ASTNode { + this.skipWhitespace(); + + // Check for chained member access (e.g., result.field or result.method()) + if (this.peek() === '.') { + this.advance(); // skip '.' + const member = this.parseIdentifier(); + + this.skipWhitespace(); + + // Check for method call + if (this.peek() === '(') { + this.advance(); + const args = this.parseArguments(); + const methodCall: ASTNode = { + type: 'MethodCall', + value: member, + children: [base, ...args], // First child is the object + }; + return this.maybeParseChainedAccess(methodCall); + } + + // Field access + const fieldAccess: ASTNode = { + type: 'FieldAccess', + value: member, + children: [base], + }; + return this.maybeParseChainedAccess(fieldAccess); + } + + // Check for history access + return this.maybeParseHistoryAccess(base); } private parseArguments(): ASTNode[] { diff --git a/packages/oakscript-engine/src/transpiler/PineToTS.ts b/packages/oakscript-engine/src/transpiler/PineToTS.ts index 383c13b..7074508 100644 --- a/packages/oakscript-engine/src/transpiler/PineToTS.ts +++ b/packages/oakscript-engine/src/transpiler/PineToTS.ts @@ -5,7 +5,7 @@ */ import { PineParser, ASTNode } from './PineParser.js'; -import type { TranspileOptions, TranspileResult, TranspileError, TranspileWarning, InputDefinition } from './types.js'; +import type { TranspileOptions, TranspileResult, TranspileError, TranspileWarning, InputDefinition, TypeInfo, MethodInfo, FieldInfo } from './types.js'; /** * Transpile PineScript source code to TypeScript @@ -71,6 +71,8 @@ class CodeGenerator { private inputs: InputDefinition[] = []; private usesSyminfo: boolean = false; private usesTimeframe: boolean = false; + private types: Map = new Map(); + private methods: Map = new Map(); public warnings: TranspileWarning[] = []; constructor(options: TranspileOptions) { @@ -88,6 +90,8 @@ class CodeGenerator { this.inputs = []; this.usesSyminfo = false; this.usesTimeframe = false; + this.types.clear(); + this.methods.clear(); // First pass: collect information this.collectInfo(ast); @@ -101,6 +105,11 @@ class CodeGenerator { // Generate helper functions this.generateHelperFunctions(); + // Generate user-defined types (interfaces and namespace objects) + if (this.types.size > 0) { + this.generateUserDefinedTypes(); + } + // Generate interfaces if inputs exist if (this.inputs.length > 0) { this.generateInputsInterface(); @@ -203,6 +212,153 @@ class CodeGenerator { this.emit(''); } + private generateUserDefinedTypes(): void { + this.emit('// User-defined types'); + + for (const [typeName, typeInfo] of this.types) { + const exportKeyword = typeInfo.exported ? 'export ' : ''; + + // Generate interface + this.emit(`${exportKeyword}interface ${typeName} {`); + this.indent++; + for (const field of typeInfo.fields) { + const tsType = this.pineTypeToTs(field.fieldType); + this.emit(`${field.name}: ${tsType};`); + } + this.indent--; + this.emit('}'); + this.emit(''); + + // Generate namespace object with new() factory and methods + this.emit(`${exportKeyword}const ${typeName} = {`); + this.indent++; + + // Generate new() factory function + const params = typeInfo.fields.map(f => { + const tsType = this.pineTypeToTs(f.fieldType); + const defaultVal = f.defaultValue || this.getDefaultForPineType(f.fieldType); + return `${f.name}: ${tsType} = ${defaultVal}`; + }).join(', '); + + const fieldNames = typeInfo.fields.map(f => f.name).join(', '); + + this.emit(`new: (${params}): ${typeName} => ({`); + this.indent++; + this.emit(`${fieldNames},`); + this.indent--; + this.emit('}),'); + + // Generate methods bound to this type + const typeMethods = this.methods.get(typeName) || []; + for (const method of typeMethods) { + this.generateMethodInNamespace(method, typeName); + } + + this.indent--; + this.emit('};'); + this.emit(''); + } + } + + private generateMethodInNamespace(method: MethodInfo, typeName: string): void { + const selfType = typeName; + const otherParams = method.parameters.map(p => { + const tsType = this.pineTypeToTs(p.paramType); + const defaultVal = p.defaultValue ? ` = ${p.defaultValue}` : ''; + return `${p.name}: ${tsType}${defaultVal}`; + }).join(', '); + + const allParams = otherParams ? `self: ${selfType}, ${otherParams}` : `self: ${selfType}`; + + // Determine return type - for now, use void or any + const returnType = 'void'; + + this.emit(`${method.name}: (${allParams}): ${returnType} => {`); + this.indent++; + + // Generate method body + if (method.bodyNode) { + const bodyNode = method.bodyNode as ASTNode; + if (bodyNode.type === 'Block' && bodyNode.children) { + for (const stmt of bodyNode.children) { + this.generateStatement(stmt); + } + } else { + // Single expression body + const expr = this.generateExpression(bodyNode); + this.emit(`return ${expr};`); + } + } + + this.indent--; + this.emit('},'); + } + + private pineTypeToTs(pineType: string): string { + // Map PineScript types to TypeScript types + const typeMap: Record = { + 'int': 'number', + 'float': 'number', + 'bool': 'boolean', + 'string': 'string', + 'color': 'string', + 'line': 'Line | null', + 'label': 'Label | null', + 'box': 'Box | null', + 'table': 'Table | null', + 'chart.point': 'ChartPoint', + }; + + // Handle generic array types + if (pineType.startsWith('array<') && pineType.endsWith('>')) { + const innerType = pineType.slice(6, -1); + return `${this.pineTypeToTs(innerType)}[]`; + } + + if (typeMap[pineType]) { + return typeMap[pineType]; + } + + // Check if it's a user-defined type + if (this.types.has(pineType)) { + return pineType; + } + + // Assume it's a custom type (e.g., user-defined) + return pineType; + } + + private getDefaultForPineType(pineType: string): string { + const defaults: Record = { + 'int': '0', + 'float': '0.0', + 'bool': 'false', + 'string': '""', + 'color': '"#000000"', + 'line': 'null', + 'label': 'null', + 'box': 'null', + 'table': 'null', + 'chart.point': 'null', + }; + + // Handle generic array types + if (pineType.startsWith('array<')) { + return '[]'; + } + + if (defaults[pineType]) { + return defaults[pineType]; + } + + // For user-defined types, call their new() with no args + if (this.types.has(pineType)) { + return `${pineType}.new()`; + } + + return 'null'; + } + private generateInputsInterface(): void { this.emit('export interface IndicatorInputs {'); this.indent++; @@ -397,6 +553,59 @@ class CodeGenerator { } } + // Collect type definitions + if (node.type === 'TypeDeclaration') { + const typeName = String(node.value || ''); + const fields: FieldInfo[] = []; + + if (node.children) { + for (const fieldNode of node.children) { + if (fieldNode.type === 'FieldDeclaration') { + const field: FieldInfo = { + name: String(fieldNode.value || ''), + fieldType: String(fieldNode.fieldType || 'unknown'), + defaultValue: fieldNode.children && fieldNode.children.length > 0 + ? this.generateExpression(fieldNode.children[0]) + : undefined, + isOptional: !!(fieldNode.children && fieldNode.children.length > 0), + }; + fields.push(field); + } + } + } + + this.types.set(typeName, { + name: typeName, + exported: node.exported || false, + fields: fields, + }); + } + + // Collect method definitions + if (node.type === 'MethodDeclaration') { + const methodName = String(node.value || ''); + const boundType = String(node.boundType || ''); + + const methodInfo: MethodInfo = { + name: methodName, + boundType: boundType, + exported: node.exported || false, + parameters: (node.params || []).map(p => ({ + name: String(p.value || ''), + paramType: String(p.fieldType || 'unknown'), + defaultValue: p.children && p.children.length > 0 + ? this.generateExpression(p.children[0]) + : undefined, + })), + bodyNode: node.children && node.children.length > 0 ? node.children[0] : undefined, + }; + + if (!this.methods.has(boundType)) { + this.methods.set(boundType, []); + } + this.methods.get(boundType)!.push(methodInfo); + } + // Collect input definitions from ExpressionStatement containing Assignment // This is the main path - we only check at ExpressionStatement level to avoid duplicates if (node.type === 'ExpressionStatement' && node.children && node.children.length > 0) { @@ -436,6 +645,13 @@ class CodeGenerator { this.collectInfo(child); } } + + // Also check params for method declarations + if (node.params) { + for (const param of node.params) { + this.collectInfo(param); + } + } } private parseInputFunction(varName: string, funcName: string, args: ASTNode[]): InputDefinition | null { @@ -611,6 +827,14 @@ class CodeGenerator { // Already processed in collectInfo break; + case 'TypeDeclaration': + // Already processed in collectInfo and generateUserDefinedTypes + break; + + case 'MethodDeclaration': + // Already processed in collectInfo and generateUserDefinedTypes + break; + case 'VariableDeclaration': this.generateVariableDeclaration(node); break; @@ -991,6 +1215,18 @@ class CodeGenerator { case 'FunctionCall': return this.generateFunctionCall(node); + case 'TypeInstantiation': + return this.generateTypeInstantiation(node); + + case 'MethodCall': + return this.generateMethodCall(node); + + case 'FieldAccess': + return this.generateFieldAccess(node); + + case 'GenericFunctionCall': + return this.generateGenericFunctionCall(node); + case 'BinaryExpression': return this.generateBinaryExpression(node); @@ -1022,11 +1258,105 @@ class CodeGenerator { case 'SwitchExpression': return this.generateSwitchExpression(node); + case 'ArrayLiteral': + return this.generateArrayLiteral(node); + default: return ''; } } + private generateTypeInstantiation(node: ASTNode): string { + const typeName = String(node.value || ''); + const args = (node.children || []).map(c => this.generateExpression(c)).join(', '); + return `${typeName}.new(${args})`; + } + + private generateMethodCall(node: ASTNode): string { + const methodName = String(node.value || ''); + if (!node.children || node.children.length < 1) return ''; + + const objectNode = node.children[0]; + const args = node.children.slice(1).map(c => this.generateExpression(c)).join(', '); + const objectExpr = this.generateExpression(objectNode); + + // Try to determine the type of the object to generate TypeName.method(object, args) + // For now, if it's a simple identifier and we know its type from context, use that + // Otherwise, generate object.method(args) style (JS compatible) + + // Check if object is a type instantiation or we have type info + const objectType = this.inferObjectType(objectNode); + if (objectType && this.types.has(objectType)) { + // Use TypeName.method(object, args) pattern + const allArgs = args ? `${objectExpr}, ${args}` : objectExpr; + return `${objectType}.${methodName}(${allArgs})`; + } + + // Check if method exists on any known type (method on UDT) + for (const [typeName, methods] of this.methods) { + if (methods.some(m => m.name === methodName)) { + const allArgs = args ? `${objectExpr}, ${args}` : objectExpr; + return `${typeName}.${methodName}(${allArgs})`; + } + } + + // Fall back to object.method(args) style for built-in methods + return args ? `${objectExpr}.${methodName}(${args})` : `${objectExpr}.${methodName}()`; + } + + private generateFieldAccess(node: ASTNode): string { + const fieldName = String(node.value || ''); + if (!node.children || node.children.length < 1) return fieldName; + + const objectExpr = this.generateExpression(node.children[0]); + return `${objectExpr}.${fieldName}`; + } + + private generateGenericFunctionCall(node: ASTNode): string { + const funcName = String(node.value || ''); + const genericType = String(node.name || ''); + const args = (node.children || []).map(c => this.generateExpression(c)).join(', '); + + // Handle array.new() -> [] + if (funcName === 'array.new') { + if (args) { + // array.new(size) -> new Array(size).fill(null) + // array.new(size, default) -> new Array(size).fill(default) + const argList = (node.children || []); + if (argList.length === 1) { + const sizeExpr = this.generateExpression(argList[0]); + return `new Array(${sizeExpr}).fill(null)`; + } else if (argList.length >= 2) { + const sizeExpr = this.generateExpression(argList[0]); + const defaultExpr = this.generateExpression(argList[1]); + return `new Array(${sizeExpr}).fill(${defaultExpr})`; + } + } + return '[]'; + } + + // For other generic functions, pass through + return `${funcName}(${args})`; + } + + private generateArrayLiteral(node: ASTNode): string { + const elements = (node.children || []).map(c => this.generateExpression(c)).join(', '); + return `[${elements}]`; + } + + private inferObjectType(node: ASTNode): string | null { + if (!node) return null; + + // If it's a type instantiation, we know the type + if (node.type === 'TypeInstantiation') { + return String(node.value || ''); + } + + // If it's an identifier, check if we've seen it assigned a type + // For now, return null and let the method call use fallback + return null; + } + private generateTernaryExpression(node: ASTNode): string { if (!node.children || node.children.length < 3) return ''; @@ -1206,6 +1536,7 @@ class CodeGenerator { 'true': 'true', 'false': 'false', 'bar_index': 'i', + 'this': 'self', // Method context: 'this' becomes 'self' }; if (builtins[name]) { @@ -1220,6 +1551,11 @@ class CodeGenerator { if (name.startsWith('color.')) { return `"${name.replace('color.', '')}"`; } + + // Handle 'this.field' -> 'self.field' for method context + if (name.startsWith('this.')) { + return name.replace('this.', 'self.'); + } // Handle barstate variables const barstateMap: Record = { diff --git a/packages/oakscript-engine/src/transpiler/types.ts b/packages/oakscript-engine/src/transpiler/types.ts index 256a08e..078e429 100644 --- a/packages/oakscript-engine/src/transpiler/types.ts +++ b/packages/oakscript-engine/src/transpiler/types.ts @@ -82,3 +82,59 @@ export interface TranspileWarning { line?: number; column?: number; } + +/** + * Field information for user-defined types + */ +export interface FieldInfo { + /** Field name */ + name: string; + /** Field type (PineScript type) */ + fieldType: string; + /** Default value expression (as string) */ + defaultValue?: string; + /** Whether the field is optional (has default or is nullable) */ + isOptional: boolean; +} + +/** + * User-defined type information + */ +export interface TypeInfo { + /** Type name */ + name: string; + /** Whether this type is exported */ + exported: boolean; + /** Fields in the type */ + fields: FieldInfo[]; +} + +/** + * Method information for user-defined types + */ +export interface MethodInfo { + /** Method name */ + name: string; + /** The type this method is bound to (first parameter type) */ + boundType: string; + /** Whether this method is exported */ + exported: boolean; + /** Parameter list (excluding 'this' parameter) */ + parameters: MethodParameter[]; + /** Return type (if specified) */ + returnType?: string; + /** Method body AST node */ + bodyNode?: unknown; +} + +/** + * Method parameter information + */ +export interface MethodParameter { + /** Parameter name */ + name: string; + /** Parameter type */ + paramType: string; + /** Default value (if any) */ + defaultValue?: string; +} diff --git a/packages/oakscript-engine/tests/transpiler-phase3.test.ts b/packages/oakscript-engine/tests/transpiler-phase3.test.ts new file mode 100644 index 0000000..7ad15b6 --- /dev/null +++ b/packages/oakscript-engine/tests/transpiler-phase3.test.ts @@ -0,0 +1,578 @@ +import { describe, it, expect } from 'vitest'; +import { PineParser } from '../src/transpiler/PineParser'; +import { transpile } from '../src/transpiler/PineToTS'; + +describe('Phase 3: User-Defined Types and Methods', () => { + describe('Type Declarations', () => { + describe('Basic type parsing', () => { + it('should parse simple type declaration', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`type Point + float x = 0.0 + float y = 0.0`); + + expect(errors).toHaveLength(0); + expect(ast.children).toBeDefined(); + expect(ast.children![0]!.type).toBe('TypeDeclaration'); + expect(ast.children![0]!.value).toBe('Point'); + expect(ast.children![0]!.children).toHaveLength(2); + }); + + it('should parse type with multiple field types', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`type Settings + float devThreshold = 5.0 + int depth = 10 + color lineColor = color.blue + bool extendLast = true + string differencePriceMode = "Absolute"`); + + expect(errors).toHaveLength(0); + const typeDecl = ast.children![0]!; + expect(typeDecl.type).toBe('TypeDeclaration'); + expect(typeDecl.value).toBe('Settings'); + expect(typeDecl.children).toHaveLength(5); + + // Check field types + expect(typeDecl.children![0]!.fieldType).toBe('float'); + expect(typeDecl.children![1]!.fieldType).toBe('int'); + expect(typeDecl.children![2]!.fieldType).toBe('color'); + expect(typeDecl.children![3]!.fieldType).toBe('bool'); + expect(typeDecl.children![4]!.fieldType).toBe('string'); + }); + + it('should parse exported type', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`export type ZigZag + float sumVol = 0`); + + expect(errors).toHaveLength(0); + expect(ast.children![0]!.type).toBe('TypeDeclaration'); + expect(ast.children![0]!.exported).toBe(true); + }); + + it('should parse type with complex field types', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`type Pivot + line ln + label lb + chart.point start + chart.point end`); + + expect(errors).toHaveLength(0); + const typeDecl = ast.children![0]!; + expect(typeDecl.children![0]!.fieldType).toBe('line'); + expect(typeDecl.children![1]!.fieldType).toBe('label'); + expect(typeDecl.children![2]!.fieldType).toBe('chart.point'); + expect(typeDecl.children![3]!.fieldType).toBe('chart.point'); + }); + + it('should parse type with generic array field', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`type ZigZag + array pivots`); + + expect(errors).toHaveLength(0); + const typeDecl = ast.children![0]!; + expect(typeDecl.children![0]!.fieldType).toBe('array'); + }); + }); + + describe('Type code generation', () => { + it('should generate TypeScript interface for simple type', () => { + const source = `indicator("UDT Test") +type Point + float x = 0.0 + float y = 0.0`; + + const result = transpile(source); + + expect(result).toContain('interface Point'); + expect(result).toContain('x: number'); + expect(result).toContain('y: number'); + }); + + it('should generate namespace object with new() factory', () => { + const source = `indicator("UDT Test") +type Point + float x = 0.0 + float y = 0.0`; + + const result = transpile(source); + + expect(result).toContain('const Point = {'); + expect(result).toContain('new:'); + // Check for parameter declaration with default value (0 or 0.0) + expect(result).toMatch(/x: number = 0(\.0)?/); + expect(result).toMatch(/y: number = 0(\.0)?/); + }); + + it('should export interface and namespace when type is exported', () => { + const source = `indicator("UDT Test") +export type Settings + float threshold = 5.0`; + + const result = transpile(source); + + expect(result).toContain('export interface Settings'); + expect(result).toContain('export const Settings = {'); + }); + + it('should handle array field types', () => { + const source = `indicator("UDT Test") +type Container + array values`; + + const result = transpile(source); + + expect(result).toContain('values: number[]'); + }); + }); + }); + + describe('Type Instantiation', () => { + describe('Type.new() parsing', () => { + it('should parse Type.new() without arguments', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('p = Point.new()'); + + expect(errors).toHaveLength(0); + const assignment = ast.children![0]!.children![0]!; + expect(assignment.type).toBe('Assignment'); + const instantiation = assignment.children![1]!; + expect(instantiation.type).toBe('TypeInstantiation'); + expect(instantiation.value).toBe('Point'); + }); + + it('should parse Type.new() with arguments', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('p = Point.new(10.0, 20.0)'); + + expect(errors).toHaveLength(0); + const assignment = ast.children![0]!.children![0]!; + const instantiation = assignment.children![1]!; + expect(instantiation.type).toBe('TypeInstantiation'); + expect(instantiation.value).toBe('Point'); + expect(instantiation.children).toHaveLength(2); + }); + }); + + describe('Type.new() code generation', () => { + it('should generate Type.new() call', () => { + const source = `indicator("UDT Test") +type Point + float x = 0.0 + float y = 0.0 + +p = Point.new(10.0, 20.0)`; + + const result = transpile(source); + + expect(result).toContain('Point.new(10, 20)'); + }); + + it('should generate Type.new() with no args', () => { + const source = `indicator("UDT Test") +type Point + float x = 0.0 + float y = 0.0 + +p = Point.new()`; + + const result = transpile(source); + + expect(result).toContain('Point.new()'); + }); + }); + }); + + describe('Field Access', () => { + describe('Field access parsing', () => { + it('should parse simple field access', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('value = point.x'); + + expect(errors).toHaveLength(0); + // After member access parsing, we get a MemberExpression + const assignment = ast.children![0]!.children![0]!; + const right = assignment.children![1]!; + // Due to how parseMemberAccess works, this becomes MemberExpression + expect(right.type).toBe('MemberExpression'); + }); + + it('should parse chained field access', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('price = pivot.end.price'); + + expect(errors).toHaveLength(0); + // This gets parsed as MemberExpression "pivot.end.price" + const assignment = ast.children![0]!.children![0]!; + const right = assignment.children![1]!; + expect(right.value).toBe('pivot.end.price'); + }); + }); + + describe('Field access code generation', () => { + it('should generate simple field access', () => { + const source = `indicator("UDT Test") +type Point + float x = 0.0 + float y = 0.0 + +p = Point.new(10.0, 20.0) +xVal = p.x`; + + const result = transpile(source); + + expect(result).toContain('p.x'); + }); + }); + }); + + describe('Field Assignment', () => { + it('should parse field reassignment with :=', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('point.x := 5.0'); + + expect(errors).toHaveLength(0); + const reassignment = ast.children![0]!.children![0]!; + expect(reassignment.type).toBe('Reassignment'); + }); + + it('should generate field assignment', () => { + const source = `indicator("UDT Test") +type Point + float x = 0.0 + float y = 0.0 + +var p = Point.new() +p.x := 5.0`; + + const result = transpile(source); + + expect(result).toContain('p.x = 5'); + }); + }); + + describe('Method Declarations', () => { + describe('Method parsing', () => { + it('should parse simple method declaration', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`method getValue(Counter this) => + this.value`); + + expect(errors).toHaveLength(0); + const methodDecl = ast.children![0]!; + expect(methodDecl.type).toBe('MethodDeclaration'); + expect(methodDecl.value).toBe('getValue'); + expect(methodDecl.boundType).toBe('Counter'); + }); + + it('should parse method with additional parameters', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`method add(Counter this, int amount) => + this.value := this.value + amount`); + + expect(errors).toHaveLength(0); + const methodDecl = ast.children![0]!; + expect(methodDecl.type).toBe('MethodDeclaration'); + expect(methodDecl.value).toBe('add'); + expect(methodDecl.boundType).toBe('Counter'); + expect(methodDecl.params).toHaveLength(1); + expect(methodDecl.params![0]!.value).toBe('amount'); + expect(methodDecl.params![0]!.fieldType).toBe('int'); + }); + + it('should parse exported method', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`export method reset(Counter this) => + this.value := 0`); + + expect(errors).toHaveLength(0); + const methodDecl = ast.children![0]!; + expect(methodDecl.type).toBe('MethodDeclaration'); + expect(methodDecl.exported).toBe(true); + }); + + it('should parse method with multi-line body', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse(`method updatePivot(Pivot this, float vol) => + this.vol := vol + this.isHigh := true`); + + expect(errors).toHaveLength(0); + const methodDecl = ast.children![0]!; + expect(methodDecl.type).toBe('MethodDeclaration'); + expect(methodDecl.children![0]!.type).toBe('Block'); + expect(methodDecl.children![0]!.children).toHaveLength(2); + }); + }); + + describe('Method code generation', () => { + it('should generate method in type namespace', () => { + const source = `indicator("UDT Test") +type Counter + int value = 0 + +method increment(Counter this) => + this.value := this.value + 1`; + + const result = transpile(source); + + expect(result).toContain('const Counter = {'); + expect(result).toContain('new:'); + expect(result).toContain('increment:'); + expect(result).toContain('self: Counter'); + }); + + it('should generate method with additional parameters', () => { + const source = `indicator("UDT Test") +type Counter + int value = 0 + +method add(Counter this, int amount) => + this.value := this.value + amount`; + + const result = transpile(source); + + expect(result).toContain('add:'); + expect(result).toContain('self: Counter, amount: number'); + }); + }); + }); + + describe('Method Calls', () => { + describe('Method call parsing', () => { + it('should parse object.method() as FunctionCall', () => { + // Without type information at parse time, we parse as FunctionCall + const parser = new PineParser(); + const { ast, errors } = parser.parse('counter.increment()'); + + expect(errors).toHaveLength(0); + const stmt = ast.children![0]!; + const expr = stmt.children![0]!; + // Parsed as FunctionCall with value 'counter.increment' + expect(expr.type).toBe('FunctionCall'); + expect(expr.value).toBe('counter.increment'); + }); + + it('should parse object.method(args) as FunctionCall', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('counter.add(5)'); + + expect(errors).toHaveLength(0); + const stmt = ast.children![0]!; + const expr = stmt.children![0]!; + expect(expr.type).toBe('FunctionCall'); + expect(expr.value).toBe('counter.add'); + expect(expr.children!.length).toBe(1); // 1 arg + }); + + it('should parse chained method call after Type.new() as MethodCall', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('result = Point.new().getX()'); + + expect(errors).toHaveLength(0); + // After Type.new(), we can detect method calls + const assignment = ast.children![0]!.children![0]!; + const right = assignment.children![1]!; + expect(right.type).toBe('MethodCall'); + expect(right.value).toBe('getX'); + }); + }); + + describe('Method call code generation', () => { + it('should generate method call on variable', () => { + const source = `indicator("UDT Test") +type Counter + int value = 0 + +method increment(Counter this) => + this.value := this.value + 1 + +var c = Counter.new() +c.increment()`; + + const result = transpile(source); + + // Method call generates c.increment() for now + // (full type inference would generate Counter.increment(c)) + expect(result).toContain('c.increment()'); + }); + }); + }); + + describe('Generic Arrays with UDT', () => { + describe('array.new() parsing', () => { + it('should parse array.new()', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('arr = array.new()'); + + expect(errors).toHaveLength(0); + const assignment = ast.children![0]!.children![0]!; + const call = assignment.children![1]!; + expect(call.type).toBe('GenericFunctionCall'); + expect(call.value).toBe('array.new'); + expect(call.name).toBe('Point'); + }); + + it('should parse array.new(size)', () => { + const parser = new PineParser(); + const { ast, errors } = parser.parse('arr = array.new(10)'); + + expect(errors).toHaveLength(0); + const assignment = ast.children![0]!.children![0]!; + const call = assignment.children![1]!; + expect(call.type).toBe('GenericFunctionCall'); + expect(call.children).toHaveLength(1); + }); + }); + + describe('array.new() code generation', () => { + it('should generate empty array for array.new()', () => { + const source = `indicator("UDT Test") +type Point + float x = 0.0 + +var arr = array.new()`; + + const result = transpile(source); + + expect(result).toContain('[]'); + }); + + it('should generate Array with fill for array.new(size)', () => { + const source = `indicator("UDT Test") +type Point + float x = 0.0 + +var arr = array.new(10)`; + + const result = transpile(source); + + expect(result).toContain('new Array(10).fill(null)'); + }); + }); + }); + + describe('Integration Tests', () => { + it('should handle basic UDT example from spec', () => { + const source = `indicator("UDT Basic Test") + +type Point + float x = 0.0 + float y = 0.0 + +type Rectangle + Point topLeft + Point bottomRight + color fillColor = color.blue + +p1 = Point.new(10.0, 20.0) +p2 = Point.new(100.0, 50.0) +rect = Rectangle.new(p1, p2, color.red)`; + + // Should not throw + expect(() => transpile(source)).not.toThrow(); + + const result = transpile(source); + + // Check interface generation + expect(result).toContain('interface Point'); + expect(result).toContain('interface Rectangle'); + + // Check namespace generation + expect(result).toContain('const Point = {'); + expect(result).toContain('const Rectangle = {'); + + // Check instantiation + expect(result).toContain('Point.new(10, 20)'); + expect(result).toContain('Point.new(100, 50)'); + }); + + it('should handle UDT with methods example from spec', () => { + const source = `indicator("UDT Methods Test") + +type Counter + int value = 0 + string name = "default" + +method increment(Counter this) => + this.value := this.value + 1 + +method reset(Counter this) => + this.value := 0 + +method getValue(Counter this) => + this.value + +var counter = Counter.new(0, "myCounter")`; + + // Should not throw + expect(() => transpile(source)).not.toThrow(); + + const result = transpile(source); + + // Check interface + expect(result).toContain('interface Counter'); + + // Check methods in namespace + expect(result).toContain('increment:'); + expect(result).toContain('reset:'); + expect(result).toContain('getValue:'); + + // Check instantiation + expect(result).toContain('Counter.new(0, "myCounter")'); + }); + + it('should handle UDT array example from spec', () => { + const source = `indicator("UDT Array Test") + +type DataPoint + int index + float price + float volume + +var points = array.new()`; + + // Should not throw + expect(() => transpile(source)).not.toThrow(); + + const result = transpile(source); + + expect(result).toContain('interface DataPoint'); + expect(result).toContain('const DataPoint = {'); + expect(result).toContain('[]'); + }); + + it('should compile generated TypeScript without syntax errors', () => { + const source = `indicator("Complete UDT Test") + +type Settings + float threshold = 5.0 + int depth = 10 + bool enabled = true + +type Counter + int value = 0 + Settings settings + +method increment(Counter this) => + this.value := this.value + 1 + +var s = Settings.new(10.0, 20, false) +var c = Counter.new(0, s)`; + + // Should not throw + expect(() => transpile(source)).not.toThrow(); + + const result = transpile(source); + + // Basic structure checks + expect(result).toContain('export function'); + expect(result).toContain('return {'); + expect(result).toContain('metadata:'); + expect(result).toContain('plots:'); + }); + }); +}); From 5887bcd1fe7b4b42cd706459501dd0b3317c28d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:27:13 +0000 Subject: [PATCH 3/3] Address code review feedback for Phase 3 UDT support Co-authored-by: deepentropy <8287111+deepentropy@users.noreply.github.com> --- .../src/transpiler/PineParser.ts | 5 ++++- .../src/transpiler/PineToTS.ts | 21 ++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/oakscript-engine/src/transpiler/PineParser.ts b/packages/oakscript-engine/src/transpiler/PineParser.ts index ecf0293..d7689c5 100644 --- a/packages/oakscript-engine/src/transpiler/PineParser.ts +++ b/packages/oakscript-engine/src/transpiler/PineParser.ts @@ -110,6 +110,7 @@ export class PineParser { } // Parse export keyword (for types, methods, functions) + const savedPositionForExport = this.position; if (this.matchKeyword('export')) { this.skipWhitespace(); if (this.matchKeyword('type')) { @@ -118,7 +119,9 @@ export class PineParser { if (this.matchKeyword('method')) { return this.parseMethodDeclaration(true); } - // export function or other exports - fall through to expression parsing + // export function or other exports - restore position and fall through to expression parsing + // This handles cases like `export newInstance(...)` which would parse as a function call + this.position = savedPositionForExport; } // Parse type declaration diff --git a/packages/oakscript-engine/src/transpiler/PineToTS.ts b/packages/oakscript-engine/src/transpiler/PineToTS.ts index 7074508..cbc0609 100644 --- a/packages/oakscript-engine/src/transpiler/PineToTS.ts +++ b/packages/oakscript-engine/src/transpiler/PineToTS.ts @@ -351,9 +351,11 @@ class CodeGenerator { return defaults[pineType]; } - // For user-defined types, call their new() with no args + // For user-defined types, use null to avoid potential infinite recursion + // (when a type has a field of its own type) + // Runtime code should handle this with explicit instantiation if (this.types.has(pineType)) { - return `${pineType}.new()`; + return 'null'; } return 'null'; @@ -1287,20 +1289,15 @@ class CodeGenerator { // Check if object is a type instantiation or we have type info const objectType = this.inferObjectType(objectNode); if (objectType && this.types.has(objectType)) { - // Use TypeName.method(object, args) pattern - const allArgs = args ? `${objectExpr}, ${args}` : objectExpr; - return `${objectType}.${methodName}(${allArgs})`; - } - - // Check if method exists on any known type (method on UDT) - for (const [typeName, methods] of this.methods) { - if (methods.some(m => m.name === methodName)) { + // Verify the method exists on this type before using TypeName.method pattern + const typeMethods = this.methods.get(objectType) || []; + if (typeMethods.some(m => m.name === methodName)) { const allArgs = args ? `${objectExpr}, ${args}` : objectExpr; - return `${typeName}.${methodName}(${allArgs})`; + return `${objectType}.${methodName}(${allArgs})`; } } - // Fall back to object.method(args) style for built-in methods + // Fall back to object.method(args) style for built-in methods and unknown types return args ? `${objectExpr}.${methodName}(${args})` : `${objectExpr}.${methodName}()`; }