Source

logging.ts

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

/**
 * @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];
  }

  /**
   * @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),
    config?: Partial<LoggingConfig>
  ): Logger {
    method = method
      ? typeof method === "string"
        ? method
        : method.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") {
          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} [stack] - Optional stack trace to include in the log
   * @return {string} A formatted log string with all components
   */
  protected createLog(
    level: LogLevel,
    message: StringLike | Error,
    stack?: string
  ): string {
    const log: string[] = [];
    const style = this.config("style");
    if (this.config("timestamp")) {
      const date = new Date().toISOString();
      const timestamp = style ? Logging.theme(date, "timestamp", level) : date;
      log.push(timestamp);
    }

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

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

    if (this.config("correlationId")) {
      {
        const id: string = style
          ? Logging.theme(this.config("correlationId")!.toString(), "id", level)
          : this.config("correlationId")!.toString();
        log.push(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.push(msg);
    if (stack || message instanceof Error) {
      stack = style
        ? Logging.theme(
            (stack || (message as Error).stack) as string,
            "stack",
            level
          )
        : stack;
      log.push(`\nStack trace:\n${stack}`);
    }

    return log.join(this.config("separator") as string);
  }

  /**
   * @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} [stack] - Optional stack trace to include in the log
   * @return {void}
   */
  protected log(
    level: LogLevel,
    msg: StringLike | Error,
    stack?: string
  ): void {
    if (
      NumericLogLevels[this.config("level") as LogLevel] <
      NumericLogLevels[level]
    )
      return;
    let method;
    switch (level) {
      case LogLevel.info:
        method = console.log;
        break;
      case LogLevel.verbose:
      case LogLevel.debug:
        method = console.debug;
        break;
      case LogLevel.error:
        method = console.error;
        break;
      default:
        throw new Error("Invalid log level");
    }
    method(this.createLog(level, msg, stack));
  }

  /**
   * @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
   * @return {void}
   */
  error(msg: StringLike | Error): void {
    this.log(LogLevel.error, 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);
  };
  /**
   * @description Configuration for the logging system
   * @summary Stores the global logging configuration including verbosity, log level, styling, and formatting settings
   */
  private static _config: LoggingConfig = DefaultLoggingConfig;

  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>) {
    Object.assign(this._config, config);
  }

  /**
   * @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(): LoggingConfig {
    return Object.assign({}, 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 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 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 an error message.
   * @summary Delegates the error logging to the global logger instance.
   *
   * @param msg - The message to be logged.
   */
  static error(msg: StringLike): void {
    return this.get().error(msg);
  }

  /**
   * @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 context.
   *
   * @summary This static method creates a new logger instance using the factory function,
   * based on a given reason or context.
   *
   * @param reason - A string describing the reason or context for creating this logger.
   * @param id
   * @returns A new VerbosityLogger or ClassLogger instance.
   */
  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;
    const logger = Logging.get().for(this.theme);

    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:
              logger.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:
            logger.error(`Not a valid theme option: ${option}`);
            return t;
        }
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
      } catch (e: unknown) {
        logger.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);
  }
}