bump package version to 1.0.1
[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 * @typedef {object} PackageDetails
28 * @property {string} name - package name
29 * @property {string} version - package version
30 */
31 /**
32 * Read and parse package.json from a path.
33 * @param {string} packagePath - path to package.json
34 * @returns {PackageDetails} selected details from package.json
35 */
36 function readPackageJSON(packagePath) {
37 try {
38 const content = fs.readFileSync(path.join(packagePath, 'package.json')); // eslint-disable-line security/detect-non-literal-fs-filename
39 return JSON.parse(content);
40 } catch (e) {
41 return {
42 name: '(unknown)',
43 version: '(unknown)',
44 };
45 }
46 }
47
48
49 /**
50 * Returns whether path p exists.
51 * @param {string} p path
52 * @returns {boolean} exists
53 */
54 function pathExists(p) {
55 try {
56 fs.statSync(p); // eslint-disable-line security/detect-non-literal-fs-filename
57 return true;
58 } catch (e) {
59 if (e.code !== 'ENOENT') {
60 throw e;
61 }
62 return false;
63 }
64 }
65
66
67 /**
68 * Walk up the path of provided filename until directory with
69 * package.json is located. We assume this is the package root.
70 * @param {string} filename path to file
71 * @returns {string} path to package root
72 */
73 function locatePackageBase(filename) {
74 let currentPath = filename;
75 do {
76 const d = path.dirname(currentPath);
77 try {
78 fs.statSync(path.join(d, 'package.json')); // eslint-disable-line security/detect-non-literal-fs-filename
79 return d + '/';
80 } catch (e) {
81 if (e.code !== 'ENOENT') {
82 throw e;
83 }
84 }
85 currentPath = d;
86 } while (currentPath !== '/');
87 throw new FileScopeError('unable to find package root');
88 }
89
90
91 /**
92 * Get default options based on package directory structure.
93 * @param {string} packageBase path to package root
94 * @returns {object} options
95 */
96 function defaultOptions(packageBase) {
97 const options = {
98 includePath: false,
99 includePackage: false,
100 includeVersion: false,
101 leftTrim: 0,
102 _errorEncountered: false,
103 errorPrefix: '?',
104 delimiter: ':',
105 };
106
107 if (packageBase) {
108 try {
109 options.includePath = true;
110 if (pathExists(path.join(packageBase, 'lib'))) {
111 options.leftTrim = 4;
112 options.includePackage = true;
113 options.includeVersion = true;
114 } else if (pathExists(path.join(packageBase, 'src'))) {
115 options.leftTrim = 4;
116 }
117 } catch (e) {
118 options._errorEncountered = true;
119 options.includePath = false;
120 }
121 }
122
123 return options;
124 }
125
126
127 /**
128 * Returns a function suitable for decorating a function name with
129 * package information and details of the source file.
130 * @param {string} filepath full path from __filename
131 * @param {object=} options controlling component inclusion
132 * @param {boolean=} options.includePackage full package name
133 * @param {boolean=} options.includeVersion package version
134 * @param {boolean=} options.includePath when indicating filename
135 * @param {string=} options.prefix static string to include
136 * @param {number=} options.leftTrim characters to omit from start of path/filename
137 * @param {string=} options.errorPrefix string to include at start if an error was encountered
138 * @param {string=} options.delimiter joining selected components
139 * @returns {Function} marks up provided string with selected components
140 */
141 function fileScope(filepath, options) {
142 let errorEncountered = false;
143 let packageBase = '';
144 try {
145 packageBase = locatePackageBase(filepath);
146 } catch (e) {
147 errorEncountered = true;
148 }
149 const defaults = defaultOptions(packageBase);
150 errorEncountered |= defaults._errorEncountered;
151 const {
152 includePackage,
153 includeVersion,
154 includePath,
155 prefix,
156 leftTrim,
157 errorPrefix,
158 delimiter,
159 } = {
160 ...defaults,
161 ...options,
162 };
163
164 let packageIdentifier;
165 if (includePackage || includeVersion) {
166 const { name: packageName, version: packageVersion } = readPackageJSON(packageBase);
167 // Including version implies including package
168 packageIdentifier = includeVersion ? `${packageName}@${packageVersion}` : packageName;
169 }
170
171 const { base, name, ext } = path.parse(filepath);
172 const isIndex = (name === 'index') && ['.js', '.ts', '.mjs', '.cjs'].includes(ext);
173
174 // If filepath is an index, trim off filename and last slash, otherwise just trim off extension
175 const rightTrim = 0 - (isIndex ? (base.length + 1) : ext.length);
176 // We can't trim both ends at once as dirname won't work when isIndex is true
177 const rightTrimmed = filepath.slice(0, rightTrim);
178
179 // Trim leading part of path
180 const trim = (includePath && packageBase) ? (leftTrim + packageBase.length) : (path.dirname(rightTrimmed).length + 1);
181 const trimmedFilename = rightTrimmed.slice(trim);
182
183 const components = [errorEncountered ? errorPrefix : '', prefix, packageIdentifier, trimmedFilename]
184 .filter((x) => x);
185
186 const scope = components.join(delimiter);
187
188 return (name) => `${scope}${delimiter}${name}`;
189 }
190
191
192 module.exports = {
193 FileScopeError,
194 readPackageJSON,
195 pathExists,
196 locatePackageBase,
197 defaultOptions,
198 fileScope,
199 };