echo/src/lib/char.ts
creations f313a8a329
All checks were successful
Code quality checks / biome (push) Successful in 9s
try fix for windows, change how logging works allows much more flexibility
2025-06-11 18:24:48 -04:00

379 lines
8.4 KiB
TypeScript

import { basename } from "node:path";
import { format, inspect } from "node:util";
import { ansiColors, defaultLevelColor, logLevelValues } from "@lib/config";
import { format as formatDate } from "date-fns-tz";
import type {
LogLevel,
LogLevelValue,
LoggerConfig,
PatternContext,
PatternTokens,
} from "@types";
function getTimestamp(config: Required<LoggerConfig>): {
prettyTimestamp: string;
timestamp: string;
} {
const now = new Date();
if (config.timezone === "local") {
return {
prettyTimestamp: formatDate(now, config.dateFormat),
timestamp: now.toISOString(),
};
}
return {
prettyTimestamp: formatDate(now, config.dateFormat, {
timeZone: config.timezone,
}),
timestamp: now.toISOString(),
};
}
function getCallerInfo(config: Required<LoggerConfig>): {
id: string;
fileName: string;
line: string;
column: string;
timestamp: string;
prettyTimestamp: string;
} {
const id = generateShortId();
const timestampInfo = getTimestamp(config);
const fallback = {
id,
fileName: "unknown",
line: "0",
column: "0",
timestamp: timestampInfo.timestamp,
prettyTimestamp: timestampInfo.prettyTimestamp,
};
const stack = new Error().stack;
if (!stack) return fallback;
const lines = stack.split("\n");
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
// try file:// URLs first (works on all platforms)
const fileURLMatch = line.match(
/at\s+(?:.*\()?file:\/\/(.*):(\d+):(\d+)\)?/,
);
if (fileURLMatch) {
const [_, fullPath, lineNumber, columnNumber] = fileURLMatch;
const isInternal =
fullPath.includes("atums.echo") || fullPath.includes("@atums/echo");
if (isInternal) continue;
return {
id,
fileName: basename(fullPath),
line: lineNumber,
column: columnNumber,
timestamp: timestampInfo.timestamp,
prettyTimestamp: timestampInfo.prettyTimestamp,
};
}
const pathMatch = parseStackTracePath(line);
if (pathMatch) {
const { fullPath, lineNumber, columnNumber } = pathMatch;
const isInternal =
fullPath.includes("atums.echo") || fullPath.includes("@atums/echo");
if (isInternal) continue;
return {
id,
fileName: basename(fullPath),
line: lineNumber,
column: columnNumber,
timestamp: timestampInfo.timestamp,
prettyTimestamp: timestampInfo.prettyTimestamp,
};
}
}
return fallback;
}
function parseStackTracePath(line: string): {
fullPath: string;
lineNumber: string;
columnNumber: string;
} | null {
// remove "at " prefix and trim
const cleanLine = line.replace(/^\s*at\s+/, "").trim();
let pathPart: string;
// extract path from parentheses if present
const parenMatch = cleanLine.match(/\(([^)]+)\)$/);
if (parenMatch) {
pathPart = parenMatch[1];
} else {
pathPart = cleanLine;
}
let match: RegExpMatchArray | null = null;
if (process.platform === "win32") {
// windows-specific parsing
// handle drive letters (C:) vs line numbers (:10:5)
match = pathPart.match(
/^((?:[a-zA-Z]:|\\\\[^\\]+\\[^\\]+|[^:]+)):(\d+):(\d+)$/,
);
if (!match) {
// try alternative windows format with forward slashes
match = pathPart.match(/^([a-zA-Z]:[^:]+):(\d+):(\d+)$/);
}
if (!match) {
// try UNC path format
match = pathPart.match(/^(\\\\[^:]+):(\d+):(\d+)$/);
}
} else {
// unix-like systems (Linux, macOS)
match = pathPart.match(/^([^:]+):(\d+):(\d+)$/);
}
if (match) {
const [_, fullPath, lineNumber, columnNumber] = match;
return {
fullPath: fullPath.trim(),
lineNumber,
columnNumber,
};
}
return null;
}
function generateShortId(length = 8): string {
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id = "";
for (let i = 0; i < length; i++) {
const rand = Math.floor(Math.random() * chars.length);
id += chars[rand];
}
return id;
}
function formatData(data: unknown, config: Required<LoggerConfig>): string {
return config.prettyPrint && typeof data === "object" && data !== null
? inspect(data, {
depth: null,
colors: config.consoleColor,
breakLength: 1,
compact: false,
})
: format(data);
}
function getConsoleMethod(level: LogLevel): "log" | "warn" | "error" {
if (level === "error" || level === "fatal") return "error";
if (level === "warn") return "warn";
return "log";
}
function resolveColor(
colorKey: string,
config: Required<LoggerConfig>,
level?: LogLevel,
tag?: string,
): string {
if (!config.consoleColor) return "";
if (colorKey === "levelColor" && level) {
const colorForLevel =
config.levelColor?.[level] ?? defaultLevelColor[level];
return ansiColors[colorForLevel ?? ""] ?? "";
}
if (colorKey === "tagColor" && tag) {
const normalizedTag = tag.toUpperCase();
return ansiColors[config.customColors?.[normalizedTag] ?? "green"] ?? "";
}
if (colorKey === "contextColor") {
return ansiColors.cyan ?? "";
}
return ansiColors[colorKey] ?? "";
}
function serializeLogData(data: unknown): unknown {
if (data instanceof Error) {
return {
name: data.name,
message: data.message,
stack: data.stack,
};
}
if (
typeof data === "string" ||
typeof data === "number" ||
typeof data === "boolean" ||
data === null ||
data === undefined
) {
return data;
}
if (typeof data === "object") {
try {
return JSON.parse(JSON.stringify(data));
} catch (err) {
if (
(err instanceof TypeError && err.message.includes("circular")) ||
(err instanceof Error && err.message.includes("cyclic"))
) {
return createSafeObjectRepresentation(data);
}
return String(data);
}
}
return data;
}
function createSafeObjectRepresentation(
obj: unknown,
seen = new WeakSet(),
): unknown {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (seen.has(obj as object)) {
return "[Circular Reference]";
}
seen.add(obj as object);
if (Array.isArray(obj)) {
return obj.map((item) => createSafeObjectRepresentation(item, seen));
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
try {
result[key] = createSafeObjectRepresentation(value, seen);
} catch {
result[key] = "[Unserializable]";
}
}
return result;
}
function processPattern(
pattern: string,
tokens: PatternTokens,
config: Required<LoggerConfig>,
level?: LogLevel,
tag?: string,
): string {
let processed = pattern;
if (tokens.timestamp) {
processed = processed.replace(/{timestamp}/g, tokens.timestamp);
}
if (tokens.prettyTimestamp) {
processed = processed.replace(
/{pretty-timestamp}/g,
tokens.prettyTimestamp,
);
}
if (tokens.levelName) {
processed = processed.replace(/{level-name}/g, tokens.levelName);
}
if (tokens.level) {
processed = processed.replace(/{level}/g, tokens.level);
}
if (tokens.fileName) {
processed = processed.replace(/{file-name}/g, tokens.fileName);
}
if (tokens.line) {
processed = processed.replace(/{line}/g, tokens.line);
}
if (tokens.column) {
processed = processed.replace(/{column}/g, tokens.column);
}
if (tokens.data) {
processed = processed.replace(/{data}/g, tokens.data);
}
if (tokens.id) {
processed = processed.replace(/{id}/g, tokens.id);
}
if (tokens.tag) {
processed = processed.replace(/{tag}/g, tokens.tag);
}
if (tokens.context) {
processed = processed.replace(/{context}/g, tokens.context);
}
processed = processed.replace(/{color:(\w+)}/g, (_, colorKey) => {
return resolveColor(colorKey, config, level, tag);
});
processed = processed.replace(
/{reset}/g,
config.consoleColor ? ansiColors.reset : "",
);
return processed;
}
function formatMultipleData(
dataArray: unknown[],
config: Required<LoggerConfig>,
): string {
return dataArray.map((item) => formatData(item, config)).join(" ");
}
function parsePattern(ctx: PatternContext): string {
const { level, data, config } = ctx;
const { id, fileName, line, column, timestamp, prettyTimestamp } =
getCallerInfo(config);
const resolvedData: string = Array.isArray(data)
? formatMultipleData(data, config)
: formatData(data, config);
const numericLevel: LogLevelValue = logLevelValues[level];
const tokens: PatternTokens = {
timestamp,
prettyTimestamp,
levelName: level.toUpperCase(),
level: String(numericLevel),
fileName,
line,
column,
data: resolvedData,
id,
};
return processPattern(config.pattern, tokens, config, level);
}
export {
parsePattern,
getCallerInfo,
getTimestamp,
generateShortId,
formatData,
getConsoleMethod,
resolveColor,
serializeLogData,
processPattern,
};