UNPKG

meld

Version:

Meld: A template language for LLM prompts

860 lines (738 loc) 30.8 kB
/** * ErrorDisplayService.ts * * Service for displaying errors with source context, highlighting, and formatting. */ import chalk from 'chalk'; import fs from 'fs/promises'; import { injectable, inject } from 'tsyringe'; import { MeldError, ErrorSeverity } from '@core/errors/MeldError.js'; import { MeldParseError } from '@core/errors/MeldParseError.js'; import { MeldResolutionError } from '@core/errors/MeldResolutionError.js'; import { MeldInterpreterError } from '@core/errors/MeldInterpreterError.js'; import { MeldImportError } from '@core/errors/MeldImportError.js'; import { MeldFileSystemError } from '@core/errors/MeldFileSystemError.js'; import { MeldFileNotFoundError } from '@core/errors/MeldFileNotFoundError.js'; import { MeldOutputError } from '@core/errors/MeldOutputError.js'; import { MeldDirectiveError } from '@core/errors/MeldDirectiveError.js'; import { PathValidationError as CorePathValidationError } from '@core/errors/PathValidationError.js'; import { ServiceInitializationError } from '@core/errors/ServiceInitializationError.js'; import { sourceMapService } from '@core/utils/SourceMapService.js'; import { extractLocationFromErrorObject } from '@core/utils/sourceMapUtils.js'; import { FileSystemService } from '@services/fs/FileSystemService/FileSystemService.js'; import { NodeFileSystem } from '@services/fs/FileSystemService/NodeFileSystem.js'; /** * Represents a source location with file path, line, and column */ export interface SourceLocation { filePath: string; line: number; column: number; } export interface IErrorDisplayService { formatError(error: MeldError): string; displayErrorWithSourceContext(error: MeldError): Promise<string>; enhanceErrorDisplay(error: unknown): Promise<string>; } @injectable() export class ErrorDisplayService implements IErrorDisplayService { private fileSystem: FileSystemService; constructor( @inject('FileSystemService') fileSystem?: FileSystemService ) { // Use injected FileSystemService or create a new one if not provided this.fileSystem = fileSystem || new FileSystemService(new NodeFileSystem()); } /** * Format a basic error message without source context * Simplified to remove error type labels and just show the location */ formatError(error: MeldError): string { // Format file path if available if (error.filePath) { return chalk.dim(` at ${chalk.cyan(error.filePath)}`); } // Fall back to a minimal error format if no file path is available return chalk.dim(` at unknown location`); } /** * Get an appropriate error type label based on the error class */ private getErrorTypeLabel(error: MeldError): string { if (error instanceof MeldParseError) return 'Parse Error'; if (error instanceof MeldResolutionError) return 'Resolution Error'; if (error instanceof MeldInterpreterError) return 'Interpreter Error'; if (error instanceof MeldImportError) return 'Import Error'; if (error instanceof MeldFileSystemError) return 'File System Error'; if (error instanceof MeldFileNotFoundError) return 'File Not Found'; if (error instanceof MeldOutputError) return 'Output Error'; if (error instanceof MeldDirectiveError) return 'Directive Error'; if (error instanceof CorePathValidationError) return 'Path Error'; if (error instanceof ServiceInitializationError) return 'Service Error'; // Use error.code as fallback if available if (error.code) return error.code; // Generic error label as last resort return 'Error'; } /** * Get color function based on error severity */ private getSeverityColor(severity: ErrorSeverity): (text: string) => string { switch (severity) { case ErrorSeverity.Fatal: return chalk.red.bold; case ErrorSeverity.Recoverable: return chalk.yellow.bold; case ErrorSeverity.Warning: return chalk.yellow; default: return chalk.red.bold; } } /** * Format resolution error with details */ private formatResolutionError(error: MeldResolutionError, baseMessage: string): string { const details = error.details; if (!details) return baseMessage; const messages = [baseMessage]; // Add variable information if available if (details.variableName) { messages.push(chalk.dim(`Variable: ${chalk.bold(details.variableName)}${details.variableType ? ` (${details.variableType})` : ''}`)); } // Add field path if available if (details.fieldPath) { messages.push(chalk.dim(`Field path: ${chalk.bold(details.fieldPath)}`)); } // Add value preview if available if (details.value) { const valuePreview = details.value.length > 80 ? details.value.substring(0, 77) + '...' : details.value; messages.push(chalk.dim(`Value: ${valuePreview}`)); } // Add content preview if available if (details.contentPreview) { const contentPreview = details.contentPreview.length > 80 ? details.contentPreview.substring(0, 77) + '...' : details.contentPreview; messages.push(chalk.dim(`Content: ${contentPreview}`)); } // Add context info if available if (details.context) { messages.push(chalk.dim(`Context: ${details.context}`)); } return messages.join('\n'); } /** * Format import error with details */ private formatImportError(error: MeldImportError, baseMessage: string): string { // Get details from the context field const details = error.context; if (!details) return baseMessage; const messages = [baseMessage]; // Add imported path if available if (details.importPath) { messages.push(chalk.dim(`Import path: ${chalk.bold(details.importPath)}`)); } // Add resolved path if available and different from import path if (details.resolvedPath && details.resolvedPath !== details.importPath) { messages.push(chalk.dim(`Resolved path: ${chalk.bold(details.resolvedPath)}`)); } // Add importing file if available if (details.importingFile && details.importingFile !== error.filePath) { messages.push(chalk.dim(`Importing from: ${chalk.bold(details.importingFile)}`)); } return messages.join('\n'); } /** * Format directive error with details */ private formatDirectiveError(error: MeldDirectiveError, baseMessage: string): string { const messages = [baseMessage]; // Add directive kind if available if (error.directiveKind) { messages.push(chalk.dim(`Directive: ${chalk.bold(error.directiveKind)}`)); } // Add extra context details if available const details = error.context; if (details) { if (details.directiveName && details.directiveName !== error.directiveKind) { messages.push(chalk.dim(`Name: ${chalk.bold(details.directiveName)}`)); } if (details.reason) { messages.push(chalk.dim(`Reason: ${details.reason}`)); } if (details.expected && details.received) { messages.push(chalk.dim(`Expected: ${chalk.green(details.expected)}`)); messages.push(chalk.dim(`Received: ${chalk.red(details.received)}`)); } } return messages.join('\n'); } /** * Format path validation error with details */ private formatPathValidationError(error: CorePathValidationError, baseMessage: string): string { const messages = [baseMessage]; // Add path code details if (error.code) { messages.push(chalk.dim(`Code: ${chalk.bold(error.code)}`)); } // Add context details if available const details = error.context; if (details) { if (details.path && details.path !== error.filePath) { messages.push(chalk.dim(`Path: ${chalk.bold(details.path)}`)); } if (details.expected) { messages.push(chalk.dim(`Expected: ${chalk.green(details.expected)}`)); } if (details.received) { messages.push(chalk.dim(`Received: ${chalk.red(details.received)}`)); } } return messages.join('\n'); } /** * Format file system error with details */ private formatFileSystemError(error: MeldFileSystemError, baseMessage: string): string { const messages = [baseMessage]; // Add command if available if (error.command) { messages.push(chalk.dim(`Command: ${chalk.bold(error.command)}`)); } // Add working directory if available if (error.cwd) { messages.push(chalk.dim(`Working directory: ${chalk.bold(error.cwd)}`)); } return messages.join('\n'); } /** * Format file not found error */ private formatFileNotFoundError(error: MeldFileNotFoundError, baseMessage: string): string { return baseMessage; } /** * Format output error with details */ private formatOutputError(error: MeldOutputError, baseMessage: string): string { const messages = [baseMessage]; // Add format if available if (error.format) { messages.push(chalk.dim(`Format: ${chalk.bold(error.format)}`)); } return messages.join('\n'); } /** * Display an error with source code context, highlighting, and formatting */ async displayErrorWithSourceContext(error: MeldError): Promise<string> { // Extract location based on error type let location = this.extractLocationFromError(error); // If no source location found, fall back to basic formatting if (!location) { return this.formatError(error); } const { filePath, line, column } = location; try { // Get the source line from the file const sourceCode = await this.getSourceLine(filePath, line); if (!sourceCode) { return this.formatError(error); } // Get the error length - determine how many characters to highlight let errorLength = 1; // Default to highlighting a single character // Extract length from different error types if (error.context?.length) { errorLength = error.context.length; } else if (error instanceof MeldParseError && error.location?.end) { // For parse errors, use start/end positions to calculate length if (error.location.start.line === error.location.end.line) { errorLength = error.location.end.column - error.location.start.column; } } else if (error instanceof MeldInterpreterError && error.context?.nodeLength) { errorLength = error.context.nodeLength; } else if (error instanceof MeldDirectiveError && error.context?.length) { errorLength = error.context.length; } // Ensure error length is at least 1 errorLength = Math.max(1, errorLength); // Create the error display with highlighting const errorDisplay = this.highlightErrorInSource(sourceCode, column, errorLength); // Try to get surrounding context lines (2 lines before and after) const contextLines = []; // Get lines before the error line for (let i = Math.max(1, line - 2); i < line; i++) { const contextLine = await this.getSourceLine(filePath, i); if (contextLine !== null) { contextLines.push({ lineNumber: i, content: chalk.dim(`${i.toString().padStart(4)} | `) + chalk.dim(contextLine) }); } } // Add the error line with highlighting contextLines.push({ lineNumber: line, content: chalk.bold(`${line.toString().padStart(4)} | `) + errorDisplay.codeLine, pointer: ' | ' + errorDisplay.pointerLine }); // Get lines after the error line for (let i = line + 1; i <= line + 2; i++) { const contextLine = await this.getSourceLine(filePath, i); if (contextLine !== null) { contextLines.push({ lineNumber: i, content: chalk.dim(`${i.toString().padStart(4)} | `) + chalk.dim(contextLine) }); } } // Build the complete error message with context - SIMPLIFIED VERSION // Skip error type labels and just show the location information const errorHeader = [ chalk.dim(` at ${chalk.cyan(filePath)}:${chalk.yellow(line.toString())}:${chalk.yellow(column.toString())}`) ]; // Add the code context with line numbers and highlighting const codeContext = []; contextLines.forEach(ctx => { codeContext.push(ctx.content); if (ctx.pointer) { codeContext.push(ctx.pointer); } }); return [ ...errorHeader, '', ...codeContext ].join('\n'); } catch (e) { // Fallback if reading the source or highlighting fails console.error("Failed to load source context:", e); return this.formatError(error); } } /** * Extract location information from different error types * Also recursively searches nested error objects to find location information */ private extractLocationFromError(error: MeldError): { filePath: string, line: number, column: number } | null { // Initialize with default values let location: { filePath: string, line: number, column: number } | null = null; // Get file path from the error let filePath: string | undefined = error.filePath; // Helper to recursively search for location in the error chain const searchErrorChain = (err: any): void => { if (!err) return; // Handle different error types to extract location information if (err instanceof MeldParseError) { // For parse errors, use the location field if (err.location) { location = { filePath: err.location.filePath || filePath || '', line: err.location.start.line, column: err.location.start.column }; return; // Found location, stop searching } } else if (err instanceof MeldInterpreterError) { // For interpreter errors, use the location field if (err.location) { location = { filePath: err.location.filePath || filePath || '', line: err.location.line, column: err.location.column }; return; // Found location, stop searching } } else if (err instanceof MeldResolutionError) { // For resolution errors, check details.location if (err.details?.location) { location = { filePath: err.details.location.filePath || filePath || '', line: err.details.location.start.line, column: err.details.location.start.column }; return; // Found location, stop searching } } else if (err instanceof MeldDirectiveError) { // For directive errors, use the location field if (err.location) { location = { filePath: err.location.filePath || filePath || '', line: err.location.line, column: err.location.column }; return; // Found location, stop searching } // If no location on the directive error, extract from the message if possible const msgMatch = err.message.match(/at line (\d+), column (\d+)/i); if (msgMatch && msgMatch.length >= 3) { const line = parseInt(msgMatch[1], 10); const column = parseInt(msgMatch[2], 10); // Validate numbers are reasonable if (!isNaN(line) && !isNaN(column) && line > 0 && column >= 0) { location = { filePath: err.filePath || filePath || '', line: line, column: column }; return; // Found location, stop searching } } } // Check for any direct line and column properties (might come from meld-ast) if (err.line !== undefined && err.column !== undefined) { // This checks direct line/column properties that might be on error objects // particularly from the meld-ast library location = { filePath: err.sourceFile || err.filePath || filePath || '', line: err.line, column: err.column }; return; // Found location, stop searching } // Check the context field for source location information if (err.context?.sourceLocation) { location = { filePath: err.context.sourceLocation.filePath || filePath || '', line: err.context.sourceLocation.line, column: err.context.sourceLocation.column }; return; // Found location, stop searching } // Continue searching in any nested error objects if (err.originalError) searchErrorChain(err.originalError); if (err.cause) searchErrorChain(err.cause); if (err.previous) searchErrorChain(err.previous); if (err.parent) searchErrorChain(err.parent); }; // Start searching from the error object searchErrorChain(error); // If we didn't find a location but have a file path and there's a line/column in the error message // Try to extract line/column information from the error message itself const errorMsg = error.message || ''; const lineColMatch = errorMsg.match(/(?:at|on|in|line)\s+(\d+)(?:,\s+column\s+|:|,\s+col(?:umn)?\s+)(\d+)/i); if (!location && filePath && lineColMatch) { return { filePath, line: parseInt(lineColMatch[1], 10), column: parseInt(lineColMatch[2], 10) }; } // If we still didn't find a location and have node.location from the error and a file path if (!location && filePath && error.location) { if (error.location.start) { return { filePath, line: error.location.start.line, column: error.location.start.column }; } else if (error.location.line && error.location.column) { return { filePath, line: error.location.line, column: error.location.column }; } } // Last resort: if we have a file path but no location, use defaults if (!location && filePath) { // Check if the error has line and column as direct properties if (typeof error.line === 'number' && typeof error.column === 'number') { return { filePath, line: error.line, column: error.column }; } return { filePath, line: 1, column: 1 }; } return location; } /** * Get additional details to display for specific error types */ private getErrorDetails(error: MeldError): string[] { const details: string[] = []; if (error instanceof MeldResolutionError && error.details) { if (error.details.variableName) { details.push(`Variable: ${chalk.bold(error.details.variableName)}${error.details.variableType ? ` (${error.details.variableType})` : ''}`); } if (error.details.fieldPath) { details.push(`Field path: ${chalk.bold(error.details.fieldPath)}`); } } else if (error instanceof MeldImportError && error.context) { if (error.context.importPath) { details.push(`Import path: ${chalk.bold(error.context.importPath)}`); } } else if (error instanceof MeldDirectiveError) { if (error.directiveKind) { details.push(`Directive: ${chalk.bold(error.directiveKind)}`); } } else if (error instanceof MeldInterpreterError) { if (error.nodeType) { details.push(`Node type: ${chalk.bold(error.nodeType)}`); } } return details; } /** * Read a specific line from a source file */ private async getSourceLine(filePath: string, lineNumber: number): Promise<string | null> { try { // Make sure line number is valid if (lineNumber <= 0) { console.error(`Invalid line number: ${lineNumber} (must be > 0)`); return null; } // Check if the file exists const exists = await this.fileSystem.exists(filePath); if (!exists) { console.error(`File does not exist: ${filePath}`); return null; } try { // Read the file content const content = await this.fileSystem.readFile(filePath); // Split by lines and get the specific line (adjusting for 1-based indexing) const lines = content.split('\n'); if (lineNumber <= lines.length) { return lines[lineNumber - 1]; } else { console.error(`Line number ${lineNumber} exceeds file length (${lines.length} lines)`); // Return the last line as a fallback if line number is too large if (lines.length > 0) { return lines[lines.length - 1]; } return null; } } catch (readError) { console.error(`Failed to read file: ${filePath}`, readError); return null; } } catch (error) { console.error(`Failed to read source line from ${filePath}:${lineNumber}`, error); return null; } } /** * Highlight the error portion of a source line */ private highlightErrorInSource( sourceLine: string, column: number, length: number = 1 ): { codeLine: string, pointerLine: string } { // Handle empty source line gracefully if (!sourceLine) { return { codeLine: chalk.dim('[empty line]'), pointerLine: chalk.red('^') }; } // Ensure column is within bounds (1-based in source maps, 0-based for string ops) // Subtract 1 from column if it's greater than 0 (to convert from 1-based to 0-based) const adjustedColumn = column > 0 ? Math.max(0, Math.min(column - 1, sourceLine.length)) : 0; // Ensure error length is reasonable const adjustedLength = Math.max(1, Math.min(length, sourceLine.length - adjustedColumn)); // Create highlighted code line with bounds checking const beforeError = sourceLine.substring(0, adjustedColumn); const errorPart = sourceLine.substring(adjustedColumn, Math.min(adjustedColumn + adjustedLength, sourceLine.length)) || ' '; const afterError = sourceLine.substring(Math.min(adjustedColumn + adjustedLength, sourceLine.length)); // Create colorized code line const codeLine = chalk.white(beforeError) + chalk.bgRed.white(errorPart) + chalk.white(afterError); // Create pointer line with caret(s) let pointerLine; // For very long lines, add a margin indicator to show where the error is if (adjustedColumn > 80) { // For errors far to the right, show an arrow pointing to the error pointerLine = chalk.dim('... ') + ' '.repeat(Math.min(76, adjustedColumn - 4)) + chalk.red('^'.repeat(Math.max(1, errorPart.length))); } else { pointerLine = ' '.repeat(adjustedColumn) + chalk.red('^'.repeat(Math.max(1, errorPart.length))); } return { codeLine, pointerLine }; } /** * Enhance an error with source mapping information if available * Adds improved support for handling nested errors from meld-ast */ async enhanceErrorDisplay(error: unknown): Promise<string> { // 1. Convert unknown errors to MeldError let meldError: MeldError; if (error instanceof MeldError) { // Use the error directly if it's already a MeldError meldError = error; // Add the original error as the cause to preserve it for extraction if (error.originalError && !error.cause) { meldError.cause = error.originalError; } } else if (error instanceof Error) { // For standard Error instances, wrap them and preserve the stack meldError = new MeldError(error.message, { cause: error, context: { stack: error.stack, name: error.name } }); // Copy any location properties directly from the original error // This helps with errors from meld-ast which might have line/column if ('line' in error && 'column' in error) { if (!meldError.context) meldError.context = {}; meldError.context.sourceLocation = { filePath: (error as any).sourceFile || (error as any).filePath || '', line: (error as any).line, column: (error as any).column }; } // Try to extract file path, line, and column from stack trace as backup const stackInfo = this.extractInfoFromStack(error.stack); if (stackInfo && !meldError.context?.sourceLocation) { if (!meldError.context) meldError.context = {}; meldError.context.sourceLocation = { filePath: stackInfo.filePath, line: stackInfo.line, column: stackInfo.column }; } } else { // For non-Error values, convert to string meldError = new MeldError(String(error)); } // 2. Try different strategies to get location information // Check for location in directive errors if (meldError instanceof MeldDirectiveError && meldError.location) { if (!meldError.context) meldError.context = {}; meldError.context.sourceLocation = { filePath: meldError.location.filePath || meldError.filePath || '', line: meldError.location.line, column: meldError.location.column }; return this.displayErrorWithSourceContext(meldError); } // Extract line and column directly from error message const lineColumnFromMessage = extractErrorLocation(meldError); if (lineColumnFromMessage && meldError.filePath) { if (!meldError.context) meldError.context = {}; meldError.context.sourceLocation = { filePath: meldError.filePath, line: lineColumnFromMessage.line, column: lineColumnFromMessage.column }; return this.displayErrorWithSourceContext(meldError); } // Check if we already have a location on the error or nested errors let location = this.extractLocationFromError(meldError); if (location) { // Update the error with the extracted location to ensure it's used in display if (!meldError.context) meldError.context = {}; meldError.context.sourceLocation = location; // We found location directly in the error or nested errors, use it for display return this.displayErrorWithSourceContext(meldError); } // Check if we can use source mapping to find location try { // Try to extract location from the error object or stack const extractedLocation = extractLocationFromErrorObject(meldError); if (extractedLocation) { // Try to find original source location using sourcemaps const sourceLocation = sourceMapService.findOriginalLocation( extractedLocation.line, extractedLocation.column ); if (sourceLocation) { // We found a source-mapped location, add it to the error context if (!meldError.context) meldError.context = {}; meldError.context.sourceLocation = sourceLocation; // Display with source context return this.displayErrorWithSourceContext(meldError); } // If we have a filePath but source mapping failed, create a direct location if (meldError.filePath) { if (!meldError.context) meldError.context = {}; meldError.context.sourceLocation = { filePath: meldError.filePath, line: extractedLocation.line, column: extractedLocation.column }; return this.displayErrorWithSourceContext(meldError); } } } catch (mappingError) { console.error("Error during source mapping:", mappingError); // Continue to fallback methods if source mapping fails } // Try to handle composite error messages with recursive extraction if (meldError.message.includes('at line') && meldError.message.includes('column')) { const nestedLocations = []; // Extract all line/column references from the message let errorMsg = meldError.message; const pattern = /(?:at|on|in|line)\s+(?:line\s+)?(\d+)(?:,\s+column\s+|:)(\d+)/gi; let match; while ((match = pattern.exec(errorMsg)) !== null) { if (match && match.length >= 3) { const line = parseInt(match[1], 10); const column = parseInt(match[2], 10); if (!isNaN(line) && !isNaN(column)) { nestedLocations.push({ line, column, index: match.index }); } } } // If we found multiple locations, use the last one (most specific) if (nestedLocations.length > 0 && meldError.filePath) { // Sort by position in the string (later matches are more specific) nestedLocations.sort((a, b) => b.index - a.index); const lastMatch = nestedLocations[0]; if (!meldError.context) meldError.context = {}; meldError.context.sourceLocation = { filePath: meldError.filePath, line: lastMatch.line, column: lastMatch.column }; return this.displayErrorWithSourceContext(meldError); } } // 3. If all else fails, just format the error without source context, // but still prioritize showing just location information if (meldError.filePath) { return chalk.dim(` at ${chalk.cyan(meldError.filePath)}`); } return this.formatError(meldError); } /** * Extract file path, line, and column from a stack trace string * * Example stack format: "at Object.<anonymous> (/path/to/file.js:10:15)" */ private extractInfoFromStack(stack?: string): { filePath: string, line: number, column: number } | null { if (!stack) return null; // Get lines from the stack const lines = stack.split('\n'); // Skip the first line which is usually the error message for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); // Match common Node.js stack trace format const match = line.match(/at\s+(?:\w+\s+)?\(?([^:]+):(\d+):(\d+)\)?/); if (match) { const [, filePath, lineStr, columnStr] = match; return { filePath, line: parseInt(lineStr, 10), column: parseInt(columnStr, 10) }; } } return null; } }