initial commit
[squeep-log-helper] / lib / file-scope.js
diff --git a/lib/file-scope.js b/lib/file-scope.js
new file mode 100644 (file)
index 0000000..57421a4
--- /dev/null
@@ -0,0 +1,194 @@
+'use strict';
+
+/**
+ * An opinionated way to gather a source identifier to include in logged
+ * messages, using possibly too much information.
+ */
+
+const path = require('node:path');
+const fs = require('node:fs');
+
+/**
+ * Internal exception
+ */
+class FileScopeError extends Error {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(this, this.constructor);
+  }
+
+  get name() {
+    return this.constructor.name;
+  }
+}
+
+
+/**
+ * Read and parse package.json from a path.
+ * @param {String} packagePath
+ * @returns {Object}
+ */
+function readPackageJSON(packagePath) {
+  try {
+    const content = fs.readFileSync(path.join(packagePath, 'package.json')); // eslint-disable-line security/detect-non-literal-fs-filename
+    return JSON.parse(content);
+  } catch (e) {
+    return {
+      name: '(unknown)',
+      version: '(unknown)',
+    };
+  }
+}
+
+
+/**
+ * Returns whether path p exists.
+ * @param {String} p
+ * @returns {Boolean}
+ */
+function pathExists(p) {
+  try {
+    fs.statSync(p); // eslint-disable-line security/detect-non-literal-fs-filename
+    return true;
+  } catch (e) {
+    if (e.code !== 'ENOENT') {
+      throw e;
+    }
+    return false;
+  }
+}
+
+
+/**
+ * Walk up the path of provided filename until directory with
+ * package.json is located.  We assume this is the package root.
+ * @param {String} filename
+ * @returns {String}
+ */
+function locatePackageBase(filename) {
+  let currentPath = filename;
+  do {
+    const d = path.dirname(currentPath);
+    try {
+      fs.statSync(path.join(d, 'package.json'));  // eslint-disable-line security/detect-non-literal-fs-filename
+      return d + '/';
+    } catch (e) {
+      if (e.code !== 'ENOENT') {
+        throw e;
+      }
+    }
+    currentPath = d;
+  } while (currentPath !== '/');
+  throw new FileScopeError('unable to find package root');
+}
+
+
+/**
+ * Get default options based on package directory structure.
+ * @param {String} packageBase
+ * @returns {Object}
+ */
+function defaultOptions(packageBase) {
+  const options = {
+    includePath: false,
+    includePackage: false,
+    includeVersion: false,
+    leftTrim: 0,
+    _errorEncountered: false,
+    errorPrefix: '?',
+    delimiter: ':',
+  };
+
+  if (packageBase) {
+    try {
+      options.includePath = true;
+      if (pathExists(path.join(packageBase, 'lib'))) {
+        options.leftTrim = 4;
+        options.includePackage = true;
+        options.includeVersion = true;
+      } else if (pathExists(path.join(packageBase, 'src'))) {
+        options.leftTrim = 4;
+      }
+    } catch (e) {
+      options._errorEncountered = true;
+      options.includePath = false;
+    }
+  }
+
+  return options;
+}
+
+
+/**
+ * Returns a function suitable for decorating a function name with
+ * package information and details of the source file.
+ * @param {String} filepath full path from __filename
+ * @param {Object=} options
+ * @param {Boolean=} options.includePackage
+ * @param {Boolean=} options.includeVersion
+ * @param {Boolean=} options.includePath
+ * @param {String=} options.prefix
+ * @param {Number=} options.leftTrim
+ * @param {String=} options.errorPrefix
+ * @param {String=} options.delimiter
+ * @returns {Function}
+ */
+function fileScope(filepath, options) {
+  let errorEncountered = false;
+  let packageBase = '';
+  try {
+    packageBase = locatePackageBase(filepath);
+  } catch (e) {
+    errorEncountered = true;
+  }
+  const defaults = defaultOptions(packageBase);
+  errorEncountered |= defaults._errorEncountered;
+  const {
+    includePackage,
+    includeVersion,
+    includePath,
+    prefix,
+    leftTrim,
+    errorPrefix,
+    delimiter,
+  } = {
+    ...defaults,
+    ...options,
+  };
+
+  let packageIdentifier;
+  if (includePackage || includeVersion) {
+    const { name: packageName, version: packageVersion } = readPackageJSON(packageBase);
+    // including version implies including package
+    packageIdentifier = includeVersion ? `${packageName}@${packageVersion}` : packageName;
+  }
+
+  const { base, name, ext } = path.parse(filepath);
+  const isIndex = (name === 'index') && ['.js', '.ts', '.mjs', '.cjs'].includes(ext);
+
+  // If filepath is an index, trim off filename and last slash, otherwise just trim off extension
+  const rightTrim = 0 - (isIndex ? (base.length + 1) : ext.length);
+  // We can't trim both ends at once as dirname won't work when isIndex is true
+  const rightTrimmed = filepath.slice(0, rightTrim);
+
+  // Trim leading part of path
+  const trim = (includePath && packageBase) ? (leftTrim + packageBase.length) : (path.dirname(rightTrimmed).length + 1);
+  const trimmedFilename = rightTrimmed.slice(trim);
+
+  const components = [errorEncountered ? errorPrefix : '', prefix, packageIdentifier, trimmedFilename]
+    .filter((x) => x);
+
+  const scope = components.join(delimiter);
+
+  return (name) => `${scope}${delimiter}${name}`;
+}
+
+
+module.exports = {
+  FileScopeError,
+  readPackageJSON,
+  pathExists,
+  locatePackageBase,
+  defaultOptions,
+  fileScope,
+};