/** * This is a little tool to generate reference documentation of all math.js * functions under ./lib/functions. This is NO generic solution. * * The tool can parse documentation information from the block comment in the * functions code, and generate a markdown file with the documentation. */ var fs = require('fs'); var glob = require('glob'); var mkdirp = require('mkdirp'); var gutil = require('gulp-util'); // special cases for function syntax var SYNTAX = { cbrt: 'math.cbrt(x [, allRoots])', createUnit: 'math.createUnit(units)', gcd: 'math.gcd(a, b)', log: 'math.log(x [, base])', lcm: 'math.lcm(a, b)', norm: 'math.norm(x [, p])', round: 'math.round(x [, n])', complex: 'math.complex(re, im)', matrix: 'math.matrix(x)', sparse: 'math.sparse(x)', unit: 'math.unit(x)', eval: 'math.eval(expr [, scope])', parse: 'math.parse(expr [, scope])', concat: 'math.concat(a, b, c, ... [, dim])', ones: 'math.ones(m, n, p, ...)', range: 'math.range(start, end [, step])', resize: 'math.resize(x, size [, defaultValue])', subset: 'math.subset(x, index [, replacement])', splitUnit: 'math.splitUnit(unit, parts)', zeros: 'math.zeros(m, n, p, ...)', permutations: 'math.permutations(n [, k])', random: 'math.random([min, max])', randomInt: 'math.randomInt([min, max])', format: 'math.format(value [, precision])', 'import': 'math.import(object, override)', print: 'math.print(template, values [, precision])' }; var IGNORE_FUNCTIONS = { distribution: true }; var IGNORE_WARNINGS = { seeAlso: ['help', 'intersect', 'clone', 'typeof', 'chain', 'import', 'config', 'typed'], parameters: ['parser'], returns: ['forEach', 'import'] }; /** * Extract JSON documentation from the comments in a file with JavaScript code * @param {String} name Function name * @param {String} code javascript code containing a block comment * describing a math.js function * @return {Object} doc json document */ function generateDoc(name, code) { // get block comment from code var match = /\/\*([^*]|[\r\n]|(\*+([^*\/]|[\r\n])))*\*+\//.exec(code); if (!match) { return null; } // get text content inside block comment var comment = match[0].replace('/**', '') .replace('*/', '') .replace(/\n\s*\* ?/g, '\n') .replace(/\r/g, ''); var lines = comment.split('\n'), line = ''; // get next line function next () { line = lines.shift(); } // returns true if current line is empty function empty() { return !line || !line.trim(); } // returns true if there still is a current line function exists() { return line !== undefined; } // returns true if current line is a header like 'Syntax:' function isHeader() { return /^(Name|Syntax|Description|Examples|See also)/i.test(line); } // returns true if the current line starts with an annotation like @param function isAnnotation() { return /^@/.test(line); } function skipEmptyLines () { while (exists() && empty()) next(); } function stripLeadingSpaces(lines) { var spaces = null; lines.forEach(function (line) { var match = /^ +/.exec(line); var s = match && match[0] && match[0].length; if (s > 0 && (spaces == null || s < spaces)) { spaces = s; } }); if (spaces) { lines.forEach(function (line, index) { lines[index] = line.substring(spaces); }) } } function parseDescription () { var description = ''; while (exists() && !isHeader() && !isAnnotation()) { description += line + '\n'; next(); } // remove trailing returns while (description.charAt(description.length - 1) == '\n') { description = description.substring(0, description.length - 1); } doc.description = description; } function parseSyntax() { if (/^syntax/i.test(line)) { next(); skipEmptyLines(); while (exists() && !empty()) { doc.syntax.push(line); next(); } stripLeadingSpaces(doc.syntax); skipEmptyLines(); return true; } return false; } function parseWhere() { if (/^where/i.test(line)) { next(); skipEmptyLines(); while (exists() && !empty()) { doc.where.push(line); next(); } skipEmptyLines(); return true; } return false; } function parseExamples() { if (/^example/i.test(line)) { next(); skipEmptyLines(); while (exists() && (empty() || line.charAt(0) == ' ')) { doc.examples.push(line); next(); } stripLeadingSpaces(doc.examples); if (doc.examples.length > 0 && doc.examples[doc.examples.length - 1].trim() == '') { doc.examples.pop(); } skipEmptyLines(); return true; } return false; } function parseSeeAlso() { if (/^see also/i.test(line)) { next(); skipEmptyLines(); while (exists() && !empty()) { var names = line.split(','); doc.seeAlso = doc.seeAlso.concat(names.map(function (name) { return name.trim(); })); next(); } skipEmptyLines(); return true; } return false; } function trim (text) { return text.trim(); } // replace characters like '<' with HTML entities like '<' function escapeTags (text) { return text.replace(//g, '>'); } function parseParameters() { var count = 0; do { var match = /\s*@param\s*\{(.*)}\s*\[?(\w*)]?\s*(.*)?$/.exec(line); if (match) { next(); count++; var annotation = { name: match[2] || '', description: (match[3] || '').trim(), types: match[1].split('|').map(trim).map(escapeTags) }; doc.parameters.push(annotation); // TODO: this is an ugly hack to extract the default value var index = annotation.description.indexOf(']'); var defaultValue = null; if (index != -1) { defaultValue = annotation.description.substring(1, index).trim(); annotation.description = annotation.description.substring(index + 1).trim(); } // multi line description while (exists() && !empty() && /^\s{6}/.test(line)) { annotation.description += ' ' + line.trim(); next(); } if (defaultValue !== null) { annotation.description += ' Default value: ' + defaultValue + '.'; } } } while (match); return count > 0; } function parseReturns() { var match = /\s*@returns?\s*\{(.*)}\s*(.*)?$/.exec(line); if (match) { next(); doc.returns = { description: match[2] || '', types: match[1].split('|').map(trim).map(escapeTags) }; // multi line description while (exists() && !empty() && /^\s{6}/.test(line)) { doc.returns.description += ' ' + line.trim(); next(); } return true; } return false; } // initialize doc var doc = { name: name, description: '', syntax: [], where: [], examples: [], seeAlso: [], parameters: [], returns: null }; next(); skipEmptyLines(); parseDescription(); do { skipEmptyLines(); var handled = parseSyntax() || parseWhere() || parseExamples() || parseSeeAlso() || parseParameters() || parseReturns(); if (!handled) { // skip this line, no one knows what to do with it next(); } } while (exists()); return doc; } /** * Validate whether all required fields are available in given doc * @param {Object} doc * @return {String[]} issues */ function validateDoc (doc) { var issues = []; function ignore(field) { return IGNORE_WARNINGS[field].indexOf(doc.name) !== -1 } if (!doc.name) { issues.push('name missing in document'); } if (!doc.description) { issues.push('function "' + doc.name + '": description missing'); } if (!doc.syntax || doc.syntax.length == 0) { issues.push('function "' + doc.name + '": syntax missing'); } if (!doc.examples || doc.examples.length == 0) { issues.push('function "' + doc.name + '": examples missing'); } if (doc.parameters && doc.parameters.length) { doc.parameters.forEach(function (param, index) { if (!param.name || !param.name.trim()) { issues.push('function "' + doc.name + '": name missing of parameter ' + index + ''); } if (!param.description || !param.description.trim()) { issues.push('function "' + doc.name + '": description missing for parameter ' + (param.name || index)); } if (!param.types || !param.types.length) { issues.push('function "' + doc.name + '": types missing for parameter ' + (param.name || index)); } }); } else { if (!ignore('parameters')) { issues.push('function "' + doc.name + '": parameters missing'); } } if (doc.returns) { if (!doc.returns.description || !doc.returns.description.trim()) { issues.push('function "' + doc.name + '": description missing of returns'); } if (!doc.returns.types || !doc.returns.types.length) { issues.push('function "' + doc.name + '": types missing of returns'); } } else { if (!ignore('returns')) { issues.push('function "' + doc.name + '": returns missing'); } } if (!doc.seeAlso || doc.seeAlso.length == 0) { if (!ignore('seeAlso')) { issues.push('function "' + doc.name + '": seeAlso missing'); } } return issues; } /** * Generate markdown * @param {Object} doc A JSON object generated with generateDoc() * @param {Object} functions All functions, used to generate correct links * under seeAlso * @returns {string} markdown Markdown contents */ function generateMarkdown (doc, functions) { var text = ''; // TODO: should escape HTML characters in text text += '\n\n'; text += '# Function ' + doc.name + '\n\n'; text += doc.description + '\n\n\n'; if (doc.syntax && doc.syntax.length) { text += '## Syntax\n\n' + '```js\n' + doc.syntax.join('\n') + '\n```\n\n'; } if (doc.where && doc.where.length) { text += '### Where\n\n' + doc.where.join('\n') + '\n\n'; } text += '### Parameters\n\n' + 'Parameter | Type | Description\n' + '--------- | ---- | -----------\n' + doc.parameters.map(function (p) { return '`' + p.name + '` | ' + (p.types ? p.types.join(' | ') : '') + ' | ' + p.description }).join('\n') + '\n\n'; if (doc.returns) { text += '### Returns\n\n' + 'Type | Description\n' + '---- | -----------\n' + (doc.returns.types ? doc.returns.types.join(' | ') : '') + ' | ' + doc.returns.description + '\n\n\n'; } if (doc.examples && doc.examples.length) { text += '## Examples\n\n' + '```js\n' + doc.examples.join('\n') + '\n```\n\n\n'; } if (doc.seeAlso && doc.seeAlso.length) { text += '## See also\n\n' + doc.seeAlso.map(function (name) { return '[' + name + '](' + name + '.md)'; }).join(',\n') + '\n'; } return text; } /** * Iterate over all source files and generate markdown documents for each of them * @param {String} inputPath Path to /lib/ * @param {String} outputPath Path to /docs/reference/functions * @param {String} outputRoot Path to /docs/reference */ function iteratePath (inputPath, outputPath, outputRoot) { if (!fs.existsSync(outputPath)) { mkdirp.sync(outputPath); } glob(inputPath + '**/*.js', null, function (err, files) { // generate path information for each of the files var functions = {}; // TODO: change to array files.forEach(function (fullPath) { var path = fullPath.split('/'); var name = path.pop().replace(/.js$/, ''); var functionIndex = path.indexOf('function'); var category; // Note: determining whether a file is a function and what it's category // is is a bit tricky and quite specific to the structure of the code, // we reckon with some edge cases here. if (path.indexOf('docs') === -1 && functionIndex !== -1) { if (path.indexOf('expression') !== -1) { category = 'expression'; } else if (/^.\/lib\/type\/[a-zA-Z0-9_]*\/function/.test(fullPath)) { category = 'construction'; } else if (/^.\/lib\/core\/function/.test(fullPath)) { category = 'core'; } else { category = path[functionIndex + 1]; } } else if (path.join('/') === './lib/type') { // for boolean.js, number.js, string.js category = 'construction'; } if (IGNORE_FUNCTIONS[name]) { category = null; } if (category) { functions[name] = { name: name, category: category, fullPath: fullPath, relativePath: fullPath.substring(inputPath.length) }; } }); // loop over all files, generate a doc for each of them var issues = []; for (var name in functions) { if (functions.hasOwnProperty(name)) { var fn = functions[name]; var code = String(fs.readFileSync(fn.fullPath)); var isFunction = code.indexOf('exports.name') !== -1 && code.indexOf('exports.factory') !== -1 && code.indexOf('exports.path') === -1; var doc = isFunction && generateDoc(name, code); if (isFunction && doc) { fn.doc = doc; issues = issues.concat(validateDoc(doc)); var markdown = generateMarkdown(doc, functions); fs.writeFileSync(outputPath + '/' + fn.name + '.md', markdown); } else { //gutil.log('Ignoring', fn.fullPath); delete functions[name]; } } } /** * Helper function to generate a markdown list entry for a function. * Used to generate both alphabetical and categorical index pages. * @param {string} name Function name * @returns {string} Returns a markdown list entry */ function functionEntry (name) { var fn = functions[name]; var syntax = SYNTAX[name] || fn.doc && fn.doc.syntax && fn.doc.syntax[0] || name; syntax = syntax //.replace(/^math\./, '') .replace(/\s+\/\/.*$/, '') .replace(/;$/, ''); if (syntax.length < 40) { syntax = syntax.replace(/ /g, ' '); } var description = ''; if (fn.doc.description) { description = fn.doc.description.replace(/\n/g, ' ').split('.')[0] + '.'; } return '[' + syntax + '](functions/' + name + '.md) | ' + description; } /** * Change the first letter of the given string to upper case * @param {string} text */ function toCapital (text) { return text[0].toUpperCase() + text.slice(1); } var order = ['core', 'construction', 'expression']; // and then the rest function categoryIndex (entry) { var index = order.indexOf(entry); return index === -1 ? Infinity : index; } function compareAsc (a, b) { return a > b ? 1 : (a < b ? -1 : 0); } function compareCategory (a, b) { var indexA = categoryIndex(a); var indexB = categoryIndex(b); return (indexA > indexB) ? 1 : (indexA < indexB ? -1 : compareAsc(a, b)); } // generate categorical page with all functions var categories = {}; Object.keys(functions).forEach(function (name) { var fn = functions[name]; var category = categories[fn.category]; if (!category) { categories[fn.category] = {}; } categories[fn.category][name] = fn; }); var categorical = '# Function reference\n\n'; categorical += Object.keys(categories).sort(compareCategory).map(function (category) { var functions = categories[category]; return '## ' + toCapital(category) + ' functions\n\n' + 'Function | Description\n' + '---- | -----------\n' + Object.keys(functions).sort().map(functionEntry).join('\n') + '\n'; }).join('\n'); categorical += '\n\n\n\n'; fs.writeFileSync(outputRoot + 'functions.md', categorical); // output all issues if (issues.length) { issues.forEach(function (issue) { gutil.log('Warning: ' + issue); }); gutil.log(issues.length + ' warnings'); } }); } // exports exports.iteratePath = iteratePath; exports.generateDoc = generateDoc; exports.validateDoc = validateDoc; exports.generateMarkdown = generateMarkdown;