sverweij/dependency-cruiser

View on GitHub
src/report/mermaid.mjs

Summary

Maintainability
Test Coverage
/* eslint-disable security/detect-object-injection */
const ACORN_DUMMY_VALUE = "✖";
const REPORT_DEFAULTS = {
  minify: true,
};

const renderNode = (pNode, pText) =>
  `${pNode}["${pText.length > 0 ? pText : " "}"]`;

const renderEdge = (pFrom, pTo) => `${pFrom.node}-->${pTo.node}`;

const renderEdges = (pEdges) =>
  pEdges.map((pEdge) => renderEdge(pEdge.from, pEdge.to)).join("\n");

/**
 * @param {import('../../types/dependency-cruiser').ICruiseResult} pCruiseResult
 * @param {Map<string, string>} pNamesHashMap
 */
function convertEdgeSources(pCruiseResult, pNamesHashMap) {
  return pCruiseResult.modules.flatMap((pModule) => {
    const lFromNode = pNamesHashMap.get(pModule.source);
    const lFrom = {
      node: lFromNode,
      text: pModule.source.split("/").slice(-1)[0],
    };

    return pModule.dependencies.map((pDependency) => {
      const lToNode = pNamesHashMap.get(pDependency.resolved);
      return {
        from: lFrom,
        to: {
          node: lToNode,
          text: pDependency.resolved.split("/").slice(-1)[0],
        },
      };
    });
  });
}

const indent = (pDepth = 0, pMinify) => (pMinify ? "" : "  ".repeat(pDepth));

const renderSubgraph = (pNode, pText, pChildren, pIndent) =>
  `${pIndent}subgraph ${renderNode(pNode, pText)}
${pChildren}
${pIndent}end`;

function renderSubgraphs(pSource, pOptions, pDepth = 0) {
  return Object.keys(pSource)
    .map((pName) => {
      const lIndent = indent(pDepth, pOptions.minify);
      const source = pSource[pName];
      const children = renderSubgraphs(source.children, pOptions, pDepth + 1);
      if (children === "")
        return `${lIndent}${renderNode(source.node, source.text)}`;

      return renderSubgraph(source.node, source.text, children, lIndent);
    })
    .join("\n");
}

/**
 * @param {import('../../types/dependency-cruiser').ICruiseResult} pCruiseResult
 * @param {Map<string, string>} pNamesHashMap
 */
function convertSubgraphSources(pCruiseResult, pNamesHashMap) {
  let lTree = {};

  pCruiseResult.modules.forEach((pModule) => {
    const lPaths = pModule.source.split("/");

    lPaths.reduce((pChildren, pCurrentPath, pIndex) => {
      if (!pChildren[pCurrentPath]) {
        const node = lPaths.slice(0, pIndex + 1).join("/");
        pChildren[pCurrentPath] = {
          node: pNamesHashMap.get(node),
          text: pCurrentPath,
          children: {},
        };
      }
      return pChildren[pCurrentPath].children;
    }, lTree);
  });

  return lTree;
}

/**
 * @param {import("../../types/cruise-result").IModule[]} pModules
 * @param {Map<string, string>} pNamesHashMap
 */
function focusHighlights(pModules, pNamesHashMap) {
  const lHighLightStyle = "fill:lime,color:black";

  return pModules
    .filter(
      (pModule) =>
        pModule.matchesFocus ||
        pModule.matchesReaches ||
        pModule.matchesHighlight
    )
    .reduce((pAll, pModule) => {
      const lSource = pNamesHashMap.get(pModule.source);
      return `${pAll}\nstyle ${lSource} ${lHighLightStyle}`;
    }, "");
}

const hashToReadableNodeName = (pNode) =>
  pNode
    .replace(ACORN_DUMMY_VALUE, "__unknown__")
    .replace(/^\.$|^\.\//g, "__currentPath__")
    .replace(/^\.{2}$|^\.{2}\//g, "__prevPath__")
    .replace(/[[\]/.@~-]/g, "_");

/**
 * @param {import("../../types/cruise-result").IModule[]} pModules
 * @param {Boolean} pMinify
 * @return {Map<string, string>}
 */
function hashModuleNames(pModules, pMinify) {
  const lBase = 36;
  const lNamesHashMap = new Map();
  let lCount = 0;

  pModules.forEach((pModule) => {
    const lPaths = pModule.source.split("/");

    for (let lIndex = 0; lIndex < lPaths.length; lIndex += 1) {
      const lName = lPaths.slice(0, lIndex + 1).join("/");
      if (!lNamesHashMap.has(lName)) {
        if (pMinify) {
          // toUpperCase because otherwise we generate e.g. o-->a and x-->a
          // which are ambiguous in mermaid (o--> and x--> are edge shapes
          // whereas e.g. a--> is node 'a' + the arrow shape -->). The only
          // collision within alphanums are 'o' and 'x', so upper casing
          // saves us...
          // ref: https://mermaid.js.org/syntax/flowchart.html#new-arrow-types
          // another way to solve this would be to place a space between the
          // node name and the edge shape (x --> a) - however, this would make
          // for a larger output, running into the mermaid max source size
          // earlier
          lNamesHashMap.set(lName, lCount.toString(lBase).toUpperCase());
          lCount += 1;
        } else {
          lNamesHashMap.set(lName, hashToReadableNodeName(lName));
        }
      }
    }
  });

  return lNamesHashMap;
}

/**
 * @param {import('../../types/dependency-cruiser').ICruiseResult} pCruiseResult
 * @param {import("../../types/reporter-options").IMermaidReporterOptions} pOptions
 */
function renderMermaidSource(pCruiseResult, pOptions) {
  const lOptions = { ...REPORT_DEFAULTS, ...(pOptions || {}) };
  const lNamesHashMap = hashModuleNames(pCruiseResult.modules, lOptions.minify);
  const lSubgraphs = convertSubgraphSources(pCruiseResult, lNamesHashMap);
  const lEdges = convertEdgeSources(pCruiseResult, lNamesHashMap);

  return `flowchart LR

${renderSubgraphs(lSubgraphs, lOptions)}
${renderEdges(lEdges)}
${focusHighlights(pCruiseResult.modules, lNamesHashMap)}`;
}

/**
 * mermaid reporter
 *
 * @param {import('../../types/dependency-cruiser').ICruiseResult} pCruiseResult
 * @param {import("../../types/reporter-options").IMermaidReporterOptions} pOptions
 * @return {import('../../types/dependency-cruiser').IReporterOutput}
 */
export default function mermaid(pCruiseResult, pOptions) {
  return {
    output: renderMermaidSource(pCruiseResult, pOptions),
    exitCode: 0,
  };
}