diff --git a/commands/codedocs.js b/commands/codedocs.js new file mode 100644 index 0000000..0b9601b --- /dev/null +++ b/commands/codedocs.js @@ -0,0 +1,345 @@ +// you may not need all of these but they come up a lot +import fs from "fs"; +import assert from "assert"; +import logging from '../lib/logging.js'; +import { mkdir_to, glob } from "../lib/builderator.js"; +import { create_renderer } from "../lib/docgen.js"; +import path from "path"; +import slugify from "slugify"; +import * as acorn from "acorn"; +import * as acorn_walk from "acorn-walk"; + +const log = logging.create(import.meta.url); + +export const description = "Describe your command here." + +// your command uses the npm package commander's options format +export const options = [ + ["--quiet", "Don't output the stats at the end."], + ["--readme", "README file to use as the initial page", "README.md"] +] + +// example of a positional argument, it's the 1st argument to main +export const argument = ["", "source input globs"]; + +// put required options in the required variable +export const required = [ + ["--output ", "Save to file rather than stdout."], +] + +const RENDERER = create_renderer(); +const CAPS_WORDS = ["BUG", "TODO", "WARNING", "FOOTGUN", "DEPRECATED"]; +const STATS = {total: 0, docs: 0, undoc: 0}; + +const slug = (instring) => slugify(instring, { lower: true, strict: true, trim: true}); + +/* + Strips 1 leading space from the comments, or the \s\* combinations + in traditional documentation comments. + + If we strip more it'll mess up formatting in markdown for indentation + formats. Weirdly Remarkable seems to be able to handle leading spaces pretty + well so only need to remove one space or \s\* combinations like with + traditional comment docs. +*/ +const render_comment = (comment) => { + const lines = comment.split(/\n/).map(l => l.replace(/^(\s*\*\s?|\s)/, '')); + return RENDERER.render(lines.join("\n")); +} + +/* Handy function for checking things are good and aborting. */ +const check = (test, fail_message) => { + if(!test) { + log.error(fail_message); + process.exit(1); + } +} + +const dump = (obj) => { + return JSON.stringify(obj, null, 4); +} + +class ParseWalker { + constructor(comments, code) { + this.comments = comments; + this.exported = []; + this.code = code; + } + + handle_class(root) { + const new_class = { + isa: "class", + slug: slug(root.declaration.id.name), + name: root.declaration.id.name, + line_start: root.loc.start.line, + methods: [], + } + + acorn_walk.simple(root, { + ClassDeclaration: (cls_node) => { + assert(cls_node.id.name === new_class.name, "Name of class changed!"); + new_class.range = [cls_node.start, cls_node.body.start]; + this.add_export(cls_node.id, new_class); + }, + + MethodDefinition: (meth_node) => { + const new_method = { + isa: "method", + static: meth_node.static, + async: meth_node.value.async, + generator: meth_node.value.generator, + slug: slug(`${new_class.name}-${meth_node.key.name}`), + name: meth_node.key.name, + line_start: meth_node.loc.start.line, + range: [meth_node.start, meth_node.value.body.start], + params: this.handle_params(meth_node.value.params), + comment: this.find_comment(meth_node.loc.start.line), + } + + this.has_CAPS(new_method); + + new_method.code = this.slice_code(new_method.range); + + this.update_stats(new_method); // methods can't go through add_export + new_class.methods.push(new_method); + } + }); + } + + handle_params(param_list) { + const result = []; + + for(let param of param_list) { + acorn_walk.simple(param, { + Identifier: (_node) => { + result.push({isa: "identifier", name: _node.name}); + }, + + AssignmentPattern: (_node) => { + result.push({ + isa: "assignment", + name: _node.left.name, + right: { + type: _node.right.type.toLowerCase(), + raw: _node.right.raw, + value: _node.right.value, + name: _node.right.name, + }, + }); + } + }); + } + + return result; + } + + /* + Used to add information when something is mentioned in the + comment like BUG, TODO, etc. + */ + has_CAPS(exp) { + if(exp.comment) { + exp.caps = CAPS_WORDS.filter(phrase => exp.comment.includes(phrase)); + } else { + exp.caps = []; + } + } + + update_stats(exp) { + STATS.total += 1; + + if(exp.comment) { + STATS.docs += 1; + } else { + STATS.undoc += 1; + } + } + + add_export(id, exp) { + exp.name = id.name; + exp.slug = exp.slug ? exp.slug : slug(id.name); + exp.line_start = id.loc.start.line; + exp.comment = this.find_comment(exp.line_start); + this.has_CAPS(exp); + exp.code = this.slice_code(exp.range); + this.exported.push(exp); + + this.update_stats(exp); + } + + slice_code(range) { + return this.code.slice(range[0], range[1]); + } + + handle_arrow_func(id, arrow) { + this.add_export(id, { + isa: "function", + async: arrow.async, + generator: arrow.generator, + expression: arrow.expression, + range: [id.start, arrow.body.start], + params: this.handle_params(arrow.params), + }); + } + + handle_variable(root) { + const declare = root.declaration.declarations[0]; + const id = declare.id; + const _node = declare.init; + const init_is = declare.init.type; + + if(init_is === "ArrowFunctionExpression") { + this.handle_arrow_func(id, declare.init); + } else { + this.add_export(id, { + isa: _node.type.toLowerCase(), + value: _node.value, + range: declare.range, + raw: _node.raw + }); + } + } + + /* + Find the nearest comment to this line, giving + about 2 lines of slack. + */ + find_comment(line) { + for(let c of this.comments) { + const distance = c.end - line; + if(!c.found && distance == -1) { + c.found = true; + return render_comment(c.value); + } + } + + return undefined; + } + + + /* + Returns the first comment as the file's main doc comment, or undefined if there isn't one. + */ + file_comment() { + const comment = this.comments[0]; + + if(comment && comment.start === 1) { + // kind of a hack, but find_comment will find this now + return this.find_comment(comment.end + 1); + } else { + return undefined; + } + } + + handle_export(_node) { + switch(_node.declaration.type) { + case "ClassDeclaration": + this.handle_class(_node); + break; + case "VariableDeclaration": { + this.handle_variable(_node); + break; + } + default: + console.log(">>>", _node.declaration.type); + } + } +} + +const parse_source = (source) => { + const code = fs.readFileSync(source); + + let comments = []; + + const acorn_opts = { + sourceType: "module", + ecmaVersion: "2023", + locations: true, + sourceFile: source, + ranges: true, + onComment: comments + } + + const parsed = acorn.parse(code, acorn_opts); + + comments = comments.filter(c => c.type === "Block").map(c => { + return { + start: c.loc.start.line, + end: c.loc.end.line, + value: c.value, + type: "comment", + found: false, + } + }); + + const walker = new ParseWalker(comments, code.toString()); + // acorn is stupid and they grab a reference to the functions so that _removes_ + // this from the object, instead of just...calling walker.function() like a normal person + acorn_walk.simple(parsed, { + ExportNamedDeclaration: (_node) => walker.handle_export(_node), + }); + + let comment = walker.file_comment(); + + return { + // normalize to / even on windows + source: source.replaceAll("\\", "/"), + // find the first comment for the file's comment + comment, + exports: walker.exported, + orphan_comments: walker.comments.filter(c => !c.found) + }; +} + +const normalize_name = (fname) => { + const no_slash = fname.replaceAll("\\", "/"); + + if(fname.startsWith("./")) { + return no_slash.slice(2); + } else if(fname.startsWith("/")) { + return no_slash.slice(1); + } else { + return no_slash; + } +} + + +export const main = async (source_globs, opts) => { + const index = {}; + mkdir_to(opts.output); + + for(let source of source_globs) { + const source_list = glob(source); + + for(let fname of source_list) { + const result = parse_source(fname); + + const target = `${path.join(opts.output, fname)}.json`; + mkdir_to(target); + fs.writeFileSync(target, dump(result)); + + const name = normalize_name(fname); + + index[name] = result.exports.map(e => { + return {isa: e.isa, name: e.name}; + }); + } + } + + // now write the grand index + const index_name = path.join(opts.output, "index.json"); + fs.writeFileSync(index_name, dump(index)); + + // render the README.md to the initial docs + const readme_name = path.join(opts.output, "index.html"); + const md_out = RENDERER.render(fs.readFileSync(opts.readme).toString()); + fs.writeFileSync(readme_name, md_out); + + const percent = Math.floor(100 * STATS.docs / STATS.total); + + if(!opts.quiet) { + console.log(`Total ${STATS.total}, ${percent}% documented (${STATS.docs} docs vs. ${STATS.undoc} no docs).`); + } + + process.exit(0); +} diff --git a/commands/coverage.js b/commands/coverage.js new file mode 100644 index 0000000..c46499b --- /dev/null +++ b/commands/coverage.js @@ -0,0 +1,105 @@ +import libCoverage from 'istanbul-lib-coverage'; +import libReport from 'istanbul-lib-report'; +import reports from 'istanbul-reports'; +import { glob } from "../lib/builderator.js"; +import fs from "fs"; +import v8toIstanbul from 'v8-to-istanbul'; +import assert from "assert"; +import url from "url"; +import normalize from "normalize-path"; + +export const description = "Takes the output of a nv8 coverage directory and generates a report."; + +export const argument = ["", "coverage directory"]; + +export const main = async (coverage_dir) => { + const covdir = normalize(coverage_dir); + const covpattern = `${covdir}/**/*.json`; + console.log(`Searching ${covpattern} for coverage files...`); + + const covfiles = glob(covpattern); + console.log(`Found ${covfiles.length} .json files in ${covdir}`); + + const coverage = {}; + + const excludes = [ + "node_modules", + "secrets", + "/net" + ]; + + for(const fname of covfiles) { + const data = JSON.parse(fs.readFileSync(fname)); + + // test removing just node modules + data.result = data.result.filter(x => { + if(x.url) { + // we need to do surgery on the URL because node is bad at them + let pathname = url.parse(x.url).pathname; + + // fix the URL and turn it into a file name + if(!pathname) { + return false; + } else if(pathname.startsWith("/C:")) { + // why does url not parse windows paths right? + // remove the leading / so it's parsed correctly + x.url = pathname.slice(1); + } else { + x.url = pathname; + } + + const excluded = excludes.filter(e => x.url.includes(e)); + return excluded.length === 0 && fs.existsSync(x.url); + } else { + return false; + } + }); + + // looks good, save it + if(data.result.length > 0) { + coverage[fname] = data; + } + } + + const coverageMap = libCoverage.createCoverageMap(); + console.log("After filtering, found count is:", Object.entries(coverage).length); + + for(const [fname, data] of Object.entries(coverage)) { + for(const entry of data.result) { + let converter; + const pathname = url.parse(entry.url).pathname + assert(fs.existsSync(pathname), `coverage entry in ${fname} contains ${entry.url} that doesn't exist but should`); + + converter = v8toIstanbul(pathname, 0, {source: entry.source}, path => { + const excluded = excludes.filter(e => path.includes(e)); + return excluded.length > 0; + }); + + try { + await converter.load(); + converter.applyCoverage(entry.functions); + coverageMap.merge(converter.toIstanbul()); + } catch(error) { + console.error(error, "load", entry.url); + } + } + } + + const watermarks = undefined; // used in check coverage ignored here + + const context = libReport.createContext({ + dir: "coverage", + watermarks, + coverageMap + }); + + ["text","html"].forEach(format => { + reports.create(format, { + skipEmpty: false, + skipFull: true, + maxCols: 100 + }).execute(context); + }); + + process.exit(0); +}