1 | /*
|
2 | * Licensed under the Apache License, Version 2.0 (the "License");
|
3 | * you may not use this file except in compliance with the License.
|
4 | * You may obtain a copy of the License at
|
5 | *
|
6 | * http://www.apache.org/licenses/LICENSE-2.0
|
7 | *
|
8 | * Unless required by applicable law or agreed to in writing, software
|
9 | * distributed under the License is distributed on an "AS IS" BASIS,
|
10 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
11 | * See the License for the specific language governing permissions and
|
12 | * limitations under the License.
|
13 | */
|
14 |
|
15 | ;
|
16 |
|
17 | const doctrine = require('doctrine');
|
18 | const acorn = require('acorn');
|
19 |
|
20 | /**
|
21 | * Processes a single Javascript file (.js extension)
|
22 | *
|
23 | * @param {string} file - the file to process
|
24 | * @param {Object} fileProcessor - the processor instance to use to generate code
|
25 | * @private
|
26 | * @class
|
27 | * @memberof module:concerto-core
|
28 | */
|
29 | class JavaScriptParser {
|
30 |
|
31 | /**
|
32 | * Create a JavaScriptParser.
|
33 | *
|
34 | * @param {string} fileContents - the text of the JS file to parse
|
35 | * @param {boolean} [includePrivates] - if true methods tagged as private are also returned
|
36 | * @param {number} [ecmaVersion] - the ECMAScript version to use
|
37 | * @param {boolean} [engineMode] - true if being used by engine for TP/ACL function Parsing
|
38 | */
|
39 | constructor(fileContents, includePrivates, ecmaVersion, engineMode = true) {
|
40 | let comments = [];
|
41 | this.tokens = [];
|
42 |
|
43 | // If the magic flag is set, don't collect tokens. They eat up a lot of
|
44 | // memory and this is problematic for the HLFv1 runtime (the only place
|
45 | // that this flag is set).
|
46 | let noTokens = !!global.composerJavaScriptParserNoTokens;
|
47 |
|
48 | let options = {
|
49 | // collect ranges for each node
|
50 | ranges: true,
|
51 | // collect comments in Esprima's format
|
52 | onComment: comments,
|
53 | // collect token ranges
|
54 | onToken: noTokens ? null : this.tokens,
|
55 | // collect token locations
|
56 | locations: true,
|
57 | // locations: true,
|
58 | plugins: {
|
59 | 'composereof': true
|
60 | }
|
61 | };
|
62 |
|
63 |
|
64 | if (ecmaVersion) {
|
65 | options.ecmaVersion = ecmaVersion;
|
66 | }
|
67 |
|
68 | acorn.plugins.composereof = function (parser) {
|
69 |
|
70 | parser.extend('parseTopLevel', function (nextMethod) {
|
71 | return function (node) {
|
72 | let this$1 = this;
|
73 |
|
74 | let exports = {};
|
75 | if (!node.body) {
|
76 | node.body = [];
|
77 | }
|
78 | while (this.type.label !== 'eof') {
|
79 | let stmt = this$1.parseStatement(true, true, exports);
|
80 | node.body.push(stmt);
|
81 | }
|
82 | this.next();
|
83 | if (this.options.ecmaVersion >= 6) {
|
84 | node.sourceType = this.options.sourceType;
|
85 | }
|
86 | return this.finishNode(node, 'Program');
|
87 | };
|
88 | });
|
89 | };
|
90 | let ast = acorn.parse(fileContents, options);
|
91 | this.includes = [];
|
92 | this.classes = [];
|
93 | this.functions = [];
|
94 |
|
95 | let nodesToProcess = ast.body;
|
96 |
|
97 | // engine mode only wants to look at the top level function definitions
|
98 | // but the js doc generator wants to look at everything so this parser
|
99 | // needs to handle both requirements.
|
100 | if (!engineMode) {
|
101 | nodesToProcess = [];
|
102 | const walk = require('acorn/dist/walk');
|
103 | walk.simple(ast, {
|
104 | FunctionDeclaration(node) {
|
105 | if (node.id && node.id.name) {
|
106 | nodesToProcess.push(node);
|
107 | }
|
108 | },
|
109 | FunctionExpression(node) {
|
110 | if (node.id && node.id.name) {
|
111 | nodesToProcess.push(node);
|
112 | }
|
113 | },
|
114 | ClassDeclaration(node) {
|
115 | nodesToProcess.push(node);
|
116 | }
|
117 | });
|
118 | }
|
119 |
|
120 | for (let n = 0; n < nodesToProcess.length; n++) {
|
121 | let statement = nodesToProcess[n];
|
122 |
|
123 | // record the end of the previous node, required for engineMode only
|
124 | let previousEnd = -1;
|
125 | if (n !== 0) {
|
126 | previousEnd = nodesToProcess[n - 1].end;
|
127 | }
|
128 |
|
129 | if (statement.type === 'VariableDeclaration') {
|
130 | let variableDeclarations = statement.declarations;
|
131 |
|
132 | for (let n = 0; n < variableDeclarations.length; n++) {
|
133 | let variableDeclaration = variableDeclarations[n];
|
134 |
|
135 | if (variableDeclaration.init && variableDeclaration.init.type === 'CallExpression' &&
|
136 | variableDeclaration.init.callee.name === 'require') {
|
137 | let requireName = variableDeclaration.init.arguments[0].value;
|
138 | // we only care about the code we require with a relative path
|
139 | if (requireName.startsWith('.')) {
|
140 | this.includes.push(variableDeclaration.init.arguments[0].value);
|
141 | }
|
142 | }
|
143 | }
|
144 | } else if (statement.type === 'FunctionDeclaration' || (statement.type === 'FunctionExpression' && !engineMode)) {
|
145 | let closestComment;
|
146 | // different approaches to finding comments depending on mode as they are not compatible.
|
147 | if (!engineMode) {
|
148 | closestComment = JavaScriptParser.findCommentBefore(comments, statement.loc.start.line);
|
149 | } else {
|
150 | closestComment = JavaScriptParser.searchForComment(statement.start, statement.end, previousEnd, comments);
|
151 | }
|
152 | let returnType = '';
|
153 | let visibility = '+';
|
154 | let parameterTypes = [];
|
155 | let parameterNames = [];
|
156 | let decorators = [];
|
157 | let throws = '';
|
158 | let example = '';
|
159 | let commentData;
|
160 | if (closestComment >= 0) {
|
161 | let comment = comments[closestComment].value;
|
162 | commentData = doctrine.parse(comment, {
|
163 | unwrap: true,
|
164 | sloppy: true
|
165 | });
|
166 | returnType = JavaScriptParser.getReturnType(comment);
|
167 | visibility = JavaScriptParser.getVisibility(comment);
|
168 | parameterTypes = JavaScriptParser.getMethodArguments(comment);
|
169 | throws = JavaScriptParser.getThrows(comment);
|
170 | decorators = JavaScriptParser.getDecorators(comment);
|
171 | example = JavaScriptParser.getExample(comment);
|
172 | }
|
173 |
|
174 | if (visibility === '+' || includePrivates) {
|
175 | for (let n = 0; n < statement.params.length; n++) {
|
176 | parameterNames.push(statement.params[n].name);
|
177 | }
|
178 | const func = {
|
179 | visibility: visibility,
|
180 | returnType: returnType,
|
181 | name: statement.id.name,
|
182 | parameterTypes: parameterTypes,
|
183 | parameterNames: parameterNames,
|
184 | throws: throws,
|
185 | decorators: decorators,
|
186 | functionText: JavaScriptParser.getText(statement.start, statement.end, fileContents),
|
187 | example: example,
|
188 | commentData: commentData
|
189 | };
|
190 | this.functions.push(func);
|
191 | }
|
192 | } else if (statement.type === 'ClassDeclaration') {
|
193 | let closestComment;
|
194 | if (!engineMode) {
|
195 | closestComment = JavaScriptParser.findCommentBefore(comments, statement.loc.start.line);
|
196 | } else {
|
197 | closestComment = JavaScriptParser.searchForComment(statement.start, statement.end, previousEnd, comments);
|
198 | }
|
199 | let privateClass = false;
|
200 | let d;
|
201 | if (closestComment >= 0) {
|
202 | let comment = comments[closestComment].value;
|
203 | d = doctrine.parse(comment, {
|
204 | unwrap: true,
|
205 | sloppy: true
|
206 | });
|
207 | privateClass = JavaScriptParser.getVisibility(comment) === '-';
|
208 | }
|
209 |
|
210 | if (privateClass === false || includePrivates) {
|
211 | d = d || [];
|
212 | const clazz = {
|
213 | name: statement.id.name,
|
214 | commentData: d
|
215 | };
|
216 | clazz.methods = [];
|
217 |
|
218 | for (let n = 0; n < statement.body.body.length; n++) {
|
219 | let thing = statement.body.body[n];
|
220 |
|
221 | if (thing.type === 'MethodDefinition') {
|
222 | let closestComment;
|
223 | if (!engineMode) {
|
224 | closestComment = JavaScriptParser.findCommentBefore(comments, thing.loc.start.line);
|
225 | } else {
|
226 | // previousEnd is the end of the node before the ClassDeclaration
|
227 | let previousThingEnd = previousEnd;
|
228 | if (n !== 0) {
|
229 | // record the end of the previous thing inside the ClassDeclaration
|
230 | previousThingEnd = statement.body.body[n - 1].end;
|
231 | }
|
232 | closestComment = JavaScriptParser.searchForComment(thing.key.start, thing.key.end, previousThingEnd, comments);
|
233 | }
|
234 | let returnType = '';
|
235 | let visibility = '+';
|
236 | let methodArgs = [];
|
237 | let throws = '';
|
238 | let decorators = [];
|
239 | let example = '';
|
240 | let commentData;
|
241 | if (closestComment >= 0) {
|
242 | let comment = comments[closestComment].value;
|
243 | commentData = doctrine.parse(comment, {
|
244 | unwrap: true,
|
245 | sloppy: true
|
246 | });
|
247 | returnType = JavaScriptParser.getReturnType(comment);
|
248 | visibility = JavaScriptParser.getVisibility(comment);
|
249 | methodArgs = JavaScriptParser.getMethodArguments(comment);
|
250 | decorators = JavaScriptParser.getDecorators(comment);
|
251 | throws = JavaScriptParser.getThrows(comment);
|
252 | example = JavaScriptParser.getExample(comment);
|
253 | }
|
254 | let name = thing.key.name;
|
255 | if(!thing.key.name && thing.key.property.name){
|
256 | name = thing.key.property.name;
|
257 | }
|
258 |
|
259 | commentData = commentData || [];
|
260 | if (visibility === '+' || visibility === '~' || includePrivates) {
|
261 | const method = {
|
262 | visibility: visibility,
|
263 | returnType: returnType,
|
264 | name: name,
|
265 | methodArgs: methodArgs,
|
266 | decorators: decorators,
|
267 | throws: throws,
|
268 | example: example,
|
269 | commentData: commentData
|
270 | };
|
271 | clazz.methods.push(method);
|
272 | }
|
273 | }
|
274 | }
|
275 |
|
276 | if (statement.superClass) {
|
277 | clazz.superClass = statement.superClass.name;
|
278 | }
|
279 |
|
280 | this.classes.push(clazz);
|
281 | }
|
282 | }
|
283 | }
|
284 | }
|
285 |
|
286 | /**
|
287 | * Return the includes that were extracted from the JS file.
|
288 | *
|
289 | * @return {Object[]} information about each include
|
290 | */
|
291 | getIncludes() {
|
292 | return this.includes;
|
293 | }
|
294 |
|
295 | /**
|
296 | * Return the classes that were extracted from the JS file.
|
297 | *
|
298 | * @return {Object[]} information about each class
|
299 | */
|
300 | getClasses() {
|
301 | return this.classes;
|
302 | }
|
303 |
|
304 | /**
|
305 | * Return the methods that were extracted from the JS file.
|
306 | *
|
307 | * @return {Object[]} information about each method
|
308 | */
|
309 | getFunctions() {
|
310 | return this.functions;
|
311 | }
|
312 |
|
313 | /**
|
314 | * Return the tokens that were extracted from the JS file.
|
315 | *
|
316 | * @return {Object[]} information about each tokens
|
317 | */
|
318 | getTokens() {
|
319 | return this.tokens;
|
320 | }
|
321 |
|
322 | /**
|
323 | * Grab the text between a range
|
324 | *
|
325 | * @param {integer} rangeStart - the start of the range
|
326 | * @param {integer} rangeEnd - the end of the range
|
327 | * @param {string} source - the source text
|
328 | * @return {string} the text between start and end
|
329 | * @private
|
330 | */
|
331 | static getText(rangeStart, rangeEnd, source) {
|
332 | return source.substring(rangeStart, rangeEnd);
|
333 | }
|
334 |
|
335 | /**
|
336 | * Find the comments that are directly above a specific line number.
|
337 | * This is used when order of the nodes cannot be guaranteed but
|
338 | * limitation is that all comments must directly precede what they
|
339 | * are commenting (ie no blank lines)
|
340 | *
|
341 | * @param {string[]} comments - the end of the range
|
342 | * @param {integer} lineNumber - current linenumber
|
343 | * @return {integer} the comment index or -1 if there are no comments
|
344 | * @private
|
345 | */
|
346 | static findCommentBefore(comments, lineNumber) {
|
347 | let foundIndex = -1;
|
348 |
|
349 | for (let n = 0; n < comments.length; n++) {
|
350 | let comment = comments[n];
|
351 | let endComment = parseInt(comment.loc.end.line);
|
352 |
|
353 | if ((lineNumber - endComment) === 1) {
|
354 | foundIndex = n;
|
355 | break;
|
356 | }
|
357 |
|
358 | }
|
359 | return foundIndex;
|
360 | }
|
361 |
|
362 | /**
|
363 | * Find the comments that are above and closest to the start of the range.
|
364 | * This is used in engineMode and supports locating comments that aren't
|
365 | * directly before a TP function. It assumes that nodes will be in order
|
366 | *
|
367 | * @param {integer} rangeStart - the start of the range
|
368 | * @param {integer} rangeEnd - the end of the range
|
369 | * @param {integer} stopPoint - the point to stop searching for previous comments
|
370 | * @param {string[]} comments - the end of the range
|
371 | * @return {integer} the comment index or -1 if there are no comments
|
372 | * @private
|
373 | */
|
374 | static searchForComment(rangeStart, rangeEnd, stopPoint, comments) {
|
375 | let foundIndex = -1;
|
376 | let distance = -1;
|
377 |
|
378 | for (let n = 0; n < comments.length; n++) {
|
379 | let comment = comments[n];
|
380 | let endComment = comment.end;
|
381 | if (rangeStart > endComment && comment.start > stopPoint) {
|
382 |
|
383 | if (distance === -1 || rangeStart - endComment < distance) {
|
384 | distance = rangeStart - endComment;
|
385 | foundIndex = n;
|
386 | }
|
387 | }
|
388 | }
|
389 | return foundIndex;
|
390 | }
|
391 |
|
392 | /**
|
393 | * Grabs all the @ prefixed decorators from a comment block.
|
394 | * @param {string} comment - the comment block
|
395 | * @return {string[]} the @ prefixed decorators within the comment block
|
396 | * @private
|
397 | */
|
398 | static getDecorators(comment) {
|
399 | const re = /(?:^|\W)@(\w+)/g;
|
400 | let match;
|
401 | const matches = [];
|
402 | match = re.exec(comment);
|
403 | while (match) {
|
404 | matches.push(match[1]);
|
405 | match = re.exec(comment);
|
406 | }
|
407 | return matches;
|
408 | }
|
409 |
|
410 | /**
|
411 | * Extracts the visibilty from a comment block
|
412 | * @param {string} comment - the comment block
|
413 | * @return {string} the return visibility (either + for public, ~ for protected, or - for private)
|
414 | * @private
|
415 | */
|
416 | static getVisibility(comment) {
|
417 | const PRIVATE = 'private';
|
418 | const PROTECTED = 'protected';
|
419 |
|
420 | let parsedComment = doctrine.parse(comment, {
|
421 | unwrap: true,
|
422 | sloppy: true,
|
423 | tags: [PRIVATE, PROTECTED]
|
424 | });
|
425 | const tags = parsedComment.tags;
|
426 |
|
427 | if (tags.length > 0) {
|
428 | switch (tags[0].title) {
|
429 | case PRIVATE:
|
430 | return '-';
|
431 | case PROTECTED:
|
432 | return '~';
|
433 | default:
|
434 | return '+';
|
435 | }
|
436 | }
|
437 | return '+';
|
438 | }
|
439 |
|
440 | /**
|
441 | * Extracts the return type from a comment block.
|
442 | * @param {string} comment - the comment block
|
443 | * @return {string} the return type of the comment
|
444 | * @private
|
445 | */
|
446 | static getReturnType(comment) {
|
447 | const RETURN = 'return';
|
448 | const RETURNS = 'returns';
|
449 |
|
450 | let result = 'void';
|
451 | let parsedComment = doctrine.parse(comment, {
|
452 | unwrap: true,
|
453 | sloppy: true,
|
454 | tags: [RETURN, RETURNS]
|
455 | });
|
456 |
|
457 | const tags = parsedComment.tags;
|
458 |
|
459 | if (tags.length > 1) {
|
460 | throw new Error('Malformed JSDoc comment. More than one returns: ' + comment);
|
461 | }
|
462 |
|
463 | tags.forEach((tag) => {
|
464 | if (tag.type) {
|
465 | if (!tag.type.name && !tag.type) {
|
466 | throw new Error('Malformed JSDoc comment. ' + comment);
|
467 | }
|
468 |
|
469 | if (tag.type.name) {
|
470 | result = tag.type.name;
|
471 | } else if (tag.type.applications) {
|
472 | if(!tag.type.applications[0].name && tag.type.applications[0].type === 'RecordType'){
|
473 | result = 'Object[]';
|
474 | } else {
|
475 | result = tag.type.applications[0].name + '[]';
|
476 | }
|
477 | } else if (tag.type.expression) {
|
478 | result = tag.type.expression.name;
|
479 |
|
480 | }
|
481 | } else {
|
482 | throw new Error('Malformed JSDoc comment. ' + comment);
|
483 | }
|
484 | });
|
485 | return result;
|
486 | }
|
487 |
|
488 | /**
|
489 | * Extracts the return type from a comment block.
|
490 | * @param {string} comment - the comment block
|
491 | * @return {string} the return type of the comment
|
492 | * @private
|
493 | */
|
494 | static getThrows(comment) {
|
495 | const THROWS = 'throws';
|
496 | const EXCEPTION = 'exception';
|
497 | let result = '';
|
498 | let parsedComment = doctrine.parse(comment, {
|
499 | unwrap: true,
|
500 | sloppy: true,
|
501 | tags: [THROWS, EXCEPTION]
|
502 | });
|
503 |
|
504 | const tags = parsedComment.tags;
|
505 |
|
506 | if (tags.length > 1) {
|
507 | throw new Error('Malformed JSDoc comment. More than one throws/exception: ' + comment);
|
508 | }
|
509 |
|
510 | tags.forEach((tag) => {
|
511 | if (tag.type) {
|
512 | if (!tag.type.type || !tag.type.name) {
|
513 | throw new Error('Malformed JSDoc comment. ' + comment);
|
514 | }
|
515 | result = tag.type.name;
|
516 | } else {
|
517 | throw new Error('Malformed JSDoc comment. ' + comment);
|
518 | }
|
519 | });
|
520 |
|
521 | return result;
|
522 | }
|
523 |
|
524 | /**
|
525 | * Extracts the method arguments from a comment block.
|
526 | * @param {string} comment - the comment block
|
527 | * @return {string} the the argument types
|
528 | * @private
|
529 | */
|
530 | static getMethodArguments(comment) {
|
531 | const TAG = 'param';
|
532 | let paramTypes = [];
|
533 | let parsedComment = doctrine.parse(comment, {
|
534 | unwrap: true,
|
535 | sloppy: true,
|
536 | tags: [TAG]
|
537 | });
|
538 |
|
539 | const tags = parsedComment.tags;
|
540 |
|
541 | // param is mentioned but not picked up by parser
|
542 | if (comment.indexOf('@' + TAG) !== -1 && tags.length === 0) {
|
543 | throw new Error('Malformed JSDoc comment: ' + comment);
|
544 | }
|
545 |
|
546 | tags.forEach((tag) => {
|
547 | if (tag.description) {
|
548 | //If description starts with }
|
549 | if (tag.description.trim().indexOf('}') === 0 ||
|
550 | !tag.type ||
|
551 | !tag.name) {
|
552 | throw new Error('Malformed JSDoc comment: ' + comment);
|
553 | }
|
554 | }
|
555 | if (tag.type.name) {
|
556 | if (tag.type.name.indexOf(' ') !== -1) {
|
557 | throw new Error('Malformed JSDoc comment: ' + comment);
|
558 | }
|
559 | }
|
560 |
|
561 | if (tag.type.name) {
|
562 | paramTypes.push(tag.type.name);
|
563 | } else if (tag.type.applications) {
|
564 | paramTypes.push(tag.type.applications[0].name + '[]');
|
565 | } else if (tag.type.expression) {
|
566 | paramTypes.push(tag.type.expression.name);
|
567 |
|
568 | }
|
569 | });
|
570 | return paramTypes;
|
571 | }
|
572 |
|
573 | /**
|
574 | * Extracts the example tag from a comment block.
|
575 | * @param {string} comment - the comment block
|
576 | * @return {string} the the argument types
|
577 | * @private
|
578 | */
|
579 | static getExample(comment) {
|
580 | const EXAMPLE = 'example';
|
581 | let result = '';
|
582 | let parsedComment = doctrine.parse(comment, {
|
583 | unwrap: true,
|
584 | sloppy: true,
|
585 | tags: [EXAMPLE]
|
586 | });
|
587 |
|
588 | const tags = parsedComment.tags;
|
589 |
|
590 | if (tags.length > 0) {
|
591 | result = tags[0].description;
|
592 | }
|
593 |
|
594 | return result;
|
595 | }
|
596 | }
|
597 |
|
598 | module.exports = JavaScriptParser;
|