// Native implementation of util.format functionality function format(f: unknown, ...args: unknown[]): string { if (typeof f !== 'string') { // If first argument is not a string, just join all args with spaces return [f, ...args].map(arg => { if (arg === null) return 'null'; if (arg === undefined) return 'undefined'; if (typeof arg === 'object') { try { return JSON.stringify(arg, null, 0); } catch { // Handle circular references and other JSON errors return '[object Object]'; } } return String(arg); }).join(' '); } let i = 0; // First, handle %% replacement - if there are no args, %% stays as %% // If there are args, %% becomes % const handlePercentPercent = args.length === 0 ? '%%' : '%'; const str = f.replace(/%[sdifj%]/g, (match: string) => { if (match === '%%') { return handlePercentPercent; } if (i >= args.length) return match; const arg = args[i++]; switch (match) { case '%s': if (arg === null) return 'null'; if (arg === undefined) return 'undefined'; if (typeof arg === 'object') { try { // For objects without %j, use a simplified string representation if (Array.isArray(arg)) { return `[ ${arg.join(', ')} ]`; } // For plain objects, show key-value pairs const entries = Object.entries(arg).map(([k, v]) => `${k}: ${typeof v === 'string' ? `'${v}'` : v}`); return `{ ${entries.join(', ')} }`; } catch { return '[object Object]'; } } try { return String(arg); } catch { return '[object Object]'; } case '%d': if (arg === null) return '0'; if (arg === undefined) return 'NaN'; return String(Number(arg)); case '%i': if (arg === null) return '0'; if (arg === undefined) return 'NaN'; return String(parseInt(String(Number(arg)), 10)); case '%f': if (arg === null) return '0'; if (arg === undefined) return 'NaN'; return String(parseFloat(String(Number(arg)))); case '%j': try { return JSON.stringify(arg); } catch { return '[Circular]'; } default: return match; } }); // Append any remaining arguments const remainingArgs = args.slice(i); if (remainingArgs.length > 0) { return str + ' ' + remainingArgs.map(arg => { if (arg === null) return 'null'; if (arg === undefined) return 'undefined'; if (typeof arg === 'object') { try { if (Array.isArray(arg)) { return `[ ${arg.join(', ')} ]`; } const entries = Object.entries(arg).map(([k, v]) => `${k}: ${typeof v === 'string' ? `'${v}'` : v}`); return `{ ${entries.join(', ')} }`; } catch { return '[object Object]'; } } return String(arg); }).join(' '); } return str; } export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; export interface LogLevels { [level: string]: number; silent: -1; error: 0; warn: 1; info: 2; debug: 3; } export interface Colours { [level: string]: string; error: string; warn: string; info: string; debug: string; reset: string; } export interface LogEntry { level: string; levelNumber: number; time: string; pid: number; hostname: string; msg: string; callerFile?: string; callerLine?: number; [key: string]: unknown; } export type Formatter = (logEntry: LogEntry) => string; export interface LoggerOptions { level?: LogLevel; levels?: Partial; format?: 'json' | 'simple'; time?: 'long' | 'short'; callerLevel?: LogLevel; colours?: Partial; } class Logger { options: { level: LogLevel; levels: LogLevels; format: 'json' | 'simple'; time: 'long' | 'short'; callerLevel: LogLevel; colours: Colours; }; isRedirected: boolean; formatters: { [key: string]: Formatter }; callerErrorCount: number; maxCallerErrors: number; constructor(options: LoggerOptions = {}) { this.validateOptions(options); const defaultLevels: LogLevels = { silent: -1, error: 0, warn: 1, info: 2, debug: 3, }; const defaultColours: Colours = { error: '\x1b[91m', warn: '\x1b[33m', info: '\x1b[94m', debug: '\x1b[37m', reset: '\x1b[0m', }; this.options = { level: options.level || 'info', levels: Object.assign({}, defaultLevels, options.levels), format: options.format || 'json', time: options.time || 'short', callerLevel: options.callerLevel || 'warn', colours: Object.assign({}, defaultColours, options.colours), }; // Detect if output is redirected to a file this.isRedirected = !Deno.stdout.isTerminal(); // Initialize formatters registry this.formatters = { json: this.jsonFormatter.bind(this), simple: this.simpleFormatter.bind(this), }; // prevent infinite loop when reporting internal errors in getCallerInfo() this.callerErrorCount = 0; this.maxCallerErrors = 5; } validateOptions(options: LoggerOptions): void { // Validate level if provided if (options.level !== undefined) { const validLevels: LogLevel[] = ['silent', 'error', 'warn', 'info', 'debug']; if (!validLevels.includes(options.level)) { throw new Error( `Invalid log level: ${ options.level }. Valid levels are: ${validLevels.join(', ')}` ); } } // Validate format if provided if (options.format !== undefined) { const validFormats = ['json', 'simple']; if (!validFormats.includes(options.format)) { throw new Error( `Invalid format: ${ options.format }. Valid formats are: ${validFormats.join(', ')}` ); } } // Validate time if provided if (options.time !== undefined) { const validTimes = ['long', 'short']; if (!validTimes.includes(options.time)) { throw new Error( `Invalid time: ${ options.time }. Valid times are: ${validTimes.join(', ')}` ); } } // Validate callerLevel if provided if (options.callerLevel !== undefined) { const validLevels: LogLevel[] = ['silent', 'error', 'warn', 'info', 'debug']; if (!validLevels.includes(options.callerLevel)) { throw new Error( `Invalid callerLevel: ${ options.callerLevel }. Valid levels are: ${validLevels.join(', ')}` ); } } // Validate colours if provided (should be an object) if (options.colours !== undefined && typeof options.colours !== 'object') { throw new Error('colours option must be an object'); } // Validate levels if provided (should be an object with numeric values) if (options.levels !== undefined) { if (typeof options.levels !== 'object') { throw new Error('levels option must be an object'); } for (const [level, value] of Object.entries(options.levels)) { if ( typeof value !== 'number' || value < 0 || !Number.isInteger(value) ) { throw new Error( `Level value for '${level}' must be a non-negative integer` ); } } } } // JSON log formatter jsonFormatter(logEntry: LogEntry): string { try { return JSON.stringify(logEntry); } catch (error) { // Fallback for circular references or other JSON.stringify errors try { // Try to create a safe version by stringifying individual fields const safeEntry = { level: logEntry.level, levelNumber: logEntry.levelNumber, time: logEntry.time, pid: logEntry.pid, hostname: logEntry.hostname, msg: String(logEntry.msg), // Convert to string safely callerFile: logEntry.callerFile, callerLine: logEntry.callerLine, jsonError: `JSON stringify failed: ${error instanceof Error ? error.message : 'Unknown error'}`, }; return JSON.stringify(safeEntry); } catch { // Last resort - return a plain string return `{"level":"${logEntry.level}","msg":"${String( logEntry.msg // eslint-disable-next-line no-useless-escape ).replace(/"/g, '\"')}","jsonError":"Multiple JSON errors occurred"}`; } } } // Simple text log formatter simpleFormatter(logEntry: LogEntry): string { const levelPadded = logEntry.level.toUpperCase().padEnd(5); const caller = logEntry.callerFile ? `${logEntry.callerFile.split('/').pop()}:${logEntry.callerLine}` : null; return caller ? `[${logEntry.time}] [${levelPadded}] [${caller}] ${logEntry.msg}` : `[${logEntry.time}] [${levelPadded}] ${logEntry.msg}`; } getCallerInfo(): { callerFile: string; callerLine: number } { const originalFunc = (Error as unknown as { prepareStackTrace?: unknown }).prepareStackTrace; let callerFile = 'unknown'; let callerLine = 0; try { const err = new Error(); // deno-lint-ignore prefer-const let currentFile: string | undefined; (Error as unknown as { prepareStackTrace?: unknown }).prepareStackTrace = function (_err: unknown, stack: unknown) { return stack; }; const stack = err.stack as unknown as { shift: () => { getFileName: () => string; getLineNumber: () => number }; length: number }; currentFile = stack.shift().getFileName(); while (stack.length) { const stackFrame = stack.shift(); callerFile = stackFrame.getFileName(); if (currentFile !== callerFile) { callerLine = stackFrame.getLineNumber(); break; } } this.callerErrorCount = 0; } catch (e) { this.callerErrorCount++; if (this.callerErrorCount <= this.maxCallerErrors) { console.error('Error retrieving caller info:', e); if (this.callerErrorCount === this.maxCallerErrors) { // loop detected console.error( `Caller detection failed ${this.maxCallerErrors} times. Suppressing further caller error messages.` ); } } // callerFile and callerLine already set to defaults above } finally { (Error as unknown as { prepareStackTrace?: unknown }).prepareStackTrace = originalFunc; } return { callerFile, callerLine }; } log(level: LogLevel, message: unknown, ...args: unknown[]): void { if (this.options.levels[level] > this.options.levels[this.options.level]) { return; } // Only get caller info if current level is at or above callerLevel threshold const shouldIncludeCaller = this.options.levels[level] <= this.options.levels[this.options.callerLevel]; const { callerFile, callerLine } = shouldIncludeCaller ? this.getCallerInfo() : { callerFile: undefined, callerLine: undefined }; const now = new Date(); const time = this.options.time === 'long' ? now.toISOString() : now.toISOString().slice(0, 16).replace('T', ' '); const logEntry: LogEntry = { level, levelNumber: this.options.levels[level], time: time, pid: Deno.pid, hostname: Deno.hostname(), msg: format(message, ...args), }; // Only include caller info if it was requested if (shouldIncludeCaller && callerFile !== undefined && callerLine !== undefined) { logEntry.callerFile = callerFile; logEntry.callerLine = callerLine; } const colour = this.options.colours[level]; const resetColour = this.options.colours.reset; // Select the appropriate formatter const formatter = this.formatters[this.options.format] || this.formatters.json; let formattedLog: string; try { formattedLog = formatter(logEntry); // Ensure formatter returned a string if (typeof formattedLog !== 'string') { throw new Error( `Formatter returned ${typeof formattedLog} instead of string` ); } } catch (error) { // Formatter failed, fall back to JSON formatter try { const safeEntry = { ...logEntry, formatterError: `Formatter failed: ${error instanceof Error ? error.message : 'Unknown error'}`, }; formattedLog = this.formatters.json(safeEntry); } catch { // Even JSON formatter failed, create minimal safe output formattedLog = `{"level":"${logEntry.level}","msg":"${String( logEntry.msg ).replace( /"/g, // eslint-disable-next-line no-useless-escape '\"' )}","formatterError":"Formatter failed: ${error instanceof Error ? error.message : 'Unknown error'}"}`; } } // only show colours if logging to console if (this.isRedirected) { console.log(formattedLog); } else { console.log(`${colour}${formattedLog}${resetColour}`); } } error(message: unknown, ...args: unknown[]): void { this.log('error', message, ...args); } warn(message: unknown, ...args: unknown[]): void { this.log('warn', message, ...args); } info(message: unknown, ...args: unknown[]): void { this.log('info', message, ...args); } debug(message: unknown, ...args: unknown[]): void { this.log('debug', message, ...args); } level(): LogLevel; level(newLevel: LogLevel): LogLevel; level(newLevel?: LogLevel): LogLevel { // If argument provided, set the new level if (arguments.length > 0 && newLevel !== undefined) { if (Object.hasOwn(this.options.levels, newLevel)) { this.options.level = newLevel; } else { throw new Error(`Invalid log level: ${newLevel}`); } } return this.options.level; } setLevel(): LogLevel; setLevel(newLevel: LogLevel): LogLevel; setLevel(newLevel?: LogLevel): LogLevel { if (arguments.length === 0) { // Why are you using this as a getter? return this.level(); } return this.level(newLevel!); } } export default Logger;