src/goLogging: extend logging facility

so it can be used to output to vscode.OutputChannel

Change-Id: Ib84eb56c65aeb7e81f14ad1fea7f72d6f72c9219
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/310749
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Suzy Mueller <suzmue@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Suzy Mueller <suzmue@golang.org>
diff --git a/src/goLogging.ts b/src/goLogging.ts
index 3c30247..cb36201 100644
--- a/src/goLogging.ts
+++ b/src/goLogging.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
 /*---------------------------------------------------------
  * Copyright 2020 The Go Authors. All rights reserved.
  * Licensed under the MIT License. See LICENSE in the project root for license information.
@@ -6,76 +5,100 @@
 
 'use strict';
 
-// Our log level.
-enum LogLevel {
-	Off = 100,
-	Error = 50,
-	Info = 30,
-	Verbose = 20
-	// TODO: Trace, Warn level
+type LogLevel = 'off' | 'error' | 'info' | 'trace' | 'verbose';
+
+const levels: { [key in LogLevel]: number } = {
+	off: -1,
+	error: 0,
+	info: 1,
+	trace: 2,
+	verbose: 3
+};
+// TODO: consider 'warning' level.
+
+function levelToString(level: number) {
+	switch (level) {
+		case levels.error:
+			return 'Error';
+		case levels.info:
+			return 'Info';
+		case levels.trace:
+			return 'Trace';
+		case levels.verbose:
+			return 'Verbose';
+	}
+	return '';
 }
 
-let currentLogLevel: LogLevel = LogLevel.Error;
+interface outputChannelType {
+	appendLine: (msg: string) => void;
+}
+// Logger outputs messages of the specified log levels to the vscode output channel or console.
+export class Logger {
+	protected minLevel: number;
 
-const levelMap: { [k: string]: LogLevel } = {
-	off: LogLevel.Off,
-	error: LogLevel.Error,
-	info: LogLevel.Info,
-	verbose: LogLevel.Verbose
-};
+	constructor(levelName: LogLevel, private outputChannel?: outputChannelType, private logToConsole?: boolean) {
+		this.minLevel = levels[levelName] || levels.error;
+	}
 
-function levelPrefix(l: LogLevel): string {
-	switch (l) {
-		case LogLevel.Off:
-			return 'Go[O]:';
-		case LogLevel.Error:
-			return 'Go[E]:';
-		case LogLevel.Info:
-			return 'Go[I]:';
-		case LogLevel.Verbose:
-			return 'Go[V]:';
-		default:
-			return 'Go[?]:';
+	protected log(msgLevel: number, msg: string) {
+		if (this.minLevel < 0) {
+			return; // logging is off.
+		}
+		if (this.minLevel < msgLevel) {
+			return;
+		}
+		this.outputChannel?.appendLine(msg);
+		if (this.logToConsole) console.log(msg);
+	}
+
+	error(msg: string) {
+		this.log(levels.error, msg);
+	}
+	info(msg: string) {
+		this.log(levels.info, msg);
+	}
+	trace(msg: string) {
+		this.log(levels.trace, msg);
+	}
+	debug(msg: string) {
+		this.log(levels.verbose, msg);
+	}
+}
+
+// TimestampedLogger is a logger that prepends the timestamp to every log message.
+export class TimestampedLogger extends Logger {
+	log(msgLevel: number, msg: string) {
+		const ts = new Date();
+		const hhmmss = ts.toLocaleTimeString([], {
+			hour: '2-digit',
+			minute: '2-digit',
+			second: '2-digit',
+			hour12: false
+		});
+		const msec = ts.getMilliseconds();
+		super.log(msgLevel, `[${levelToString(msgLevel)} - ${hhmmss}.${msec}] ${msg}`);
 	}
 }
 
 export interface LogConfig {
-	level: string;
+	level: LogLevel;
 }
 
+let defaultLogger: Logger;
+
 export function setLogConfig(cfg: LogConfig) {
-	const logLevel = cfg?.level || 'error';
-	const l = levelMap[logLevel];
-	if (l) {
-		currentLogLevel = l;
-		return;
-	}
-	logError(`setLogLevel requested with invalid log level ${logLevel}, ignoring...`);
+	defaultLogger = new Logger(cfg.level);
 }
 
-// tslint:disable-next-line:no-any
-function log(logLevel: LogLevel, ...args: any[]) {
-	if (logLevel < currentLogLevel) {
-		return;
-	}
-	const p = levelPrefix(logLevel);
-	const a = Array.from(args);
-	a.unshift(p);
-	console.log(...a);
-	// TODO: support logging in vscode output channel.
+export function logVerbose(msg: string) {
+	defaultLogger?.debug(msg);
 }
 
-// tslint:disable-next-line:no-any
-export function logVerbose(...args: any[]) {
-	log(LogLevel.Verbose, ...args);
+export function logError(msg: string) {
+	defaultLogger?.error(msg);
 }
 
-// tslint:disable-next-line:no-any
-export function logError(...args: any[]) {
-	log(LogLevel.Error, ...args);
-}
-
-// tslint:disable-next-line:no-any
-export function logInfo(...args: any[]) {
-	log(LogLevel.Info, ...args);
+export function logInfo(msg: string) {
+	defaultLogger?.info(msg);
 }
diff --git a/test/unit/logger.test.ts b/test/unit/logger.test.ts
new file mode 100644
index 0000000..0efd15e
--- /dev/null
+++ b/test/unit/logger.test.ts
@@ -0,0 +1,39 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+
+import * as assert from 'assert';
+import sinon = require('sinon');
+import { Logger } from '../../src/goLogging';
+
+suite('Logger Tests', () => {
+	let sandbox: sinon.SinonSandbox;
+
+	setup(() => {
+		sandbox = sinon.createSandbox();
+	});
+	teardown(() => {
+		sandbox.restore();
+	});
+
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	function runTest(level: any, want: number) {
+		const appendLine = sandbox.fake();
+		const logger = new Logger(level, { appendLine });
+		logger.error('error');
+		logger.info('info');
+		logger.debug('debug');
+		logger.trace('trace');
+		assert.strictEqual(appendLine.callCount, want, `called ${appendLine.callCount} times, want ${want}`);
+	}
+	test('logger level = off', () => runTest('off', 0));
+	test('logger level = error', () => runTest('error', 1));
+	test('logger level = info', () => runTest('info', 2));
+	test('logger level = trace', () => runTest('trace', 3));
+	test('logger level = verbose', () => runTest('verbose', 4));
+	test('logger level = undefined', () => runTest(undefined, 1));
+	test('logger level = ""', () => runTest('', 1));
+	test('logger level = object', () => runTest({}, 1));
+	test('logger level = number', () => runTest(10, 1));
+});