UNPKG

meld

Version:

Meld: A template language for LLM prompts

267 lines (228 loc) 9.32 kB
import { describe, it, expect, beforeEach } from 'vitest'; import { ParserService } from './ParserService.js'; import { MeldParseError } from '@core/errors/MeldParseError.js'; import type { MeldNode, DirectiveNode, TextNode, CodeFenceNode } from 'meld-spec'; import type { Location, Position } from '@core/types/index.js'; // Import the centralized syntax examples and helpers import { textDirectiveExamples, codefenceExamples, contentExamples } from '@core/syntax/index.js'; import { getExample, getInvalidExample } from '@tests/utils/syntax-test-helpers.js'; // Define a type that combines the meld-spec Location with our filePath type LocationWithFilePath = { start: { line: number | undefined; column: number | undefined }; end: { line: number | undefined; column: number | undefined }; filePath?: string; }; // Helper function to create test locations function createTestLocation(startLine: number | undefined, startColumn: number | undefined, endLine: number | undefined, endColumn: number | undefined, filePath?: string): LocationWithFilePath { return { start: { line: startLine, column: startColumn }, end: { line: endLine, column: endColumn }, filePath }; } // Type guard for Location function isLocation(value: any): value is LocationWithFilePath { return ( value && typeof value === 'object' && 'start' in value && 'end' in value && 'filePath' in value ); } // Type guard for checking if a location has a filePath function hasFilePath(location: any): location is LocationWithFilePath { return ( location && typeof location === 'object' && 'start' in location && 'end' in location && 'filePath' in location ); } describe('ParserService', () => { let service: ParserService; beforeEach(() => { service = new ParserService(); }); describe('parse', () => { it('should parse text content', async () => { const content = contentExamples.atomic.simpleParagraph.code; const mockResult = [ { type: 'Text', content: 'This is a simple paragraph of text.', location: { start: { line: 1, column: 1 }, end: { line: 1, column: 36 } } } ]; const result = await service.parse(content); expect(result).toEqual(mockResult); }); it('should parse directive content', async () => { const content = textDirectiveExamples.atomic.simpleString.code; const mockResult = [ { type: 'Directive', directive: { kind: 'text', identifier: 'greeting', source: 'literal', value: 'Hello', }, location: { start: { line: 1, column: 2 }, end: { line: 1, column: 25 }, }, }, ]; const result = await service.parse(content); expect(result).toEqual(mockResult); }); it('should parse code fence content', async () => { const content = codefenceExamples.atomic.simpleCodeFence.code; const mockResult = [ { type: 'CodeFence', language: 'js', content: "```js\nconst greeting = 'Hello, world!';\nconsole.log(greeting);\n```", location: { start: { line: 1, column: 1 }, end: { line: 4, column: 4 }, }, }, ]; const result = await service.parse(content); expect(result).toEqual(mockResult); }); it('should parse code fence without language', async () => { const content = codefenceExamples.atomic.withoutLanguage.code; const mockResult = [ { type: 'CodeFence', language: undefined, content: '```\nThis is a code block without a language specified.\n```', location: { start: { line: 1, column: 1 }, end: { line: 3, column: 4 } } } ]; const result = await service.parse(content); expect(result).toEqual(mockResult); }); it('should treat directives as literal text in code fences', async () => { const content = codefenceExamples.combinations.withDirectives.code; const result = await service.parse(content); expect(result).toHaveLength(3); expect(result[0].type).toBe('Directive'); expect(result[1].type).toBe('Text'); expect(result[2].type).toBe('CodeFence'); const codeFence = result[2] as CodeFenceNode; expect(codeFence.content).toContain('```{{language}}'); expect(codeFence.content).toContain('console.log'); }); it('should handle nested code fences', async () => { const content = codefenceExamples.combinations.nestedFences.code; const result = await service.parse(content); expect(result).toHaveLength(1); expect(result[0].type).toBe('CodeFence'); expect((result[0] as CodeFenceNode).content).toContain('```js'); expect((result[0] as CodeFenceNode).content).toContain('console.log'); }); it('should parse code fences with equal backtick counts', async () => { const content = codefenceExamples.combinations.equalBacktickCounts.code; const result = await service.parse(content); expect(result).toHaveLength(3); expect(result[0].type).toBe('CodeFence'); expect((result[0] as CodeFenceNode).content).toBe('```\nouter\n```'); expect(result[1].type).toBe('Text'); expect((result[1] as TextNode).content).toBe('inner\n'); expect(result[2].type).toBe('CodeFence'); expect((result[2] as CodeFenceNode).content).toBe('```\n\n```'); }); it('should parse mixed content', async () => { const content = contentExamples.atomic.simpleParagraph.code; const result = await service.parse(content); // Verify we have at least one text node expect(result.length).toBeGreaterThan(0); const types = new Set(result.map(node => node.type)); expect(types.has('Text')).toBe(true); // Check that the nodes have proper location information result.forEach(node => { expect(node.location).toBeDefined(); expect(node.location.start).toBeDefined(); expect(node.location.end).toBeDefined(); }); }); it('should handle empty content', async () => { const result = await service.parse(''); expect(result).toEqual([]); expect(result).toHaveLength(0); }); it('should throw MeldParseError with location for invalid directive', async () => { const content = contentExamples.invalid.unknownDirective.code; await expect(service.parse(content)).rejects.toThrow(MeldParseError); await expect(service.parse(content)).rejects.toThrow(/Parse error/); }); it('should throw MeldParseError for malformed directive', async () => { const content = textDirectiveExamples.invalid.unclosedString.code; await expect(service.parse(content)).rejects.toThrow(MeldParseError); await expect(service.parse(content)).rejects.toThrow(/Parse error/); }); }); describe('parseWithLocations', () => { it('should include file path in locations', async () => { const content = contentExamples.atomic.simpleParagraph.code; const filePath = 'test.meld'; const result = await service.parseWithLocations(content, filePath); // Check that all nodes have the file path in their location result.forEach(node => { expect(node.location).toBeDefined(); expect(node.location.filePath).toBe(filePath); }); // Check that we have at least one text node expect(result.some(node => node.type === 'Text')).toBe(true); }); it('should preserve original locations when adding filePath', async () => { const content = textDirectiveExamples.atomic.simpleString.code; const filePath = 'test.meld'; const result = await service.parseWithLocations(content, filePath); expect(result[0].location).toEqual(expect.objectContaining({ start: expect.objectContaining({ line: 1 }), end: expect.objectContaining({ line: 1 }), filePath })); }); it('should include filePath in error for invalid content', async () => { const content = textDirectiveExamples.invalid.invalidVarName.code; const filePath = 'test.meld'; await expect(service.parseWithLocations(content, filePath)).rejects.toThrow(MeldParseError); await expect(service.parseWithLocations(content, filePath)).rejects.toThrow(/Parse error/); }); }); describe('error handling', () => { it('should handle unknown errors gracefully', async () => { const content = contentExamples.atomic.simpleParagraph.code; const result = await service.parse(content); expect(result).toEqual([{ type: 'Text', content: 'This is a simple paragraph of text.', location: { start: { line: 1, column: 1 }, end: { line: 1, column: 36 } } }]); }); it('should preserve MeldParseError instances', async () => { const content = textDirectiveExamples.invalid.invalidVarName.code; await expect(service.parse(content)).rejects.toThrow(MeldParseError); }); }); });