@anirudw/repolens 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +34 -0
- package/README.md +42 -0
- package/bin/repolens.js +6 -0
- package/package.json +42 -0
- package/src/cli/commands.ts +112 -0
- package/src/cli/index.ts +4 -0
- package/src/graph/analyzer/pagerank.ts +31 -0
- package/src/graph/index.ts +2 -0
- package/src/graph/network.ts +148 -0
- package/src/parser/index.ts +32 -0
- package/src/parser/strategies/java.ts +154 -0
- package/src/parser/strategies/javascript.ts +199 -0
- package/src/parser/strategies/markdown.ts +83 -0
- package/src/parser/strategies/python.ts +184 -0
- package/src/parser/types.ts +18 -0
- package/src/renderers/json/exporter.ts +56 -0
- package/src/scanner/file-types.ts +42 -0
- package/src/scanner/ignore.ts +58 -0
- package/src/scanner/index.ts +9 -0
- package/src/scanner/walker.ts +125 -0
- package/src/utils/colors.ts +13 -0
- package/tests/fixtures/dummy-repo/README.md +5 -0
- package/tests/fixtures/dummy-repo/secret-keys.py~ +0 -0
- package/tests/fixtures/dummy-repo/src/App.java +11 -0
- package/tests/fixtures/dummy-repo/src/api.py +9 -0
- package/tests/fixtures/dummy-repo/src/components/App.tsx +0 -0
- package/tests/fixtures/dummy-repo/src/index.js +7 -0
- package/tests/parser.test.ts +36 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Publish to NPM
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout code
|
|
14
|
+
uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Set up Node.js
|
|
17
|
+
uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '20.x'
|
|
20
|
+
registry-url: 'https://registry.npmjs.org/'
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: npm ci
|
|
24
|
+
|
|
25
|
+
- name: Run tests
|
|
26
|
+
run: npm run test
|
|
27
|
+
|
|
28
|
+
- name: Build project
|
|
29
|
+
run: npm run build
|
|
30
|
+
|
|
31
|
+
- name: Publish to NPM
|
|
32
|
+
run: npm publish --access public
|
|
33
|
+
env:
|
|
34
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# repolens
|
|
2
|
+
|
|
3
|
+
A CLI tool to visualize repository dependency graphs by parsing ASTs across multiple languages.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Multi-language support**: JavaScript, TypeScript, Python, Java, and Markdown
|
|
8
|
+
- **Smart scanning**: Respects `.gitignore` rules and common build directories
|
|
9
|
+
- **Dependency analysis**: Builds a graph of imports and references
|
|
10
|
+
- **Multiple outputs**: Terminal summaries, JSON export, and interactive HTML visualization
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
npm run build
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Scan current directory
|
|
23
|
+
repolens
|
|
24
|
+
|
|
25
|
+
# Scan specific directory
|
|
26
|
+
repolens ./path/to/repo
|
|
27
|
+
|
|
28
|
+
# With verbose output
|
|
29
|
+
repolens --verbose
|
|
30
|
+
|
|
31
|
+
# Output formats
|
|
32
|
+
repolens --format json --output graph.json
|
|
33
|
+
repolens --format html --output graph.html
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Supported Languages
|
|
37
|
+
|
|
38
|
+
- JavaScript/JSX (.js, .jsx)
|
|
39
|
+
- TypeScript/TSX (.ts, .tsx)
|
|
40
|
+
- Python (.py)
|
|
41
|
+
- Java (.java)
|
|
42
|
+
- Markdown (.md)
|
package/bin/repolens.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@anirudw/repolens",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A CLI tool to visualize repository dependency graphs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"repolens": "./bin/repolens.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/cli/index.ts",
|
|
12
|
+
"test": "vitest",
|
|
13
|
+
"lint": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"repository",
|
|
17
|
+
"graph",
|
|
18
|
+
"dependencies",
|
|
19
|
+
"cli"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^12.1.0",
|
|
25
|
+
"ignore": "^5.3.1",
|
|
26
|
+
"picocolors": "^1.0.1",
|
|
27
|
+
"remark": "^15.0.1",
|
|
28
|
+
"remark-parse": "^11.0.0",
|
|
29
|
+
"tree-sitter": "^0.21.1",
|
|
30
|
+
"tree-sitter-java": "^0.21.0",
|
|
31
|
+
"tree-sitter-javascript": "^0.21.2",
|
|
32
|
+
"tree-sitter-python": "^0.21.0",
|
|
33
|
+
"unist-util-visit": "^5.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/mdast": "^4.0.4",
|
|
37
|
+
"@types/node": "^20.11.0",
|
|
38
|
+
"tsx": "^4.7.0",
|
|
39
|
+
"typescript": "^5.3.3",
|
|
40
|
+
"vitest": "^1.2.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { scanDirectory } from "../scanner/index.js";
|
|
5
|
+
import { createParser } from "../parser/index.js";
|
|
6
|
+
import { Graph, analyzeGraph } from "../graph/index.js";
|
|
7
|
+
import { exportGraphToJson } from "../renderers/json/exporter.js";
|
|
8
|
+
import type { ParsedFile } from "../parser/types.js";
|
|
9
|
+
import type { ScanResult } from "../scanner/walker.js";
|
|
10
|
+
import { pc } from "../utils/colors.js";
|
|
11
|
+
|
|
12
|
+
export function createCommand(): Command {
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name("repolens")
|
|
17
|
+
.description("Visualize repository dependency graphs")
|
|
18
|
+
.version("0.1.0")
|
|
19
|
+
.argument("[path]", "Directory to scan", process.cwd())
|
|
20
|
+
.option("-v, --verbose", "Enable verbose output", false)
|
|
21
|
+
.option(
|
|
22
|
+
"-f, --format <format>",
|
|
23
|
+
"Output format (text, json)",
|
|
24
|
+
"text"
|
|
25
|
+
)
|
|
26
|
+
.option("-o, --output <file>", "Output file path")
|
|
27
|
+
.action(async (path, options) => {
|
|
28
|
+
const scanResult = scanDirectory({ rootDir: path, verbose: options.verbose });
|
|
29
|
+
|
|
30
|
+
if (options.verbose) {
|
|
31
|
+
console.log(pc.dim("\nParsing files..."));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parsedFiles: ParsedFile[] = [];
|
|
35
|
+
for (const file of scanResult.files) {
|
|
36
|
+
const parser = createParser(file.absolutePath);
|
|
37
|
+
if (parser) {
|
|
38
|
+
try {
|
|
39
|
+
const content = readFileSync(file.absolutePath, "utf-8");
|
|
40
|
+
const parsed = await parser.parse(file.absolutePath, content);
|
|
41
|
+
parsedFiles.push(parsed);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (options.verbose) {
|
|
44
|
+
console.warn(`Warning: Failed to parse ${file.relativePath}: ${err}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const graph = new Graph(parsedFiles);
|
|
51
|
+
const rankedNodes = analyzeGraph(graph);
|
|
52
|
+
|
|
53
|
+
if (options.format === "json") {
|
|
54
|
+
const outputPath = options.output ?? resolve(process.cwd(), "repolens-graph.json");
|
|
55
|
+
await exportGraphToJson(graph, parsedFiles, outputPath);
|
|
56
|
+
console.log(`Graph exported to ${outputPath}`);
|
|
57
|
+
} else {
|
|
58
|
+
printSummary(scanResult, rankedNodes, options.verbose);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return program;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function printSummary(
|
|
66
|
+
result: ScanResult,
|
|
67
|
+
rankedNodes: ReturnType<typeof analyzeGraph>,
|
|
68
|
+
verbose: boolean
|
|
69
|
+
): void {
|
|
70
|
+
console.log(pc.bold("\nRepository Scan Summary\n"));
|
|
71
|
+
console.log(`Total files found: ${pc.cyan(result.totalFiles.toString())}`);
|
|
72
|
+
console.log(`Files ignored: ${result.ignoredCount}\n`);
|
|
73
|
+
|
|
74
|
+
console.log(pc.bold("Files by language:"));
|
|
75
|
+
const langColors: Record<string, (s: string) => string> = {
|
|
76
|
+
javascript: pc.yellow,
|
|
77
|
+
typescript: pc.blue,
|
|
78
|
+
python: pc.green,
|
|
79
|
+
java: pc.red,
|
|
80
|
+
markdown: pc.magenta,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const [lang, count] of Object.entries(result.filesByLanguage)) {
|
|
84
|
+
if (count > 0) {
|
|
85
|
+
const color = langColors[lang] ?? pc.white;
|
|
86
|
+
console.log(` ${color(`• ${lang}`)}: ${pc.bold(count.toString())}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const topHubs = rankedNodes.filter((n) => n.inboundEdges > 0).slice(0, 3);
|
|
91
|
+
if (topHubs.length > 0) {
|
|
92
|
+
console.log(pc.bold("\nTop Hubs (Most Connected):"));
|
|
93
|
+
for (const hub of topHubs) {
|
|
94
|
+
const badge = hub.metadata?.heuristics?.isReact ? " [React]" : "";
|
|
95
|
+
console.log(
|
|
96
|
+
` ${pc.cyan(hub.relativePath)}${badge}: ${pc.bold(hub.inboundEdges.toString())} inbound`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (verbose && result.files.length > 0) {
|
|
102
|
+
console.log(pc.bold("\nScanned files:"));
|
|
103
|
+
for (const file of result.files.slice(0, 50)) {
|
|
104
|
+
console.log(` ${pc.dim(file.relativePath)}`);
|
|
105
|
+
}
|
|
106
|
+
if (result.files.length > 50) {
|
|
107
|
+
console.log(` ${pc.dim(`... and ${result.files.length - 50} more`)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log();
|
|
112
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Graph, type GraphNode } from "../network.js";
|
|
2
|
+
|
|
3
|
+
export interface RankedNode extends GraphNode {
|
|
4
|
+
centrality: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function analyzeGraph(graph: Graph): RankedNode[] {
|
|
8
|
+
const nodes = graph.getNodes();
|
|
9
|
+
const ranked: RankedNode[] = [];
|
|
10
|
+
|
|
11
|
+
for (const [, node] of nodes) {
|
|
12
|
+
let centrality = node.inboundEdges;
|
|
13
|
+
|
|
14
|
+
if (node.metadata?.heuristics?.isReact) {
|
|
15
|
+
centrality += 5;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (node.metadata?.heuristics?.hasMainMethod) {
|
|
19
|
+
centrality += 5;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ranked.push({
|
|
23
|
+
...node,
|
|
24
|
+
centrality,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ranked.sort((a, b) => b.centrality - a.centrality);
|
|
29
|
+
|
|
30
|
+
return ranked;
|
|
31
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { resolve, dirname, join } from "node:path";
|
|
2
|
+
import type { ParsedFile, ExtractedDependency } from "../parser/types.js";
|
|
3
|
+
|
|
4
|
+
export interface GraphNode {
|
|
5
|
+
id: string;
|
|
6
|
+
relativePath: string;
|
|
7
|
+
language: string;
|
|
8
|
+
metadata: ParsedFile["metadata"];
|
|
9
|
+
inboundEdges: number;
|
|
10
|
+
outboundEdges: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GraphEdge {
|
|
14
|
+
source: string;
|
|
15
|
+
target: string;
|
|
16
|
+
type: ExtractedDependency["type"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class Graph {
|
|
20
|
+
private nodes: Map<string, GraphNode> = new Map();
|
|
21
|
+
private edges: GraphEdge[] = [];
|
|
22
|
+
|
|
23
|
+
constructor(files: ParsedFile[]) {
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
this.nodes.set(file.id, {
|
|
26
|
+
id: file.id,
|
|
27
|
+
relativePath: file.relativePath,
|
|
28
|
+
language: file.language,
|
|
29
|
+
metadata: file.metadata,
|
|
30
|
+
inboundEdges: 0,
|
|
31
|
+
outboundEdges: 0,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.resolveEdges(files);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private resolveEdges(files: ParsedFile[]): void {
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const dir = dirname(file.id);
|
|
41
|
+
|
|
42
|
+
for (const dep of file.dependencies) {
|
|
43
|
+
if (!dep.rawSpecifier) continue;
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
dep.rawSpecifier.startsWith("http") ||
|
|
47
|
+
dep.rawSpecifier.startsWith("//") ||
|
|
48
|
+
dep.rawSpecifier.startsWith("#") ||
|
|
49
|
+
!this.isLocalImport(dep.rawSpecifier)
|
|
50
|
+
) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const resolvedPath = this.resolveImportPath(
|
|
55
|
+
dir,
|
|
56
|
+
dep.rawSpecifier
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (resolvedPath && this.nodes.has(resolvedPath)) {
|
|
60
|
+
dep.resolvedPath = resolvedPath;
|
|
61
|
+
this.edges.push({
|
|
62
|
+
source: file.id,
|
|
63
|
+
target: resolvedPath,
|
|
64
|
+
type: dep.type,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const targetNode = this.nodes.get(resolvedPath);
|
|
68
|
+
if (targetNode) {
|
|
69
|
+
targetNode.inboundEdges++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sourceNode = this.nodes.get(file.id);
|
|
73
|
+
if (sourceNode) {
|
|
74
|
+
sourceNode.outboundEdges++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private resolveImportPath(
|
|
82
|
+
fromDir: string,
|
|
83
|
+
specifier: string
|
|
84
|
+
): string | null {
|
|
85
|
+
const candidatePaths = [
|
|
86
|
+
specifier,
|
|
87
|
+
`${specifier}.js`,
|
|
88
|
+
`${specifier}.ts`,
|
|
89
|
+
`${specifier}.jsx`,
|
|
90
|
+
`${specifier}.tsx`,
|
|
91
|
+
`${specifier}/index.js`,
|
|
92
|
+
`${specifier}/index.ts`,
|
|
93
|
+
`${specifier}/index.jsx`,
|
|
94
|
+
`${specifier}/index.tsx`,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
for (const candidate of candidatePaths) {
|
|
98
|
+
try {
|
|
99
|
+
const fullPath = resolve(fromDir, candidate);
|
|
100
|
+
if (this.nodes.has(fullPath)) {
|
|
101
|
+
return fullPath;
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private isLocalImport(specifier: string): boolean {
|
|
112
|
+
return (
|
|
113
|
+
specifier.startsWith("./") ||
|
|
114
|
+
specifier.startsWith("../") ||
|
|
115
|
+
specifier.startsWith("/")
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getNodes(): Map<string, GraphNode> {
|
|
120
|
+
return this.nodes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getEdges(): GraphEdge[] {
|
|
124
|
+
return this.edges;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getNode(id: string): GraphNode | undefined {
|
|
128
|
+
return this.nodes.get(id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getNeighbors(id: string): { inbound: GraphNode[]; outbound: GraphNode[] } {
|
|
132
|
+
const inbound: GraphNode[] = [];
|
|
133
|
+
const outbound: GraphNode[] = [];
|
|
134
|
+
|
|
135
|
+
for (const edge of this.edges) {
|
|
136
|
+
if (edge.target === id) {
|
|
137
|
+
const node = this.nodes.get(edge.source);
|
|
138
|
+
if (node) inbound.push(node);
|
|
139
|
+
}
|
|
140
|
+
if (edge.source === id) {
|
|
141
|
+
const node = this.nodes.get(edge.target);
|
|
142
|
+
if (node) outbound.push(node);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { inbound, outbound };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ParserStrategy } from "./types.js";
|
|
2
|
+
import { MarkdownParser } from "./strategies/markdown.js";
|
|
3
|
+
import { JavaScriptParser } from "./strategies/javascript.js";
|
|
4
|
+
import { PythonParser } from "./strategies/python.js";
|
|
5
|
+
import { JavaParser } from "./strategies/java.js";
|
|
6
|
+
|
|
7
|
+
const JAVASCRIPT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
|
|
8
|
+
const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"]);
|
|
9
|
+
const PYTHON_EXTENSIONS = new Set([".py"]);
|
|
10
|
+
const JAVA_EXTENSIONS = new Set([".java"]);
|
|
11
|
+
|
|
12
|
+
export function createParser(filePath: string): ParserStrategy | null {
|
|
13
|
+
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
14
|
+
|
|
15
|
+
if (JAVASCRIPT_EXTENSIONS.has(ext)) {
|
|
16
|
+
return new JavaScriptParser();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (MARKDOWN_EXTENSIONS.has(ext)) {
|
|
20
|
+
return new MarkdownParser();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (PYTHON_EXTENSIONS.has(ext)) {
|
|
24
|
+
return new PythonParser();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (JAVA_EXTENSIONS.has(ext)) {
|
|
28
|
+
return new JavaParser();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ParserStrategy, ExtractedDependency, ParsedFile } from "../types.js";
|
|
2
|
+
|
|
3
|
+
type TreeSitterParser = {
|
|
4
|
+
parse(content: string): { rootNode: TreeNode };
|
|
5
|
+
setLanguage(lang: unknown): void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
let TreeSitterParser: new () => TreeSitterParser;
|
|
9
|
+
let Java: unknown;
|
|
10
|
+
|
|
11
|
+
async function ensureLoaded(): Promise<void> {
|
|
12
|
+
if (!TreeSitterParser) {
|
|
13
|
+
const [ts, j] = await Promise.all([
|
|
14
|
+
import("tree-sitter"),
|
|
15
|
+
import("tree-sitter-java"),
|
|
16
|
+
]);
|
|
17
|
+
TreeSitterParser = ts.default as new () => TreeSitterParser;
|
|
18
|
+
Java = j.default;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type TreeNode = {
|
|
23
|
+
type: string;
|
|
24
|
+
text: string;
|
|
25
|
+
startPosition: { row: number; column: number };
|
|
26
|
+
endPosition: { row: number; column: number };
|
|
27
|
+
children: TreeNode[];
|
|
28
|
+
childForFieldName(name: string): TreeNode | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class JavaParser implements ParserStrategy {
|
|
32
|
+
private parser: TreeSitterParser | null = null;
|
|
33
|
+
|
|
34
|
+
private async ensureParser(): Promise<TreeSitterParser> {
|
|
35
|
+
if (!this.parser) {
|
|
36
|
+
await ensureLoaded();
|
|
37
|
+
this.parser = new TreeSitterParser();
|
|
38
|
+
this.parser.setLanguage(Java);
|
|
39
|
+
}
|
|
40
|
+
return this.parser;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async parse(filePath: string, content: string): Promise<ParsedFile> {
|
|
44
|
+
const parser = await this.ensureParser();
|
|
45
|
+
const tree = parser.parse(content);
|
|
46
|
+
const rootNode = tree.rootNode as TreeNode;
|
|
47
|
+
const dependencies: ExtractedDependency[] = [];
|
|
48
|
+
const heuristics: Record<string, boolean> = {};
|
|
49
|
+
|
|
50
|
+
this.extractImports(rootNode, dependencies);
|
|
51
|
+
heuristics.hasMainMethod = this.detectMainMethod(rootNode);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
id: filePath,
|
|
55
|
+
relativePath: filePath.split("/").pop() ?? filePath,
|
|
56
|
+
language: "java",
|
|
57
|
+
dependencies,
|
|
58
|
+
metadata: {
|
|
59
|
+
sizeBytes: Buffer.byteLength(content, "utf-8"),
|
|
60
|
+
heuristics,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private extractImports(node: TreeNode, dependencies: ExtractedDependency[]): void {
|
|
66
|
+
if (node.type === "import_declaration") {
|
|
67
|
+
const importText = this.getScopedIdentifierText(node);
|
|
68
|
+
if (importText) {
|
|
69
|
+
dependencies.push({
|
|
70
|
+
rawSpecifier: importText,
|
|
71
|
+
type: "import",
|
|
72
|
+
location: {
|
|
73
|
+
line: node.startPosition.row + 1,
|
|
74
|
+
column: node.startPosition.column + 1,
|
|
75
|
+
},
|
|
76
|
+
resolvedPath: importText,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const child of node.children) {
|
|
82
|
+
this.extractImports(child, dependencies);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private detectMainMethod(node: TreeNode): boolean {
|
|
87
|
+
if (node.type === "method_declaration") {
|
|
88
|
+
const nameNode = this.getChildByType(node, "identifier");
|
|
89
|
+
const modifiersNode = this.getChildByType(node, "modifiers");
|
|
90
|
+
|
|
91
|
+
if (nameNode?.text === "main") {
|
|
92
|
+
if (modifiersNode) {
|
|
93
|
+
const modifierTexts = this.getModifierTexts(modifiersNode);
|
|
94
|
+
if (modifierTexts.includes("public") && modifierTexts.includes("static")) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const child of node.children) {
|
|
102
|
+
if (this.detectMainMethod(child)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private getChildByType(node: TreeNode, type: string): TreeNode | null {
|
|
111
|
+
for (const child of node.children) {
|
|
112
|
+
if (child.type === type) return child;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private getModifierTexts(node: TreeNode): string[] {
|
|
118
|
+
const modifiers: string[] = [];
|
|
119
|
+
for (const child of node.children) {
|
|
120
|
+
if (child.type === "public" || child.type === "private" || child.type === "protected" ||
|
|
121
|
+
child.type === "static" || child.type === "final" || child.type === "abstract" ||
|
|
122
|
+
child.type === "synchronized" || child.type === "volatile" || child.type === "transient" ||
|
|
123
|
+
child.type === "native" || child.type === "strictfp") {
|
|
124
|
+
modifiers.push(child.text);
|
|
125
|
+
} else if (child.type === "annotation" || child.type === "identifier") {
|
|
126
|
+
modifiers.push(child.text);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return modifiers;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private getScopedIdentifierText(node: TreeNode): string {
|
|
133
|
+
for (const child of node.children) {
|
|
134
|
+
if (child.type === "scoped_identifier") {
|
|
135
|
+
const parts: string[] = [];
|
|
136
|
+
this.collectIdentifiers(child, parts);
|
|
137
|
+
return parts.join(".");
|
|
138
|
+
} else if (child.type === "identifier") {
|
|
139
|
+
return child.text;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private collectIdentifiers(node: TreeNode, parts: string[]): void {
|
|
146
|
+
for (const child of node.children) {
|
|
147
|
+
if (child.type === "identifier") {
|
|
148
|
+
parts.push(child.text);
|
|
149
|
+
} else if (child.type === "scoped_identifier") {
|
|
150
|
+
this.collectIdentifiers(child, parts);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|