Skip to content

Commit 3fba378

Browse files
authored
Merge pull request #271 from ysk8hori/refactor/mermaid-file-split
refactor: mermaidify.tsを4つのファイルに分割して保守性を向上
2 parents 2814d37 + 8d2f7e8 commit 3fba378

File tree

9 files changed

+366
-324
lines changed

9 files changed

+366
-324
lines changed

src/cli/entry.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ program
6262
'Specify the relative path to the config file (from cwd or specified by -d, --dir). Default is .tsgrc.json.',
6363
)
6464
.option('--vue', '(experimental) Enable Vue.js support')
65-
.option('--stdout', 'Output both dependency graph (Mermaid) and code metrics (JSON) to stdout');
65+
.option(
66+
'--stdout',
67+
'Output both dependency graph (Mermaid) and code metrics (JSON) to stdout',
68+
);
6669
program.parse();
6770

6871
const opt = program.opts<OptionValues>();

src/feature/graph/utils.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Graph, Node } from './models';
1+
import type { Graph, Node, Relation } from './models';
22
import {
33
getUniqueNodes,
44
getUniqueRelations,
@@ -30,43 +30,56 @@ export function updateChangeStatusFromDiff(base: Graph, head: Graph): void {
3030
const { nodes: baseNodes, relations: baseRelations } = base;
3131
const { nodes: headNodes, relations: headRelations } = head;
3232

33-
headNodes.forEach(current => {
34-
for (const baseNode of baseNodes) {
35-
if (!isSameNode(baseNode, current)) {
36-
baseNode.changeStatus = 'deleted';
37-
break;
38-
}
33+
updateNodeChangeStatus(baseNodes, headNodes);
34+
updateRelationChangeStatus(baseRelations, headRelations);
35+
}
36+
37+
function updateNodeChangeStatus(baseNodes: Node[], headNodes: Node[]): void {
38+
// Mark nodes as deleted if they exist in base but not in head
39+
baseNodes.forEach(baseNode => {
40+
const existsInHead = headNodes.some(headNode =>
41+
isSameNode(baseNode, headNode),
42+
);
43+
if (!existsInHead) {
44+
baseNode.changeStatus = 'deleted';
3945
}
4046
});
4147

42-
baseNodes.forEach(current => {
43-
for (const headNode of headNodes) {
44-
if (!isSameNode(headNode, current)) {
45-
headNode.changeStatus = 'created';
46-
break;
47-
}
48+
// Mark nodes as created if they exist in head but not in base
49+
headNodes.forEach(headNode => {
50+
const existsInBase = baseNodes.some(baseNode =>
51+
isSameNode(headNode, baseNode),
52+
);
53+
if (!existsInBase) {
54+
headNode.changeStatus = 'created';
4855
}
4956
});
57+
}
5058

51-
headRelations.forEach(current => {
52-
for (const baseRelation of baseRelations) {
53-
if (
54-
!isSameRelation(baseRelation, current) &&
55-
baseRelation.kind === 'depends_on'
56-
) {
59+
function updateRelationChangeStatus(
60+
baseRelations: Relation[],
61+
headRelations: Relation[],
62+
): void {
63+
// Mark relations as deleted if they exist in base but not in head
64+
baseRelations.forEach(baseRelation => {
65+
if (baseRelation.kind === 'depends_on') {
66+
const existsInHead = headRelations.some(headRelation =>
67+
isSameRelation(baseRelation, headRelation),
68+
);
69+
if (!existsInHead) {
5770
baseRelation.changeStatus = 'deleted';
5871
}
5972
}
6073
});
6174

62-
baseRelations.forEach(current => {
63-
for (const headRelation of headRelations) {
64-
if (
65-
!isSameRelation(headRelation, current) &&
66-
headRelation.kind === 'depends_on'
67-
) {
75+
// Mark relations as created if they exist in head but not in base
76+
headRelations.forEach(headRelation => {
77+
if (headRelation.kind === 'depends_on') {
78+
const existsInBase = baseRelations.some(baseRelation =>
79+
isSameRelation(headRelation, baseRelation),
80+
);
81+
if (!existsInBase) {
6882
headRelation.changeStatus = 'created';
69-
break;
7083
}
7184
}
7285
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import path from 'path';
2+
import type { Node } from '../graph/models';
3+
4+
/** ディレクトリツリーを表現するオブジェクト */
5+
export interface DirAndNodesTree {
6+
currentDir: string;
7+
nodes: Node[];
8+
children: DirAndNodesTree[];
9+
}
10+
11+
interface DirAndNodes {
12+
currentDir: string;
13+
dirHierarchy: string[];
14+
nodes: Node[];
15+
}
16+
17+
/**
18+
* Graph からディレクトリツリーを再現した DirAndNodesTree の配列を生成する
19+
*/
20+
export function createDirAndNodesTree(nodes: Node[]): DirAndNodesTree[] {
21+
const uniqueDirectories = getUniqueDirectories(nodes);
22+
const dirAndNodes = createDirAndNodesMapping(uniqueDirectories, nodes);
23+
24+
return buildDirectoryTree(dirAndNodes);
25+
}
26+
27+
function getDirectoryPath(filePath: string): string | undefined {
28+
const pathSegments = filePath.split('/');
29+
30+
if (pathSegments.includes('node_modules')) {
31+
return 'node_modules';
32+
}
33+
34+
if (pathSegments.length === 1) {
35+
return undefined;
36+
}
37+
38+
return path.join(...pathSegments.slice(0, -1));
39+
}
40+
41+
function getUniqueDirectories(nodes: Node[]): string[] {
42+
const allDirectories = nodes
43+
.map(({ path }) => getDirectoryPath(path))
44+
.filter((dir): dir is string => dir !== undefined)
45+
.flatMap(dirPath => {
46+
const segments = dirPath.split('/');
47+
return segments.reduce<string[]>((acc, _segment, index) => {
48+
const currentPath = segments.slice(0, index + 1).join('/');
49+
acc.push(currentPath);
50+
return acc;
51+
}, []);
52+
});
53+
54+
return [...new Set(allDirectories)];
55+
}
56+
57+
function createDirAndNodesMapping(
58+
directories: string[],
59+
nodes: Node[],
60+
): DirAndNodes[] {
61+
return directories.map(currentDir => ({
62+
currentDir,
63+
dirHierarchy: currentDir.split('/'),
64+
nodes: nodes.filter(node => getDirectoryPath(node.path) === currentDir),
65+
}));
66+
}
67+
68+
function isDirectChild(
69+
parentHierarchy: string[],
70+
candidateHierarchy: string[],
71+
): boolean {
72+
return (
73+
parentHierarchy.length === candidateHierarchy.length - 1 &&
74+
parentHierarchy.every((segment, i) => segment === candidateHierarchy[i])
75+
);
76+
}
77+
78+
function buildDirectoryTree(dirAndNodes: DirAndNodes[]): DirAndNodesTree[] {
79+
function buildRecursive(dirAndNode: DirAndNodes): DirAndNodesTree[] {
80+
const { currentDir, nodes, dirHierarchy } = dirAndNode;
81+
const children = dirAndNodes.filter(item =>
82+
isDirectChild(dirHierarchy, item.dirHierarchy),
83+
);
84+
85+
if (nodes.length === 0 && children.length <= 1) {
86+
return children.flatMap(buildRecursive);
87+
}
88+
89+
return [
90+
{
91+
currentDir,
92+
nodes,
93+
children: children.flatMap(buildRecursive),
94+
},
95+
];
96+
}
97+
98+
const rootDirectories = dirAndNodes.filter(
99+
item => item.dirHierarchy.length === 1,
100+
);
101+
return rootDirectories.flatMap(buildRecursive);
102+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getConfig } from '../../setting/config';
2+
3+
export function fileNameToMermaidId(fileName: string): string {
4+
return getConfig().reservedMermaidKeywords.reduce(
5+
(prev, [from, to]) => prev.replaceAll(from, to),
6+
fileName.split(/@|\[|\]|-|>|<|{|}|\(|\)|=|&|\|~|,|"|%|\^|\*|_/).join('//'),
7+
);
8+
}
9+
10+
export function fileNameToMermaidName(fileName: string): string {
11+
return fileName.split(/"/).join('//');
12+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import type { Graph, Node, Relation } from '../graph/models';
2+
import type { OptionValues } from '../../setting/model';
3+
import type { DirAndNodesTree } from './directoryTreeBuilder';
4+
import { fileNameToMermaidId, fileNameToMermaidName } from './mermaidUtils';
5+
6+
type Options = Omit<OptionValues, 'watchMetrics'> & {
7+
rootDir: string;
8+
};
9+
10+
const indent = ' ';
11+
const CLASSNAME_DIR = 'dir';
12+
const CLASSNAME_HIGHLIGHT = 'highlight';
13+
const CLASSNAME_CREATED = 'created';
14+
const CLASSNAME_MODIFIED = 'modified';
15+
const CLASSNAME_DELETED = 'deleted';
16+
17+
export function writeFlowchartDirection(
18+
write: (arg: string) => void,
19+
options: Pick<Options, 'LR' | 'TB'>,
20+
) {
21+
if (options.LR) {
22+
write(`flowchart LR\n`);
23+
} else if (options.TB) {
24+
write(`flowchart TB\n`);
25+
} else {
26+
write(`flowchart\n`);
27+
}
28+
}
29+
30+
export function writeClassDefinitions(
31+
write: (arg: string) => void,
32+
graph: Graph,
33+
options: Pick<Options, 'abstraction' | 'highlight'>,
34+
) {
35+
if (options.abstraction) {
36+
write(`${indent}classDef ${CLASSNAME_DIR} fill:#0000,stroke:#999\n`);
37+
}
38+
39+
if (options.highlight) {
40+
write(`${indent}classDef ${CLASSNAME_HIGHLIGHT} fill:yellow,color:black\n`);
41+
}
42+
43+
const changeStatusClassDefs = [
44+
{
45+
status: 'created' as const,
46+
className: CLASSNAME_CREATED,
47+
style: 'fill:cyan,stroke:#999,color:black',
48+
},
49+
{
50+
status: 'modified' as const,
51+
className: CLASSNAME_MODIFIED,
52+
style: 'fill:yellow,stroke:#999,color:black',
53+
},
54+
{
55+
status: 'deleted' as const,
56+
className: CLASSNAME_DELETED,
57+
style:
58+
'fill:dimgray,stroke:#999,color:black,stroke-dasharray: 4 4,stroke-width:2px;',
59+
},
60+
];
61+
62+
changeStatusClassDefs.forEach(({ status, className, style }) => {
63+
if (graph.nodes.some(node => node.changeStatus === status)) {
64+
write(`${indent}classDef ${className} ${style}\n`);
65+
}
66+
});
67+
}
68+
69+
export function writeRelations(write: (arg: string) => void, graph: Graph) {
70+
graph.relations
71+
.map(relation => ({
72+
...relation,
73+
from: {
74+
...relation.from,
75+
mermaidId: fileNameToMermaidId(relation.from.path),
76+
},
77+
to: {
78+
...relation.to,
79+
mermaidId: fileNameToMermaidId(relation.to.path),
80+
},
81+
}))
82+
.forEach(relation => {
83+
const connectionStyle = getConnectionStyle(relation);
84+
write(
85+
` ${relation.from.mermaidId}${connectionStyle}${relation.to.mermaidId}`,
86+
);
87+
write('\n');
88+
});
89+
}
90+
91+
function getConnectionStyle(relation: Relation): string {
92+
if (relation.kind === 'rename_to') {
93+
return `-.->|"rename to"|`;
94+
} else if (relation.changeStatus === 'deleted') {
95+
return `-.->`;
96+
} else {
97+
return `-->`;
98+
}
99+
}
100+
101+
export function writeFileNodesWithSubgraph(
102+
write: (arg: string) => void,
103+
trees: DirAndNodesTree[],
104+
) {
105+
trees.forEach(tree => addGraph(write, tree));
106+
}
107+
108+
function addGraph(
109+
write: (arg: string) => void,
110+
tree: DirAndNodesTree,
111+
indentNumber = 0,
112+
parent?: string,
113+
) {
114+
const currentIndent = indent.repeat(indentNumber + 1);
115+
const displayName = parent
116+
? tree.currentDir.replace(parent, '')
117+
: tree.currentDir;
118+
119+
writeSubgraphStart(write, currentIndent, tree.currentDir, displayName);
120+
writeNodes(write, currentIndent, tree.nodes);
121+
writeChildGraphs(write, tree, indentNumber);
122+
writeSubgraphEnd(write, currentIndent);
123+
}
124+
125+
function writeSubgraphStart(
126+
write: (arg: string) => void,
127+
currentIndent: string,
128+
currentDir: string,
129+
displayName: string,
130+
) {
131+
write(
132+
`${currentIndent}subgraph ${fileNameToMermaidId(currentDir)}["${fileNameToMermaidName(displayName)}"]\n`,
133+
);
134+
}
135+
136+
function writeNodes(
137+
write: (arg: string) => void,
138+
currentIndent: string,
139+
nodes: Node[],
140+
) {
141+
nodes
142+
.map(node => ({ ...node, mermaidId: fileNameToMermaidId(node.path) }))
143+
.forEach(node => {
144+
const classString = getNodeClassString(node);
145+
write(
146+
`${currentIndent}${indent}${node.mermaidId}["${fileNameToMermaidName(node.name)}"]${classString}\n`,
147+
);
148+
});
149+
}
150+
151+
function getNodeClassString(node: Node & { mermaidId: string }): string {
152+
if (node.highlight) {
153+
return `:::${CLASSNAME_HIGHLIGHT}`;
154+
}
155+
156+
if (node.isDirectory) {
157+
return `:::${CLASSNAME_DIR}`;
158+
}
159+
160+
const statusClassMap = {
161+
created: CLASSNAME_CREATED,
162+
modified: CLASSNAME_MODIFIED,
163+
deleted: CLASSNAME_DELETED,
164+
not_modified: undefined,
165+
} as const;
166+
167+
const className = statusClassMap[node.changeStatus];
168+
return className ? `:::${className}` : '';
169+
}
170+
171+
function writeChildGraphs(
172+
write: (arg: string) => void,
173+
tree: DirAndNodesTree,
174+
indentNumber: number,
175+
) {
176+
tree.children.forEach(child =>
177+
addGraph(write, child, indentNumber + 1, tree.currentDir),
178+
);
179+
}
180+
181+
function writeSubgraphEnd(write: (arg: string) => void, currentIndent: string) {
182+
write(`${currentIndent}end\n`);
183+
}

0 commit comments

Comments
 (0)