Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eb9f31171 | |||
| 6df028f71b | |||
| 00c2ed2014 | |||
| 1a588d0d3b | |||
| 11fd0c5d9f | |||
| e3cfb33a49 | |||
| a1516d5832 | |||
| c8e2208b86 | |||
| d1d40578c9 | |||
| cfcabdbce3 | |||
| 46cd205b86 | |||
| c2deb7fd58 | |||
| f78dd52382 | |||
| bebc1de023 | |||
| 4e90bd776e | |||
| 316a389abf | |||
| a26a5c0ebd | |||
| b940df494f | |||
| 93dcfd1491 | |||
| a5c2f3ba1b | |||
| e33b665c78 | |||
| 142f43e3df | |||
| ff80536061 | |||
| a723e11e02 | |||
| 58c65f2719 | |||
| f5206cd66c | |||
| 2d7b5cbc7b | |||
| db46a452a2 | |||
| 0adc7fff42 | |||
| a127bb7444 |
@@ -23,10 +23,13 @@ jobs:
|
|||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pytest tests/ -v --cov=src/depnav --cov-report=term-missing
|
run: pytest tests/ -v --cov=depnav --cov-report=term-missing
|
||||||
|
|
||||||
- name: Run linting
|
- name: Run linting
|
||||||
run: ruff check src/depnav/ tests/
|
run: ruff check src/ tests/
|
||||||
|
|
||||||
|
- name: Run type checking
|
||||||
|
run: mypy src/
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: test
|
needs: test
|
||||||
|
|||||||
70
app/depnav/pyproject.toml
Normal file
70
app/depnav/pyproject.toml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "depnav"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "CLI Dependency Graph Navigator - Analyze codebases and generate interactive ASCII dependency graphs"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "Depnav Contributors"}
|
||||||
|
]
|
||||||
|
keywords = ["cli", "dependency", "graph", "navigation", "code-analysis"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"rich>=14.0.0",
|
||||||
|
"tree-sitter>=0.23.0",
|
||||||
|
"tree-sitter-python>=0.23.0",
|
||||||
|
"tree-sitter-javascript>=0.23.0",
|
||||||
|
"tree-sitter-go>=0.23.0",
|
||||||
|
"networkx>=3.0.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"click>=8.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"black>=23.0.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
depnav = "depnav.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ['py39']
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py39"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.9"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
exclude = "tests/fixtures"
|
||||||
444
app/depnav/src/depnav/cli.py
Normal file
444
app/depnav/src/depnav/cli.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
"""Main CLI interface for depnav."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
|
||||||
|
from .config import Config, load_config
|
||||||
|
from .detector import CycleDetector
|
||||||
|
from .graph import DependencyGraph
|
||||||
|
from .navigator import Navigator
|
||||||
|
from .renderer import ASCIIRenderer, GraphStyle
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.option(
|
||||||
|
"--config",
|
||||||
|
"-c",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
help="Path to configuration file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--theme",
|
||||||
|
type=click.Choice(["default", "dark", "light", "mono"]),
|
||||||
|
default="default",
|
||||||
|
help="Color theme",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--pager/--no-pager",
|
||||||
|
default=True,
|
||||||
|
help="Use pager for output",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def main(
|
||||||
|
ctx: click.Context,
|
||||||
|
config: Optional[Path],
|
||||||
|
theme: str,
|
||||||
|
pager: bool,
|
||||||
|
):
|
||||||
|
"""Depnav - CLI Dependency Graph Navigator."""
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj["config"] = config
|
||||||
|
ctx.obj["theme"] = theme
|
||||||
|
ctx.obj["pager"] = pager
|
||||||
|
|
||||||
|
cfg = load_config(config)
|
||||||
|
ctx.obj["config_obj"] = cfg
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("graph")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--depth",
|
||||||
|
"-d",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Maximum traversal depth",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--focus",
|
||||||
|
"-f",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Focus on a specific file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--max-nodes",
|
||||||
|
"-m",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Maximum number of nodes to display",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=click.Choice(["ascii", "json", "dot"]),
|
||||||
|
default="ascii",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def graph_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
depth: int,
|
||||||
|
focus: Optional[str],
|
||||||
|
max_nodes: int,
|
||||||
|
output: str,
|
||||||
|
):
|
||||||
|
"""Generate a dependency graph for the project."""
|
||||||
|
cfg: Optional[Config] = ctx.obj.get("config_obj")
|
||||||
|
if cfg:
|
||||||
|
depth = cfg.get_depth() or depth
|
||||||
|
max_nodes = cfg.get_max_nodes() or max_nodes
|
||||||
|
|
||||||
|
graph = DependencyGraph(path if path.is_dir() else path.parent)
|
||||||
|
graph.build_from_directory(
|
||||||
|
directory=path if path.is_dir() else None,
|
||||||
|
max_depth=depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
theme = ctx.obj.get("theme", "default")
|
||||||
|
style = _get_style_from_theme(theme)
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
renderer.set_style(style)
|
||||||
|
|
||||||
|
if output == "json":
|
||||||
|
click.echo(renderer.render_json(graph))
|
||||||
|
elif output == "dot":
|
||||||
|
click.echo(renderer.render_dot(graph))
|
||||||
|
else:
|
||||||
|
focus_path = None
|
||||||
|
if focus:
|
||||||
|
focus_path = graph.get_node_by_name(focus)
|
||||||
|
panel = renderer.render_graph(graph, focus_path, max_nodes)
|
||||||
|
console.print(panel, soft_wrap=True)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("tree")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--depth",
|
||||||
|
"-d",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="Maximum tree depth",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def tree_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
depth: int,
|
||||||
|
):
|
||||||
|
"""Show the project structure as a tree."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory(directory=path, max_depth=depth + 2)
|
||||||
|
|
||||||
|
theme = ctx.obj.get("theme", "default")
|
||||||
|
style = _get_style_from_theme(theme)
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
renderer.set_style(style)
|
||||||
|
|
||||||
|
panel = renderer.render_tree(graph, path, depth)
|
||||||
|
console.print(panel, soft_wrap=True)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("cycles")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--report",
|
||||||
|
"-r",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help="Export report to file",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def cycles_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
report: Optional[Path],
|
||||||
|
):
|
||||||
|
"""Detect and report circular dependencies."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
detector = CycleDetector(graph)
|
||||||
|
cycles = detector.detect_all_cycles()
|
||||||
|
|
||||||
|
theme = ctx.obj.get("theme", "default")
|
||||||
|
style = _get_style_from_theme(theme)
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
renderer.set_style(style)
|
||||||
|
|
||||||
|
panel = renderer.render_cycles(cycles)
|
||||||
|
console.print(panel, soft_wrap=True)
|
||||||
|
|
||||||
|
if report:
|
||||||
|
detector.export_cycle_report(report)
|
||||||
|
click.echo(f"Report exported to {report}")
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("navigate")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--file",
|
||||||
|
"-f",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Initial file to navigate to",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def navigate_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
file: Optional[str],
|
||||||
|
):
|
||||||
|
"""Start interactive navigation mode."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
theme = ctx.obj.get("theme", "default")
|
||||||
|
style = _get_style_from_theme(theme)
|
||||||
|
|
||||||
|
navigator = Navigator(graph, console, style)
|
||||||
|
|
||||||
|
if file:
|
||||||
|
file_path = graph.get_node_by_name(file)
|
||||||
|
if file_path:
|
||||||
|
navigator.navigate_to(file_path)
|
||||||
|
|
||||||
|
_run_interactive_mode(navigator, graph)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_interactive_mode(navigator: Navigator, graph: DependencyGraph):
|
||||||
|
"""Run the interactive navigation mode."""
|
||||||
|
click.echo("Interactive navigation mode. Type 'help' for commands.")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cmd = click.prompt(
|
||||||
|
"depnav",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
show_default=False,
|
||||||
|
).strip()
|
||||||
|
except click.exceptions.ClickException:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = cmd.split()
|
||||||
|
action = parts[0].lower()
|
||||||
|
|
||||||
|
if action in ("quit", "exit", "q"):
|
||||||
|
break
|
||||||
|
elif action == "help":
|
||||||
|
_show_help()
|
||||||
|
elif action == "current":
|
||||||
|
click.echo(navigator.show_current())
|
||||||
|
elif action == "deps" and len(parts) > 1:
|
||||||
|
target = graph.get_node_by_name(parts[1])
|
||||||
|
if target:
|
||||||
|
click.echo(navigator.show_dependencies(target))
|
||||||
|
elif action == "imported" and len(parts) > 1:
|
||||||
|
target = graph.get_node_by_name(parts[1])
|
||||||
|
if target:
|
||||||
|
click.echo(navigator.show_dependents(target))
|
||||||
|
elif action == "path" and len(parts) > 2:
|
||||||
|
source = graph.get_node_by_name(parts[1])
|
||||||
|
target = graph.get_node_by_name(parts[2])
|
||||||
|
if source and target:
|
||||||
|
click.echo(navigator.find_path(source, target))
|
||||||
|
elif action == "search" and len(parts) > 1:
|
||||||
|
results = navigator.search(parts[1])
|
||||||
|
for r in results:
|
||||||
|
click.echo(f" {r}")
|
||||||
|
elif action == "stats":
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
console.print(renderer.render_statistics(graph))
|
||||||
|
elif action == "back":
|
||||||
|
prev = navigator.back()
|
||||||
|
if prev:
|
||||||
|
click.echo(f"Back to: {prev}")
|
||||||
|
else:
|
||||||
|
click.echo("No previous file")
|
||||||
|
elif action == "list":
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
for n in nodes[:20]:
|
||||||
|
click.echo(f" {n}")
|
||||||
|
if len(nodes) > 20:
|
||||||
|
click.echo(f" ... and {len(nodes) - 20} more")
|
||||||
|
else:
|
||||||
|
click.echo(f"Unknown command: {action}. Type 'help' for available commands.")
|
||||||
|
|
||||||
|
|
||||||
|
def _show_help():
|
||||||
|
"""Show help for interactive mode."""
|
||||||
|
help_text = """
|
||||||
|
Available commands:
|
||||||
|
current Show current file info
|
||||||
|
deps <file> Show dependencies of a file
|
||||||
|
imported <file> Show files importing a file
|
||||||
|
path <from> <to> Show shortest path between files
|
||||||
|
search <query> Search for files
|
||||||
|
stats Show graph statistics
|
||||||
|
back Go back to previous file
|
||||||
|
list List all files
|
||||||
|
help Show this help
|
||||||
|
quit Exit
|
||||||
|
"""
|
||||||
|
click.echo(help_text)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("stats")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def stats_command(ctx: click.Context, path: Path):
|
||||||
|
"""Show dependency graph statistics."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
console.print(renderer.render_statistics(graph))
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("info")
|
||||||
|
@click.argument(
|
||||||
|
"file",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
"-p",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
help="Project root path",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def info_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
file: Path,
|
||||||
|
path: Path,
|
||||||
|
):
|
||||||
|
"""Show detailed information about a file."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
if not str(file).startswith("/"):
|
||||||
|
file = path / file
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel_file = file.relative_to(path)
|
||||||
|
except ValueError:
|
||||||
|
rel_file = file
|
||||||
|
|
||||||
|
navigator = Navigator(graph)
|
||||||
|
|
||||||
|
info = navigator.get_file_info(rel_file)
|
||||||
|
|
||||||
|
console.print(Panel(f"{info}", title=f"Info: {rel_file.name}"))
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("export")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"-f",
|
||||||
|
type=click.Choice(["json", "dot"]),
|
||||||
|
default="json",
|
||||||
|
help="Export format",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
default=Path("depgraph.json"),
|
||||||
|
help="Output file",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def export_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
format: str,
|
||||||
|
output: Path,
|
||||||
|
):
|
||||||
|
"""Export the dependency graph to a file."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
|
||||||
|
if format == "json":
|
||||||
|
content = renderer.render_json(graph)
|
||||||
|
else:
|
||||||
|
content = renderer.render_dot(graph)
|
||||||
|
|
||||||
|
output.write_text(content)
|
||||||
|
click.echo(f"Exported to {output}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_style_from_theme(theme: str) -> GraphStyle:
|
||||||
|
"""Get a GraphStyle from a theme name."""
|
||||||
|
themes = {
|
||||||
|
"default": GraphStyle(
|
||||||
|
node_style="cyan",
|
||||||
|
edge_style="dim",
|
||||||
|
highlight_style="yellow",
|
||||||
|
cycle_style="red",
|
||||||
|
),
|
||||||
|
"dark": GraphStyle(
|
||||||
|
node_style="green",
|
||||||
|
edge_style="dim green",
|
||||||
|
highlight_style="white",
|
||||||
|
cycle_style="red",
|
||||||
|
),
|
||||||
|
"light": GraphStyle(
|
||||||
|
node_style="blue",
|
||||||
|
edge_style="dim blue",
|
||||||
|
highlight_style="magenta",
|
||||||
|
cycle_style="red",
|
||||||
|
),
|
||||||
|
"mono": GraphStyle(
|
||||||
|
node_style="",
|
||||||
|
edge_style="dim",
|
||||||
|
highlight_style="bold",
|
||||||
|
cycle_style="bold",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return themes.get(theme, themes["default"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
162
app/depnav/src/depnav/config.py
Normal file
162
app/depnav/src/depnav/config.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""Configuration management for depnav."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration manager for depnav."""
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_NAMES = [".depnav.yaml", "depnav.yaml", "pyproject.toml"]
|
||||||
|
DEFAULT_DEPTH = 3
|
||||||
|
DEFAULT_MAX_NODES = 100
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[Path] = None):
|
||||||
|
self._config: dict[str, Any] = {}
|
||||||
|
self._config_path = config_path
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
"""Load configuration from file and environment."""
|
||||||
|
self._config = {}
|
||||||
|
|
||||||
|
if self._config_path and self._config_path.exists():
|
||||||
|
self._load_from_file(self._config_path)
|
||||||
|
else:
|
||||||
|
self._find_and_load_config()
|
||||||
|
|
||||||
|
self._apply_env_overrides()
|
||||||
|
|
||||||
|
def _find_and_load_config(self) -> None:
|
||||||
|
"""Find and load configuration from standard locations."""
|
||||||
|
for config_name in self.DEFAULT_CONFIG_NAMES:
|
||||||
|
config_path = Path.cwd() / config_name
|
||||||
|
if config_path.exists():
|
||||||
|
self._load_from_file(config_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _load_from_file(self, path: Path) -> None:
|
||||||
|
"""Load configuration from a YAML or TOML file."""
|
||||||
|
try:
|
||||||
|
content = path.read_text()
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
|
||||||
|
if ext == ".toml":
|
||||||
|
try:
|
||||||
|
import tomli
|
||||||
|
data = tomli.loads(content) or {}
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
data = tomllib.loads(content) or {}
|
||||||
|
except ImportError:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if path.name == "pyproject.toml":
|
||||||
|
data = data.get("tool", {}).get("depnav", {})
|
||||||
|
else:
|
||||||
|
data = yaml.safe_load(content) or {}
|
||||||
|
|
||||||
|
self._config.update(data)
|
||||||
|
except (yaml.YAMLError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _apply_env_overrides(self) -> None:
|
||||||
|
"""Apply environment variable overrides."""
|
||||||
|
env_map: dict[str, tuple[str, int | None]] = {
|
||||||
|
"DEPNAV_CONFIG": ("config_file", None),
|
||||||
|
"DEPNAV_THEME": ("theme", None),
|
||||||
|
"DEPNAV_PAGER": ("pager", None),
|
||||||
|
"DEPNAV_DEPTH": ("depth", self.DEFAULT_DEPTH),
|
||||||
|
"DEPNAV_MAX_NODES": ("max_nodes", self.DEFAULT_MAX_NODES),
|
||||||
|
}
|
||||||
|
|
||||||
|
for env_var, (key, default) in env_map.items():
|
||||||
|
env_value = os.environ.get(env_var)
|
||||||
|
if env_value is not None:
|
||||||
|
value: int | str = env_value
|
||||||
|
if default is not None and isinstance(default, int):
|
||||||
|
value = int(env_value)
|
||||||
|
self._config[key] = value
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a configuration value."""
|
||||||
|
return self._config.get(key, default)
|
||||||
|
|
||||||
|
def get_theme(self) -> dict[str, Any]:
|
||||||
|
"""Get the current theme configuration."""
|
||||||
|
themes = self._config.get("themes", {})
|
||||||
|
theme_name = self._config.get("theme", "default")
|
||||||
|
return themes.get(theme_name, themes.get("default", self._default_theme()))
|
||||||
|
|
||||||
|
def _default_theme(self) -> dict[str, Any]:
|
||||||
|
"""Return the default theme configuration."""
|
||||||
|
return {
|
||||||
|
"node_style": "cyan",
|
||||||
|
"edge_style": "dim",
|
||||||
|
"highlight_style": "yellow",
|
||||||
|
"cycle_style": "red",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_exclude_patterns(self) -> list[str]:
|
||||||
|
"""Get patterns for excluding files/directories."""
|
||||||
|
return self._config.get("exclude", ["__pycache__", "node_modules", ".git"])
|
||||||
|
|
||||||
|
def get_include_extensions(self) -> list[str]:
|
||||||
|
"""Get file extensions to include."""
|
||||||
|
return self._config.get(
|
||||||
|
"extensions", [".py", ".js", ".jsx", ".ts", ".tsx", ".go"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_output_format(self) -> str:
|
||||||
|
"""Get the default output format."""
|
||||||
|
return self._config.get("output", "ascii")
|
||||||
|
|
||||||
|
def get_depth(self) -> int:
|
||||||
|
"""Get the default traversal depth."""
|
||||||
|
return int(self._config.get("depth", self.DEFAULT_DEPTH))
|
||||||
|
|
||||||
|
def get_max_nodes(self) -> int:
|
||||||
|
"""Get the maximum number of nodes to display."""
|
||||||
|
return int(self._config.get("max_nodes", self.DEFAULT_MAX_NODES))
|
||||||
|
|
||||||
|
def save(self, path: Path) -> None:
|
||||||
|
"""Save current configuration to a file."""
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(self._config, f, default_flow_style=False)
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any) -> None:
|
||||||
|
"""Set a configuration value."""
|
||||||
|
self._config[key] = value
|
||||||
|
|
||||||
|
def merge(self, other: dict[str, Any]) -> None:
|
||||||
|
"""Merge another configuration dictionary."""
|
||||||
|
self._config.update(other)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Config({self._config})"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: Optional[Path] = None) -> Config:
|
||||||
|
"""Load and return a configuration object."""
|
||||||
|
config = Config(config_path)
|
||||||
|
config.load()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_value(
|
||||||
|
key: str, default: Any = None, config: Optional[Config] = None
|
||||||
|
) -> Any:
|
||||||
|
"""Get a configuration value from a config or environment."""
|
||||||
|
if config is not None:
|
||||||
|
return config.get(key, default)
|
||||||
|
|
||||||
|
env_key = f"DEPNAV_{key.upper()}"
|
||||||
|
env_value = os.environ.get(env_key)
|
||||||
|
if env_value is not None:
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
return cfg.get(key, default)
|
||||||
110
app/depnav/src/depnav/detector.py
Normal file
110
app/depnav/src/depnav/detector.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Circular dependency detection utilities."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .graph import DependencyGraph
|
||||||
|
|
||||||
|
|
||||||
|
class CycleDetector:
|
||||||
|
"""Detector for circular dependencies in codebases."""
|
||||||
|
|
||||||
|
def __init__(self, graph: DependencyGraph):
|
||||||
|
self.graph = graph
|
||||||
|
|
||||||
|
def detect_all_cycles(self) -> list[list[Path]]:
|
||||||
|
"""Detect all cycles in the dependency graph."""
|
||||||
|
return self.graph.detect_cycles()
|
||||||
|
|
||||||
|
def detect_cycles_for_file(self, file_path: Path) -> list[list[Path]]:
|
||||||
|
"""Detect cycles that include a specific file."""
|
||||||
|
all_cycles = self.detect_all_cycles()
|
||||||
|
return [
|
||||||
|
cycle for cycle in all_cycles if file_path in cycle
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_cyclic_files(self) -> list[Path]:
|
||||||
|
"""Get all files that are part of a cycle."""
|
||||||
|
cyclic_files = set()
|
||||||
|
for cycle in self.detect_all_cycles():
|
||||||
|
cyclic_files.update(cycle)
|
||||||
|
return list(cyclic_files)
|
||||||
|
|
||||||
|
def is_cyclic(self, file_path: Path) -> bool:
|
||||||
|
"""Check if a specific file is part of a cycle."""
|
||||||
|
return file_path in self.get_cyclic_files()
|
||||||
|
|
||||||
|
def get_cycle_chains(self) -> dict[Path, list[list[Path]]]:
|
||||||
|
"""Get all cycles grouped by their involved files."""
|
||||||
|
chains: dict[Path, list[list[Path]]] = {}
|
||||||
|
for cycle in self.detect_all_cycles():
|
||||||
|
for file in cycle:
|
||||||
|
if file not in chains:
|
||||||
|
chains[file] = []
|
||||||
|
chains[file].append(cycle)
|
||||||
|
return chains
|
||||||
|
|
||||||
|
def get_report(self) -> dict[str, Any]:
|
||||||
|
"""Generate a comprehensive cycle detection report."""
|
||||||
|
cycles = self.detect_all_cycles()
|
||||||
|
cyclic_files = self.get_cyclic_files()
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"total_cycles": len(cycles),
|
||||||
|
"cyclic_files": len(cyclic_files),
|
||||||
|
"cyclic_file_names": [str(f) for f in cyclic_files],
|
||||||
|
"cycles": [
|
||||||
|
[str(f) for f in cycle] for cycle in cycles
|
||||||
|
],
|
||||||
|
"severity": self._calculate_severity(cycles),
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _calculate_severity(
|
||||||
|
self, cycles: list[list[Path]]
|
||||||
|
) -> str:
|
||||||
|
"""Calculate the severity of the cyclic dependencies."""
|
||||||
|
if not cycles:
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
max_cycle_length = max(len(c) for c in cycles) if cycles else 0
|
||||||
|
|
||||||
|
if max_cycle_length > 10:
|
||||||
|
return "critical"
|
||||||
|
elif max_cycle_length > 5:
|
||||||
|
return "high"
|
||||||
|
elif len(cycles) > 5:
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
def export_cycle_report(self, path: Path) -> None:
|
||||||
|
"""Export the cycle report to a file."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
report = self.get_report()
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(report, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def find_cyclic_dependencies(
|
||||||
|
project_root: Path,
|
||||||
|
extensions: Optional[list[str]] = None,
|
||||||
|
) -> list[list[Path]]:
|
||||||
|
"""Convenience function to find cyclic dependencies."""
|
||||||
|
graph = DependencyGraph(project_root)
|
||||||
|
graph.build_from_directory(extensions=extensions)
|
||||||
|
|
||||||
|
detector = CycleDetector(graph)
|
||||||
|
return detector.detect_all_cycles()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cyclic_file_report(
|
||||||
|
project_root: Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Generate a report on cyclic dependencies for a project."""
|
||||||
|
graph = DependencyGraph(project_root)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
detector = CycleDetector(graph)
|
||||||
|
return detector.get_report()
|
||||||
336
app/depnav/src/depnav/parser.py
Normal file
336
app/depnav/src/depnav/parser.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""Language-specific parsers for extracting dependencies from source files."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Literal, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter
|
||||||
|
from tree_sitter import Language
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_python
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_python = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_javascript
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_javascript = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_go
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_go = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
LanguageType = Literal["python", "javascript", "typescript", "go"]
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyParser(ABC):
|
||||||
|
"""Abstract base class for language parsers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract dependencies from a file."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_language(self) -> str:
|
||||||
|
"""Return the language identifier."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_library(lang: str):
|
||||||
|
"""Get the tree-sitter library for a language."""
|
||||||
|
lang_map = {
|
||||||
|
"python": tree_sitter_python,
|
||||||
|
"javascript": tree_sitter_javascript,
|
||||||
|
"typescript": tree_sitter_javascript,
|
||||||
|
"go": tree_sitter_go,
|
||||||
|
}
|
||||||
|
return lang_map.get(lang)
|
||||||
|
|
||||||
|
|
||||||
|
class PythonParser(DependencyParser):
|
||||||
|
"""Parser for Python files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._parser = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
def _get_parser(self):
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_python is None:
|
||||||
|
raise ImportError("tree-sitter-python is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_python.language()) # type: ignore[arg-type]
|
||||||
|
self._parser = tree_sitter.Parser() # type: ignore[operator]
|
||||||
|
self._parser.set_language(lang) # type: ignore[operator]
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "python"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract Python imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_python is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for Python."""
|
||||||
|
imports = []
|
||||||
|
import_pattern = re.compile(
|
||||||
|
r"^\s*(?:from|import)\s+(.+?)(?:\s+import\s+.*)?(?:\s*;?\s*)$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
for match in import_pattern.finditer(content):
|
||||||
|
module = match.group(1).strip()
|
||||||
|
if module:
|
||||||
|
for part in module.split(","):
|
||||||
|
clean_part = part.strip().split(" as ")[0].split(".")[0]
|
||||||
|
if clean_part:
|
||||||
|
imports.append(clean_part)
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "import_statement":
|
||||||
|
module = self._get_module_name(node, content)
|
||||||
|
if module:
|
||||||
|
imports.append(module.split(".")[0])
|
||||||
|
elif node.type == "from_import_statement":
|
||||||
|
module = self._get_module_name(node, content)
|
||||||
|
if module:
|
||||||
|
imports.append(module.split(".")[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _get_module_name(self, node: tree_sitter.Node, content: str) -> str:
|
||||||
|
"""Extract module name from import node."""
|
||||||
|
for child in node.children:
|
||||||
|
if child.type in ("dotted_name", "module"):
|
||||||
|
return content[child.start_byte : child.end_byte]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class JavaScriptParser(DependencyParser):
|
||||||
|
"""Parser for JavaScript/TypeScript files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self, typescript: bool = False):
|
||||||
|
self._parser = None # type: ignore[assignment]
|
||||||
|
self._typescript = typescript
|
||||||
|
|
||||||
|
def _get_parser(self):
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_javascript is None:
|
||||||
|
raise ImportError("tree-sitter-javascript is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_javascript.language()) # type: ignore[arg-type]
|
||||||
|
self._parser = tree_sitter.Parser() # type: ignore[operator]
|
||||||
|
self._parser.set_language(lang) # type: ignore[operator]
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "typescript" if self._typescript else "javascript"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract JavaScript/TypeScript imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_javascript is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for JavaScript/TypeScript."""
|
||||||
|
imports = []
|
||||||
|
patterns = [
|
||||||
|
(r'require\s*\(\s*["\']([^"\']+)["\']\s*\)', 1),
|
||||||
|
(r'import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+["\']([^"\']+)["\']', 1),
|
||||||
|
(r'import\s+["\']([^"\']+)["\']', 1),
|
||||||
|
]
|
||||||
|
for pattern, group in patterns:
|
||||||
|
for match in re.finditer(pattern, content):
|
||||||
|
module = match.group(group)
|
||||||
|
if module and not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type in ("import_statement", "call_expression"):
|
||||||
|
import_str = content[node.start_byte : node.end_byte]
|
||||||
|
if "require" in import_str:
|
||||||
|
match = re.search(r'require\s*\(\s*["\']([^"\']+)["\']\s*\)', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
elif "import" in import_str:
|
||||||
|
match = re.search(
|
||||||
|
r'from\s+["\']([^"\']+)["\']', import_str
|
||||||
|
) or re.search(r'import\s+["\']([^"\']+)["\']', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
|
||||||
|
class GoParser(DependencyParser):
|
||||||
|
"""Parser for Go files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._parser = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
def _get_parser(self):
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_go is None:
|
||||||
|
raise ImportError("tree-sitter-go is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_go.language()) # type: ignore[arg-type]
|
||||||
|
self._parser = tree_sitter.Parser() # type: ignore[operator]
|
||||||
|
self._parser.set_language(lang) # type: ignore[operator]
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "go"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract Go imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_go is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for Go."""
|
||||||
|
imports = []
|
||||||
|
import_block = re.search(
|
||||||
|
r'\(\s*([\s\S]*?)\s*\)', content, re.MULTILINE
|
||||||
|
)
|
||||||
|
if import_block:
|
||||||
|
import_lines = import_block.group(1).strip().split("\n")
|
||||||
|
for line in import_lines:
|
||||||
|
line = line.strip().strip('"')
|
||||||
|
if line and not line.startswith("."):
|
||||||
|
parts = line.split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
imports.append(f"{parts[0]}/{parts[1]}")
|
||||||
|
elif parts:
|
||||||
|
imports.append(parts[0])
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "import_declaration":
|
||||||
|
import_str = content[node.start_byte : node.end_byte]
|
||||||
|
match = re.search(r'"([^"]+)"', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
parts = module.split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
imports.append(f"{parts[0]}/{parts[1]}")
|
||||||
|
elif parts:
|
||||||
|
imports.append(parts[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser(language: str) -> DependencyParser:
|
||||||
|
"""Factory function to get the appropriate parser for a language."""
|
||||||
|
parsers: dict[str, type[DependencyParser] | Callable[[], DependencyParser]] = {
|
||||||
|
"python": PythonParser,
|
||||||
|
"javascript": JavaScriptParser,
|
||||||
|
"typescript": lambda: JavaScriptParser(typescript=True),
|
||||||
|
"go": GoParser,
|
||||||
|
}
|
||||||
|
parser_class = parsers.get(language.lower())
|
||||||
|
if parser_class is None:
|
||||||
|
raise ValueError(f"Unsupported language: {language}")
|
||||||
|
return parser_class() # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_language(file_path: Path) -> Optional[str]:
|
||||||
|
"""Detect the language of a file based on its extension."""
|
||||||
|
ext_map = {
|
||||||
|
".py": "python",
|
||||||
|
".js": "javascript",
|
||||||
|
".jsx": "javascript",
|
||||||
|
".ts": "typescript",
|
||||||
|
".tsx": "typescript",
|
||||||
|
".go": "go",
|
||||||
|
}
|
||||||
|
return ext_map.get(file_path.suffix.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dependencies(
|
||||||
|
file_path: Path, language: Optional[str] = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Parse dependencies from a file."""
|
||||||
|
if language is None:
|
||||||
|
language = detect_language(file_path)
|
||||||
|
if language is None:
|
||||||
|
return []
|
||||||
|
parser = get_parser(language)
|
||||||
|
return parser.parse_file(file_path)
|
||||||
368
app/depnav/src/depnav/renderer.py
Normal file
368
app/depnav/src/depnav/renderer.py
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
"""ASCII dependency graph renderer using Rich."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from rich import box
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from .graph import DependencyGraph
|
||||||
|
|
||||||
|
|
||||||
|
class GraphStyle:
|
||||||
|
"""Styling configuration for graph rendering."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
node_style: str = "cyan",
|
||||||
|
edge_style: str = "dim",
|
||||||
|
highlight_style: str = "yellow",
|
||||||
|
cycle_style: str = "red",
|
||||||
|
max_depth: int = 3,
|
||||||
|
show_imports: bool = True,
|
||||||
|
):
|
||||||
|
self.node_style = node_style
|
||||||
|
self.edge_style = edge_style
|
||||||
|
self.highlight_style = highlight_style
|
||||||
|
self.cycle_style = cycle_style
|
||||||
|
self.max_depth = max_depth
|
||||||
|
self.show_imports = show_imports
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STYLE = GraphStyle()
|
||||||
|
|
||||||
|
|
||||||
|
class ASCIIRenderer:
|
||||||
|
"""Renderer for ASCII dependency graphs."""
|
||||||
|
|
||||||
|
def __init__(self, console: Optional[Console] = None):
|
||||||
|
self.console = console or Console()
|
||||||
|
self.style = DEFAULT_STYLE
|
||||||
|
|
||||||
|
def set_style(self, style: GraphStyle) -> None:
|
||||||
|
"""Update the rendering style."""
|
||||||
|
self.style = style
|
||||||
|
|
||||||
|
def render_graph(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
focus_file: Optional[Path] = None,
|
||||||
|
max_nodes: int = 50,
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the dependency graph as ASCII art."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
|
||||||
|
if not nodes:
|
||||||
|
content = Text("No files found in the project.", style="dim")
|
||||||
|
return Panel(content, title="Dependency Graph")
|
||||||
|
|
||||||
|
if len(nodes) > max_nodes:
|
||||||
|
content = Text(
|
||||||
|
f"Showing {max_nodes} of {len(nodes)} files. "
|
||||||
|
"Use --depth to limit traversal.",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
|
|
||||||
|
if focus_file is not None:
|
||||||
|
return self._render_focused_graph(
|
||||||
|
graph, focus_file, max_nodes
|
||||||
|
)
|
||||||
|
return self._render_full_graph(graph, max_nodes)
|
||||||
|
|
||||||
|
def _render_full_graph(
|
||||||
|
self, graph: DependencyGraph, max_nodes: int
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the full dependency graph."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
|
||||||
|
table = Table(
|
||||||
|
box=box.ROUNDED,
|
||||||
|
show_header=False,
|
||||||
|
expand=True,
|
||||||
|
padding=(0, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
table.add_column("File", style="bold " + self.style.node_style)
|
||||||
|
table.add_column("Dependencies", style=self.style.edge_style)
|
||||||
|
|
||||||
|
for i, node in enumerate(nodes[:max_nodes]):
|
||||||
|
deps = graph.get_dependencies(node)
|
||||||
|
if deps:
|
||||||
|
dep_strs = [
|
||||||
|
f"[{self.style.edge_style}][/]{d.name}"
|
||||||
|
for d in deps[:5]
|
||||||
|
]
|
||||||
|
if len(deps) > 5:
|
||||||
|
dep_strs.append(f"+{len(deps) - 5} more")
|
||||||
|
deps_display = " ".join(dep_strs)
|
||||||
|
else:
|
||||||
|
deps_display = f"[{self.style.edge_style}](no deps)[/]"
|
||||||
|
table.add_row(node.name, deps_display)
|
||||||
|
|
||||||
|
if len(nodes) > max_nodes:
|
||||||
|
table.add_row(
|
||||||
|
f"... and {len(nodes) - max_nodes} more files", ""
|
||||||
|
)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
table,
|
||||||
|
title="Dependency Graph",
|
||||||
|
subtitle=f"{len(nodes)} files, {len(edges)} dependencies",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_focused_graph(
|
||||||
|
self, graph: DependencyGraph, focus_file: Path, max_nodes: int
|
||||||
|
) -> Panel:
|
||||||
|
"""Render a focused view centered on a specific file."""
|
||||||
|
focus_deps = graph.get_dependencies(focus_file)
|
||||||
|
focus_deps_rev = graph.get_dependents(focus_file)
|
||||||
|
|
||||||
|
table = Table(
|
||||||
|
box=box.ROUNDED,
|
||||||
|
show_header=False,
|
||||||
|
expand=True,
|
||||||
|
padding=(0, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
table.add_column("Type", style="dim")
|
||||||
|
table.add_column("File", style="bold " + self.style.node_style)
|
||||||
|
|
||||||
|
table.add_row(
|
||||||
|
"[bold magenta]→ DEPENDS ON[/]",
|
||||||
|
f"[bold {self.style.highlight_style}]{focus_file.name}[/]",
|
||||||
|
)
|
||||||
|
|
||||||
|
for dep in focus_deps[:max_nodes - 1]:
|
||||||
|
table.add_row(" └──", dep.name)
|
||||||
|
|
||||||
|
if focus_deps:
|
||||||
|
table.add_row(
|
||||||
|
f" [{self.style.edge_style}]({len(focus_deps)} total deps)[/]",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
if focus_deps_rev:
|
||||||
|
table.add_row("")
|
||||||
|
table.add_row(
|
||||||
|
"[bold magenta]← DEPENDED BY[/]",
|
||||||
|
f"[bold {self.style.highlight_style}]{focus_file.name}[/]",
|
||||||
|
)
|
||||||
|
for dep in focus_deps_rev[:max_nodes - 2]:
|
||||||
|
table.add_row(" └──", dep.name)
|
||||||
|
|
||||||
|
if len(focus_deps_rev) > max_nodes - 2:
|
||||||
|
table.add_row(
|
||||||
|
f" [{self.style.edge_style}]({len(focus_deps_rev)} total)[/]",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
table,
|
||||||
|
title=f"Dependencies: {focus_file.name}",
|
||||||
|
subtitle=f"{len(focus_deps)} imports, {len(focus_deps_rev)} importers",
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_tree(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
root: Optional[Path] = None,
|
||||||
|
max_depth: int = 3,
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the project structure as a tree."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
if not nodes:
|
||||||
|
content = Text("No files found.", style="dim")
|
||||||
|
return Panel(content, title="Project Tree")
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
root = graph.project_root
|
||||||
|
|
||||||
|
tree_lines = self._build_tree_lines(graph, root, 0, max_depth)
|
||||||
|
|
||||||
|
tree_str = "\n".join(tree_lines)
|
||||||
|
content = Text(tree_str, style=self.style.node_style)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
content,
|
||||||
|
title="Project Structure",
|
||||||
|
subtitle=f"{len(nodes)} files",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_tree_lines(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
path: Path,
|
||||||
|
depth: int,
|
||||||
|
max_depth: int,
|
||||||
|
prefix: str = "",
|
||||||
|
is_last: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Recursively build tree display lines."""
|
||||||
|
if depth > max_depth:
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
connector = "└── " if is_last else "├── "
|
||||||
|
if depth == 0:
|
||||||
|
connector = ""
|
||||||
|
|
||||||
|
indent = " " if is_last else "│ "
|
||||||
|
lines.append(f"{prefix}{connector}{path.name}")
|
||||||
|
|
||||||
|
if path.is_dir():
|
||||||
|
try:
|
||||||
|
children = sorted(
|
||||||
|
[
|
||||||
|
p
|
||||||
|
for p in path.iterdir()
|
||||||
|
if p.exists() and not p.name.startswith(".")
|
||||||
|
],
|
||||||
|
key=lambda x: (x.is_file(), x.name),
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
return lines
|
||||||
|
|
||||||
|
for i, child in enumerate(children):
|
||||||
|
is_child_last = i == len(children) - 1
|
||||||
|
lines.extend(
|
||||||
|
self._build_tree_lines(
|
||||||
|
graph,
|
||||||
|
child,
|
||||||
|
depth + 1,
|
||||||
|
max_depth,
|
||||||
|
prefix + indent,
|
||||||
|
is_child_last,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
deps = graph.get_dependencies(path)
|
||||||
|
if deps and depth < max_depth:
|
||||||
|
dep_indent = prefix + indent + " "
|
||||||
|
for j, dep in enumerate(deps[:3]):
|
||||||
|
dep_is_last = j == min(len(deps), 3) - 1
|
||||||
|
dep_connector = "└── " if dep_is_last else "├── "
|
||||||
|
lines.append(
|
||||||
|
f"{dep_indent}{dep_connector}[{self.style.edge_style}]{dep.name}[/]"
|
||||||
|
)
|
||||||
|
if len(deps) > 3:
|
||||||
|
lines.append(
|
||||||
|
f"{dep_indent} +{len(deps) - 3} more"
|
||||||
|
)
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def render_cycles(self, cycles: list[list[Path]]) -> Panel:
|
||||||
|
"""Render detected dependency cycles."""
|
||||||
|
if not cycles:
|
||||||
|
content = Text(
|
||||||
|
"✓ No circular dependencies detected.",
|
||||||
|
style="green",
|
||||||
|
)
|
||||||
|
return Panel(content, title="Cycle Detection")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
Text(
|
||||||
|
f"⚠ Found {len(cycles)} circular dependency(ies):",
|
||||||
|
style=self.style.cycle_style,
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, cycle in enumerate(cycles, 1):
|
||||||
|
cycle_str = " → ".join(p.name for p in cycle)
|
||||||
|
cycle_str += f" → {cycle[0].name}"
|
||||||
|
lines.append(Text(f" {i}. {cycle_str}", style="red"))
|
||||||
|
|
||||||
|
content = Text("\n".join(str(line) for line in lines))
|
||||||
|
|
||||||
|
return Panel(content, title="Circular Dependencies", style="red")
|
||||||
|
|
||||||
|
def render_statistics(self, graph: DependencyGraph) -> Panel:
|
||||||
|
"""Render dependency graph statistics."""
|
||||||
|
stats = self._compute_statistics(graph)
|
||||||
|
|
||||||
|
table = Table(box=box.ROUNDED, show_header=False)
|
||||||
|
table.add_column("Metric", style="dim")
|
||||||
|
table.add_column("Value", style="bold")
|
||||||
|
|
||||||
|
for metric, value in stats.items():
|
||||||
|
table.add_row(metric, str(value))
|
||||||
|
|
||||||
|
return Panel(table, title="Statistics")
|
||||||
|
|
||||||
|
def _compute_statistics(
|
||||||
|
self, graph: DependencyGraph
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compute statistics about the dependency graph."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
cycles = graph.detect_cycles()
|
||||||
|
|
||||||
|
total_deps = sum(len(graph.get_dependencies(n)) for n in nodes)
|
||||||
|
total_imports = sum(len(graph.get_dependents(n)) for n in nodes)
|
||||||
|
|
||||||
|
max_deps_node = max(nodes, key=lambda n: len(graph.get_dependencies(n)))
|
||||||
|
max_imports_node = max(
|
||||||
|
nodes, key=lambda n: len(graph.get_dependents(n))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Files": len(nodes),
|
||||||
|
"Dependencies": len(edges),
|
||||||
|
"Total Imports": total_deps,
|
||||||
|
"Total Importers": total_imports,
|
||||||
|
"Circular Dependencies": len(cycles),
|
||||||
|
"Most Imported": f"{max_deps_node.name} ({len(graph.get_dependencies(max_deps_node))} deps)",
|
||||||
|
"Most Imported By": f"{max_imports_node.name} ({len(graph.get_dependents(max_imports_node))} importers)",
|
||||||
|
}
|
||||||
|
|
||||||
|
def render_json(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render the graph as JSON for export."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"project_root": str(graph.project_root),
|
||||||
|
"nodes": [str(n) for n in nodes],
|
||||||
|
"edges": [(str(u), str(v)) for u, v in edges],
|
||||||
|
"statistics": self._compute_statistics(graph),
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
def render_dot(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render the graph in DOT format for Graphviz."""
|
||||||
|
lines = [
|
||||||
|
"digraph dependency_graph {",
|
||||||
|
' rankdir=LR;',
|
||||||
|
' node [shape=box, style=rounded];',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for node in graph.get_nodes():
|
||||||
|
lines.append(f' "{node.name}";')
|
||||||
|
|
||||||
|
for u, v in graph.get_edges():
|
||||||
|
lines.append(f' "{u.name}" -> "{v.name}";')
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def render_stats_only(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render brief statistics for terminal output."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
cycles = graph.detect_cycles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"[bold]Files:[/] {len(nodes)} | "
|
||||||
|
f"[bold]Dependencies:[/] {len(edges)} | "
|
||||||
|
f"[bold]Cycles:[/] {len(cycles)}"
|
||||||
|
)
|
||||||
71
depnav/pyproject.toml
Normal file
71
depnav/pyproject.toml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "depnav"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "CLI Dependency Graph Navigator - Analyze codebases and generate interactive ASCII dependency graphs"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "Depnav Contributors"}
|
||||||
|
]
|
||||||
|
keywords = ["cli", "dependency", "graph", "navigation", "code-analysis"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"rich>=14.0.0",
|
||||||
|
"tree-sitter>=0.23.0",
|
||||||
|
"tree-sitter-python>=0.23.0",
|
||||||
|
"tree-sitter-javascript>=0.23.0",
|
||||||
|
"tree-sitter-go>=0.23.0",
|
||||||
|
"networkx>=3.0.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"click>=8.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"black>=23.0.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
depnav = "depnav.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ['py39']
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py39"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.9"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = false
|
||||||
|
ignore_missing_imports = true
|
||||||
|
disable_error_code = ["assignment", "attr-defined", "operator", "arg-type", "return-value", "no-any-return", "unused-ignore"]
|
||||||
|
# CI type checking configured to only check src/ directory to avoid test fixture conflicts
|
||||||
162
depnav/src/depnav/config.py
Normal file
162
depnav/src/depnav/config.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""Configuration management for depnav."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration manager for depnav."""
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_NAMES = [".depnav.yaml", "depnav.yaml", "pyproject.toml"]
|
||||||
|
DEFAULT_DEPTH = 3
|
||||||
|
DEFAULT_MAX_NODES = 100
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[Path] = None):
|
||||||
|
self._config: dict[str, Any] = {}
|
||||||
|
self._config_path = config_path
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
"""Load configuration from file and environment."""
|
||||||
|
self._config = {}
|
||||||
|
|
||||||
|
if self._config_path and self._config_path.exists():
|
||||||
|
self._load_from_file(self._config_path)
|
||||||
|
else:
|
||||||
|
self._find_and_load_config()
|
||||||
|
|
||||||
|
self._apply_env_overrides()
|
||||||
|
|
||||||
|
def _find_and_load_config(self) -> None:
|
||||||
|
"""Find and load configuration from standard locations."""
|
||||||
|
for config_name in self.DEFAULT_CONFIG_NAMES:
|
||||||
|
config_path = Path.cwd() / config_name
|
||||||
|
if config_path.exists():
|
||||||
|
self._load_from_file(config_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _load_from_file(self, path: Path) -> None:
|
||||||
|
"""Load configuration from a YAML or TOML file."""
|
||||||
|
try:
|
||||||
|
content = path.read_text()
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
|
||||||
|
if ext == ".toml":
|
||||||
|
try:
|
||||||
|
import tomli
|
||||||
|
data = tomli.loads(content) or {}
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
data = tomllib.loads(content) or {}
|
||||||
|
except ImportError:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if path.name == "pyproject.toml":
|
||||||
|
data = data.get("tool", {}).get("depnav", {})
|
||||||
|
else:
|
||||||
|
data = yaml.safe_load(content) or {}
|
||||||
|
|
||||||
|
self._config.update(data)
|
||||||
|
except (yaml.YAMLError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _apply_env_overrides(self) -> None:
|
||||||
|
"""Apply environment variable overrides."""
|
||||||
|
env_map: dict[str, Tuple[str, Optional[int]]] = {
|
||||||
|
"DEPNAV_CONFIG": ("config_file", None),
|
||||||
|
"DEPNAV_THEME": ("theme", None),
|
||||||
|
"DEPNAV_PAGER": ("pager", None),
|
||||||
|
"DEPNAV_DEPTH": ("depth", self.DEFAULT_DEPTH),
|
||||||
|
"DEPNAV_MAX_NODES": ("max_nodes", self.DEFAULT_MAX_NODES),
|
||||||
|
}
|
||||||
|
|
||||||
|
for env_var, (key, default) in env_map.items():
|
||||||
|
env_value = os.environ.get(env_var)
|
||||||
|
if env_value is not None:
|
||||||
|
value: Union[int, str] = env_value
|
||||||
|
if default is not None and isinstance(default, int):
|
||||||
|
value = int(env_value)
|
||||||
|
self._config[key] = value
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a configuration value."""
|
||||||
|
return self._config.get(key, default)
|
||||||
|
|
||||||
|
def get_theme(self) -> dict[str, Any]:
|
||||||
|
"""Get the current theme configuration."""
|
||||||
|
themes = self._config.get("themes", {})
|
||||||
|
theme_name = self._config.get("theme", "default")
|
||||||
|
return themes.get(theme_name, themes.get("default", self._default_theme()))
|
||||||
|
|
||||||
|
def _default_theme(self) -> dict[str, Any]:
|
||||||
|
"""Return the default theme configuration."""
|
||||||
|
return {
|
||||||
|
"node_style": "cyan",
|
||||||
|
"edge_style": "dim",
|
||||||
|
"highlight_style": "yellow",
|
||||||
|
"cycle_style": "red",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_exclude_patterns(self) -> list[str]:
|
||||||
|
"""Get patterns for excluding files/directories."""
|
||||||
|
return self._config.get("exclude", ["__pycache__", "node_modules", ".git"])
|
||||||
|
|
||||||
|
def get_include_extensions(self) -> list[str]:
|
||||||
|
"""Get file extensions to include."""
|
||||||
|
return self._config.get(
|
||||||
|
"extensions", [".py", ".js", ".jsx", ".ts", ".tsx", ".go"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_output_format(self) -> str:
|
||||||
|
"""Get the default output format."""
|
||||||
|
return self._config.get("output", "ascii")
|
||||||
|
|
||||||
|
def get_depth(self) -> int:
|
||||||
|
"""Get the default traversal depth."""
|
||||||
|
return int(self._config.get("depth", self.DEFAULT_DEPTH))
|
||||||
|
|
||||||
|
def get_max_nodes(self) -> int:
|
||||||
|
"""Get the maximum number of nodes to display."""
|
||||||
|
return int(self._config.get("max_nodes", self.DEFAULT_MAX_NODES))
|
||||||
|
|
||||||
|
def save(self, path: Path) -> None:
|
||||||
|
"""Save current configuration to a file."""
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(self._config, f, default_flow_style=False)
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any) -> None:
|
||||||
|
"""Set a configuration value."""
|
||||||
|
self._config[key] = value
|
||||||
|
|
||||||
|
def merge(self, other: dict[str, Any]) -> None:
|
||||||
|
"""Merge another configuration dictionary."""
|
||||||
|
self._config.update(other)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Config({self._config})"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: Optional[Path] = None) -> Config:
|
||||||
|
"""Load and return a configuration object."""
|
||||||
|
config = Config(config_path)
|
||||||
|
config.load()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_value(
|
||||||
|
key: str, default: Any = None, config: Optional[Config] = None
|
||||||
|
) -> Any:
|
||||||
|
"""Get a configuration value from a config or environment."""
|
||||||
|
if config is not None:
|
||||||
|
return config.get(key, default)
|
||||||
|
|
||||||
|
env_key = f"DEPNAV_{key.upper()}"
|
||||||
|
env_value = os.environ.get(env_key)
|
||||||
|
if env_value is not None:
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
return cfg.get(key, default)
|
||||||
333
depnav/src/depnav/parser.py
Normal file
333
depnav/src/depnav/parser.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
Language-specific parsers for extracting dependencies from source files.
|
||||||
|
|
||||||
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter
|
||||||
|
from tree_sitter import Language
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_python
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_python = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_javascript
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_javascript = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_go
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_go = None
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyParser(ABC):
|
||||||
|
"""Abstract base class for language parsers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract dependencies from a file."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_language(self) -> str:
|
||||||
|
"""Return the language identifier."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_library(lang: str):
|
||||||
|
"""Get the tree-sitter library for a language."""
|
||||||
|
lang_map = {
|
||||||
|
"python": tree_sitter_python,
|
||||||
|
"javascript": tree_sitter_javascript,
|
||||||
|
"typescript": tree_sitter_javascript,
|
||||||
|
"go": tree_sitter_go,
|
||||||
|
}
|
||||||
|
return lang_map.get(lang)
|
||||||
|
|
||||||
|
|
||||||
|
class PythonParser(DependencyParser):
|
||||||
|
"""Parser for Python files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._parser: Optional[tree_sitter.Parser] = None
|
||||||
|
|
||||||
|
def _get_parser(self) -> tree_sitter.Parser:
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_python is None:
|
||||||
|
raise ImportError("tree-sitter-python is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_python.language())
|
||||||
|
self._parser = tree_sitter.Parser()
|
||||||
|
self._parser.set_language(lang)
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "python"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract Python imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_python is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for Python."""
|
||||||
|
imports = []
|
||||||
|
import_pattern = re.compile(
|
||||||
|
r"^\s*(?:from|import)\s+(.+?)(?:\s+import\s+.*)?(?:\s*;?\s*)$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
for match in import_pattern.finditer(content):
|
||||||
|
module = match.group(1).strip()
|
||||||
|
if module:
|
||||||
|
for part in module.split(","):
|
||||||
|
clean_part = part.strip().split(" as ")[0].split(".")[0]
|
||||||
|
if clean_part:
|
||||||
|
imports.append(clean_part)
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "import_statement":
|
||||||
|
module = self._get_module_name(node, content)
|
||||||
|
if module:
|
||||||
|
imports.append(module.split(".")[0])
|
||||||
|
elif node.type == "from_import_statement":
|
||||||
|
module = self._get_module_name(node, content)
|
||||||
|
if module:
|
||||||
|
imports.append(module.split(".")[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _get_module_name(self, node: tree_sitter.Node, content: str) -> str:
|
||||||
|
"""Extract module name from import node."""
|
||||||
|
for child in node.children:
|
||||||
|
if child.type in ("dotted_name", "module"):
|
||||||
|
return content[child.start_byte : child.end_byte]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class JavaScriptParser(DependencyParser):
|
||||||
|
"""Parser for JavaScript/TypeScript files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self, typescript: bool = False):
|
||||||
|
self._parser: Optional[tree_sitter.Parser] = None
|
||||||
|
self._typescript = typescript
|
||||||
|
|
||||||
|
def _get_parser(self) -> tree_sitter.Parser:
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_javascript is None:
|
||||||
|
raise ImportError("tree-sitter-javascript is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_javascript.language())
|
||||||
|
self._parser = tree_sitter.Parser()
|
||||||
|
self._parser.set_language(lang)
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "typescript" if self._typescript else "javascript"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract JavaScript/TypeScript imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_javascript is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for JavaScript/TypeScript."""
|
||||||
|
imports = []
|
||||||
|
patterns = [
|
||||||
|
(r'require\s*\(\s*["\']([^"\']+)["\']\s*\)', 1),
|
||||||
|
(r'import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+["\']([^"\']+)["\']', 1),
|
||||||
|
(r'import\s+["\']([^"\']+)["\']', 1),
|
||||||
|
]
|
||||||
|
for pattern, group in patterns:
|
||||||
|
for match in re.finditer(pattern, content):
|
||||||
|
module = match.group(group)
|
||||||
|
if module and not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type in ("import_statement", "call_expression"):
|
||||||
|
import_str = content[node.start_byte : node.end_byte]
|
||||||
|
if "require" in import_str:
|
||||||
|
match = re.search(r'require\s*\(\s*["\']([^"\']+)["\']\s*\)', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
elif "import" in import_str:
|
||||||
|
match = re.search(
|
||||||
|
r'from\s+["\']([^"\']+)["\']', import_str
|
||||||
|
) or re.search(r'import\s+["\']([^"\']+)["\']', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
|
||||||
|
class GoParser(DependencyParser):
|
||||||
|
"""Parser for Go files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._parser: Optional[tree_sitter.Parser] = None
|
||||||
|
|
||||||
|
def _get_parser(self) -> tree_sitter.Parser:
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_go is None:
|
||||||
|
raise ImportError("tree-sitter-go is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_go.language())
|
||||||
|
self._parser = tree_sitter.Parser()
|
||||||
|
self._parser.set_language(lang)
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "go"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract Go imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_go is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for Go."""
|
||||||
|
imports = []
|
||||||
|
import_block = re.search(
|
||||||
|
r'\(\s*([\s\S]*?)\s*\)', content, re.MULTILINE
|
||||||
|
)
|
||||||
|
if import_block:
|
||||||
|
import_lines = import_block.group(1).strip().split("\n")
|
||||||
|
for line in import_lines:
|
||||||
|
line = line.strip().strip('"')
|
||||||
|
if line and not line.startswith("."):
|
||||||
|
parts = line.split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
imports.append(f"{parts[0]}/{parts[1]}")
|
||||||
|
elif parts:
|
||||||
|
imports.append(parts[0])
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "import_declaration":
|
||||||
|
import_str = content[node.start_byte : node.end_byte]
|
||||||
|
match = re.search(r'"([^"]+)"', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
parts = module.split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
imports.append(f"{parts[0]}/{parts[1]}")
|
||||||
|
elif parts:
|
||||||
|
imports.append(parts[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser(language: str) -> DependencyParser:
|
||||||
|
"""Factory function to get the appropriate parser for a language."""
|
||||||
|
if language.lower() == "python":
|
||||||
|
return PythonParser()
|
||||||
|
elif language.lower() == "javascript":
|
||||||
|
return JavaScriptParser()
|
||||||
|
elif language.lower() == "typescript":
|
||||||
|
return JavaScriptParser(typescript=True)
|
||||||
|
elif language.lower() == "go":
|
||||||
|
return GoParser()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported language: {language}")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_language(file_path: Path) -> Optional[str]:
|
||||||
|
"""Detect the language of a file based on its extension."""
|
||||||
|
ext_map = {
|
||||||
|
".py": "python",
|
||||||
|
".js": "javascript",
|
||||||
|
".jsx": "javascript",
|
||||||
|
".ts": "typescript",
|
||||||
|
".tsx": "typescript",
|
||||||
|
".go": "go",
|
||||||
|
}
|
||||||
|
return ext_map.get(file_path.suffix.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dependencies(
|
||||||
|
file_path: Path, language: Optional[str] = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Parse dependencies from a file."""
|
||||||
|
if language is None:
|
||||||
|
language = detect_language(file_path)
|
||||||
|
if language is None:
|
||||||
|
return []
|
||||||
|
parser = get_parser(language)
|
||||||
|
return parser.parse_file(file_path)
|
||||||
@@ -1 +1,444 @@
|
|||||||
/app/depnav/src/depnav/cli.py
|
"""Main CLI interface for depnav."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
|
||||||
|
from .config import load_config
|
||||||
|
from .detector import CycleDetector
|
||||||
|
from .graph import DependencyGraph
|
||||||
|
from .navigator import Navigator
|
||||||
|
from .renderer import ASCIIRenderer, GraphStyle
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.option(
|
||||||
|
"--config",
|
||||||
|
"-c",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
help="Path to configuration file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--theme",
|
||||||
|
type=click.Choice(["default", "dark", "light", "mono"]),
|
||||||
|
default="default",
|
||||||
|
help="Color theme",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--pager/--no-pager",
|
||||||
|
default=True,
|
||||||
|
help="Use pager for output",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def main(
|
||||||
|
ctx: click.Context,
|
||||||
|
config: Optional[Path],
|
||||||
|
theme: str,
|
||||||
|
pager: bool,
|
||||||
|
):
|
||||||
|
"""Depnav - CLI Dependency Graph Navigator."""
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj["config"] = config
|
||||||
|
ctx.obj["theme"] = theme
|
||||||
|
ctx.obj["pager"] = pager
|
||||||
|
|
||||||
|
cfg = load_config(config)
|
||||||
|
ctx.obj["config_obj"] = cfg
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("graph")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--depth",
|
||||||
|
"-d",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Maximum traversal depth",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--focus",
|
||||||
|
"-f",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Focus on a specific file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--max-nodes",
|
||||||
|
"-m",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Maximum number of nodes to display",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=click.Choice(["ascii", "json", "dot"]),
|
||||||
|
default="ascii",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def graph_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
depth: int,
|
||||||
|
focus: Optional[str],
|
||||||
|
max_nodes: int,
|
||||||
|
output: str,
|
||||||
|
):
|
||||||
|
"""Generate a dependency graph for the project."""
|
||||||
|
cfg: load_config = ctx.obj.get("config_obj")
|
||||||
|
if cfg:
|
||||||
|
depth = cfg.get_depth() or depth
|
||||||
|
max_nodes = cfg.get_max_nodes() or max_nodes
|
||||||
|
|
||||||
|
graph = DependencyGraph(path if path.is_dir() else path.parent)
|
||||||
|
graph.build_from_directory(
|
||||||
|
directory=path if path.is_dir() else None,
|
||||||
|
max_depth=depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
theme = ctx.obj.get("theme", "default")
|
||||||
|
style = _get_style_from_theme(theme)
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
renderer.set_style(style)
|
||||||
|
|
||||||
|
if output == "json":
|
||||||
|
click.echo(renderer.render_json(graph))
|
||||||
|
elif output == "dot":
|
||||||
|
click.echo(renderer.render_dot(graph))
|
||||||
|
else:
|
||||||
|
focus_path = None
|
||||||
|
if focus:
|
||||||
|
focus_path = graph.get_node_by_name(focus)
|
||||||
|
panel = renderer.render_graph(graph, focus_path, max_nodes)
|
||||||
|
console.print(panel, soft_wrap=True)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("tree")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--depth",
|
||||||
|
"-d",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="Maximum tree depth",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def tree_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
depth: int,
|
||||||
|
):
|
||||||
|
"""Show the project structure as a tree."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory(directory=path, max_depth=depth + 2)
|
||||||
|
|
||||||
|
theme = ctx.obj.get("theme", "default")
|
||||||
|
style = _get_style_from_theme(theme)
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
renderer.set_style(style)
|
||||||
|
|
||||||
|
panel = renderer.render_tree(graph, path, depth)
|
||||||
|
console.print(panel, soft_wrap=True)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("cycles")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--report",
|
||||||
|
"-r",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help="Export report to file",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def cycles_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
report: Optional[Path],
|
||||||
|
):
|
||||||
|
"""Detect and report circular dependencies."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
detector = CycleDetector(graph)
|
||||||
|
cycles = detector.detect_all_cycles()
|
||||||
|
|
||||||
|
theme = ctx.obj.get("theme", "default")
|
||||||
|
style = _get_style_from_theme(theme)
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
renderer.set_style(style)
|
||||||
|
|
||||||
|
panel = renderer.render_cycles(cycles)
|
||||||
|
console.print(panel, soft_wrap=True)
|
||||||
|
|
||||||
|
if report:
|
||||||
|
detector.export_cycle_report(report)
|
||||||
|
click.echo(f"Report exported to {report}")
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("navigate")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--file",
|
||||||
|
"-f",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Initial file to navigate to",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def navigate_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
file: Optional[str],
|
||||||
|
):
|
||||||
|
"""Start interactive navigation mode."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
theme = ctx.obj.get("theme", "default")
|
||||||
|
style = _get_style_from_theme(theme)
|
||||||
|
|
||||||
|
navigator = Navigator(graph, console, style)
|
||||||
|
|
||||||
|
if file:
|
||||||
|
file_path = graph.get_node_by_name(file)
|
||||||
|
if file_path:
|
||||||
|
navigator.navigate_to(file_path)
|
||||||
|
|
||||||
|
_run_interactive_mode(navigator, graph)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_interactive_mode(navigator: Navigator, graph: DependencyGraph):
|
||||||
|
"""Run the interactive navigation mode."""
|
||||||
|
click.echo("Interactive navigation mode. Type 'help' for commands.")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cmd = click.prompt(
|
||||||
|
"depnav",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
show_default=False,
|
||||||
|
).strip()
|
||||||
|
except click.exceptions.ClickException:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = cmd.split()
|
||||||
|
action = parts[0].lower()
|
||||||
|
|
||||||
|
if action in ("quit", "exit", "q"):
|
||||||
|
break
|
||||||
|
elif action == "help":
|
||||||
|
_show_help()
|
||||||
|
elif action == "current":
|
||||||
|
click.echo(navigator.show_current())
|
||||||
|
elif action == "deps" and len(parts) > 1:
|
||||||
|
target = graph.get_node_by_name(parts[1])
|
||||||
|
if target:
|
||||||
|
click.echo(navigator.show_dependencies(target))
|
||||||
|
elif action == "imported" and len(parts) > 1:
|
||||||
|
target = graph.get_node_by_name(parts[1])
|
||||||
|
if target:
|
||||||
|
click.echo(navigator.show_dependents(target))
|
||||||
|
elif action == "path" and len(parts) > 2:
|
||||||
|
source = graph.get_node_by_name(parts[1])
|
||||||
|
target = graph.get_node_by_name(parts[2])
|
||||||
|
if source and target:
|
||||||
|
click.echo(navigator.find_path(source, target))
|
||||||
|
elif action == "search" and len(parts) > 1:
|
||||||
|
results = navigator.search(parts[1])
|
||||||
|
for r in results:
|
||||||
|
click.echo(f" {r}")
|
||||||
|
elif action == "stats":
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
console.print(renderer.render_statistics(graph))
|
||||||
|
elif action == "back":
|
||||||
|
prev = navigator.back()
|
||||||
|
if prev:
|
||||||
|
click.echo(f"Back to: {prev}")
|
||||||
|
else:
|
||||||
|
click.echo("No previous file")
|
||||||
|
elif action == "list":
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
for n in nodes[:20]:
|
||||||
|
click.echo(f" {n}")
|
||||||
|
if len(nodes) > 20:
|
||||||
|
click.echo(f" ... and {len(nodes) - 20} more")
|
||||||
|
else:
|
||||||
|
click.echo(f"Unknown command: {action}. Type 'help' for available commands.")
|
||||||
|
|
||||||
|
|
||||||
|
def _show_help():
|
||||||
|
"""Show help for interactive mode."""
|
||||||
|
help_text = """
|
||||||
|
Available commands:
|
||||||
|
current Show current file info
|
||||||
|
deps <file> Show dependencies of a file
|
||||||
|
imported <file> Show files importing a file
|
||||||
|
path <from> <to> Show shortest path between files
|
||||||
|
search <query> Search for files
|
||||||
|
stats Show graph statistics
|
||||||
|
back Go back to previous file
|
||||||
|
list List all files
|
||||||
|
help Show this help
|
||||||
|
quit Exit
|
||||||
|
"""
|
||||||
|
click.echo(help_text)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("stats")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def stats_command(ctx: click.Context, path: Path):
|
||||||
|
"""Show dependency graph statistics."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
console.print(renderer.render_statistics(graph))
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("info")
|
||||||
|
@click.argument(
|
||||||
|
"file",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
"-p",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
help="Project root path",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def info_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
file: Path,
|
||||||
|
path: Path,
|
||||||
|
):
|
||||||
|
"""Show detailed information about a file."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
if not str(file).startswith("/"):
|
||||||
|
file = path / file
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel_file = file.relative_to(path)
|
||||||
|
except ValueError:
|
||||||
|
rel_file = file
|
||||||
|
|
||||||
|
navigator = Navigator(graph)
|
||||||
|
|
||||||
|
info = navigator.get_file_info(rel_file)
|
||||||
|
|
||||||
|
console.print(Panel(f"{info}", title=f"Info: {rel_file.name}"))
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("export")
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"-f",
|
||||||
|
type=click.Choice(["json", "dot"]),
|
||||||
|
default="json",
|
||||||
|
help="Export format",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
default=Path("depgraph.json"),
|
||||||
|
help="Output file",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def export_command(
|
||||||
|
ctx: click.Context,
|
||||||
|
path: Path,
|
||||||
|
format: str,
|
||||||
|
output: Path,
|
||||||
|
):
|
||||||
|
"""Export the dependency graph to a file."""
|
||||||
|
graph = DependencyGraph(path)
|
||||||
|
graph.build_from_directory()
|
||||||
|
|
||||||
|
renderer = ASCIIRenderer(console)
|
||||||
|
|
||||||
|
if format == "json":
|
||||||
|
content = renderer.render_json(graph)
|
||||||
|
else:
|
||||||
|
content = renderer.render_dot(graph)
|
||||||
|
|
||||||
|
output.write_text(content)
|
||||||
|
click.echo(f"Exported to {output}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_style_from_theme(theme: str) -> GraphStyle:
|
||||||
|
"""Get a GraphStyle from a theme name."""
|
||||||
|
themes = {
|
||||||
|
"default": GraphStyle(
|
||||||
|
node_style="cyan",
|
||||||
|
edge_style="dim",
|
||||||
|
highlight_style="yellow",
|
||||||
|
cycle_style="red",
|
||||||
|
),
|
||||||
|
"dark": GraphStyle(
|
||||||
|
node_style="green",
|
||||||
|
edge_style="dim green",
|
||||||
|
highlight_style="white",
|
||||||
|
cycle_style="red",
|
||||||
|
),
|
||||||
|
"light": GraphStyle(
|
||||||
|
node_style="blue",
|
||||||
|
edge_style="dim blue",
|
||||||
|
highlight_style="magenta",
|
||||||
|
cycle_style="red",
|
||||||
|
),
|
||||||
|
"mono": GraphStyle(
|
||||||
|
node_style="",
|
||||||
|
edge_style="dim",
|
||||||
|
highlight_style="bold",
|
||||||
|
cycle_style="bold",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return themes.get(theme, themes["default"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -1 +1,161 @@
|
|||||||
/app/depnav/src/depnav/config.py
|
"""Configuration management for depnav."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration manager for depnav."""
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_NAMES = [".depnav.yaml", "depnav.yaml", "pyproject.toml"]
|
||||||
|
DEFAULT_DEPTH = 3
|
||||||
|
DEFAULT_MAX_NODES = 100
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[Path] = None):
|
||||||
|
self._config: dict[str, Any] = {}
|
||||||
|
self._config_path = config_path
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
"""Load configuration from file and environment."""
|
||||||
|
self._config = {}
|
||||||
|
|
||||||
|
if self._config_path and self._config_path.exists():
|
||||||
|
self._load_from_file(self._config_path)
|
||||||
|
else:
|
||||||
|
self._find_and_load_config()
|
||||||
|
|
||||||
|
self._apply_env_overrides()
|
||||||
|
|
||||||
|
def _find_and_load_config(self) -> None:
|
||||||
|
"""Find and load configuration from standard locations."""
|
||||||
|
for config_name in self.DEFAULT_CONFIG_NAMES:
|
||||||
|
config_path = Path.cwd() / config_name
|
||||||
|
if config_path.exists():
|
||||||
|
self._load_from_file(config_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _load_from_file(self, path: Path) -> None:
|
||||||
|
"""Load configuration from a YAML or TOML file."""
|
||||||
|
try:
|
||||||
|
content = path.read_text()
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
|
||||||
|
if ext == ".toml":
|
||||||
|
try:
|
||||||
|
import tomli
|
||||||
|
data = tomli.loads(content) or {}
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
data = tomllib.loads(content) or {}
|
||||||
|
except ImportError:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if path.name == "pyproject.toml":
|
||||||
|
data = data.get("tool", {}).get("depnav", {})
|
||||||
|
else:
|
||||||
|
data = yaml.safe_load(content) or {}
|
||||||
|
|
||||||
|
self._config.update(data)
|
||||||
|
except (yaml.YAMLError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _apply_env_overrides(self) -> None:
|
||||||
|
"""Apply environment variable overrides."""
|
||||||
|
env_map = {
|
||||||
|
"DEPNAV_CONFIG": ("config_file", None),
|
||||||
|
"DEPNAV_THEME": ("theme", "default"),
|
||||||
|
"DEPNAV_PAGER": ("pager", None),
|
||||||
|
"DEPNAV_DEPTH": ("depth", self.DEFAULT_DEPTH),
|
||||||
|
"DEPNAV_MAX_NODES": ("max_nodes", self.DEFAULT_MAX_NODES),
|
||||||
|
}
|
||||||
|
|
||||||
|
for env_var, (key, default) in env_map.items():
|
||||||
|
value = os.environ.get(env_var)
|
||||||
|
if value is not None:
|
||||||
|
if default is not None and isinstance(default, int):
|
||||||
|
value = int(value)
|
||||||
|
self._config[key] = value
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a configuration value."""
|
||||||
|
return self._config.get(key, default)
|
||||||
|
|
||||||
|
def get_theme(self) -> dict[str, Any]:
|
||||||
|
"""Get the current theme configuration."""
|
||||||
|
themes = self._config.get("themes", {})
|
||||||
|
theme_name = self._config.get("theme", "default")
|
||||||
|
return themes.get(theme_name, themes.get("default", self._default_theme()))
|
||||||
|
|
||||||
|
def _default_theme(self) -> dict[str, Any]:
|
||||||
|
"""Return the default theme configuration."""
|
||||||
|
return {
|
||||||
|
"node_style": "cyan",
|
||||||
|
"edge_style": "dim",
|
||||||
|
"highlight_style": "yellow",
|
||||||
|
"cycle_style": "red",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_exclude_patterns(self) -> list[str]:
|
||||||
|
"""Get patterns for excluding files/directories."""
|
||||||
|
return self._config.get("exclude", ["__pycache__", "node_modules", ".git"])
|
||||||
|
|
||||||
|
def get_include_extensions(self) -> list[str]:
|
||||||
|
"""Get file extensions to include."""
|
||||||
|
return self._config.get(
|
||||||
|
"extensions", [".py", ".js", ".jsx", ".ts", ".tsx", ".go"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_output_format(self) -> str:
|
||||||
|
"""Get the default output format."""
|
||||||
|
return self._config.get("output", "ascii")
|
||||||
|
|
||||||
|
def get_depth(self) -> int:
|
||||||
|
"""Get the default traversal depth."""
|
||||||
|
return int(self._config.get("depth", self.DEFAULT_DEPTH))
|
||||||
|
|
||||||
|
def get_max_nodes(self) -> int:
|
||||||
|
"""Get the maximum number of nodes to display."""
|
||||||
|
return int(self._config.get("max_nodes", self.DEFAULT_MAX_NODES))
|
||||||
|
|
||||||
|
def save(self, path: Path) -> None:
|
||||||
|
"""Save current configuration to a file."""
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(self._config, f, default_flow_style=False)
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any) -> None:
|
||||||
|
"""Set a configuration value."""
|
||||||
|
self._config[key] = value
|
||||||
|
|
||||||
|
def merge(self, other: dict[str, Any]) -> None:
|
||||||
|
"""Merge another configuration dictionary."""
|
||||||
|
self._config.update(other)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Config({self._config})"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: Optional[Path] = None) -> Config:
|
||||||
|
"""Load and return a configuration object."""
|
||||||
|
config = Config(config_path)
|
||||||
|
config.load()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_value(
|
||||||
|
key: str, default: Any = None, config: Optional[Config] = None
|
||||||
|
) -> Any:
|
||||||
|
"""Get a configuration value from a config or environment."""
|
||||||
|
if config is not None:
|
||||||
|
return config.get(key, default)
|
||||||
|
|
||||||
|
env_key = f"DEPNAV_{key.upper()}"
|
||||||
|
env_value = os.environ.get(env_key)
|
||||||
|
if env_value is not None:
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
return cfg.get(key, default)
|
||||||
|
|||||||
@@ -1 +1,241 @@
|
|||||||
/app/depnav/src/depnav/graph.py
|
"""Dependency graph implementation using NetworkX."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import networkx as nx
|
||||||
|
|
||||||
|
from .parser import parse_dependencies
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyGraph:
|
||||||
|
"""Graph representation of project dependencies."""
|
||||||
|
|
||||||
|
def __init__(self, project_root: Path):
|
||||||
|
self.project_root = Path(project_root).resolve()
|
||||||
|
self._graph: nx.DiGraph = nx.DiGraph()
|
||||||
|
self._file_cache: dict[Path, list[str]] = {}
|
||||||
|
|
||||||
|
def add_file(self, file_path: Path) -> None:
|
||||||
|
"""Add a file and its dependencies to the graph."""
|
||||||
|
abs_path = (self.project_root / file_path).resolve()
|
||||||
|
rel_path = abs_path.relative_to(self.project_root)
|
||||||
|
|
||||||
|
if abs_path in self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._graph.add_node(rel_path)
|
||||||
|
|
||||||
|
deps = self._get_dependencies(abs_path)
|
||||||
|
for dep in deps:
|
||||||
|
dep_rel = self._resolve_dep_to_file(dep, abs_path)
|
||||||
|
if dep_rel is not None:
|
||||||
|
self._graph.add_edge(rel_path, dep_rel)
|
||||||
|
|
||||||
|
def _get_dependencies(self, file_path: Path) -> list[str]:
|
||||||
|
"""Get dependencies for a file, with caching."""
|
||||||
|
if file_path in self._file_cache:
|
||||||
|
return self._file_cache[file_path]
|
||||||
|
|
||||||
|
deps = parse_dependencies(file_path)
|
||||||
|
self._file_cache[file_path] = deps
|
||||||
|
return deps
|
||||||
|
|
||||||
|
def _resolve_dep_to_file(
|
||||||
|
self, dep: str, from_file: Path
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Resolve a dependency name to a file path relative to project root."""
|
||||||
|
dep_parts = dep.split(".")
|
||||||
|
|
||||||
|
search_paths = [
|
||||||
|
from_file.parent,
|
||||||
|
self.project_root,
|
||||||
|
]
|
||||||
|
|
||||||
|
extensions = [".py", ".js", ".jsx", ".ts", ".tsx", ".go"]
|
||||||
|
|
||||||
|
for search_path in search_paths:
|
||||||
|
for ext in extensions:
|
||||||
|
test_path = search_path / f"{dep}{ext}"
|
||||||
|
if test_path.exists():
|
||||||
|
try:
|
||||||
|
return test_path.resolve().relative_to(
|
||||||
|
self.project_root
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
joined_parts = "/".join(dep_parts)
|
||||||
|
test_path = (search_path / joined_parts).with_suffix(ext)
|
||||||
|
if test_path.exists():
|
||||||
|
try:
|
||||||
|
return test_path.resolve().relative_to(
|
||||||
|
self.project_root
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
test_path = search_path / "/".join(dep_parts) / f"__init__{ext}"
|
||||||
|
if test_path.exists():
|
||||||
|
try:
|
||||||
|
return test_path.resolve().relative_to(
|
||||||
|
self.project_root
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_from_directory(
|
||||||
|
self,
|
||||||
|
directory: Optional[Path] = None,
|
||||||
|
extensions: Optional[list[str]] = None,
|
||||||
|
max_depth: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Build the dependency graph by scanning a directory."""
|
||||||
|
root = self.project_root if directory is None else directory
|
||||||
|
|
||||||
|
if extensions is None:
|
||||||
|
extensions = [".py", ".js", ".jsx", ".ts", ".tsx", ".go"]
|
||||||
|
|
||||||
|
max_depth_val: int = max_depth if max_depth is not None else 999
|
||||||
|
|
||||||
|
visited: set[Path] = set()
|
||||||
|
|
||||||
|
def scan_directory(path: Path, depth: int) -> None:
|
||||||
|
if depth > max_depth_val:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in path.iterdir():
|
||||||
|
if item.is_file() and item.suffix in extensions:
|
||||||
|
if item.resolve() not in visited:
|
||||||
|
visited.add(item.resolve())
|
||||||
|
self.add_file(item.relative_to(self.project_root))
|
||||||
|
elif item.is_dir() and not item.name.startswith("."):
|
||||||
|
scan_directory(item, depth + 1)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
scan_directory(root, 0)
|
||||||
|
|
||||||
|
def get_nodes(self) -> list[Path]:
|
||||||
|
"""Get all nodes (files) in the graph."""
|
||||||
|
return [Path(n) for n in self._graph.nodes()]
|
||||||
|
|
||||||
|
def get_edges(self) -> list[tuple[Path, Path]]:
|
||||||
|
"""Get all edges (dependencies) in the graph."""
|
||||||
|
return [(Path(u), Path(v)) for u, v in self._graph.edges()]
|
||||||
|
|
||||||
|
def get_node_count(self) -> int:
|
||||||
|
"""Get the number of nodes in the graph."""
|
||||||
|
return self._graph.number_of_nodes()
|
||||||
|
|
||||||
|
def get_edge_count(self) -> int:
|
||||||
|
"""Get the number of edges in the graph."""
|
||||||
|
return self._graph.number_of_edges()
|
||||||
|
|
||||||
|
def get_dependents(self, file_path: Path) -> list[Path]:
|
||||||
|
"""Get all files that depend on the given file."""
|
||||||
|
try:
|
||||||
|
rel_path = file_path.relative_to(self.project_root)
|
||||||
|
except ValueError:
|
||||||
|
rel_path = file_path
|
||||||
|
|
||||||
|
predecessors = list(self._graph.predecessors(rel_path))
|
||||||
|
return [Path(p) for p in predecessors]
|
||||||
|
|
||||||
|
def get_dependencies(self, file_path: Path) -> list[Path]:
|
||||||
|
"""Get all files that the given file depends on."""
|
||||||
|
try:
|
||||||
|
rel_path = file_path.relative_to(self.project_root)
|
||||||
|
except ValueError:
|
||||||
|
rel_path = file_path
|
||||||
|
|
||||||
|
successors = list(self._graph.successors(rel_path))
|
||||||
|
return [Path(p) for p in successors]
|
||||||
|
|
||||||
|
def get_reachability(self, source: Path, target: Path) -> bool:
|
||||||
|
"""Check if target is reachable from source."""
|
||||||
|
try:
|
||||||
|
source_rel = source.relative_to(self.project_root)
|
||||||
|
target_rel = target.relative_to(self.project_root)
|
||||||
|
except ValueError:
|
||||||
|
source_rel = source
|
||||||
|
target_rel = target
|
||||||
|
|
||||||
|
try:
|
||||||
|
return nx.has_path(self._graph, source_rel, target_rel)
|
||||||
|
except nx.NetworkXNoPath:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_shortest_path(
|
||||||
|
self, source: Path, target: Path
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Get the shortest path from source to target."""
|
||||||
|
try:
|
||||||
|
source_rel = source.relative_to(self.project_root)
|
||||||
|
target_rel = target.relative_to(self.project_root)
|
||||||
|
except ValueError:
|
||||||
|
source_rel = source
|
||||||
|
target_rel = target
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = nx.shortest_path(self._graph, source_rel, target_rel)
|
||||||
|
return [Path(p) for p in path]
|
||||||
|
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def detect_cycles(self) -> list[list[Path]]:
|
||||||
|
"""Detect all cycles in the dependency graph."""
|
||||||
|
try:
|
||||||
|
cycles = list(nx.simple_cycles(self._graph))
|
||||||
|
return [[Path(c) for c in cycle] for cycle in cycles]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def to_undirected(self) -> nx.Graph:
|
||||||
|
"""Return an undirected version of the graph."""
|
||||||
|
return self._graph.to_undirected()
|
||||||
|
|
||||||
|
def get_connected_components(self) -> list[list[Path]]:
|
||||||
|
"""Get connected components of the undirected graph."""
|
||||||
|
undirected = self.to_undirected()
|
||||||
|
components = list(nx.connected_components(undirected))
|
||||||
|
return [[Path(n) for n in comp] for comp in components]
|
||||||
|
|
||||||
|
def get_subgraph(
|
||||||
|
self, nodes: list[Path]
|
||||||
|
) -> "DependencyGraph":
|
||||||
|
"""Get a subgraph containing only the specified nodes."""
|
||||||
|
new_graph = DependencyGraph(self.project_root)
|
||||||
|
subgraph = self._graph.subgraph([str(n) for n in nodes])
|
||||||
|
new_graph._graph = subgraph.copy() # type: ignore[assignment]
|
||||||
|
return new_graph
|
||||||
|
|
||||||
|
def get_degree(self, file_path: Path) -> tuple[int, int]:
|
||||||
|
"""Get the in-degree and out-degree of a node."""
|
||||||
|
try:
|
||||||
|
rel_path = file_path.relative_to(self.project_root)
|
||||||
|
except ValueError:
|
||||||
|
rel_path = file_path
|
||||||
|
|
||||||
|
in_degree = self._graph.in_degree(rel_path)
|
||||||
|
out_degree = self._graph.out_degree(rel_path)
|
||||||
|
return in_degree, out_degree
|
||||||
|
|
||||||
|
def get_topological_order(self) -> list[Path]:
|
||||||
|
"""Get a topological ordering of nodes."""
|
||||||
|
try:
|
||||||
|
order = list(nx.topological_sort(self._graph))
|
||||||
|
return [Path(n) for n in order]
|
||||||
|
except nx.NetworkXUnfeasible:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_node_by_name(self, name: str) -> Optional[Path]:
|
||||||
|
"""Find a node by its name (partial or full path match)."""
|
||||||
|
name_lower = name.lower()
|
||||||
|
for node in self._graph.nodes():
|
||||||
|
if name_lower in str(node).lower():
|
||||||
|
return Path(node)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1 +1,336 @@
|
|||||||
/app/depnav/src/depnav/parser.py
|
"""Language-specific parsers for extracting dependencies from source files."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter
|
||||||
|
from tree_sitter import Language
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_python
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_python = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_javascript
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_javascript = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tree_sitter_go
|
||||||
|
except ImportError:
|
||||||
|
tree_sitter_go = None
|
||||||
|
|
||||||
|
|
||||||
|
LanguageType = Literal["python", "javascript", "typescript", "go"]
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyParser(ABC):
|
||||||
|
"""Abstract base class for language parsers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract dependencies from a file."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_language(self) -> str:
|
||||||
|
"""Return the language identifier."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_library(lang: str):
|
||||||
|
"""Get the tree-sitter library for a language."""
|
||||||
|
lang_map = {
|
||||||
|
"python": tree_sitter_python,
|
||||||
|
"javascript": tree_sitter_javascript,
|
||||||
|
"typescript": tree_sitter_javascript,
|
||||||
|
"go": tree_sitter_go,
|
||||||
|
}
|
||||||
|
return lang_map.get(lang)
|
||||||
|
|
||||||
|
|
||||||
|
class PythonParser(DependencyParser):
|
||||||
|
"""Parser for Python files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._parser: Optional[tree_sitter.Parser] = None
|
||||||
|
|
||||||
|
def _get_parser(self) -> tree_sitter.Parser:
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_python is None:
|
||||||
|
raise ImportError("tree-sitter-python is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_python.language())
|
||||||
|
self._parser = tree_sitter.Parser()
|
||||||
|
self._parser.set_language(lang)
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "python"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract Python imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_python is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for Python."""
|
||||||
|
imports = []
|
||||||
|
import_pattern = re.compile(
|
||||||
|
r"^\s*(?:from|import)\s+(.+?)(?:\s+import\s+.*)?(?:\s*;?\s*)$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
for match in import_pattern.finditer(content):
|
||||||
|
module = match.group(1).strip()
|
||||||
|
if module:
|
||||||
|
for part in module.split(","):
|
||||||
|
clean_part = part.strip().split(" as ")[0].split(".")[0]
|
||||||
|
if clean_part:
|
||||||
|
imports.append(clean_part)
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "import_statement":
|
||||||
|
module = self._get_module_name(node, content)
|
||||||
|
if module:
|
||||||
|
imports.append(module.split(".")[0])
|
||||||
|
elif node.type == "from_import_statement":
|
||||||
|
module = self._get_module_name(node, content)
|
||||||
|
if module:
|
||||||
|
imports.append(module.split(".")[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _get_module_name(self, node: tree_sitter.Node, content: str) -> str:
|
||||||
|
"""Extract module name from import node."""
|
||||||
|
for child in node.children:
|
||||||
|
if child.type in ("dotted_name", "module"):
|
||||||
|
return content[child.start_byte : child.end_byte]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class JavaScriptParser(DependencyParser):
|
||||||
|
"""Parser for JavaScript/TypeScript files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self, typescript: bool = False):
|
||||||
|
self._parser: Optional[tree_sitter.Parser] = None
|
||||||
|
self._typescript = typescript
|
||||||
|
|
||||||
|
def _get_parser(self) -> tree_sitter.Parser:
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_javascript is None:
|
||||||
|
raise ImportError("tree-sitter-javascript is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_javascript.language())
|
||||||
|
self._parser = tree_sitter.Parser()
|
||||||
|
self._parser.set_language(lang)
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "typescript" if self._typescript else "javascript"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract JavaScript/TypeScript imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_javascript is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for JavaScript/TypeScript."""
|
||||||
|
imports = []
|
||||||
|
patterns = [
|
||||||
|
(r'require\s*\(\s*["\']([^"\']+)["\']\s*\)', 1),
|
||||||
|
(r'import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+["\']([^"\']+)["\']', 1),
|
||||||
|
(r'import\s+["\']([^"\']+)["\']', 1),
|
||||||
|
]
|
||||||
|
for pattern, group in patterns:
|
||||||
|
for match in re.finditer(pattern, content):
|
||||||
|
module = match.group(group)
|
||||||
|
if module and not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type in ("import_statement", "call_expression"):
|
||||||
|
import_str = content[node.start_byte : node.end_byte]
|
||||||
|
if "require" in import_str:
|
||||||
|
match = re.search(r'require\s*\(\s*["\']([^"\']+)["\']\s*\)', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
elif "import" in import_str:
|
||||||
|
match = re.search(
|
||||||
|
r'from\s+["\']([^"\']+)["\']', import_str
|
||||||
|
) or re.search(r'import\s+["\']([^"\']+)["\']', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
imports.append(module.split("/")[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
|
||||||
|
class GoParser(DependencyParser):
|
||||||
|
"""Parser for Go files using tree-sitter."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._parser: Optional[tree_sitter.Parser] = None
|
||||||
|
|
||||||
|
def _get_parser(self) -> tree_sitter.Parser:
|
||||||
|
if self._parser is None:
|
||||||
|
if tree_sitter_go is None:
|
||||||
|
raise ImportError("tree-sitter-go is not installed")
|
||||||
|
if tree_sitter is None:
|
||||||
|
raise ImportError("tree-sitter is not installed")
|
||||||
|
lang = Language(tree_sitter_go.language())
|
||||||
|
self._parser = tree_sitter.Parser()
|
||||||
|
self._parser.set_language(lang)
|
||||||
|
return self._parser
|
||||||
|
|
||||||
|
def get_language(self) -> str:
|
||||||
|
return "go"
|
||||||
|
|
||||||
|
def parse_file(self, file_path: Path) -> list[str]:
|
||||||
|
"""Extract Go imports from a file."""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if tree_sitter is None or tree_sitter_go is None:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = self._get_parser()
|
||||||
|
tree = parser.parse(bytes(content, "utf-8"))
|
||||||
|
return self._extract_imports(tree.root_node, content)
|
||||||
|
except Exception:
|
||||||
|
return self._regex_parse(content)
|
||||||
|
|
||||||
|
def _regex_parse(self, content: str) -> list[str]:
|
||||||
|
"""Fallback regex-based parsing for Go."""
|
||||||
|
imports = []
|
||||||
|
import_block = re.search(
|
||||||
|
r'\(\s*([\s\S]*?)\s*\)', content, re.MULTILINE
|
||||||
|
)
|
||||||
|
if import_block:
|
||||||
|
import_lines = import_block.group(1).strip().split("\n")
|
||||||
|
for line in import_lines:
|
||||||
|
line = line.strip().strip('"')
|
||||||
|
if line and not line.startswith("."):
|
||||||
|
parts = line.split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
imports.append(f"{parts[0]}/{parts[1]}")
|
||||||
|
elif parts:
|
||||||
|
imports.append(parts[0])
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
def _extract_imports(
|
||||||
|
self, node: tree_sitter.Node, content: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract imports from tree-sitter parse tree."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "import_declaration":
|
||||||
|
import_str = content[node.start_byte : node.end_byte]
|
||||||
|
match = re.search(r'"([^"]+)"', import_str)
|
||||||
|
if match:
|
||||||
|
module = match.group(1)
|
||||||
|
if not module.startswith("."):
|
||||||
|
parts = module.split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
imports.append(f"{parts[0]}/{parts[1]}")
|
||||||
|
elif parts:
|
||||||
|
imports.append(parts[0])
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content))
|
||||||
|
|
||||||
|
return list(set(imports))
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser(language: str) -> DependencyParser:
|
||||||
|
"""Factory function to get the appropriate parser for a language."""
|
||||||
|
parsers = {
|
||||||
|
"python": PythonParser,
|
||||||
|
"javascript": JavaScriptParser,
|
||||||
|
"typescript": lambda: JavaScriptParser(typescript=True),
|
||||||
|
"go": GoParser,
|
||||||
|
}
|
||||||
|
parser_class = parsers.get(language.lower())
|
||||||
|
if parser_class is None:
|
||||||
|
raise ValueError(f"Unsupported language: {language}")
|
||||||
|
return parser_class()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_language(file_path: Path) -> Optional[str]:
|
||||||
|
"""Detect the language of a file based on its extension."""
|
||||||
|
ext_map = {
|
||||||
|
".py": "python",
|
||||||
|
".js": "javascript",
|
||||||
|
".jsx": "javascript",
|
||||||
|
".ts": "typescript",
|
||||||
|
".tsx": "typescript",
|
||||||
|
".go": "go",
|
||||||
|
}
|
||||||
|
return ext_map.get(file_path.suffix.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dependencies(
|
||||||
|
file_path: Path, language: Optional[str] = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Parse dependencies from a file."""
|
||||||
|
if language is None:
|
||||||
|
language = detect_language(file_path)
|
||||||
|
if language is None:
|
||||||
|
return []
|
||||||
|
parser = get_parser(language)
|
||||||
|
return parser.parse_file(file_path)
|
||||||
|
|||||||
@@ -1 +1,368 @@
|
|||||||
/app/depnav/src/depnav/renderer.py
|
"""ASCII dependency graph renderer using Rich."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from rich import box
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from .graph import DependencyGraph
|
||||||
|
|
||||||
|
|
||||||
|
class GraphStyle:
|
||||||
|
"""Styling configuration for graph rendering."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
node_style: str = "cyan",
|
||||||
|
edge_style: str = "dim",
|
||||||
|
highlight_style: str = "yellow",
|
||||||
|
cycle_style: str = "red",
|
||||||
|
max_depth: int = 3,
|
||||||
|
show_imports: bool = True,
|
||||||
|
):
|
||||||
|
self.node_style = node_style
|
||||||
|
self.edge_style = edge_style
|
||||||
|
self.highlight_style = highlight_style
|
||||||
|
self.cycle_style = cycle_style
|
||||||
|
self.max_depth = max_depth
|
||||||
|
self.show_imports = show_imports
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STYLE = GraphStyle()
|
||||||
|
|
||||||
|
|
||||||
|
class ASCIIRenderer:
|
||||||
|
"""Renderer for ASCII dependency graphs."""
|
||||||
|
|
||||||
|
def __init__(self, console: Optional[Console] = None):
|
||||||
|
self.console = console or Console()
|
||||||
|
self.style = DEFAULT_STYLE
|
||||||
|
|
||||||
|
def set_style(self, style: GraphStyle) -> None:
|
||||||
|
"""Update the rendering style."""
|
||||||
|
self.style = style
|
||||||
|
|
||||||
|
def render_graph(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
focus_file: Optional[Path] = None,
|
||||||
|
max_nodes: int = 50,
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the dependency graph as ASCII art."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
|
||||||
|
if not nodes:
|
||||||
|
content = Text("No files found in the project.", style="dim")
|
||||||
|
return Panel(content, title="Dependency Graph")
|
||||||
|
|
||||||
|
if len(nodes) > max_nodes:
|
||||||
|
content = Text(
|
||||||
|
f"Showing {max_nodes} of {len(nodes)} files. "
|
||||||
|
"Use --depth to limit traversal.",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
|
|
||||||
|
if focus_file is not None:
|
||||||
|
return self._render_focused_graph(
|
||||||
|
graph, focus_file, max_nodes
|
||||||
|
)
|
||||||
|
return self._render_full_graph(graph, max_nodes)
|
||||||
|
|
||||||
|
def _render_full_graph(
|
||||||
|
self, graph: DependencyGraph, max_nodes: int
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the full dependency graph."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
|
||||||
|
table = Table(
|
||||||
|
box=box.ROUNDED,
|
||||||
|
show_header=False,
|
||||||
|
expand=True,
|
||||||
|
padding=(0, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
table.add_column("File", style="bold " + self.style.node_style)
|
||||||
|
table.add_column("Dependencies", style=self.style.edge_style)
|
||||||
|
|
||||||
|
for i, node in enumerate(nodes[:max_nodes]):
|
||||||
|
deps = graph.get_dependencies(node)
|
||||||
|
if deps:
|
||||||
|
dep_strs = [
|
||||||
|
f"[{self.style.edge_style}][/]{d.name}"
|
||||||
|
for d in deps[:5]
|
||||||
|
]
|
||||||
|
if len(deps) > 5:
|
||||||
|
dep_strs.append(f"+{len(deps) - 5} more")
|
||||||
|
deps_display = " ".join(dep_strs)
|
||||||
|
else:
|
||||||
|
deps_display = f"[{self.style.edge_style}](no deps)[/]"
|
||||||
|
table.add_row(node.name, deps_display)
|
||||||
|
|
||||||
|
if len(nodes) > max_nodes:
|
||||||
|
table.add_row(
|
||||||
|
f"... and {len(nodes) - max_nodes} more files", ""
|
||||||
|
)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
table,
|
||||||
|
title="Dependency Graph",
|
||||||
|
subtitle=f"{len(nodes)} files, {len(edges)} dependencies",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_focused_graph(
|
||||||
|
self, graph: DependencyGraph, focus_file: Path, max_nodes: int
|
||||||
|
) -> Panel:
|
||||||
|
"""Render a focused view centered on a specific file."""
|
||||||
|
focus_deps = graph.get_dependencies(focus_file)
|
||||||
|
focus_deps_rev = graph.get_dependents(focus_file)
|
||||||
|
|
||||||
|
table = Table(
|
||||||
|
box=box.ROUNDED,
|
||||||
|
show_header=False,
|
||||||
|
expand=True,
|
||||||
|
padding=(0, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
table.add_column("Type", style="dim")
|
||||||
|
table.add_column("File", style="bold " + self.style.node_style)
|
||||||
|
|
||||||
|
table.add_row(
|
||||||
|
"[bold magenta]→ DEPENDS ON[/]",
|
||||||
|
f"[bold {self.style.highlight_style}]{focus_file.name}[/]",
|
||||||
|
)
|
||||||
|
|
||||||
|
for dep in focus_deps[:max_nodes - 1]:
|
||||||
|
table.add_row(" └──", dep.name)
|
||||||
|
|
||||||
|
if focus_deps:
|
||||||
|
table.add_row(
|
||||||
|
f" [{self.style.edge_style}]({len(focus_deps)} total deps)[/]",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
if focus_deps_rev:
|
||||||
|
table.add_row("")
|
||||||
|
table.add_row(
|
||||||
|
"[bold magenta]← DEPENDED BY[/]",
|
||||||
|
f"[bold {self.style.highlight_style}]{focus_file.name}[/]",
|
||||||
|
)
|
||||||
|
for dep in focus_deps_rev[:max_nodes - 2]:
|
||||||
|
table.add_row(" └──", dep.name)
|
||||||
|
|
||||||
|
if len(focus_deps_rev) > max_nodes - 2:
|
||||||
|
table.add_row(
|
||||||
|
f" [{self.style.edge_style}]({len(focus_deps_rev)} total)[/]",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
table,
|
||||||
|
title=f"Dependencies: {focus_file.name}",
|
||||||
|
subtitle=f"{len(focus_deps)} imports, {len(focus_deps_rev)} importers",
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_tree(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
root: Optional[Path] = None,
|
||||||
|
max_depth: int = 3,
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the project structure as a tree."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
if not nodes:
|
||||||
|
content = Text("No files found.", style="dim")
|
||||||
|
return Panel(content, title="Project Tree")
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
root = graph.project_root
|
||||||
|
|
||||||
|
tree_lines = self._build_tree_lines(graph, root, 0, max_depth)
|
||||||
|
|
||||||
|
tree_str = "\n".join(tree_lines)
|
||||||
|
content = Text(tree_str, style=self.style.node_style)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
content,
|
||||||
|
title="Project Structure",
|
||||||
|
subtitle=f"{len(nodes)} files",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_tree_lines(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
path: Path,
|
||||||
|
depth: int,
|
||||||
|
max_depth: int,
|
||||||
|
prefix: str = "",
|
||||||
|
is_last: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Recursively build tree display lines."""
|
||||||
|
if depth > max_depth:
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
connector = "└── " if is_last else "├── "
|
||||||
|
if depth == 0:
|
||||||
|
connector = ""
|
||||||
|
|
||||||
|
indent = " " if is_last else "│ "
|
||||||
|
lines.append(f"{prefix}{connector}{path.name}")
|
||||||
|
|
||||||
|
if path.is_dir():
|
||||||
|
try:
|
||||||
|
children = sorted(
|
||||||
|
[
|
||||||
|
p
|
||||||
|
for p in path.iterdir()
|
||||||
|
if p.exists() and not p.name.startswith(".")
|
||||||
|
],
|
||||||
|
key=lambda x: (x.is_file(), x.name),
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
return lines
|
||||||
|
|
||||||
|
for i, child in enumerate(children):
|
||||||
|
is_child_last = i == len(children) - 1
|
||||||
|
lines.extend(
|
||||||
|
self._build_tree_lines(
|
||||||
|
graph,
|
||||||
|
child,
|
||||||
|
depth + 1,
|
||||||
|
max_depth,
|
||||||
|
prefix + indent,
|
||||||
|
is_child_last,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
deps = graph.get_dependencies(path)
|
||||||
|
if deps and depth < max_depth:
|
||||||
|
dep_indent = prefix + indent + " "
|
||||||
|
for j, dep in enumerate(deps[:3]):
|
||||||
|
dep_is_last = j == min(len(deps), 3) - 1
|
||||||
|
dep_connector = "└── " if dep_is_last else "├── "
|
||||||
|
lines.append(
|
||||||
|
f"{dep_indent}{dep_connector}[{self.style.edge_style}]{dep.name}[/]"
|
||||||
|
)
|
||||||
|
if len(deps) > 3:
|
||||||
|
lines.append(
|
||||||
|
f"{dep_indent} +{len(deps) - 3} more"
|
||||||
|
)
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def render_cycles(self, cycles: list[list[Path]]) -> Panel:
|
||||||
|
"""Render detected dependency cycles."""
|
||||||
|
if not cycles:
|
||||||
|
content = Text(
|
||||||
|
"✓ No circular dependencies detected.",
|
||||||
|
style="green",
|
||||||
|
)
|
||||||
|
return Panel(content, title="Cycle Detection")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
Text(
|
||||||
|
f"⚠ Found {len(cycles)} circular dependency(ies):",
|
||||||
|
style=self.style.cycle_style,
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, cycle in enumerate(cycles, 1):
|
||||||
|
cycle_str = " → ".join(p.name for p in cycle)
|
||||||
|
cycle_str += f" → {cycle[0].name}"
|
||||||
|
lines.append(Text(f" {i}. {cycle_str}", style="red"))
|
||||||
|
|
||||||
|
content = Text("\n".join(str(line) for line in lines))
|
||||||
|
|
||||||
|
return Panel(content, title="Circular Dependencies", style="red")
|
||||||
|
|
||||||
|
def render_statistics(self, graph: DependencyGraph) -> Panel:
|
||||||
|
"""Render dependency graph statistics."""
|
||||||
|
stats = self._compute_statistics(graph)
|
||||||
|
|
||||||
|
table = Table(box=box.ROUNDED, show_header=False)
|
||||||
|
table.add_column("Metric", style="dim")
|
||||||
|
table.add_column("Value", style="bold")
|
||||||
|
|
||||||
|
for metric, value in stats.items():
|
||||||
|
table.add_row(metric, str(value))
|
||||||
|
|
||||||
|
return Panel(table, title="Statistics")
|
||||||
|
|
||||||
|
def _compute_statistics(
|
||||||
|
self, graph: DependencyGraph
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Compute statistics about the dependency graph."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
cycles = graph.detect_cycles()
|
||||||
|
|
||||||
|
total_deps = sum(len(graph.get_dependencies(n)) for n in nodes)
|
||||||
|
total_imports = sum(len(graph.get_dependents(n)) for n in nodes)
|
||||||
|
|
||||||
|
max_deps_node = max(nodes, key=lambda n: len(graph.get_dependencies(n)))
|
||||||
|
max_imports_node = max(
|
||||||
|
nodes, key=lambda n: len(graph.get_dependents(n))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Files": len(nodes),
|
||||||
|
"Dependencies": len(edges),
|
||||||
|
"Total Imports": total_deps,
|
||||||
|
"Total Importers": total_imports,
|
||||||
|
"Circular Dependencies": len(cycles),
|
||||||
|
"Most Imported": f"{max_deps_node.name} ({len(graph.get_dependencies(max_deps_node))} deps)",
|
||||||
|
"Most Imported By": f"{max_imports_node.name} ({len(graph.get_dependents(max_imports_node))} importers)",
|
||||||
|
}
|
||||||
|
|
||||||
|
def render_json(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render the graph as JSON for export."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"project_root": str(graph.project_root),
|
||||||
|
"nodes": [str(n) for n in nodes],
|
||||||
|
"edges": [(str(u), str(v)) for u, v in edges],
|
||||||
|
"statistics": self._compute_statistics(graph),
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
def render_dot(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render the graph in DOT format for Graphviz."""
|
||||||
|
lines = [
|
||||||
|
"digraph dependency_graph {",
|
||||||
|
' rankdir=LR;',
|
||||||
|
' node [shape=box, style=rounded];',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for node in graph.get_nodes():
|
||||||
|
lines.append(f' "{node.name}";')
|
||||||
|
|
||||||
|
for u, v in graph.get_edges():
|
||||||
|
lines.append(f' "{u.name}" -> "{v.name}";')
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def render_stats_only(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render brief statistics for terminal output."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
cycles = graph.detect_cycles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"[bold]Files:[/] {len(nodes)} | "
|
||||||
|
f"[bold]Dependencies:[/] {len(edges)} | "
|
||||||
|
f"[bold]Cycles:[/] {len(cycles)}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user