meld
Version:
Meld: A template language for LLM prompts
860 lines (738 loc) • 30.8 kB
text/typescript
/**
* 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>;
}
()
export class ErrorDisplayService implements IErrorDisplayService {
private fileSystem: FileSystemService;
constructor(
('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;
}
}