@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
693 lines (592 loc) • 21.2 kB
text/typescript
/**
* @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">
{
<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');
});
});
});