UNPKG

meld

Version:

Meld: A template language for LLM prompts

306 lines (261 loc) 9.65 kB
import { ResolutionError } from '@services/resolution/ResolutionService/errors/ResolutionError.js'; import type { IParserService } from '@services/pipeline/ParserService/IParserService.js'; import type { MeldNode, TextNode } from 'meld-spec'; /** * Handles validation and parsing of string literals in text directives */ export class StringLiteralHandler { private readonly QUOTE_TYPES = ["'", '"', '`'] as const; private readonly MIN_CONTENT_LENGTH = 1; constructor(private parserService?: IParserService) {} /** * Checks if a value appears to be a string literal * This is a preliminary check before full validation */ async isStringLiteralWithAst(value: string): Promise<boolean> { if (!this.parserService) { return this.isStringLiteral(value); } try { // Wrap the string in a directive to ensure proper parsing const wrappedValue = `@text test = ${value}`; // Parse with AST const nodes = await this.parserService.parse(wrappedValue); // Look for directive nodes const directiveNode = nodes.find(node => node.type === 'Directive' && (node as any).directive?.kind === 'text' ); if (directiveNode) { // In the test environment, the mock parser doesn't create a StringLiteral type // but just passes the value through, so we need to check both formats const directiveValue = (directiveNode as any).directive?.value; // Check if it's a StringLiteral node in the AST if (directiveValue && typeof directiveValue === 'object' && directiveValue.type === 'StringLiteral') { return true; } // Check if it's a string value that looks like a string literal if (typeof directiveValue === 'string') { return this.isStringLiteral(directiveValue); } } return false; } catch (error) { // If parsing fails, fall back to manual check console.warn('Failed to check string literal with AST, falling back to manual check:', error); return this.isStringLiteral(value); } } /** * Checks if a value appears to be a string literal * This is a preliminary check before full validation */ isStringLiteral(value: string): boolean { if (!value || value.length < 2) { return false; } const firstChar = value[0]; const lastChar = value[value.length - 1]; // Check for matching quotes if (!this.QUOTE_TYPES.includes(firstChar as any) || firstChar !== lastChar) { return false; } // Check for unclosed quotes let isEscaped = false; for (let i = 1; i < value.length - 1; i++) { if (value[i] === '\\') { isEscaped = !isEscaped; } else if (value[i] === firstChar && !isEscaped) { return false; // Found an unescaped quote in the middle } else { isEscaped = false; } } return true; } /** * Validates a string literal for proper quoting and content * @throws ResolutionError if the literal is invalid */ async validateLiteralWithAst(value: string): Promise<void> { if (!this.parserService) { return this.validateLiteral(value); } try { // Wrap the string in a directive to ensure proper parsing const wrappedValue = `@text test = ${value}`; // Parse with AST const nodes = await this.parserService.parse(wrappedValue); // If parsing succeeds without errors, the literal is valid // Just check if it's actually a string literal node const directiveNode = nodes.find(node => node.type === 'Directive' && (node as any).directive?.kind === 'text' ); if (!directiveNode) { throw new ResolutionError( 'Failed to validate string literal with AST', { value } ); } const directiveValue = (directiveNode as any).directive?.value; // In the test environment, the mock parser doesn't create a StringLiteral type // but just passes the value through, so we need to check both formats if (directiveValue && typeof directiveValue === 'object' && directiveValue.type === 'StringLiteral') { // Valid string literal object return; } else if (typeof directiveValue === 'string') { // Validate the string value as a string literal return this.validateLiteral(directiveValue); } throw new ResolutionError( 'String literal is invalid', { value } ); } catch (error) { // If parsing fails, fall back to manual validation console.warn('Failed to validate string literal with AST, falling back to manual validation:', error); return this.validateLiteral(value); } } /** * Validates a string literal for proper quoting and content * @throws ResolutionError if the literal is invalid */ validateLiteral(value: string): void { if (!value || value.length < 2) { throw new ResolutionError( 'String literal is empty or too short', { value } ); } const firstChar = value[0]; const lastChar = value[value.length - 1]; // Check if starts with a valid quote if (!this.QUOTE_TYPES.includes(firstChar as any)) { throw new ResolutionError( 'String literal must start with a quote (\', ", or `)', { value } ); } // Check if quotes match if (firstChar !== lastChar) { throw new ResolutionError( 'String literal has mismatched quotes', { value } ); } // Check for mixed quotes const otherQuotes = this.QUOTE_TYPES.filter(q => q !== firstChar); const content = value.slice(1, -1); for (const quote of otherQuotes) { if (content.includes(quote) && !this.isEscaped(content, quote)) { throw new ResolutionError( 'String literal contains unescaped mixed quotes', { value } ); } } // Check content length if (content.length < this.MIN_CONTENT_LENGTH) { throw new ResolutionError( 'String literal content is empty', { value } ); } // Check for newlines in single/double quoted strings if (firstChar !== '`' && content.includes('\n')) { throw new ResolutionError( 'Single and double quoted strings cannot contain newlines', { value } ); } } /** * Parses a string literal, removing quotes and handling escapes * @throws ResolutionError if the literal is invalid */ async parseLiteralWithAst(value: string): Promise<string> { if (!this.parserService) { return this.parseLiteral(value); } try { // Validate first await this.validateLiteralWithAst(value); // Wrap the string in a directive to ensure proper parsing const wrappedValue = `@text test = ${value}`; // Parse with AST const nodes = await this.parserService.parse(wrappedValue); // Extract the string literal value const directiveNode = nodes.find(node => node.type === 'Directive' && (node as any).directive?.kind === 'text' ); if (directiveNode) { const directiveValue = (directiveNode as any).directive?.value; if (directiveValue && typeof directiveValue === 'object' && directiveValue.type === 'StringLiteral') { // The parser has already handled quote escaping return directiveValue.value; } else if (typeof directiveValue === 'string') { // In test environment, the mock parser might return the string directly // Parse the string value as a string literal return this.parseLiteral(directiveValue); } } // Fall back to manual parsing return this.parseLiteral(value); } catch (error) { // If parsing fails, fall back to manual parsing console.warn('Failed to parse string literal with AST, falling back to manual parsing:', error); return this.parseLiteral(value); } } /** * Parses a string literal, removing quotes and handling escapes * @throws ResolutionError if the literal is invalid */ parseLiteral(value: string): string { // First validate the literal this.validateLiteral(value); // Get the content between quotes const content = value.slice(1, -1); // Handle escaped quotes based on quote type const quoteType = value[0]; return this.unescapeQuotes(content, quoteType as typeof this.QUOTE_TYPES[number]); } /** * Checks if a character at a given position is escaped */ private isEscaped(str: string, char: string, pos?: number): boolean { if (pos === undefined) { // If no position given, check all occurrences let escaped = false; for (let i = 0; i < str.length; i++) { if (str[i] === char && !this.isEscaped(str, char, i)) { return false; } } return true; } // Count backslashes before the character let backslashCount = 0; let i = pos - 1; while (i >= 0 && str[i] === '\\') { backslashCount++; i--; } return backslashCount % 2 === 1; } /** * Unescapes quotes in the content based on quote type */ private unescapeQuotes(content: string, quoteType: typeof this.QUOTE_TYPES[number]): string { // Replace escaped quotes with actual quotes return content.replace( new RegExp(`\\\\${quoteType}`, 'g'), quoteType ); } }