Skip to content

Commit

Permalink
feat: support sprintf style logging when the a string contains %s, %i…
Browse files Browse the repository at this point in the history
… or %d tokens
  • Loading branch information
Codeneos committed Aug 2, 2023
1 parent 8985428 commit a431d8d
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 36 deletions.
94 changes: 94 additions & 0 deletions packages/core/src/__tests__/loggerEntry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'jest';
import { LoggerEntry } from '../logging/logEntry';

describe('LoggerEntry', () => {
describe('message (property)', () => {
it('should format message with %s placeholder as string', () => {
const entry = new LoggerEntry(0, 'test', ['Hello %s', 'World']);
expect(entry.message).toBe('Hello World');
});
it('should format parts of the message with a %s placeholder as string', () => {
const entry = new LoggerEntry(0, 'test', [
'Hello %s',
'%s World %s', 'this',
'is %s', 'Great'
]);
expect(entry.message).toBe('Hello this World is Great');
});
it('should run functions when passed as arg', () => {
const entry = new LoggerEntry(0, 'test', [
'Hello %s',
() => 'World'
]);
expect(entry.message).toBe('Hello World');
});
it('should support all args as functions and format them', () => {
const entry = new LoggerEntry(0, 'test', [
() => 'Hello %s',
() => 'amazing %s',
() => 'World'
]);
expect(entry.message).toBe('Hello amazing World');
});
it('should format numbers with specified number of decimals', () => {
const entry = new LoggerEntry(0, 'test', [
'PI %d:4',
3.14159265359
]);
expect(entry.message).toBe('PI 3.1416');
});
it('should format numbers with 2 decimals when no number of decimals are specified', () => {
const entry = new LoggerEntry(0, 'test', [
'PI %d',
3.14159265359
]);
expect(entry.message).toBe('PI 3.14');
});
it('should format number without decimals when specified as integer', () => {
const entry = new LoggerEntry(0, 'test', [
'PI %i',
3.14159265359
]);
expect(entry.message).toBe('PI 3');
});
it('should format strings as upper case when specified as %S', () => {
const entry = new LoggerEntry(0, 'test', [
'Hello %S',
'world'
]);
expect(entry.message).toBe('Hello WORLD');
});
it('should only format message once', () => {
let called = 0;
const entry = new LoggerEntry(0, 'test', [
() => {
called ++;
return 'Hello %s';
},
'world'
]);

expect(entry.message).toBe('Hello world');
expect(entry.message).toBe('Hello world');
expect(entry.message).toBe('Hello world');

expect(called).toBe(1);
});
it('should only format message once', () => {
const entry = new LoggerEntry(0, 'test', [
'Hello', 'world'
]);
expect(entry.message).toBe('Hello world');
});
it('should not replace placeholder when more placeholder count exceeds replacements', () => {
const entry = new LoggerEntry(0, 'test', [
'Hello %s %s', 'world %s %s'
]);
expect(entry.message).toBe('Hello world %s %s %s');
});
it('should not replace placeholder when there are no replacements', () => {
const entry = new LoggerEntry(0, 'test', [ 'Hello %s %s' ]);
expect(entry.message).toBe('Hello %s %s');
});
});
});
2 changes: 1 addition & 1 deletion packages/core/src/logging/distinctLogger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { decorate } from "@vlocode/util";
import { LogEntry, Logger } from './logger';
import { Logger, LogEntry } from '.';

export class DistinctLogger extends decorate(Logger) {
private uniqueMessages = new Set<string>();
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/logging/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export interface ILogger {
debug(...args: any[]): void;
}

export * from './logger';
export { Logger, LogFilter, LogWriter } from './logger';
export { LogEntry } from './logEntry';
export * from './distinctLogger';
export * from './logManager';

Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/logging/logEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { getErrorMessage } from "@vlocode/util";
import { LogLevel } from "./logLevels";

const FORMAT_REGEX = /%([sdiS])(?::([0-9]+))?/g;

/**
* Describes an entry inb the log
*/
export interface LogEntry {
readonly level: LogLevel;
readonly time: Date;
readonly category: string;
readonly message: string;
}

/**
* Log entry implementation that formats the message on demand
* @internal
*/
export class LoggerEntry implements LogEntry {

public readonly time = new Date();
private formattedMessage?: string;

/**
* Formatted message of the log entry (lazy)
*/
public get message() {
if (!this.formattedMessage) {
this.formattedMessage = this.formatMessage(this.args);
}
return this.formattedMessage;
}

constructor(
public readonly level: LogLevel,
public readonly category: string,
public readonly args: unknown[]) {
}

/**
* Format the message from the arguments array
* @returns Formatted message
*/
private formatMessage(args: any[]) : string {
if (args.length === 0) {
return '';
} else if (args.length === 1) {
return this.formatArg(args[0], 0);
}
const stringArgs = args.map((arg, index) => this.formatArg(arg, index));
const parts: string[] = [];
for (let index = 0; index < stringArgs.length; index++) {
parts.push(this.formatArgs(stringArgs, index));
}
return parts.join(' ');
}

/**
* Format a single argument for the log message
* @param arg Argument to format
* @param index Index of the argument
* @returns Formatted argument
*/
private formatArg(arg: unknown, index: number) : string {
if (arg instanceof Error) {
const error = getErrorMessage(arg);
return !index ? error : `\n${error}`;
} else if (arg !== null && typeof arg === 'object') {
try {
return JSON.stringify(arg, undefined, 2);
} catch(err) {
return '[Object]';
}
} else if (typeof arg === 'function') {
try {
return this.formatArg(arg(), index);
} catch(err) {
return `(<function error> ${this.formatArg(err, index)})`;
}
} else if (typeof arg !== 'string') {
return String(arg);
}

return arg;
}

private formatArgs(args: string[], index: number) : string {
if (index + 1 >= args.length) {
return args[index];
}

const offset = index;
const formattedArg = args[index].replace(FORMAT_REGEX, (fullMatch, type, options) => {
if (index + 1 >= args.length) {
return fullMatch;
}
const formatted = this.formatArgs(args, ++index);
if (type === 'S') {
return formatted.toUpperCase();
} else if (type === 'd') {
return Number(formatted).toFixed(options ? Number(options) : 2);
} else if (type === 'i') {
return Number(formatted).toFixed(0);
}
return formatted;
});

if (offset !== index) {
args.splice(offset + 1, index - offset);
}
return formattedArg;
}
}
101 changes: 67 additions & 34 deletions packages/core/src/logging/logger.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { LogLevel } from '../logging';
import { getErrorMessage, isPromise } from '@vlocode/util';
import { isPromise } from '@vlocode/util';
import LogManager from './logManager';
import { LogEntry, LoggerEntry } from './logEntry';

export interface LogEntry {
level: LogLevel;
time: Date;
category: string;
message: string;
}

/**
* Log filter function that can be used to filter log entries
* when returning false the log entry will be ignored otherwise it will be written
*/
export type LogFilter = ( ops: {
logger: Logger;
severity: LogLevel;
Expand Down Expand Up @@ -51,13 +49,72 @@ export class Logger {
void this.writer?.focus?.();
}

/**
* @see {@link Logger.write}
*/
public log(...args: any[]) : void { this.write(LogLevel.info, ...args); }

/**
* @see {@link Logger.write}
*/
public info(...args: any[]) : void { this.write(LogLevel.info, ...args); }

/**
* @see {@link Logger.write}
*/
public verbose(...args: any[]) : void { this.write(LogLevel.verbose, ...args); }

/**
* @see {@link Logger.write}
*/
public warn(...args: any[]) : void { this.write(LogLevel.warn, ...args); }

/**
* @see {@link Logger.write}
*/
public error(...args: any[]) : void { this.write(LogLevel.error, ...args); }

/**
* @see {@link Logger.write}
*/
public debug(...args: any[]) : void { this.write(LogLevel.debug, ...args); }

/**
* Write a log entry to the log, this will be filtered by the log level and optional filters that are set.
*
* Messages are formatted using the following rules:
* - If a single argument is passed it will be used as the message
* - If a single argument is passed and it is an error it will be used as the message and the stack trace will be appended
* - If a single argument is passed and it is an object it will be serialized to JSON
* - If a single argument is passed and it is a function it will be executed and the result will be used as the message
* - If multiple arguments are passed the first argument will be used as a format string and the remaining arguments will be used as format arguments
* - If a format string is used it can contain the following format specifiers:
* - `%s` String
* - `%i` Integer (no decimals)
* - `%d` Decimal with 2 decimals
* - `%d:<X>` Decimal with X decimals
* - `%S` String in uppercase
* - `%<any other character>` The character will be ignored
*
* @example
* ```typescript
* logger.write(LogLevel.info, 'Hello', 'world'); // Hello world this is great
* logger.write(LogLevel.info, 'Hello %s', 'world'); // Hello world
* logger.write(LogLevel.info, 'Hello %S', 'world'); // Hello WORLD
* logger.write(LogLevel.info, 'Hello %d', 1.234); // Hello 1.23
* logger.write(LogLevel.info, 'Hello %d:3', 1.234); // Hello 1.234
* logger.write(LogLevel.info, 'Hello %i', 1.234); // Hello 1
* logger.write(LogLevel.info, 'Hello %i', 1234); // Hello 1234
* logger.write(LogLevel.info, 'Hello %s', { foo: 'bar' }); // Hello {"foo":"bar"}
* logger.write(LogLevel.info, 'Hello %s', () => 'world'); // Hello world
* logger.write(LogLevel.info, 'Hello %s', 'world', 'this', 'is', 'great'); // Hello world this is great
* logger.write(LogLevel.info, 'Hello', 'world %s %s %s', 'this', 'is', 'great'); // Hello world this is great
* ```
* @param level Log level of the entry to write
* @param args Arguments to write to the log entr. If a single argument is
* passed it will be used as the message, otherwise the first argument will be used
* as a format string and the remaining arguments will be used as format arguments
*/
public write(level: LogLevel, ...args: any[]) : void {
const logLevel = this.manager?.getLogLevel(this.name);
if (logLevel !== undefined && level < logLevel) {
Expand All @@ -69,12 +126,7 @@ export class Logger {
return;
}

this.writeEntry({
category: this.name,
level,
time: new Date(),
message: args.map((item, i) => this.formatArg(item, i)).join(' ')
});
this.writeEntry(new LoggerEntry(level, this.name, args));
}

public writeEntry(entry: LogEntry) : void {
Expand All @@ -90,23 +142,4 @@ export class Logger {
}
}

private formatArg(arg: any, index: number) : string | any {
if (arg instanceof Error) {
const error = getErrorMessage(arg);
return !index ? error : `\n${error}`;
} else if (arg !== null && typeof arg === 'object') {
try {
return JSON.stringify(arg, undefined, 2);
} catch(err) {
return '{Object}';
}
} else if (typeof arg === 'function') {
try {
return this.formatArg(arg(), index);
} catch(err) {
return `(<function error> ${this.formatArg(err, index)})`;
}
}
return arg;
}
}
}

0 comments on commit a431d8d

Please sign in to comment.