parser.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. /*
  2. * apidoc
  3. * https://apidocjs.com
  4. *
  5. * Authors:
  6. * Peter Rottmann <rottmann@inveris.de>
  7. * Nicolas CARPi @ Deltablot
  8. * Copyright (c) 2013 inveris OHG
  9. * Licensed under the MIT license.
  10. */
  11. const { isObject, get, set, extend } = require('lodash');
  12. const fs = require('fs');
  13. const path = require('path');
  14. const util = require('util');
  15. const iconv = require('iconv-lite');
  16. const findFiles = require('./utils/find_files');
  17. const ParameterError = require('./errors/parameter_error');
  18. const ParserError = require('./errors/parser_error');
  19. let app = {};
  20. let filterTag = null; // define the tag to filter by
  21. function Parser (_app) {
  22. const self = this;
  23. // global variables
  24. app = _app;
  25. // class variables
  26. self.languages = {};
  27. self.parsers = {};
  28. self.parsedFileElements = [];
  29. self.parsedFiles = [];
  30. self.countDeprecated = {};
  31. // load languages
  32. const languages = Object.keys(app.languages);
  33. languages.forEach(function (language) {
  34. if (isObject(app.languages[language])) {
  35. app.log.debug('inject parser language: ' + language);
  36. self.addLanguage(language, app.languages[language]);
  37. } else {
  38. const filename = app.languages[language];
  39. app.log.debug('load parser language: ' + language + ', ' + filename);
  40. self.addLanguage(language, require(filename));
  41. }
  42. });
  43. // load parser
  44. const parsers = Object.keys(app.parsers);
  45. parsers.forEach(function (parser) {
  46. if (isObject(app.parsers[parser])) {
  47. app.log.debug('inject parser: ' + parser);
  48. self.addParser(parser, app.parsers[parser]);
  49. } else {
  50. const filename = app.parsers[parser];
  51. app.log.debug('load parser: ' + parser + ', ' + filename);
  52. self.addParser(parser, require(filename));
  53. }
  54. });
  55. // check app.options.filterBy and define the tag to filter by
  56. if (app.options.filterBy) {
  57. const tag = app.options.filterBy.split('=')[0];
  58. filterTag = tag.indexOf('api') !== -1 ? tag : null;
  59. }
  60. }
  61. /**
  62. * Inherit
  63. */
  64. util.inherits(Parser, Object);
  65. /**
  66. * Exports
  67. */
  68. module.exports = Parser;
  69. /**
  70. * Add a Language
  71. */
  72. Parser.prototype.addLanguage = function (name, language) {
  73. this.languages[name] = language;
  74. };
  75. /**
  76. * Add a Parser
  77. */
  78. Parser.prototype.addParser = function (name, parser) {
  79. this.parsers[name] = parser;
  80. };
  81. /**
  82. * Parse files in specified folder
  83. *
  84. * @param {Object} options The options used to parse and filder the files.
  85. * @param {Object[]} parsedFiles List of parsed files.
  86. * @param {String[]} parsedFilenames List of parsed files, with full path.
  87. */
  88. Parser.prototype.parseFiles = function (options, parsedFiles, parsedFilenames) {
  89. const self = this;
  90. findFiles.setPath(options.src);
  91. findFiles.setExcludeFilters(options.excludeFilters);
  92. findFiles.setIncludeFilters(options.includeFilters);
  93. const files = findFiles.search();
  94. // Parser
  95. for (let i = 0; i < files.length; i += 1) {
  96. const filename = options.src + files[i];
  97. const parsedFile = self.parseFile(filename, options.encoding);
  98. if (parsedFile) {
  99. app.log.verbose('parse file: ' + filename);
  100. parsedFiles.push(parsedFile);
  101. // only push the filename without full path to prevent information disclosure
  102. parsedFilenames.push(files[i]);
  103. }
  104. }
  105. };
  106. /**
  107. * Execute Fileparsing
  108. */
  109. Parser.prototype.parseFile = function (filename, encoding) {
  110. const self = this;
  111. if (typeof encoding === 'undefined') { encoding = 'utf8'; }
  112. app.log.debug('inspect file: ' + filename);
  113. self.filename = filename;
  114. self.extension = path.extname(filename).toLowerCase();
  115. // TODO: Not sure if this is correct. Without skipDecodeWarning we got string errors
  116. // https://github.com/apidoc/apidoc-core/pull/25
  117. const fileContent = fs.readFileSync(filename, { encoding: 'binary' });
  118. return self.parseSource(fileContent, encoding, filename);
  119. };
  120. /**
  121. * Execute Sourceparsing
  122. */
  123. Parser.prototype.parseSource = function (fileContent, encoding, filename) {
  124. const self = this;
  125. iconv.skipDecodeWarning = true;
  126. self.src = iconv.decode(fileContent, encoding);
  127. app.log.debug('size: ' + self.src.length);
  128. // unify line-breaks
  129. self.src = self.src.replace(/\r\n/g, '\n');
  130. self.blocks = [];
  131. self.indexApiBlocks = [];
  132. // determine blocks
  133. self.blocks = self._findBlocks();
  134. if (self.blocks.length === 0) { return; }
  135. app.log.debug('count blocks: ' + self.blocks.length);
  136. // determine elements in blocks
  137. self.elements = self.blocks.map(function (block, i) {
  138. const elements = self.findElements(block, filename);
  139. app.log.debug('count elements in block ' + i + ': ' + elements.length);
  140. return elements;
  141. });
  142. if (self.elements.length === 0) { return; }
  143. // determine list of blocks with API elements
  144. self.indexApiBlocks = self._findBlockWithApiGetIndex(self.elements);
  145. if (self.indexApiBlocks.length === 0) { return; }
  146. const parsedBlocks = self._parseBlockElements(self.indexApiBlocks, self.elements, filename);
  147. _sanityChecks(parsedBlocks, app.log, filename);
  148. return parsedBlocks;
  149. };
  150. /**
  151. * Parse API Elements with Plugins
  152. *
  153. * @param indexApiBlocks
  154. * @param detectedElements
  155. * @returns {Array}
  156. */
  157. Parser.prototype._parseBlockElements = function (indexApiBlocks, detectedElements, filename) {
  158. const self = this;
  159. const parsedBlocks = [];
  160. for (let i = 0; i < indexApiBlocks.length; i += 1) {
  161. const blockIndex = indexApiBlocks[i];
  162. const elements = detectedElements[blockIndex];
  163. const blockData = {
  164. global: {},
  165. local: {},
  166. };
  167. let countAllowedMultiple = 0;
  168. for (let j = 0; j < elements.length; j += 1) {
  169. const element = elements[j];
  170. const elementParser = self.parsers[element.name];
  171. if (!elementParser) {
  172. app.log.warn(`parser plugin '${element.name}' not found in block: '${blockIndex}' in file: '${filename}'`);
  173. } else if (!element.sourceName.endsWith('Example') && element.source.match(/[^\s:]\[[^\]]/)) {
  174. app.log.warn(`The use of square brackets for object properties is deprecated. Please use dot notation instead: "${element.source}"`);
  175. } else {
  176. app.log.debug('found @' + element.sourceName + ' in block: ' + blockIndex);
  177. // Deprecation warning
  178. if (elementParser.deprecated) {
  179. self.countDeprecated[element.sourceName] = self.countDeprecated[element.sourceName] ? self.countDeprecated[element.sourceName] + 1 : 1;
  180. let message = '@' + element.sourceName + ' is deprecated';
  181. if (elementParser.alternative) { message = '@' + element.sourceName + ' is deprecated, please use ' + elementParser.alternative; }
  182. if (self.countDeprecated[element.sourceName] === 1) {
  183. // show deprecated message only 1 time as warning
  184. app.log.warn(message);
  185. } else {
  186. // show deprecated message more than 1 time as verbose message
  187. app.log.verbose(message);
  188. }
  189. app.log.verbose('in file: ' + filename + ', block: ' + blockIndex);
  190. }
  191. let values = '';
  192. let preventGlobal = false;
  193. let allowMultiple = false;
  194. let pathTo = '';
  195. let attachMethod = '';
  196. try {
  197. // parse element and retrieve values
  198. values = elementParser.parse(element.content, element.source);
  199. // HINT: pathTo MUST be read after elementParser.parse, because of dynamic paths
  200. // Add all other options after parse too, in case of a custom plugin need to modify params.
  201. // check if it is allowed to add to global namespace
  202. preventGlobal = elementParser.preventGlobal === true;
  203. // allow multiple inserts into pathTo
  204. allowMultiple = true;
  205. // path to an array, where the values should be attached
  206. pathTo = '';
  207. if (elementParser.path) {
  208. if (typeof elementParser.path === 'string') { pathTo = elementParser.path; } else { pathTo = elementParser.path(); } // for dynamic paths
  209. }
  210. if (!pathTo) { throw new ParserError('pathTo is not defined in the parser file.', '', '', element.sourceName); }
  211. // method how the values should be attached (insert or push)
  212. attachMethod = elementParser.method || 'push';
  213. if (attachMethod !== 'insert' && attachMethod !== 'push') { throw new ParserError('Only push or insert are allowed parser method values.', '', '', element.sourceName); }
  214. // TODO: put this into "converters"
  215. if (values) {
  216. // Markdown.
  217. if (app.markdownParser &&
  218. elementParser.markdownFields &&
  219. elementParser.markdownFields.length > 0
  220. ) {
  221. for (let markdownIndex = 0; markdownIndex < elementParser.markdownFields.length; markdownIndex += 1) {
  222. const field = elementParser.markdownFields[markdownIndex];
  223. let value = get(values, field);
  224. if (value) {
  225. value = app.markdownParser.render(value);
  226. // remove line breaks, but not within <pre> tags
  227. value = value.replace(/(?:^|<\/pre>)[^]*?(?:<pre>|$)/g, m => {
  228. return m.replace(/(\r\n|\n|\r)/g, ' ');
  229. });
  230. value = value.trim();
  231. set(values, field, value);
  232. // TODO: Little hacky, not sure to handle this here or in template
  233. if (elementParser.markdownRemovePTags &&
  234. elementParser.markdownRemovePTags.length > 0 &&
  235. elementParser.markdownRemovePTags.indexOf(field) !== -1
  236. ) {
  237. // Remove p-Tags
  238. value = value.replace(/(<p>|<\/p>)/g, '');
  239. set(values, field, value);
  240. }
  241. }
  242. }
  243. }
  244. }
  245. } catch (e) {
  246. if (e instanceof ParameterError) {
  247. const extra = [];
  248. if (e.definition) { extra.push({ Definition: e.definition }); }
  249. if (e.example) { extra.push({ Example: e.example }); }
  250. throw new ParserError(e.message,
  251. self.filename, blockIndex + 1, element.sourceName, element.source, extra);
  252. }
  253. throw new ParserError('Undefined error.',
  254. self.filename, blockIndex + 1, element.sourceName, element.source);
  255. }
  256. if (!values) {
  257. throw new ParserError('Empty parser result.',
  258. self.filename, blockIndex + 1, element.sourceName, element.source);
  259. }
  260. if (preventGlobal) {
  261. // Check if count global namespace entries > count allowed
  262. // (e.g. @successTitle is global, but should co-exist with @apiErrorStructure)
  263. if (Object.keys(blockData.global).length > countAllowedMultiple) {
  264. throw new ParserError('Only one definition or usage is allowed in the same block.',
  265. self.filename, blockIndex + 1, element.sourceName, element.source);
  266. }
  267. }
  268. // only one global allowed per block
  269. if (pathTo === 'global' || pathTo.substr(0, 7) === 'global.') {
  270. if (allowMultiple) {
  271. countAllowedMultiple += 1;
  272. } else {
  273. if (Object.keys(blockData.global).length > 0) {
  274. throw new ParserError('Only one definition is allowed in the same block.',
  275. self.filename, blockIndex + 1, element.sourceName, element.source);
  276. }
  277. if (preventGlobal) {
  278. throw new ParserError('Only one definition or usage is allowed in the same block.',
  279. self.filename, blockIndex + 1, element.sourceName, element.source);
  280. }
  281. }
  282. }
  283. if (!blockData[pathTo]) { self._createObjectPath(blockData, pathTo, attachMethod); }
  284. const blockDataPath = self._pathToObject(pathTo, blockData);
  285. // insert Fieldvalues in Path-Array
  286. if (attachMethod === 'push') { blockDataPath.push(values); } else { extend(blockDataPath, values); }
  287. // insert Fieldvalues in Mainpath
  288. if (elementParser.extendRoot === true) { extend(blockData, values); }
  289. blockData.index = blockIndex + 1;
  290. }
  291. }
  292. if (blockData.index && blockData.index > 0) { parsedBlocks.push(blockData); }
  293. }
  294. return parsedBlocks;
  295. };
  296. /**
  297. * Create a not existing Path in an Object
  298. *
  299. * @param src
  300. * @param path
  301. * @param {String} attachMethod Create last element as object or array: 'insert', 'push'
  302. * @returns {Object}
  303. */
  304. Parser.prototype._createObjectPath = function (src, path, attachMethod) {
  305. if (!path) { return src; }
  306. const pathParts = path.split('.');
  307. let current = src;
  308. for (let i = 0; i < pathParts.length; i += 1) {
  309. const part = pathParts[i];
  310. if (!current[part]) {
  311. if (i === pathParts.length - 1 && attachMethod === 'push') { current[part] = []; } else { current[part] = {}; }
  312. }
  313. current = current[part];
  314. }
  315. return current;
  316. };
  317. /**
  318. * Return Path to Object
  319. */
  320. Parser.prototype._pathToObject = function (path, src) {
  321. if (!path) { return src; }
  322. const pathParts = path.split('.');
  323. let current = src;
  324. for (let i = 0; i < pathParts.length; i += 1) {
  325. const part = pathParts[i];
  326. current = current[part];
  327. }
  328. return current;
  329. };
  330. /**
  331. * Determine Blocks
  332. */
  333. Parser.prototype._findBlocks = function () {
  334. const self = this;
  335. const blocks = [];
  336. let src = self.src;
  337. // Replace Linebreak with Unicode
  338. src = src.replace(/\n/g, '\uffff');
  339. const regexForFile = this.languages[self.extension] || this.languages.default;
  340. let matches = regexForFile.docBlocksRegExp.exec(src);
  341. while (matches) {
  342. let block = matches[2] || matches[1];
  343. // Reverse Unicode Linebreaks
  344. block = block.replace(/\uffff/g, '\n');
  345. block = block.replace(regexForFile.inlineRegExp, '');
  346. blocks.push(block);
  347. // Find next
  348. matches = regexForFile.docBlocksRegExp.exec(src);
  349. }
  350. return blocks;
  351. };
  352. /**
  353. * Return block indexes with active API-elements
  354. *
  355. * An @apiIgnore ignores the block.
  356. * Other, non @api elements, will be ignored.
  357. */
  358. Parser.prototype._findBlockWithApiGetIndex = function (blocks) {
  359. const foundIndexes = [];
  360. // get value to filter by
  361. const valueTofilter = filterTag ? app.options.filterBy.split('=')[1] : null;
  362. for (let i = 0; i < blocks.length; i += 1) {
  363. let found = false;
  364. let isToFilterBy = false;
  365. let isDefine = false;
  366. for (let j = 0; j < blocks[i].length; j += 1) {
  367. // check apiIgnore
  368. if (blocks[i][j].name.substr(0, 9) === 'apiignore') {
  369. app.log.debug('apiIgnore found in block: ' + i);
  370. found = false;
  371. break;
  372. }
  373. // check app.options.apiprivate and apiPrivate
  374. if (!app.options.apiprivate && blocks[i][j].name.substr(0, 10) === 'apiprivate') {
  375. app.log.debug('private flag is set to false and apiPrivate found in block: ' + i);
  376. found = false;
  377. break;
  378. }
  379. // check if the user want to filter by some specific tag
  380. if (filterTag) {
  381. // we need to add all apidefine
  382. if (blocks[i][j].name.substr(0, 9) === 'apidefine') {
  383. isDefine = true;
  384. }
  385. if (blocks[i][j].name.substr(0, filterTag.length) === filterTag && blocks[i][j].content === valueTofilter) {
  386. isToFilterBy = true;
  387. }
  388. }
  389. if (blocks[i][j].name.substr(0, 3) === 'api') { found = true; }
  390. }
  391. // add block if it's apidefine or the tag is equal to the value defined in options
  392. if (filterTag) {
  393. found = found && (isToFilterBy || isDefine);
  394. }
  395. if (found) {
  396. foundIndexes.push(i);
  397. app.log.debug('api found in block: ' + i);
  398. }
  399. }
  400. return foundIndexes;
  401. };
  402. /**
  403. * Get Elements of Blocks
  404. */
  405. Parser.prototype.findElements = function (block, filename) {
  406. const elements = [];
  407. // Replace Linebreak with Unicode
  408. block = block.replace(/\n/g, '\uffff');
  409. // Elements start with @api
  410. const elementsRegExp = /(@(api\w*)\s?(.*?)(?=\uffff[\s*]*@api|$))/gm;
  411. let matches = elementsRegExp.exec(block);
  412. while (matches) {
  413. const element = {
  414. source: matches[1],
  415. name: matches[2].toLowerCase(),
  416. sourceName: matches[2],
  417. content: matches[3],
  418. };
  419. // reverse Unicode Linebreaks
  420. element.content = element.content.replace(/\uffff/g, '\n');
  421. element.source = element.source.replace(/\uffff/g, '\n');
  422. app.hook('parser-find-element-' + element.name, element, block, filename);
  423. elements.push(element);
  424. app.hook('parser-find-elements', elements, element, block, filename);
  425. // next Match
  426. matches = elementsRegExp.exec(block);
  427. }
  428. return elements;
  429. };
  430. /**
  431. * Emit warnings for inconsistent API doc elements
  432. */
  433. function _sanityChecks (parsedBlocks, log, filename) {
  434. const definedBlocksByName = {};
  435. for (const block of parsedBlocks) {
  436. if (block.global.define && block.global.define.name) {
  437. definedBlocksByName[block.global.define.name] = block;
  438. }
  439. }
  440. for (const block of parsedBlocks) {
  441. const paramFields = _paramFieldsFromBlock(block);
  442. let paramFieldsDefinedOutside = [];
  443. if (block.local.use) {
  444. for (const define of block.local.use) {
  445. const definedBlock = definedBlocksByName[define.name];
  446. if (definedBlock) {
  447. paramFieldsDefinedOutside = paramFieldsDefinedOutside.concat(_paramFieldsFromBlock(definedBlock));
  448. }
  449. }
  450. }
  451. const urlParams = [];
  452. if (block.local.url) {
  453. // The dummy URL base is only used for parses of relative URLs.
  454. const url = new URL(block.local.url, 'https://dummy.base');
  455. // For API parameters in the URL parts delimited by `/` (e.g. `/:foo/:bar`).
  456. for (const pathnamePart of url.pathname.split('/')) {
  457. if (pathnamePart.charAt(0) === ':') {
  458. urlParams.push(pathnamePart.slice(1));
  459. }
  460. }
  461. }
  462. for (const urlParam of urlParams) {
  463. if (!paramFields.some(pf => pf.field === urlParam) && !paramFieldsDefinedOutside.some(pf => pf.field === urlParam)) {
  464. log.warn(`URL contains a parameter ':${urlParam}' that is not documented as @apiParam in @api '${block.local.title}' in file: '${filename}'`);
  465. }
  466. }
  467. if (!block.global.define) {
  468. for (const paramField of paramFields) {
  469. // Emit the warning only if the field is mandatory.
  470. if (!paramField.optional && !urlParams.some(up => up === paramField.field)) {
  471. log.warn(`@apiParam '${paramField.field}' was defined but does not appear in URL of @api '${block.local.title}' in file: '${filename}'`);
  472. }
  473. }
  474. }
  475. }
  476. }
  477. function _paramFieldsFromBlock (block) {
  478. let paramFields = [];
  479. if (block.local.parameter && block.local.parameter.fields) {
  480. // Loop all fields regardless of the field group. The default field group is `Parameter` but it could be provided by the developer.
  481. for (const key in block.local.parameter.fields) {
  482. paramFields = paramFields.concat(block.local.parameter.fields[key]);
  483. }
  484. }
  485. return paramFields;
  486. }