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]"
|
||||
|
||||
- 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
|
||||
run: ruff check src/depnav/ tests/
|
||||
run: ruff check src/ tests/
|
||||
|
||||
- name: Run type checking
|
||||
run: mypy src/
|
||||
|
||||
build:
|
||||
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