meld
Version:
Meld: A template language for LLM prompts
267 lines (228 loc) • 9.32 kB
text/typescript
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);
});
});
});