UNPKG

16 kBJavaScriptView Raw
1// @flow
2
3import fs, { readdirSync, statSync } from 'fs'
4import path from 'path'
5import * as types from 'ne-types'
6import { GQLBase } from './GQLBase'
7import { GQLJSON } from './types/GQLJSON'
8import { merge } from 'lodash'
9import {
10 promisify,
11 Deferred,
12 getLatticePrefs,
13 LatticeLogs as ll
14} from './utils'
15
16// Promisify some bits
17const readdirAsync = promisify(fs.readdir)
18const statAsync = promisify(fs.stat)
19
20// Fetch some type checking bits from 'types'
21const {
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 */
39export 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
529export default ModuleParser;