4 * An opinionated way to gather a source identifier to include in logged
5 * messages, using possibly too much information.
8 const path
= require('node:path');
9 const fs
= require('node:fs');
14 class FileScopeError
extends Error
{
15 constructor(...args
) {
17 Error
.captureStackTrace(this, this.constructor);
21 return this.constructor.name
;
27 * @typedef {object} PackageDetails
28 * @property {string} name - package name
29 * @property {string} version - package version
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
36 function readPackageJSON(packagePath
) {
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
) { // eslint-disable-line no-unused-vars
50 * Returns whether path p exists.
51 * @param {string} p path
52 * @returns {boolean} exists
54 function pathExists(p
) {
56 fs
.statSync(p
); // eslint-disable-line security/detect-non-literal-fs-filename
59 if (e
.code
!== 'ENOENT') {
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
73 function locatePackageBase(filename
) {
74 let currentPath
= filename
;
76 const d
= path
.dirname(currentPath
);
78 fs
.statSync(path
.join(d
, 'package.json')); // eslint-disable-line security/detect-non-literal-fs-filename
81 if (e
.code
!== 'ENOENT') {
86 } while (currentPath
!== '/');
87 throw new FileScopeError('unable to find package root');
92 * Get default options based on package directory structure.
93 * @param {string} packageBase path to package root
94 * @returns {object} options
96 function defaultOptions(packageBase
) {
99 includePackage: false,
100 includeVersion: false,
102 _errorEncountered: false,
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;
117 } catch (e
) { // eslint-disable-line no-unused-vars
118 options
._errorEncountered
= true;
119 options
.includePath
= false;
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
141 function fileScope(filepath
, options
) {
142 let errorEncountered
= false;
143 let packageBase
= '';
145 packageBase
= locatePackageBase(filepath
);
146 } catch (e
) { // eslint-disable-line no-unused-vars
147 errorEncountered
= true;
149 const defaults
= defaultOptions(packageBase
);
150 errorEncountered
|= defaults
._errorEncountered
;
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
;
171 const { base
, name
, ext
} = path
.parse(filepath
);
172 const isIndex
= (name
=== 'index') && ['.js', '.ts', '.mjs', '.cjs'].includes(ext
);
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
);
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
);
183 const components
= [errorEncountered
? errorPrefix : '', prefix
, packageIdentifier
, trimmedFilename
]
186 const scope
= components
.join(delimiter
);
188 return (name
) => `${scope}${delimiter}${name}`;