1 | // @flow
|
2 |
|
3 | import fs, { readdirSync, statSync } from 'fs'
|
4 | import path from 'path'
|
5 | import * as types from 'ne-types'
|
6 | import { GQLBase } from './GQLBase'
|
7 | import { GQLJSON } from './types/GQLJSON'
|
8 | import { merge } from 'lodash'
|
9 | import {
|
10 | promisify,
|
11 | Deferred,
|
12 | getLatticePrefs,
|
13 | LatticeLogs as ll
|
14 | } from './utils'
|
15 |
|
16 | // Promisify some bits
|
17 | const readdirAsync = promisify(fs.readdir)
|
18 | const statAsync = promisify(fs.stat)
|
19 |
|
20 | // Fetch some type checking bits from 'types'
|
21 | const {
|
22 | typeOf,
|
23 | isString,
|
24 | isOfType,
|
25 | isPrimitive,
|
26 | isArray,
|
27 | isObject,
|
28 | extendsFrom
|
29 | } = types;
|
30 |
|
31 | /**
|
32 | * The ModuleParser is a utility class designed to loop through and iterate
|
33 | * on a directory and pull out of each .js file found, any classes or exports
|
34 | * that extend from GQLBase or a child of GQLBase.
|
35 | *
|
36 | * @class ModuleParser
|
37 | * @since 2.7.0
|
38 | */
|
39 | export class ModuleParser {
|
40 | /**
|
41 | * An internal array of `GQLBase` extended classes found during either a
|
42 | * `parse()` or `parseSync()` call.
|
43 | *
|
44 | * @memberof ModuleParser
|
45 | * @type {Array<GQLBase>}
|
46 | */
|
47 | classes: Array<GQLBase>;
|
48 |
|
49 | /**
|
50 | * An array of strings holding loose GraphQL schema documents.
|
51 | *
|
52 | * @memberof ModuleParser
|
53 | * @type {Array<string>}
|
54 | */
|
55 | looseGraphQL: Array<string> = [];
|
56 |
|
57 | /**
|
58 | * A map of skipped items on the last pass and the associated error that
|
59 | * accompanies it.
|
60 | */
|
61 | skipped: Map<string, Error>;
|
62 |
|
63 | /**
|
64 | * A string denoting the directory on disk where `ModuleParser` should be
|
65 | * searching for its classes.
|
66 | *
|
67 | * @memberof ModuleParser
|
68 | * @type {string}
|
69 | */
|
70 | directory: string;
|
71 |
|
72 | /**
|
73 | * A boolean value denoting whether or not the `ModuleParser` instance is
|
74 | * valid; i.e. the directory it points to actually exists and is a directory
|
75 | *
|
76 | * @type {boolean}
|
77 | */
|
78 | valid: boolean;
|
79 |
|
80 | /**
|
81 | * An object, optionally added during construction, that specifies some
|
82 | * configuration about the ModuleParser and how it should do its job.
|
83 | *
|
84 | * Initially, the
|
85 | *
|
86 | * @type {Object}
|
87 | */
|
88 | options: Object = {};
|
89 |
|
90 | /**
|
91 | * The constructor
|
92 | *
|
93 | * @constructor
|
94 | * @method ⎆⠀constructor
|
95 | * @memberof ModuleParser
|
96 | * @inner
|
97 | *
|
98 | * @param {string} directory a string path to a directory containing the
|
99 | * various GQLBase extended classes that should be gathered.
|
100 | */
|
101 | constructor(directory: string, options: Object = {addLatticeTypes: true}) {
|
102 | this.directory = path.resolve(directory);
|
103 | this.classes = [];
|
104 | this.skipped = new Map();
|
105 |
|
106 | merge(this.options, options);
|
107 |
|
108 | try {
|
109 | this.valid = fs.statSync(directory).isDirectory();
|
110 | }
|
111 | catch (error) {
|
112 | this.valid = false;
|
113 | }
|
114 | }
|
115 |
|
116 | /**
|
117 | * Given a file path, this method will attempt to import/require the
|
118 | * file in question and return the object it exported; whatever that
|
119 | * may be.
|
120 | *
|
121 | * @method ModuleParser#⌾⠀importClass
|
122 | * @since 2.7.0
|
123 | *
|
124 | * @param {string} filePath a path to pass to `require()`
|
125 | *
|
126 | * @return {Object} the object, or undefined, that was returned when
|
127 | * it was `require()`'ed.
|
128 | */
|
129 | importClass(filePath: string): Object {
|
130 | let moduleContents: Object = {};
|
131 | let yellow: string = '\x1b[33m'
|
132 | let clear: string = '\x1b[0m'
|
133 |
|
134 | try {
|
135 | moduleContents = require(filePath)
|
136 | }
|
137 | catch(ignore) {
|
138 | if (/\.graphql/i.test(path.extname(filePath))) {
|
139 | ll.log(`Ingesting .graphql file ${filePath}`)
|
140 | let buffer = fs.readFileSync(filePath)
|
141 | this.looseGraphQL.push(fs.readFileSync(filePath).toString())
|
142 | }
|
143 | else {
|
144 | ll.log(`${yellow}Skipping${clear} ${filePath}`)
|
145 | ll.trace(ignore)
|
146 | this.skipped.set(filePath, ignore)
|
147 | }
|
148 | }
|
149 |
|
150 | return moduleContents;
|
151 | }
|
152 |
|
153 | /**
|
154 | * Given an object, typically the result of a `require()` or `import`
|
155 | * command, iterate over its contents and find any `GQLBase` derived
|
156 | * exports. Continually, and recursively, build this list of classes out
|
157 | * so that we can add them to a `GQLExpressMiddleware`.
|
158 | *
|
159 | * @method ModuleParser#⌾⠀findGQLBaseClasses
|
160 | * @since 2.7.0
|
161 | *
|
162 | * @param {Object} contents the object to parse for properties extending
|
163 | * from `GQLBase`
|
164 | * @param {Array<GQLBase>} gqlDefinitions the results, allowed as a second
|
165 | * parameter during recursion as a means to save state between calls
|
166 | * @return {Set<mixed>} a unique set of values that are currently being
|
167 | * iterated over. Passed in as a third parameter to save state between calls
|
168 | * during recursion.
|
169 | */
|
170 | findGQLBaseClasses(
|
171 | contents: Object,
|
172 | gqlDefinitions?: Array<GQLBase> = [],
|
173 | stack?: Set<GQLBase> = new Set()
|
174 | ): Array<GQLBase> {
|
175 | // In order to prevent infinite object recursion, we should add the
|
176 | // object being iterated over to our Set. At each new recursive level
|
177 | // add the item being iterated over to the set and only recurse into
|
178 | // if the item does not already exist in the stack itself.
|
179 | stack.add(contents)
|
180 |
|
181 | for (let key in contents) {
|
182 | let value = contents[key];
|
183 |
|
184 | if (isPrimitive(value)) { continue }
|
185 |
|
186 | if (extendsFrom(value, GQLBase)) {
|
187 | gqlDefinitions.push(value)
|
188 | }
|
189 |
|
190 | if ((isObject(value) || isArray(value)) && !stack.has(value)) {
|
191 | gqlDefinitions = this.findGQLBaseClasses(value, gqlDefinitions, stack);
|
192 | }
|
193 | }
|
194 |
|
195 | // We remove the current iterable from our set as we leave this current
|
196 | // recursive iteration.
|
197 | stack.delete(contents)
|
198 |
|
199 | return gqlDefinitions
|
200 | }
|
201 |
|
202 | /**
|
203 | * This method takes a instance of ModuleParser, initialized with a directory,
|
204 | * and walks its contents, importing files as they are found, and sorting
|
205 | * any exports that extend from GQLBase into an array of such classes
|
206 | * in a resolved promise.
|
207 | *
|
208 | * @method ModuleParser#⌾⠀parse
|
209 | * @async
|
210 | * @since 2.7.0
|
211 | *
|
212 | * @return {Promise<Array<GQLBase>>} an array GQLBase classes, or an empty
|
213 | * array if none could be identified.
|
214 | */
|
215 | async parse(): Promise<Array<GQLBase>> {
|
216 | let modules
|
217 | let files
|
218 | let set = new Set();
|
219 | let opts = getLatticePrefs()
|
220 |
|
221 | if (!this.valid) {
|
222 | throw new Error(`
|
223 | ModuleParser instance is invalid for use with ${this.directory}.
|
224 | The path is either a non-existent path or it does not represent a
|
225 | directory.
|
226 | `)
|
227 | }
|
228 |
|
229 | this.skipped.clear()
|
230 |
|
231 | // @ComputedType
|
232 | files = await this.constructor.walk(this.directory)
|
233 | modules = files.map(file => this.importClass(file))
|
234 |
|
235 | // @ComputedType
|
236 | (modules
|
237 | .map(mod => this.findGQLBaseClasses(mod))
|
238 | .reduce((last, cur) => (last || []).concat(cur || []), [])
|
239 | .forEach(Class => set.add(Class)))
|
240 |
|
241 | // Convert the set back into an array
|
242 | this.classes = Array.from(set);
|
243 |
|
244 | // We can ignore equality since we came from a set; @ComputedType
|
245 | this.classes.sort((l,r) => l.name < r.name ? -1 : 1)
|
246 |
|
247 | // Add in any GraphQL Lattice types requested
|
248 | if (this.options.addLatticeTypes) {
|
249 | this.classes.push(GQLJSON)
|
250 | }
|
251 |
|
252 | // Stop flow and throw an error if some files failed to load and settings
|
253 | // declare we should do so. After Lattice 3.x we should expect this to be
|
254 | // the new default
|
255 | if (opts.ModuleParser.failOnError && this.skipped.size) {
|
256 | this.printSkipped()
|
257 | throw new Error('Some files skipped due to errors')
|
258 | }
|
259 |
|
260 | return this.classes;
|
261 | }
|
262 |
|
263 | /**
|
264 | * This method takes a instance of ModuleParser, initialized with a directory,
|
265 | * and walks its contents, importing files as they are found, and sorting
|
266 | * any exports that extend from GQLBase into an array of such classes
|
267 | *
|
268 | * @method ModuleParser#⌾⠀parseSync
|
269 | * @async
|
270 | * @since 2.7.0
|
271 | *
|
272 | * @return {Array<GQLBase>} an array GQLBase classes, or an empty
|
273 | * array if none could be identified.
|
274 | */
|
275 | parseSync(): Array<GQLBase> {
|
276 | let modules: Array<Object>;
|
277 | let files: Array<string>;
|
278 | let set = new Set();
|
279 | let opts = getLatticePrefs()
|
280 |
|
281 | if (!this.valid) {
|
282 | throw new Error(`
|
283 | ModuleParser instance is invalid for use with ${this.directory}.
|
284 | The path is either a non-existent path or it does not represent a
|
285 | directory.
|
286 | `)
|
287 | }
|
288 |
|
289 | this.skipped.clear()
|
290 |
|
291 | files = this.constructor.walkSync(this.directory)
|
292 | modules = files.map(file => {
|
293 | return this.importClass(file)
|
294 | })
|
295 |
|
296 | modules
|
297 | .map(mod => this.findGQLBaseClasses(mod))
|
298 | .reduce((last, cur) => (last || []).concat(cur || []), [])
|
299 | .forEach(Class => set.add(Class))
|
300 |
|
301 | // Convert the set back into an array
|
302 | this.classes = Array.from(set);
|
303 |
|
304 | // We can ignore equality since we came from a set; @ComputedType
|
305 | this.classes.sort((l,r) => l.name < r.name ? -1 : 1)
|
306 |
|
307 | // Add in any GraphQL Lattice types requested
|
308 | if (this.options.addLatticeTypes) {
|
309 | this.classes.push(GQLJSON)
|
310 | }
|
311 |
|
312 | // Stop flow and throw an error if some files failed to load and settings
|
313 | // declare we should do so. After Lattice 3.x we should expect this to be
|
314 | // the new default
|
315 | if (opts.ModuleParser.failOnError && this.skipped.size) {
|
316 | this.printSkipped()
|
317 | throw new Error('Some files skipped due to errors')
|
318 | }
|
319 |
|
320 | return this.classes;
|
321 | }
|
322 |
|
323 | /**
|
324 | * Prints the list of skipped files, their stack traces, and the errors
|
325 | * denoting the reasons the files were skipped.
|
326 | */
|
327 | printSkipped() {
|
328 | if (this.skipped.size) {
|
329 | ll.outWrite('\x1b[1;91m')
|
330 | ll.outWrite('Skipped\x1b[0;31m the following files\n')
|
331 |
|
332 | for (let [key, value] of this.skipped) {
|
333 | ll.log(`${path.basename(key)}: ${value.message}`)
|
334 | if (value.stack)
|
335 | ll.log(value.stack.replace(/(^)/m, '$1 '))
|
336 | }
|
337 |
|
338 | ll.outWrite('\x1b[0m')
|
339 | }
|
340 | else {
|
341 | ll.log('\x1b[1;32mNo files skipped\x1b[0m')
|
342 | }
|
343 | }
|
344 |
|
345 | /**
|
346 | * Returns the `constructor` name. If invoked as the context, or `this`,
|
347 | * object of the `toString` method of `Object`'s `prototype`, the resulting
|
348 | * value will be `[object MyClass]`, given an instance of `MyClass`
|
349 | *
|
350 | * @method ⌾⠀[Symbol.toStringTag]
|
351 | * @memberof ModuleParser
|
352 | *
|
353 | * @return {string} the name of the class this is an instance of
|
354 | * @ComputedType
|
355 | */
|
356 | get [Symbol.toStringTag]() { return this.constructor.name }
|
357 |
|
358 | /**
|
359 | * Applies the same logic as {@link #[Symbol.toStringTag]} but on a static
|
360 | * scale. So, if you perform `Object.prototype.toString.call(MyClass)`
|
361 | * the result would be `[object MyClass]`.
|
362 | *
|
363 | * @method ⌾⠀[Symbol.toStringTag]
|
364 | * @memberof ModuleParser
|
365 | * @static
|
366 | *
|
367 | * @return {string} the name of this class
|
368 | * @ComputedType
|
369 | */
|
370 | static get [Symbol.toStringTag]() { return this.name }
|
371 |
|
372 | /**
|
373 | * Recursively walks a directory and returns an array of asbolute file paths
|
374 | * to the files under the specified directory.
|
375 | *
|
376 | * @method ModuleParser~⌾⠀walk
|
377 | * @async
|
378 | * @since 2.7.0
|
379 | *
|
380 | * @param {string} dir string path to the top level directory to parse
|
381 | * @param {Array<string>} filelist an array of existing absolute file paths,
|
382 | * or if not parameter is supplied a default empty array will be used.
|
383 | * @return {Promise<Array<string>>} an array of existing absolute file paths
|
384 | * found under the supplied `dir` directory.
|
385 | */
|
386 | static async walk(
|
387 | dir: string,
|
388 | filelist: Array<string> = [],
|
389 | extensions: Array<string> = ['.js', '.jsx', '.ts', '.tsx']
|
390 | ): Promise<Array<string>> {
|
391 | let files = await readdirAsync(dir);
|
392 | let exts = ModuleParser.checkForPackageExtensions() || extensions
|
393 | let pattern = ModuleParser.arrayToPattern(exts)
|
394 | let stats
|
395 |
|
396 | files = files.map(file => path.resolve(path.join(dir, file)))
|
397 |
|
398 | for (let file of files) {
|
399 | stats = await statAsync(file)
|
400 | if (stats.isDirectory()) {
|
401 | filelist = await this.walk(file, filelist)
|
402 | }
|
403 | else {
|
404 | if (pattern.test(path.extname(file)))
|
405 | filelist = filelist.concat(file);
|
406 | }
|
407 | }
|
408 |
|
409 | return filelist;
|
410 | }
|
411 |
|
412 | /**
|
413 | * Recursively walks a directory and returns an array of asbolute file paths
|
414 | * to the files under the specified directory. This version does this in a
|
415 | * synchronous fashion.
|
416 | *
|
417 | * @method ModuleParser~⌾⠀walkSync
|
418 | * @async
|
419 | * @since 2.7.0
|
420 | *
|
421 | * @param {string} dir string path to the top level directory to parse
|
422 | * @param {Array<string>} filelist an array of existing absolute file paths,
|
423 | * or if not parameter is supplied a default empty array will be used.
|
424 | * @return {Array<string>} an array of existing absolute file paths found
|
425 | * under the supplied `dir` directory.
|
426 | */
|
427 | static walkSync(
|
428 | dir: string,
|
429 | filelist: Array<string> = [],
|
430 | extensions: Array<string> = ['.js', '.jsx', '.ts', '.tsx']
|
431 | ): Array<string> {
|
432 | let files = readdirSync(dir)
|
433 | let exts = ModuleParser.checkForPackageExtensions() || extensions
|
434 | let pattern = ModuleParser.arrayToPattern(exts)
|
435 | let stats
|
436 |
|
437 | files = files.map(file => path.resolve(path.join(dir, file)))
|
438 |
|
439 | for (let file of files) {
|
440 | stats = statSync(file)
|
441 | if (stats.isDirectory()) {
|
442 | filelist = this.walkSync(file, filelist)
|
443 | }
|
444 | else {
|
445 | if (pattern.test(path.extname(file)))
|
446 | filelist = filelist.concat(file);
|
447 | }
|
448 | }
|
449 |
|
450 | return filelist;
|
451 | }
|
452 |
|
453 | /**
|
454 | * The ModuleParser should only parse files that match the default or
|
455 | * supplied file extensions. The default list contains .js, .jsx, .ts
|
456 | * and .tsx; so JavaScript or TypeScript files and their JSX React
|
457 | * counterparts
|
458 | *
|
459 | * Since the list is customizable for a usage, however, it makes sense
|
460 | * to have a function that will match what is supplied rather than
|
461 | * creating a constant expression to use instead.
|
462 | *
|
463 | * @static
|
464 | * @memberof ModuleParser
|
465 | * @function ⌾⠀arrayToPattern
|
466 | * @since 2.13.0
|
467 | *
|
468 | * @param {Array<string>} extensions an array of extensions to
|
469 | * convert to a regular expression that would pass for each
|
470 | * @param {string} flags the value passed to a new RegExp denoting the
|
471 | * flags used in the pattern; defaults to 'i' for case insensitivity
|
472 | * @return {RegExp} a regular expression object matching the contents
|
473 | * of the array of extensions or the default extensions and that will
|
474 | * also match those values in a case insensitive manner
|
475 | */
|
476 | static arrayToPattern(
|
477 | extensions: Array<string> = ['.js', '.jsx', '.ts', '.tsx'],
|
478 | flags: string = 'i'
|
479 | ) {
|
480 | return new RegExp(
|
481 | extensions
|
482 | .join('|')
|
483 | .replace(/\./g, '\\.')
|
484 | .replace(/([\|$])/g, '\\b$1'),
|
485 | flags
|
486 | )
|
487 | }
|
488 |
|
489 | /**
|
490 | * Using the module `read-pkg-up`, finds the nearest package.json file
|
491 | * and checks to see if it has a `.lattice.moduleParser.extensions'
|
492 | * preference. If so, if the value is an array, that value is used,
|
493 | * otherwise the value is wrapped in an array. If the optional parameter
|
494 | * `toString` is `true` then `.toString()` will be invoked on any non
|
495 | * Array values found; this behavior is the default
|
496 | *
|
497 | * @static
|
498 | * @memberof ModuleParser
|
499 | * @method ⌾⠀checkForPackageExtensions
|
500 | * @since 2.13.0
|
501 | *
|
502 | * @param {boolean} toString true if any non-array values should have
|
503 | * their `.toString()` method invoked before being wrapped in an Array;
|
504 | * defaults to true
|
505 | * @return {?Array<string>} null if no value is set for the property
|
506 | * `lattice.ModuleParser.extensions` in `package.json` or the value
|
507 | * of the setting if it is an array. Finally if the value is set but is
|
508 | * not an array, the specified value wrapped in an array is returned
|
509 | */
|
510 | static checkForPackageExtensions(toString: boolean = true): ?Array<string> {
|
511 | let pkg = getLatticePrefs()
|
512 | let extensions = null
|
513 |
|
514 | if (pkg.ModuleParser && pkg.ModuleParser.extensions) {
|
515 | let packageExts = pkg.ModuleParser.extensions
|
516 |
|
517 | if (Array.isArray(packageExts)) {
|
518 | extensions = packageExts
|
519 | }
|
520 | else {
|
521 | extensions = [toString ? packageExts.toString() : packageExts]
|
522 | }
|
523 | }
|
524 |
|
525 | return extensions
|
526 | }
|
527 | }
|
528 |
|
529 | export default ModuleParser;
|