@gesslar/bedoc 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ import process from "node:process"
2
+ import {Environment} from "./Core.js"
3
+
4
+ import {
5
+ ConfigurationParameters,
6
+ ConfigurationPriorityKeys,
7
+ } from "./ConfigurationParameters.js"
8
+
9
+ import * as ActionUtil from "./util/ActionUtil.js"
10
+ import * as DataUtil from "./util/DataUtil.js"
11
+ import * as FDUtil from "./util/FDUtil.js"
12
+
13
+ const {loadJson} = ActionUtil
14
+ const {isNothing, isType, mapObject} = DataUtil
15
+ const {getFiles, resolveDirectory, resolveFilename} = FDUtil
16
+ const {fdType, fdTypes} = FDUtil
17
+
18
+ export default class Configuration {
19
+ async validate({options, source}) {
20
+ const finalOptions = {}
21
+
22
+ this.#mapEntryOptions({options, source})
23
+
24
+ // While the entry points do wrap the entire process in a try/catch, we
25
+ // should also do this here, so we can trap everything and instead
26
+ // of throwing, return friendly messages back!
27
+
28
+ // If the configuration parameters are invalid, we can't proceed. No error
29
+ // collection is needed here, because the ConfigurationParameters object
30
+ // is a static object and should be correct. OUT WITH THE TRASH!!! (I mean
31
+ // the error collection, not the ConfigurationParameters object)
32
+ // (Edit: No, I mean the ConfigurationParameters object. It's trash. Fix it
33
+ // if you get this error.)
34
+ const configValidationErrors = this.#validateConfigurationParameters()
35
+ if(configValidationErrors.length > 0)
36
+ throw new AggregateError(
37
+ configValidationErrors,
38
+ `ConfigurationParameters validation errors: `+
39
+ `${configValidationErrors.join(", ")}`,
40
+ )
41
+
42
+ const allOptions = this.#findAllOptions(options)
43
+ Object.assign(finalOptions, await this.#mergeOptions(allOptions))
44
+ this.#fixOptionValues(finalOptions)
45
+
46
+ // Priority keys are those which must be processed first. They are
47
+ // specified in order of priority.
48
+ // Find them and add them to an array; the rest will be in pushed to the
49
+ // end of the priority array.
50
+ const orderedSections = []
51
+ ConfigurationPriorityKeys.forEach((key) => {
52
+ if(!ConfigurationParameters[key])
53
+ throw new Error(`Invalid priority key: ${key}`)
54
+
55
+ if(finalOptions[key])
56
+ orderedSections.push({key, value: finalOptions[key]})
57
+ })
58
+
59
+ const remainingSections = Object.keys(ConfigurationParameters).filter(
60
+ (key) => !ConfigurationPriorityKeys.includes(key),
61
+ )
62
+ orderedSections.push(
63
+ ...remainingSections.map((key) => {
64
+ return {key, value: finalOptions[key]}
65
+ }),
66
+ )
67
+
68
+ // Check exclusive options
69
+ for(const [key, param] of Object.entries(ConfigurationParameters)) {
70
+ if(
71
+ param.exclusiveOf &&
72
+ finalOptions[key] &&
73
+ finalOptions[param.exclusiveOf]
74
+ )
75
+ throw new SyntaxError(
76
+ `Options \`${key}\` and \`${param.exclusiveOf}\` are mutually exclusive`,
77
+ )
78
+ }
79
+
80
+ for(const section of orderedSections) {
81
+ const {key} = section
82
+
83
+ // Skipping config, we've already handled it
84
+ if(key === "config")
85
+ continue
86
+
87
+ let {value} = section
88
+ const nothing = isNothing(value)
89
+ const param = ConfigurationParameters[key]
90
+ const {required, path} = param
91
+
92
+ if(nothing) {
93
+ if(required === true)
94
+ throw new SyntaxError(`Option \`${key}\` is required`)
95
+ else
96
+ continue
97
+ }
98
+
99
+ // Additional path validation if needed
100
+ if(path && !nothing) {
101
+ const {mustExist, type: pathType} = path
102
+
103
+ // Special for `input` and `exclude` because they can be a comma-
104
+ // separated list of glob patterns.
105
+ if(key === "input" || key === "exclude") {
106
+ if(isType(value, "array"))
107
+ value = await Promise.all(
108
+ value.map((pattern) => getFiles(pattern)),
109
+ )
110
+ else if(isType(value, "string"))
111
+ value = await getFiles(value)
112
+ else
113
+ throw new TypeError(
114
+ `Option \`${key}\` must be a string or an array of strings`,
115
+ )
116
+
117
+ finalOptions[key] = value.flat()
118
+
119
+ continue
120
+ } else {
121
+ if(mustExist === true) {
122
+ finalOptions[key] =
123
+ pathType === fdType.FILE
124
+ ? resolveFilename(value)
125
+ : resolveDirectory(value)
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ return {
132
+ status: "success",
133
+ validated: true,
134
+ ...finalOptions,
135
+ }
136
+ }
137
+
138
+ #mapEntryOptions({options, source}) {
139
+ // CLI already has done all the work via commander
140
+ if(source === Environment.CLI)
141
+ return options
142
+
143
+ for(const [key, value] of Object.entries(options)) {
144
+ options[key] = {value, source}
145
+ }
146
+
147
+ // We will need to inject some options if they are not available
148
+ const cwd = process.cwd()
149
+ const dir = resolveDirectory(cwd)
150
+
151
+ // Inject basePath if not available
152
+ if(!options.basePath)
153
+ options.basePath = {value: dir, source}
154
+
155
+ // Inject packageJson if not available
156
+ if(!options.packageJson) {
157
+ const jsonFile = resolveFilename("package.json", dir)
158
+ const jsonObj = loadJson(jsonFile)
159
+ options.packageJson = {value: jsonObj, source}
160
+ }
161
+
162
+ return options
163
+ }
164
+
165
+ /**
166
+ * Validate the ConfigurationParameters object. This is a sanity check to
167
+ * ensure that the ConfigurationParameters object is valid.
168
+ *
169
+ * @returns {string[]} Errors
170
+ */
171
+ #validateConfigurationParameters() {
172
+ const errors = []
173
+
174
+ for(const [key, param] of Object.entries(ConfigurationParameters)) {
175
+ // Type
176
+ if(!param.type) {
177
+ errors.push(`Option \`${key}\` has no type`)
178
+ continue
179
+ }
180
+
181
+ // Paths
182
+ if(param.subtype?.path) {
183
+ const pathType = param.subtype.path?.type
184
+
185
+ // Check if pathType is defined
186
+ if(!pathType)
187
+ errors.push(`Option \`${key}\` has no path type`)
188
+
189
+ // Check if pathType is a valid key in FdTypes
190
+ if(!fdTypes.includes(pathType))
191
+ errors.push(`Option \`${key}\` has invalid path type: ${pathType}`)
192
+ }
193
+ }
194
+
195
+ return errors
196
+ }
197
+
198
+ /**
199
+ * Find all options from all sources
200
+ *
201
+ * @param {object} entryOptions - The command line options.
202
+ * @returns {Promise<object[]>} All options from all sources.
203
+ */
204
+ #findAllOptions(entryOptions) {
205
+ const allOptions = []
206
+
207
+ const environmentVariables = this.#getEnvironmentVariables()
208
+ if(environmentVariables)
209
+ allOptions.push({source: "environment", options: environmentVariables})
210
+
211
+ const packageJson = entryOptions.packageJson
212
+ if(packageJson?.bedoc)
213
+ allOptions.push({source: "packageJson", options: packageJson.bedoc})
214
+
215
+ // Then the config file, if the options specified a config file
216
+ const useConfig =
217
+ entryOptions?.config ||
218
+ packageJson?.bedoc?.config ||
219
+ environmentVariables?.config
220
+
221
+ if(useConfig) {
222
+ const configFilename = packageJson?.bedoc?.config || entryOptions.config
223
+
224
+ if(!configFilename)
225
+ throw new Error("No config file specified")
226
+
227
+ const configFile = resolveFilename(configFilename)
228
+ const config = loadJson(configFile)
229
+
230
+ allOptions.push({source: "config", options: config})
231
+ }
232
+
233
+ allOptions.push({source: "entry", options: entryOptions})
234
+
235
+ return allOptions
236
+ }
237
+
238
+ /**
239
+ * Get environment variables
240
+ *
241
+ * @returns {object} Environment variables
242
+ */
243
+ #getEnvironmentVariables() {
244
+ const environmentVariables = {}
245
+ const params = Object.keys(ConfigurationParameters).map((param) => {
246
+ return {
247
+ param,
248
+ env: `bedoc_${param}`.toUpperCase(),
249
+ }
250
+ })
251
+
252
+ for(const param of params) {
253
+ if(process.env[param.env])
254
+ environmentVariables[param.param] = process.env[param.env]
255
+ }
256
+
257
+ return environmentVariables
258
+ }
259
+
260
+ /**
261
+ * Merge all options into one object
262
+ *
263
+ * @param {object[]} allOptions - All options from all sources.
264
+ * @returns {Promise<object>} The merged options.
265
+ */
266
+ async #mergeOptions(allOptions) {
267
+ const entryIndex = allOptions.findIndex(
268
+ option => option.source && option.source === "entry",
269
+ )
270
+ const entryOptions = allOptions[entryIndex].options
271
+ const nonEntryOptions = allOptions.filter(
272
+ option => option.source && option.source !== "entry",
273
+ )
274
+ const optionsOnly = nonEntryOptions.map((option) => option.options)
275
+ const mergedOptions = optionsOnly.reduce((acc, options) => {
276
+ for(const [key, value] of Object.entries(options)) acc[key] = value
277
+
278
+ return acc
279
+ }, {})
280
+
281
+ const mappedOptions = await mapObject(mergedOptions, (option, value) => {
282
+ const {value: entryValue, source: entrySource} = entryOptions[option] ?? {
283
+ value: undefined,
284
+ source: undefined,
285
+ }
286
+
287
+ const entryDefaulted = entrySource === "default"
288
+
289
+ if(entryValue && value !== entryValue)
290
+ return entryDefaulted ? value : entryValue
291
+
292
+ return value
293
+ })
294
+
295
+ // Last, but not least, add any defaulted options that are not in the
296
+ // mapped options
297
+ for(const [key, value] of Object.entries(entryOptions)) {
298
+ if(!mappedOptions[key]) {
299
+ if(value.source)
300
+ mappedOptions[key] = value.value
301
+ } else {
302
+ if(value.source !== "default")
303
+ mappedOptions[key] = value.value
304
+ }
305
+ }
306
+
307
+ return mappedOptions
308
+ }
309
+
310
+ /**
311
+ * Fix option values. This operation is performed in place.
312
+ *
313
+ * @param {object} options - The options to fix.
314
+ */
315
+ #fixOptionValues(options) {
316
+ for(const [key, param] of Object.entries(ConfigurationParameters)) {
317
+ // If the options passed includes this configuration parameter
318
+ if(options[key]) {
319
+ if(typeof options[key] === "string" && param.type !== "string") {
320
+ switch(param.type.toString()) {
321
+ case "boolean":
322
+ case "number":
323
+ options[key] = JSON.parse(options[key])
324
+ break
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+ }
@@ -0,0 +1,142 @@
1
+ import * as DataUtil from "./util/DataUtil.js"
2
+
3
+ const {newTypeSpec} = DataUtil
4
+
5
+ const ConfigurationParameters = Object.freeze({
6
+ input: {
7
+ short: "i",
8
+ param: "file",
9
+ description: "Comma-separated glob patterns to match files",
10
+ type: newTypeSpec("string|string[]"),
11
+ required: true,
12
+ path: {
13
+ type: "file",
14
+ mustExist: true,
15
+ },
16
+ },
17
+ exclude: {
18
+ short: "x",
19
+ param: "file",
20
+ description: "Comma-separated glob patterns to exclude files",
21
+ type: newTypeSpec("string|string[]"),
22
+ required: false,
23
+ },
24
+ language: {
25
+ short: "l",
26
+ param: "lang",
27
+ description: "Language parser to use",
28
+ type: newTypeSpec("string"),
29
+ required: false,
30
+ exclusiveOf: "parser",
31
+ },
32
+ format: {
33
+ short: "f",
34
+ description: "Output format",
35
+ type: newTypeSpec("string"),
36
+ required: false,
37
+ exclusiveOf: "printer",
38
+ },
39
+ maxConcurrent: {
40
+ short: "C",
41
+ param: "num",
42
+ description: "Maximum number of concurrent tasks",
43
+ type: newTypeSpec("number"),
44
+ required: false,
45
+ default: 10,
46
+ },
47
+ hooks: {
48
+ short: "k",
49
+ param: "file",
50
+ description: "Custom hooks JS file",
51
+ type: newTypeSpec("string"),
52
+ required: false,
53
+ path: {
54
+ type: "file",
55
+ mustExist: true,
56
+ },
57
+ },
58
+ output: {
59
+ short: "o",
60
+ param: "dir",
61
+ description: "Output directory",
62
+ type: newTypeSpec("string"),
63
+ required: false,
64
+ path: {
65
+ type: "directory",
66
+ mustExist: true,
67
+ },
68
+ },
69
+ parser: {
70
+ short: "p",
71
+ param: "file",
72
+ description: "Custom parser JS file",
73
+ type: newTypeSpec("string"),
74
+ required: false,
75
+ exclusiveOf: "language",
76
+ path: {
77
+ type: "file",
78
+ mustExist: true,
79
+ },
80
+ },
81
+ printer: {
82
+ short: "P",
83
+ param: "file",
84
+ description: "Custom printer JS file",
85
+ type: newTypeSpec("string"),
86
+ required: false,
87
+ exclusiveOf: "format",
88
+ path: {
89
+ type: "file",
90
+ mustExist: true,
91
+ },
92
+ },
93
+ hookTimeout: {
94
+ short: "T",
95
+ param: "ms",
96
+ description: "Timeout in milliseconds for hook execution",
97
+ type: newTypeSpec("number"),
98
+ required: false,
99
+ default: 5000,
100
+ },
101
+ mock: {
102
+ short: "m",
103
+ param: "dir",
104
+ description: "Path to mock parsers and printers",
105
+ type: newTypeSpec("string"),
106
+ required: false,
107
+ path: {
108
+ type: "directory",
109
+ mustExist: true,
110
+ },
111
+ },
112
+ config: {
113
+ short: "c",
114
+ param: "file",
115
+ description: "Use JSON config file",
116
+ type: newTypeSpec("string"),
117
+ required: false,
118
+ path: {
119
+ type: "file",
120
+ mustExist: true,
121
+ },
122
+ },
123
+ debug: {
124
+ short: "d",
125
+ description: "Enable debug mode",
126
+ type: newTypeSpec("boolean"),
127
+ required: false,
128
+ default: false,
129
+ },
130
+ debugLevel: {
131
+ short: "D",
132
+ param: "level",
133
+ description: "Debug level",
134
+ type: newTypeSpec("number"),
135
+ required: false,
136
+ default: 0,
137
+ },
138
+ })
139
+
140
+ const ConfigurationPriorityKeys = Object.freeze(["exclude", "input"])
141
+
142
+ export {ConfigurationParameters, ConfigurationPriorityKeys}
@@ -0,0 +1,113 @@
1
+ import * as FDUtil from "./util/FDUtil.js"
2
+
3
+ const {readFile, writeFile, composeFilename} = FDUtil
4
+
5
+ export default class Conveyor {
6
+ #succeeded = []
7
+ #errored = []
8
+
9
+ constructor(parser, printer, logger, output) {
10
+ this.parser = parser
11
+ this.printer = printer
12
+ this.logger = logger
13
+ this.output = output
14
+ }
15
+
16
+ /**
17
+ * Processes files with a concurrency limit.
18
+ *
19
+ * @param {Array} files - List of files to process.
20
+ * @param {number} maxConcurrent - Maximum number of concurrent tasks.
21
+ * @returns {Promise<object>} - Resolves when all files are processed.
22
+ */
23
+ async convey(files, maxConcurrent = 10) {
24
+ const semaphore = Array(maxConcurrent).fill(Promise.resolve())
25
+
26
+ for(const file of files) {
27
+ const slot = Promise.race(semaphore) // Wait for an available slot
28
+ semaphore.push(slot.then(async() => {
29
+ const result = await this.#processFile(file)
30
+
31
+ if(result.status === "success")
32
+ this.#succeeded.push({input: file, output: result.file})
33
+ else
34
+ this.#errored.push({input: file, error: result.error})
35
+ }))
36
+ semaphore.shift() // Remove the oldest promise
37
+ }
38
+
39
+ // Wait for all tasks to complete
40
+ await Promise.all(semaphore)
41
+
42
+ return {succeeded: this.#succeeded, errored: this.#errored}
43
+ }
44
+
45
+ /**
46
+ * Processes a single file.
47
+ *
48
+ * @param {object} file - FileMap object representing a file.
49
+ * @returns {Promise<object>} - Resolves when the file is processed
50
+ */
51
+ async #processFile(file) {
52
+ const debug = this.logger.newDebug()
53
+
54
+ try {
55
+ debug("Processing file: `%s`", 2, file.path)
56
+
57
+ // Step 1: Read file
58
+ const fileContent = await readFile(file)
59
+ debug("Read file content `%s` (%d bytes)", 2, file.path, fileContent.length)
60
+
61
+ // Step 2: Parse file
62
+ const parseResult = await this.parser.parse(file, fileContent)
63
+ if(parseResult.status === "error")
64
+ return parseResult
65
+
66
+ debug("Parsed file successfully: `%s`", 2, file.path)
67
+
68
+ // Step 3: Print file
69
+ const printResult = await this.printer.print(
70
+ file,
71
+ parseResult.result,
72
+ )
73
+ if(printResult.status === "error")
74
+ return printResult
75
+
76
+ debug("Printed file successfully: `%s`", 2, file.path)
77
+
78
+ // Step 4: Write output
79
+ const {destFile, content} = printResult
80
+ if(!destFile || !content)
81
+ return {status: "error", message: "Invalid print result"}
82
+
83
+ const writeResult = await this.#writeOutput(destFile, content)
84
+
85
+ if(writeResult.status === "success")
86
+ debug("Wrote output for: `%s` (%d bytes)", 2, file.path, content.length)
87
+
88
+ return writeResult
89
+ } catch(error) {
90
+ const mess = `Error processing file ${file.path}: ${error.message}\n${error.stack}`
91
+ this.logger.error(mess)
92
+ return {status: "error", error}
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Writes the output to the destination.
98
+ *
99
+ * @param {string} destFile - Destination file path.
100
+ * @param {string} content - File content.
101
+ * @returns {Promise<object>} - Resolves when the file is written.
102
+ */
103
+ async #writeOutput(destFile, content) {
104
+ const destFileMap = composeFilename(this.output.path, destFile)
105
+ try {
106
+ writeFile(destFileMap, content)
107
+
108
+ return {status: "success", file: destFileMap}
109
+ } catch(error) {
110
+ return {status: "error", output: destFileMap, error}
111
+ }
112
+ }
113
+ }