UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

693 lines (592 loc) 21.2 kB
/** * @fileoverview OrdoJS Parser Tests - Comprehensive test suite for recursive descent parser */ import { describe, expect, it } from 'vitest'; import { ComponentAST, DirectiveType, ExpressionType, LifecycleType, StatementType, SyntaxError } from '../types/index.js'; import { OrdoJSLexer } from './lexer.js'; import { OrdoJSParser } from './parser.js'; describe('OrdoJSParser', () => { let lexer: OrdoJSLexer; let parser: OrdoJSParser; const parseComponent = (source: string): ComponentAST => { lexer = new OrdoJSLexer(source, 'test.ordo'); const tokens = lexer.tokenize(); parser = new OrdoJSParser(tokens, {}, 'test.ordo'); return parser.parse(); }; const expectParseError = (source: string): SyntaxError[] => { try { parseComponent(source); throw new Error('Expected parsing to fail'); } catch (error) { if (error instanceof SyntaxError) { return [error]; } throw error; } }; describe('Component Parsing', () => { it('should parse minimal component', () => { const source = ` component TestComponent { markup { <div>Hello World</div> } } `; const ast = parseComponent(source); expect(ast.component.name).toBe('TestComponent'); expect(ast.component.type).toBe('Component'); expect(ast.component.markupBlock).toBeDefined(); expect(ast.component.clientBlock).toBeUndefined(); expect(ast.component.serverBlock).toBeUndefined(); }); it('should parse component with props', () => { const source = ` component TestComponent(title: string, count: number = 0) { markup { <div>{title}: {count}</div> } } `; const ast = parseComponent(source); expect(ast.component.props).toHaveLength(2); expect(ast.component.props[0].name).toBe('title'); expect(ast.component.props[0].dataType.name).toBe('string'); expect(ast.component.props[0].isRequired).toBe(true); expect(ast.component.props[1].name).toBe('count'); expect(ast.component.props[1].dataType.name).toBe('number'); expect(ast.component.props[1].isRequired).toBe(false); expect(ast.component.props[1].defaultValue).toBeDefined(); }); it('should parse component with all blocks', () => { const source = ` component FullComponent { client { let count = 0; onMount() { console.log('mounted'); } } server { public async getData(): Promise<string> { return 'data'; } } markup { <div> <button onclick={count++}>Count: {count}</button> </div> } } `; const ast = parseComponent(source); expect(ast.component.clientBlock).toBeDefined(); expect(ast.component.serverBlock).toBeDefined(); expect(ast.component.markupBlock).toBeDefined(); }); it('should handle missing component keyword', () => { const source = ` TestComponent { markup { <div>test</div> } } `; const errors = expectParseError(source); expect(errors[0].message).toContain("Expected 'component' keyword"); }); it('should handle missing component name', () => { const source = ` component { markup { <div>test</div> } } `; const errors = expectParseError(source); expect(errors[0].message).toContain('Expected component name'); }); }); describe('Client Block Parsing', () => { it('should parse reactive variables', () => { const source = ` component TestComponent { client { let count = 0; const name = "test"; let items: string[] = []; } markup { <div>test</div> } } `; const ast = parseComponent(source); const clientBlock = ast.component.clientBlock!; expect(clientBlock.reactiveVariables).toHaveLength(3); const countVar = clientBlock.reactiveVariables[0]; expect(countVar.name).toBe('count'); expect(countVar.isConst).toBe(false); expect(countVar.initialValue.expressionType).toBe(ExpressionType.LITERAL); expect(countVar.initialValue.value).toBe(0); const nameVar = clientBlock.reactiveVariables[1]; expect(nameVar.name).toBe('name'); expect(nameVar.isConst).toBe(true); expect(nameVar.initialValue.value).toBe('test'); const itemsVar = clientBlock.reactiveVariables[2]; expect(itemsVar.name).toBe('items'); expect(itemsVar.dataType.name).toBe('string'); expect(itemsVar.dataType.isArray).toBe(true); }); it('should parse lifecycle hooks', () => { const source = ` component TestComponent { client { onMount() { console.log('mounted'); } onUnmount() { console.log('unmounted'); } onUpdate() { console.log('updated'); } } markup { <div>test</div> } } `; const ast = parseComponent(source); const clientBlock = ast.component.clientBlock!; expect(clientBlock.lifecycle).toHaveLength(3); expect(clientBlock.lifecycle[0].hookType).toBe(LifecycleType.ON_MOUNT); expect(clientBlock.lifecycle[1].hookType).toBe(LifecycleType.ON_UNMOUNT); expect(clientBlock.lifecycle[2].hookType).toBe(LifecycleType.ON_UPDATE); }); it('should parse client functions', () => { const source = ` component TestComponent { client { handleClick(): void { count++; } calculateTotal(items: number[]): number { return items.reduce((sum, item) => sum + item, 0); } } markup { <div>test</div> } } `; const ast = parseComponent(source); const clientBlock = ast.component.clientBlock!; expect(clientBlock.functions).toHaveLength(2); const handleClick = clientBlock.functions[0]; expect(handleClick.name).toBe('handleClick'); expect(handleClick.returnType.name).toBe('void'); expect(handleClick.parameters).toHaveLength(0); const calculateTotal = clientBlock.functions[1]; expect(calculateTotal.name).toBe('calculateTotal'); expect(calculateTotal.returnType.name).toBe('number'); expect(calculateTotal.parameters).toHaveLength(1); expect(calculateTotal.parameters[0].name).toBe('items'); expect(calculateTotal.parameters[0].dataType.name).toBe('number'); expect(calculateTotal.parameters[0].dataType.isArray).toBe(true); }); it('should handle invalid reactive variable syntax', () => { const source = ` component TestComponent { client { let = 0; } markup { <div>test</div> } } `; const errors = expectParseError(source); expect(errors[0].message).toContain('Expected variable name'); }); }); describe('Server Block Parsing', () => { it('should parse server functions', () => { const source = ` component TestComponent { server { public async getData(): Promise<string> { return 'data'; } private processData(input: string): string { return input.toUpperCase(); } } markup { <div>test</div> } } `; const ast = parseComponent(source); const serverBlock = ast.component.serverBlock!; expect(serverBlock.functions).toHaveLength(2); const getData = serverBlock.functions[0]; expect(getData.name).toBe('getData'); expect(getData.isPublic).toBe(true); expect(getData.returnType.name).toBe('Promise'); const processData = serverBlock.functions[1]; expect(processData.name).toBe('processData'); expect(processData.isPublic).toBe(false); expect(processData.parameters).toHaveLength(1); expect(processData.parameters[0].name).toBe('input'); expect(processData.parameters[0].dataType.name).toBe('string'); }); it('should handle missing function body', () => { const source = ` component TestComponent { server { public getData(): string } markup { <div>test</div> } } `; const errors = expectParseError(source); expect(errors[0].message).toContain("Expected '{'"); }); }); describe('Markup Block Parsing', () => { it('should parse HTML elements', () => { const source = ` component TestComponent { markup { <div class="container"> <h1>Title</h1> <p>Content</p> <img src="image.jpg" alt="Image" /> </div> } } `; const ast = parseComponent(source); const markupBlock = ast.component.markupBlock; expect(markupBlock.elements).toHaveLength(1); const divElement = markupBlock.elements[0]; expect(divElement.tagName).toBe('div'); expect(divElement.attributes).toHaveLength(1); expect(divElement.attributes[0].name).toBe('class'); expect(divElement.attributes[0].value).toBe('container'); expect(divElement.children).toHaveLength(3); const imgElement = divElement.children[2] as any; expect(imgElement.tagName).toBe('img'); expect(imgElement.isSelfClosing).toBe(true); expect(imgElement.isVoidElement).toBe(true); }); it('should parse interpolations', () => { const source = ` component TestComponent { markup { <div> <h1>{title}</h1> <p>Count: {count + 1}</p> <span>{user.name}</span> </div> } } `; const ast = parseComponent(source); const markupBlock = ast.component.markupBlock; expect(markupBlock.interpolations).toHaveLength(3); const titleInterpolation = markupBlock.interpolations[0]; expect(titleInterpolation.expression.expressionType).toBe(ExpressionType.IDENTIFIER); expect(titleInterpolation.expression.identifier).toBe('title'); const countInterpolation = markupBlock.interpolations[1]; expect(countInterpolation.expression.expressionType).toBe(ExpressionType.BINARY); expect(countInterpolation.expression.operator).toBe('+'); const userInterpolation = markupBlock.interpolations[2]; expect(userInterpolation.expression.expressionType).toBe(ExpressionType.MEMBER); }); it('should parse directives', () => { const source = ` component TestComponent { markup { <input bind:value={inputValue} /> <button on:click={handleClick}>Click me</button> <div on:mouseover={handleHover}>Hover</div> } } `; const ast = parseComponent(source); const markupBlock = ast.component.markupBlock; const inputElement = markupBlock.elements[0]; expect(inputElement.attributes[0].isDirective).toBe(true); expect(inputElement.attributes[0].directiveType).toBe(DirectiveType.BIND); expect(inputElement.attributes[0].name).toBe('bind:value'); const buttonElement = markupBlock.elements[1]; expect(buttonElement.attributes[0].isDirective).toBe(true); expect(buttonElement.attributes[0].directiveType).toBe(DirectiveType.ON); expect(buttonElement.attributes[0].name).toBe('on:click'); }); it('should handle unclosed HTML tags', () => { const source = ` component TestComponent { markup { <div> <p>Unclosed paragraph </div> } } `; const errors = expectParseError(source); expect(errors[0].message).toContain('Expected'); }); it('should handle mismatched closing tags', () => { const source = ` component TestComponent { markup { <div> <p>Content</span> </div> } } `; const errors = expectParseError(source); expect(errors[0].message).toContain('Mismatched closing tag'); }); }); describe('Expression Parsing', () => { it('should parse binary expressions', () => { const source = ` component TestComponent { client { let result = a + b * c - d / e; } markup { <div>test</div> } } `; const ast = parseComponent(source); const clientBlock = ast.component.clientBlock!; const variable = clientBlock.reactiveVariables[0]; expect(variable.initialValue.expressionType).toBe(ExpressionType.BINARY); expect(variable.initialValue.operator).toBe('-'); expect(variable.initialValue.left?.expressionType).toBe(ExpressionType.BINARY); expect(variable.initialValue.left?.operator).toBe('+'); }); it('should parse function calls', () => { const source = ` component TestComponent { client { let result = Math.max(a, b, c); } markup { <div>test</div> } } `; const ast = parseComponent(source); const clientBlock = ast.component.clientBlock!; const variable = clientBlock.reactiveVariables[0]; expect(variable.initialValue.expressionType).toBe(ExpressionType.CALL); expect(variable.initialValue.callee?.expressionType).toBe(ExpressionType.MEMBER); expect(variable.initialValue.arguments).toHaveLength(3); }); it('should parse member access', () => { const source = ` component TestComponent { client { let name = user.profile.name; } markup { <div>test</div> } } `; const ast = parseComponent(source); const clientBlock = ast.component.clientBlock!; const variable = clientBlock.reactiveVariables[0]; expect(variable.initialValue.expressionType).toBe(ExpressionType.MEMBER); expect(variable.initialValue.object?.expressionType).toBe(ExpressionType.MEMBER); }); it('should parse assignment expressions', () => { const source = ` component TestComponent { client { handleClick() { count = count + 1; } } markup { <div>test</div> } } `; const ast = parseComponent(source); const clientBlock = ast.component.clientBlock!; const func = clientBlock.functions[0]; const statement = func.body[0]; expect(statement.statementType).toBe(StatementType.EXPRESSION); expect(statement.expression?.expressionType).toBe(ExpressionType.ASSIGNMENT); expect(statement.expression?.operator).toBe('='); }); it('should handle invalid expressions', () => { const source = ` component TestComponent { client { let result = + * 5; } markup { <div>test</div> } } `; const errors = expectParseError(source); expect(errors[0].message).toContain('Expected expression'); }); }); describe('Error Recovery', () => { it('should recover from multiple syntax errors', () => { const source = ` component TestComponent { client { let = 0; // Missing variable name const count; // Missing initializer invalidFunction() // Missing body } markup { <div> <p>Unclosed paragraph </div> } } `; try { lexer = new OrdoJSLexer(source, 'test.ordo'); const tokens = lexer.tokenize(); parser = new OrdoJSParser(tokens, { allowRecovery: true, maxErrors: 10 }, 'test.ordo'); parser.parse(); } catch (error) { // Should collect multiple errors const errors = parser.getErrors(); expect(errors.length).toBeGreaterThan(1); } }); it('should limit error count', () => { const source = ` component TestComponent { client { let; const; var; function; class; } markup { <div>test</div> } } `; try { lexer = new OrdoJSLexer(source, 'test.ordo'); const tokens = lexer.tokenize(); parser = new OrdoJSParser(tokens, { allowRecovery: true, maxErrors: 2 }, 'test.ordo'); parser.parse(); } catch (error) { const errors = parser.getErrors(); expect(errors.length).toBeLessThanOrEqual(2); } }); }); describe('Complex Component Parsing', () => { it('should parse a realistic component', () => { const source = ` component TodoApp(initialTodos: Todo[] = []) { client { let todos = initialTodos; let newTodo = ""; let filter = "all"; onMount() { loadTodos(); } addTodo(): void { if (newTodo.trim()) { todos = [...todos, { id: Date.now(), text: newTodo.trim(), completed: false }]; newTodo = ""; } } toggleTodo(id: number): void { todos = todos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ); } removeTodo(id: number): void { todos = todos.filter(todo => todo.id !== id); } } server { public async loadTodos(): Promise<Todo[]> { return await db.todos.findMany(); } public async saveTodo(todo: Todo): Promise<Todo> { return await db.todos.create({ data: todo }); } } markup { <div class="todo-app"> <header> <h1>Todo App</h1> <input bind:value={newTodo} on:keydown={e => e.key === 'Enter' && addTodo()} placeholder="Add a new todo..." /> <button on:click={addTodo}>Add</button> </header> <main> <ul class="todo-list"> {#each todos as todo} <li class={todo.completed ? 'completed' : ''}> <input type="checkbox" bind:checked={todo.completed} on:change={() => toggleTodo(todo.id)} /> <span>{todo.text}</span> <button on:click={() => removeTodo(todo.id)}>×</button> </li> {/each} </ul> </main> <footer> <span>{todos.filter(t => !t.completed).length} items left</span> <div class="filters"> <button class={filter === 'all' ? 'active' : ''} on:click={() => filter = 'all'} >All</button> <button class={filter === 'active' ? 'active' : ''} on:click={() => filter = 'active'} >Active</button> <button class={filter === 'completed' ? 'active' : ''} on:click={() => filter = 'completed'} >Completed</button> </div> </footer> </div> } } `; const ast = parseComponent(source); // Verify component structure expect(ast.component.name).toBe('TodoApp'); expect(ast.component.props).toHaveLength(1); expect(ast.component.clientBlock).toBeDefined(); expect(ast.component.serverBlock).toBeDefined(); expect(ast.component.markupBlock).toBeDefined(); // Verify client block const clientBlock = ast.component.clientBlock!; expect(clientBlock.reactiveVariables).toHaveLength(3); expect(clientBlock.lifecycle).toHaveLength(1); expect(clientBlock.functions).toHaveLength(3); // Verify server block const serverBlock = ast.component.serverBlock!; expect(serverBlock.functions).toHaveLength(2); expect(serverBlock.functions[0].isPublic).toBe(true); expect(serverBlock.functions[1].isPublic).toBe(true); // Verify markup structure const markupBlock = ast.component.markupBlock; expect(markupBlock.elements).toHaveLength(1); expect(markupBlock.elements[0].tagName).toBe('div'); expect(markupBlock.elements[0].attributes[0].name).toBe('class'); expect(markupBlock.elements[0].attributes[0].value).toBe('todo-app'); }); }); });