initial commit
[squeep-log-helper] / lib / file-scope.js
1 'use strict';
2
3 /**
4 * An opinionated way to gather a source identifier to include in logged
5 * messages, using possibly too much information.
6 */
7
8 const path = require('node:path');
9 const fs = require('node:fs');
10
11 /**
12 * Internal exception
13 */
14 class FileScopeError extends Error {
15 constructor(...args) {
16 super(...args);
17 Error.captureStackTrace(this, this.constructor);
18 }
19
20 get name() {
21 return this.constructor.name;
22 }
23 }
24
25
26 /**
27 * Read and parse package.json from a path.
28 * @param {String} packagePath
29 * @returns {Object}
30 */
31 function readPackageJSON(packagePath) {
32 try {
33 const content = fs.readFileSync(path.join(packagePath, 'package.json')); // eslint-disable-line security/detect-non-literal-fs-filename
34 return JSON.parse(content);
35 } catch (e) {
36 return {
37 name: '(unknown)',
38 version: '(unknown)',
39 };
40 }
41 }
42
43
44 /**
45 * Returns whether path p exists.
46 * @param {String} p
47 * @returns {Boolean}
48 */
49 function pathExists(p) {
50 try {
51 fs.statSync(p); // eslint-disable-line security/detect-non-literal-fs-filename
52 return true;
53 } catch (e) {
54 if (e.code !== 'ENOENT') {
55 throw e;
56 }
57 return false;
58 }
59 }
60
61
62 /**
63 * Walk up the path of provided filename until directory with
64 * package.json is located. We assume this is the package root.
65 * @param {String} filename
66 * @returns {String}
67 */
68 function locatePackageBase(filename) {
69 let currentPath = filename;
70 do {
71 const d = path.dirname(currentPath);
72 try {
73 fs.statSync(path.join(d, 'package.json')); // eslint-disable-line security/detect-non-literal-fs-filename
74 return d + '/';
75 } catch (e) {
76 if (e.code !== 'ENOENT') {
77 throw e;
78 }
79 }
80 currentPath = d;
81 } while (currentPath !== '/');
82 throw new FileScopeError('unable to find package root');
83 }
84
85
86 /**
87 * Get default options based on package directory structure.
88 * @param {String} packageBase
89 * @returns {Object}
90 */
91 function defaultOptions(packageBase) {
92 const options = {
93 includePath: false,
94 includePackage: false,
95 includeVersion: false,
96 leftTrim: 0,
97 _errorEncountered: false,
98 errorPrefix: '?',
99 delimiter: ':',
100 };
101
102 if (packageBase) {
103 try {
104 options.includePath = true;
105 if (pathExists(path.join(packageBase, 'lib'))) {
106 options.leftTrim = 4;
107 options.includePackage = true;
108 options.includeVersion = true;
109 } else if (pathExists(path.join(packageBase, 'src'))) {
110 options.leftTrim = 4;
111 }
112 } catch (e) {
113 options._errorEncountered = true;
114 options.includePath = false;
115 }
116 }
117
118 return options;
119 }
120
121
122 /**
123 * Returns a function suitable for decorating a function name with
124 * package information and details of the source file.
125 * @param {String} filepath full path from __filename
126 * @param {Object=} options
127 * @param {Boolean=} options.includePackage
128 * @param {Boolean=} options.includeVersion
129 * @param {Boolean=} options.includePath
130 * @param {String=} options.prefix
131 * @param {Number=} options.leftTrim
132 * @param {String=} options.errorPrefix
133 * @param {String=} options.delimiter
134 * @returns {Function}
135 */
136 function fileScope(filepath, options) {
137 let errorEncountered = false;
138 let packageBase = '';
139 try {
140 packageBase = locatePackageBase(filepath);
141 } catch (e) {
142 errorEncountered = true;
143 }
144 const defaults = defaultOptions(packageBase);
145 errorEncountered |= defaults._errorEncountered;
146 const {
147 includePackage,
148 includeVersion,
149 includePath,
150 prefix,
151 leftTrim,
152 errorPrefix,
153 delimiter,
154 } = {
155 ...defaults,
156 ...options,
157 };
158
159 let packageIdentifier;
160 if (includePackage || includeVersion) {
161 const { name: packageName, version: packageVersion } = readPackageJSON(packageBase);
162 // including version implies including package
163 packageIdentifier = includeVersion ? `${packageName}@${packageVersion}` : packageName;
164 }
165
166 const { base, name, ext } = path.parse(filepath);
167 const isIndex = (name === 'index') && ['.js', '.ts', '.mjs', '.cjs'].includes(ext);
168
169 // If filepath is an index, trim off filename and last slash, otherwise just trim off extension
170 const rightTrim = 0 - (isIndex ? (base.length + 1) : ext.length);
171 // We can't trim both ends at once as dirname won't work when isIndex is true
172 const rightTrimmed = filepath.slice(0, rightTrim);
173
174 // Trim leading part of path
175 const trim = (includePath && packageBase) ? (leftTrim + packageBase.length) : (path.dirname(rightTrimmed).length + 1);
176 const trimmedFilename = rightTrimmed.slice(trim);
177
178 const components = [errorEncountered ? errorPrefix : '', prefix, packageIdentifier, trimmedFilename]
179 .filter((x) => x);
180
181 const scope = components.join(delimiter);
182
183 return (name) => `${scope}${delimiter}${name}`;
184 }
185
186
187 module.exports = {
188 FileScopeError,
189 readPackageJSON,
190 pathExists,
191 locatePackageBase,
192 defaultOptions,
193 fileScope,
194 };