Source

logging.ts

import {
  LoggerFactory,
  LoggingConfig,
  LoggingContext,
  StringLike,
  Theme,
  ThemeOption,
  ThemeOptionByLogLevel,
  Logger,
} from "./types";
import { ColorizeOptions, style, StyledString } from "styled-string-builder";
import { DefaultTheme, LogLevel, NumericLogLevels } from "./constants";
import { sf } from "./text";
import { LoggedEnvironment } from "./environment";

/**
 * @description A minimal logger implementation.
 * @summary MiniLogger is a lightweight logging class that implements the Logger interface.
 * It provides basic logging functionality with support for different log levels, verbosity,
 * context-aware logging, and customizable formatting.
 * @param {string} context - The context (typically class name) this logger is associated with
 * @param {Partial<LoggingConfig>} conf - Optional configuration to override global settings
 * @class MiniLogger
 * @example
 * // Create a new logger for a class
 * const logger = new MiniLogger('MyClass');
 *
 * // Log messages at different levels
 * logger.info('This is an info message');
 * logger.debug('This is a debug message');
 * logger.error('Something went wrong');
 *
 * // Create a child logger for a specific method
 * const methodLogger = logger.for('myMethod');
 * methodLogger.verbose('Detailed information', 2);
 *
 * // Log with custom configuration
 * logger.for('specialMethod', { style: true }).info('Styled message');
 */
export class MiniLogger implements Logger {
  constructor(
    protected context: string,
    protected conf?: Partial<LoggingConfig>
  ) {}

  protected config(
    key: keyof LoggingConfig
  ): LoggingConfig[keyof LoggingConfig] {
    if (this.conf && key in this.conf) return this.conf[key];
    return Logging.getConfig()[key];
  }

  for(method: string | ((...args: any[]) => any)): Logger;
  for(config: Partial<LoggingConfig>): Logger;
  for(
    method: string | ((...args: any[]) => any) | Partial<LoggingConfig>,
    config: Partial<LoggingConfig>,
    ...args: any[]
  ): Logger;
  /**
   * @description Creates a child logger for a specific method or context
   * @summary Returns a new logger instance with the current context extended by the specified method name
   * @param {string | Function} method - The method name or function to create a logger for
   * @param {Partial<LoggingConfig>} config - Optional configuration to override settings
   * @param {...any[]} args - Additional arguments to pass to the logger factory
   * @return {Logger} A new logger instance for the specified method
   */
  for(
    method?: string | ((...args: any[]) => any) | Partial<LoggingConfig>,
    config?: Partial<LoggingConfig>,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    ...args: any[]
  ): Logger {
    if (!config && typeof method === "object") {
      config = method;
      method = undefined;
    } else {
      method = method
        ? typeof method === "string"
          ? method
          : (method as any).name
        : undefined;
    }

    return new Proxy(this, {
      get: (target: typeof this, p: string | symbol, receiver: any) => {
        const result = Reflect.get(target, p, receiver);
        if (p === "config") {
          return new Proxy(this.config, {
            get: (target: typeof this.config, p: string | symbol) => {
              if (config && p in config)
                return config[p as keyof LoggingConfig];
              return Reflect.get(target, p, receiver);
            },
          });
        }
        if (p === "context" && method) {
          return [result, method].join(".");
        }
        return result;
      },
    });
  }

  /**
   * @description Creates a formatted log string
   * @summary Generates a log string with timestamp, colored log level, context, and message
   * @param {LogLevel} level - The log level for this message
   * @param {StringLike | Error} message - The message to log or an Error object
   * @param {string} [error] - Optional error to extract stack trace to include in the log
   * @return {string} A formatted log string with all components
   */
  protected createLog(
    level: LogLevel,
    message: StringLike | Error,
    error?: Error
  ): string {
    const log: Record<
      | "timestamp"
      | "level"
      | "context"
      | "correlationId"
      | "message"
      | "separator"
      | "stack"
      | "app",
      string
    > = {} as any;
    const style = this.config("style");
    const separator = this.config("separator");
    const app = this.config("app");
    if (app)
      log.app = style
        ? Logging.theme(app as string, "app", level)
        : (app as string);

    if (separator)
      log.separator = style
        ? Logging.theme(separator as string, "separator", level)
        : (separator as string);

    if (this.config("timestamp")) {
      const date = new Date().toISOString();
      const timestamp = style ? Logging.theme(date, "timestamp", level) : date;
      log.timestamp = timestamp;
    }

    if (this.config("logLevel")) {
      const lvl: string = style
        ? Logging.theme(level, "logLevel", level)
        : level;
      log.level = lvl.toUpperCase();
    }

    if (this.config("context")) {
      const context: string = style
        ? Logging.theme(this.context, "class", level)
        : this.context;
      log.context = context;
    }

    if (this.config("correlationId")) {
      {
        const id: string = style
          ? Logging.theme(this.config("correlationId")!.toString(), "id", level)
          : this.config("correlationId")!.toString();
        log.correlationId = id;
      }
    }

    const msg: string = style
      ? Logging.theme(
          typeof message === "string" ? message : (message as Error).message,
          "message",
          level
        )
      : typeof message === "string"
        ? message
        : (message as Error).message;
    log.message = msg;
    if (error || message instanceof Error) {
      const stack = style
        ? Logging.theme(
            (error?.stack || (message as Error).stack) as string,
            "stack",
            level
          )
        : error?.stack || "";
      log.stack = ` | ${(error || (message as Error)).message} - Stack trace:\n${stack}`;
    }

    switch (this.config("format")) {
      case "json":
        return JSON.stringify(log);
      case "raw":
        return (this.config("pattern") as string)
          .split(" ")
          .map((s) => {
            if (!s.match(/\{.*?}/g)) return s;
            const formattedS = sf(s, log);
            if (formattedS !== s) return formattedS;
            return undefined;
          })
          .filter((s) => s)
          .join(" ");
      default:
        throw new Error(`Unsupported logging format: ${this.config("format")}`);
    }
  }

  /**
   * @description Logs a message with the specified log level
   * @summary Checks if the message should be logged based on the current log level,
   * then uses the appropriate console method to output the formatted log
   * @param {LogLevel} level - The log level of the message
   * @param {StringLike | Error} msg - The message to be logged or an Error object
   * @param {string} [error] - Optional stack trace to include in the log
   * @return {void}
   */
  protected log(level: LogLevel, msg: StringLike | Error, error?: Error): void {
    const confLvl = this.config("level") as LogLevel;
    if (NumericLogLevels[confLvl] < NumericLogLevels[level]) return;
    let method;
    switch (level) {
      case LogLevel.benchmark:
        method = console.log;
        break;
      case LogLevel.info:
        method = console.log;
        break;
      case LogLevel.verbose:
      case LogLevel.debug:
        method = console.debug;
        break;
      case LogLevel.error:
        method = console.error;
        break;
      case LogLevel.trace:
        method = console.trace;
        break;
      case LogLevel.warn:
        method = console.warn;
        break;
      case LogLevel.silly:
        method = console.trace;
        break;
      default:
        throw new Error("Invalid log level");
    }
    method(this.createLog(level, msg, error));
  }

  /**
   * @description Logs a message at the benchmark level
   * @summary Logs a message at the benchmark level if the current verbosity setting allows it
   * @param {StringLike} msg - The message to be logged
   * @return {void}
   */
  benchmark(msg: StringLike): void {
    this.log(LogLevel.benchmark, msg);
  }

  /**
   * @description Logs a message at the silly level
   * @summary Logs a message at the silly level if the current verbosity setting allows it
   * @param {StringLike} msg - The message to be logged
   * @param {number} [verbosity=0] - The verbosity level of the message
   * @return {void}
   */
  silly(msg: StringLike, verbosity: number = 0): void {
    if ((this.config("verbose") as number) >= verbosity)
      this.log(LogLevel.verbose, msg);
  }

  /**
   * @description Logs a message at the verbose level
   * @summary Logs a message at the verbose level if the current verbosity setting allows it
   * @param {StringLike} msg - The message to be logged
   * @param {number} [verbosity=0] - The verbosity level of the message
   * @return {void}
   */
  verbose(msg: StringLike, verbosity: number = 0): void {
    if ((this.config("verbose") as number) >= verbosity)
      this.log(LogLevel.verbose, msg);
  }

  /**
   * @description Logs a message at the info level
   * @summary Logs a message at the info level for general application information
   * @param {StringLike} msg - The message to be logged
   * @return {void}
   */
  info(msg: StringLike): void {
    this.log(LogLevel.info, msg);
  }

  /**
   * @description Logs a message at the debug level
   * @summary Logs a message at the debug level for detailed troubleshooting information
   * @param {StringLike} msg - The message to be logged
   * @return {void}
   */
  debug(msg: StringLike): void {
    this.log(LogLevel.debug, msg);
  }

  /**
   * @description Logs a message at the error level
   * @summary Logs a message at the error level for errors and exceptions
   * @param {StringLike | Error} msg - The message to be logged or an Error object
   * @param e
   * @return {void}
   */
  error(msg: StringLike | Error, e?: Error): void {
    this.log(LogLevel.error, msg, e);
  }

  /**
   * @description Logs a message at the error level
   * @summary Logs a message at the error level for errors and exceptions
   * @param {StringLike} msg - The message to be logged or an Error object
   * @return {void}
   */
  warn(msg: StringLike): void {
    this.log(LogLevel.warn, msg);
  }

  /**
   * @description Logs a message at the error level
   * @summary Logs a message at the error level for errors and exceptions
   * @param {StringLike} msg - The message to be logged or an Error object
   * @return {void}
   */
  trace(msg: StringLike): void {
    this.log(LogLevel.trace, msg);
  }

  /**
   * @description Updates the logger configuration
   * @summary Merges the provided configuration with the existing configuration
   * @param {Partial<LoggingConfig>} config - The configuration options to apply
   * @return {void}
   */
  setConfig(config: Partial<LoggingConfig>): void {
    this.conf = { ...(this.conf || {}), ...config };
  }
}

/**
 * @description A static class for managing logging operations
 * @summary The Logging class provides a centralized logging mechanism with support for
 * different log levels, verbosity, and styling. It uses a singleton pattern to maintain a global
 * logger instance and allows creating specific loggers for different classes and methods.
 * @class Logging
 * @example
 * // Set global configuration
 * Logging.setConfig({ level: LogLevel.debug, style: true });
 *
 * // Get a logger for a specific class
 * const logger = Logging.for('MyClass');
 *
 * // Log messages at different levels
 * logger.info('Application started');
 * logger.debug('Processing data...');
 *
 * // Log with context
 * const methodLogger = Logging.for('MyClass.myMethod');
 * methodLogger.verbose('Detailed operation information', 1);
 *
 * // Log errors
 * try {
 *   // some operation
 * } catch (error) {
 *   logger.error(error);
 * }
 * @mermaid
 * classDiagram
 *   class Logger {
 *     <<interface>>
 *     +for(method, config, ...args)
 *     +silly(msg, verbosity)
 *     +verbose(msg, verbosity)
 *     +info(msg)
 *     +debug(msg)
 *     +error(msg)
 *     +setConfig(config)
 *   }
 *
 *   class Logging {
 *     -global: Logger
 *     -_factory: LoggerFactory
 *     -_config: LoggingConfig
 *     +setFactory(factory)
 *     +setConfig(config)
 *     +getConfig()
 *     +get()
 *     +verbose(msg, verbosity)
 *     +info(msg)
 *     +debug(msg)
 *     +silly(msg)
 *     +error(msg)
 *     +for(object, config, ...args)
 *     +because(reason, id)
 *     +theme(text, type, loggerLevel, template)
 *   }
 *
 *   class MiniLogger {
 *     +constructor(context, conf?)
 *   }
 *
 *   Logging ..> Logger : creates
 *   Logging ..> MiniLogger : creates by default
 */
export class Logging {
  /**
   * @description The global logger instance
   * @summary A singleton instance of Logger used for global logging
   */
  private static global?: Logger;

  /**
   * @description Factory function for creating logger instances
   * @summary A function that creates new Logger instances. By default, it creates a MiniLogger.
   */
  private static _factory: LoggerFactory = (
    object: string,
    config?: Partial<LoggingConfig>
  ) => {
    return new MiniLogger(object, config);
  };

  private static _config: typeof LoggedEnvironment = LoggedEnvironment;

  private constructor() {}

  /**
   * @description Sets the factory function for creating logger instances
   * @summary Allows customizing how logger instances are created
   * @param {LoggerFactory} factory - The factory function to use for creating loggers
   * @return {void}
   */
  static setFactory(factory: LoggerFactory) {
    Logging._factory = factory;
  }

  /**
   * @description Updates the global logging configuration
   * @summary Allows updating the global logging configuration with new settings
   * @param {Partial<LoggingConfig>} config - The configuration options to apply
   * @return {void}
   */
  static setConfig(config: Partial<LoggingConfig>): void {
    Object.entries(config).forEach(([k, v]) => {
      (this._config as any)[k] = v as any;
    });
  }

  /**
   * @description Gets a copy of the current global logging configuration
   * @summary Returns a copy of the current global logging configuration
   * @return {LoggingConfig} A copy of the current configuration
   */
  static getConfig(): typeof LoggedEnvironment {
    return this._config;
  }

  /**
   * @description Retrieves or creates the global logger instance.
   * @summary Returns the existing global logger or creates a new one if it doesn't exist.
   *
   * @return The global VerbosityLogger instance.
   */
  static get(): Logger {
    this.global = this.global ? this.global : this._factory("Logging");
    return this.global;
  }

  /**
   * @description Logs a verbose message.
   * @summary Delegates the verbose logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   * @param verbosity - The verbosity level of the message (default: 0).
   */
  static verbose(msg: StringLike, verbosity: number = 0): void {
    return this.get().verbose(msg, verbosity);
  }

  /**
   * @description Logs an info message.
   * @summary Delegates the info logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   */
  static info(msg: StringLike): void {
    return this.get().info(msg);
  }

  /**
   * @description Logs an info message.
   * @summary Delegates the info logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   */
  static trace(msg: StringLike): void {
    return this.get().trace(msg);
  }

  /**
   * @description Logs a debug message.
   * @summary Delegates the debug logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   */
  static debug(msg: StringLike): void {
    return this.get().debug(msg);
  }

  /**
   * @description Logs a benchmark message.
   * @summary Delegates the benchmark logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   */
  static benchmark(msg: StringLike): void {
    return this.get().benchmark(msg);
  }

  /**
   * @description Logs a silly message.
   * @summary Delegates the debug logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   */
  static silly(msg: StringLike): void {
    return this.get().silly(msg);
  }

  /**
   * @description Logs a silly message.
   * @summary Delegates the debug logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   */
  static warn(msg: StringLike): void {
    return this.get().warn(msg);
  }

  /**
   * @description Logs an error message.
   * @summary Delegates the error logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   * @param e
   */
  static error(msg: StringLike, e?: Error): void {
    return this.get().error(msg, e);
  }

  /**
   * @description Creates a logger for a specific object or context
   * @summary Creates a new logger instance for the given object or context using the factory function
   * @param {LoggingContext} object - The object, class, or context to create a logger for
   * @param {Partial<LoggingConfig>} [config] - Optional configuration to override global settings
   * @param {...any} args - Additional arguments to pass to the logger factory
   * @return {Logger} A new logger instance for the specified object or context
   */
  static for(
    object: LoggingContext,
    config?: Partial<LoggingConfig>,
    ...args: any[]
  ): Logger {
    object =
      typeof object === "string"
        ? object
        : object.constructor
          ? object.constructor.name
          : object.name;
    return this._factory(object, config, ...args);
  }

  /**
   * @description Creates a logger for a specific reason or correlation context
   * @summary Utility to quickly create a logger labeled with a free-form reason and optional identifier
   * so that ad-hoc operations can be traced without tying the logger to a class or method name.
   * @param {string} reason - A textual reason or context label for this logger instance
   * @param {string} [id] - Optional identifier to help correlate related log entries
   * @return {Logger} A new logger instance labeled with the provided reason and id
   */
  static because(reason: string, id?: string): Logger {
    return this._factory(reason, this._config, id);
  }

  /**
   * @description Applies theme styling to text
   * @summary Applies styling (colors, formatting) to text based on the theme configuration
   * @param {string} text - The text to style
   * @param {string} type - The type of element to style (e.g., "class", "message", "logLevel")
   * @param {LogLevel} loggerLevel - The log level to use for styling
   * @param {Theme} [template=DefaultTheme] - The theme to use for styling
   * @return {string} The styled text
   * @mermaid
   * sequenceDiagram
   *   participant Caller
   *   participant Theme as Logging.theme
   *   participant Apply as apply function
   *   participant Style as styled-string-builder
   *
   *   Caller->>Theme: theme(text, type, loggerLevel)
   *   Theme->>Theme: Check if styling is enabled
   *   alt styling disabled
   *     Theme-->>Caller: return original text
   *   else styling enabled
   *     Theme->>Theme: Get theme for type
   *     alt theme not found
   *       Theme-->>Caller: return original text
   *     else theme found
   *       Theme->>Theme: Determine actual theme based on log level
   *       Theme->>Apply: Apply each style property
   *       Apply->>Style: Apply colors and formatting
   *       Style-->>Apply: Return styled text
   *       Apply-->>Theme: Return styled text
   *       Theme-->>Caller: Return final styled text
   *     end
   *   end
   */
  static theme(
    text: string,
    type: keyof Theme | keyof LogLevel,
    loggerLevel: LogLevel,
    template: Theme = DefaultTheme
  ) {
    if (!this._config.style) return text;
    function apply(
      txt: string,
      option: keyof ThemeOption,
      value: number | [number] | [number, number, number] | number[] | string[]
    ): string {
      try {
        const t: string | StyledString = txt;
        let c = style(t);

        function applyColor(
          val: number | [number] | [number, number, number],
          isBg = false
        ): StyledString {
          let f:
            | typeof c.background
            | typeof c.foreground
            | typeof c.rgb
            | typeof c.color256 = isBg ? c.background : c.foreground;
          if (!Array.isArray(val)) {
            return (f as typeof c.background | typeof c.foreground).call(
              c,
              value as number
            );
          }
          switch (val.length) {
            case 1:
              f = isBg ? c.bgColor256 : c.color256;
              return (f as typeof c.bgColor256 | typeof c.color256)(val[0]);
            case 3:
              f = isBg ? c.bgRgb : c.rgb;
              return c.rgb(val[0], val[1], val[2]);
            default:
              console.error(`Not a valid color option: ${option}`);
              return style(t as string);
          }
        }

        function applyStyle(v: number | string): void {
          if (typeof v === "number") {
            c = c.style(v);
          } else {
            c = c[v as keyof ColorizeOptions] as StyledString;
          }
        }

        switch (option) {
          case "bg":
          case "fg":
            return applyColor(value as number).text;
          case "style":
            if (Array.isArray(value)) {
              value.forEach(applyStyle);
            } else {
              applyStyle(value as number | string);
            }
            return c.text;
          default:
            console.error(`Not a valid theme option: ${option}`);
            return t;
        }
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
      } catch (e: unknown) {
        console.error(`Error applying style: ${option} with value ${value}`);
        return txt;
      }
    }

    const individualTheme = template[type as keyof Theme];
    if (!individualTheme || !Object.keys(individualTheme).length) {
      return text;
    }

    let actualTheme: ThemeOption = individualTheme as ThemeOption;

    const logLevels = Object.assign({}, LogLevel);
    if (Object.keys(individualTheme)[0] in logLevels)
      actualTheme =
        (individualTheme as ThemeOptionByLogLevel)[loggerLevel] || {};

    return Object.keys(actualTheme).reduce((acc: string, key: string) => {
      const val = (actualTheme as ThemeOption)[key as keyof ThemeOption];
      if (val)
        return apply(
          acc,
          key as keyof ThemeOption,
          val as
            | number
            | [number]
            | [number, number, number]
            | number[]
            | string[]
        );
      return acc;
    }, text);
  }
}