diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22ac455ba..119e4efa1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -73,7 +73,7 @@ repos: - id: deptry pass_filenames: false always_run: true - entry: bash -c "uv run --frozen --all-extras --dev deptry src --ignore DEP001 --extend-exclude 'codegen-examples/.*'" + entry: bash -c "uv run --frozen --all-extras --dev deptry src --ignore DEP001 --per-rule-ignores 'DEP002=pyright|mini-racer|hatch-vcs|pyinstrument|pip|python-levenshtein|python-semantic-release|pytest-snapshot|numpy|modal|langgraph-prebuilt' --extend-exclude 'codegen-examples/.*'" - repo: https://github.com/renovatebot/pre-commit-hooks rev: 39.264.0 diff --git a/docs/cli/about.mdx b/docs/cli/about.mdx index ac52bad9b..85533ce0f 100644 --- a/docs/cli/about.mdx +++ b/docs/cli/about.mdx @@ -8,10 +8,11 @@ iconType: "solid" The Graph-sitter CLI helps you: - Parse a local repository into graph summary data +- Inspect files and trace call relationships from the command line - Diagnose parse time, memory use, and graph size for a local repository - Initialize Graph-sitter in your repository - Create and run codemods -- Run one-shot transformations by import path +- Run one-shot transformations and focused renames For installation instructions, see our [Getting @@ -68,6 +69,9 @@ graph-sitter create my-codemod . Parse a repository and print graph summary counts. + + Inspect files, trace usages, trace callees, and rename symbols. + Report parse time, memory use, and graph size for a repository. diff --git a/docs/cli/graph.mdx b/docs/cli/graph.mdx new file mode 100644 index 000000000..b80b7dc8a --- /dev/null +++ b/docs/cli/graph.mdx @@ -0,0 +1,141 @@ +--- +title: "Graph Commands" +sidebarTitle: "graph" +icon: "diagram-project" +iconType: "solid" +--- + +Graph-sitter includes high-level CLI commands for inspecting local code structure, +tracing call relationships, and applying focused renames without writing custom +codemods. + +```bash +graph-sitter inspect src/app.py +graph-sitter using src/app.py:handler --depth 2 +graph-sitter usages src/app.py:helper --depth 2 +graph-sitter rename src/app.py:helper --to execute_helper --write +``` + +## Target Syntax + +Use `FILE:SYMBOL` for functions, classes, methods, and other named symbols: + +```bash +graph-sitter using src/app.py:handler +graph-sitter usages src/app.py:Service.run +graph-sitter rename src/app.py:handler --to run_handler --write +``` + +The CLI also accepts `FILE::SYMBOL` and dotted shorthand such as +`src/app.py.handler` when it can resolve the file unambiguously. + +## inspect + +`inspect` prints source-file structure: line count, imports, classes, functions, +and function call summaries. + +```bash +graph-sitter inspect FILE [PATH] [OPTIONS] +``` + +Options: + +- `--level summary|functions|calls|full`: Choose how much detail to print. + Defaults to `functions`. +- `--format summary|json`: Choose human-readable or machine-readable output. +- `--max-functions N`: Limit function rows. Defaults to `200`. +- `--max-calls N`: Limit call names per function. Defaults to `20`. + +Examples: + +```bash +graph-sitter inspect src/app.py . +graph-sitter inspect src/app.py . --level calls +graph-sitter inspect src/app.py . --level full --format json +``` + +## using + +`using` traces outbound calls from a target. With `--depth 2`, the output +includes the target's direct callees and the callees those functions call. + +```bash +graph-sitter using TARGET [PATH] [OPTIONS] +``` + +Options: + +- `--depth N`: Recursion depth through resolved outbound calls. Defaults to `1`. +- `--max-results N`: Maximum call edges to print. Defaults to `200`. +- `--format summary|json`: Choose human-readable or machine-readable output. + +Example: + +```bash +graph-sitter using src/app.py:handler . --depth 3 --format json +``` + +## usages + +`usages` traces inbound call sites for a target. With `--depth 2`, the output +includes direct callers and callers of those callers. + +```bash +graph-sitter usages TARGET [PATH] [OPTIONS] +``` + +Options: + +- `--depth N`: Recursion depth through inbound callers. Defaults to `1`. +- `--max-results N`: Maximum call edges to print. Defaults to `200`. +- `--format summary|json`: Choose human-readable or machine-readable output. + +Example: + +```bash +graph-sitter usages src/app.py:helper . --depth 2 +``` + +## rename + +`rename` resolves a target symbol and renames it across Graph-sitter's resolved +references. A plain command is a dry run; pass `--write` to modify files. + +```bash +graph-sitter rename TARGET [PATH] --to NEW_NAME [OPTIONS] +``` + +Options: + +- `--to NEW_NAME`: Required new symbol name. +- `--check`: Preview target and reference counts without writing. This is the + default. +- `--write`: Apply the rename and write files to disk. +- `--format summary|json`: Choose human-readable or machine-readable output. + +Examples: + +```bash +graph-sitter rename src/app.py:helper . --to execute_helper +graph-sitter rename src/app.py:helper . --to execute_helper --write +``` + +## Shared Options + +All graph commands accept the same parse controls as `parse`: + +- `--backend python|rust|auto`: Choose the graph backend. +- `--fallback python|error`: Choose fallback behavior when the Rust backend is + unavailable. Defaults to `python` for these commands. +- `--language auto|python|typescript`: Choose the repository language. +- `--subdir PATH`: Limit parsing to a repository-relative subdirectory or file. + Repeat to include multiple paths. + +## With uvx + +```bash +uvx --python 3.13 graph-sitter inspect src/app.py . +uvx --python 3.13 graph-sitter using src/app.py:handler . --depth 2 --format json +uvx --python 3.13 graph-sitter usages src/app.py:helper . --depth 2 +uvx --python 3.13 graph-sitter rename src/app.py:helper . --to execute_helper --write +``` diff --git a/docs/cli/uvx.mdx b/docs/cli/uvx.mdx index 283492525..d9c2e2b33 100644 --- a/docs/cli/uvx.mdx +++ b/docs/cli/uvx.mdx @@ -10,6 +10,8 @@ temporary environment. ```bash uvx --python 3.13 graph-sitter parse . +uvx --python 3.13 graph-sitter inspect src/app.py . +uvx --python 3.13 graph-sitter usages src/app.py:handler . --depth 2 uvx --python 3.13 graph-sitter transform ./codemods/rename.py:rename . --check uvx --python 3.13 graph-sitter run rename-function . --check ``` @@ -86,6 +88,23 @@ Write JSON to a file when another tool consumes the graph summary: uvx --python 3.13 graph-sitter parse . --format json --output graph-sitter-index.json ``` +## Graph Queries + +The high-level graph commands do not require `.codegen` initialization. + +```bash +uvx --python 3.13 graph-sitter inspect src/app.py . +uvx --python 3.13 graph-sitter using src/app.py:handler . --depth 2 --format json +uvx --python 3.13 graph-sitter usages src/app.py:helper . --depth 2 +uvx --python 3.13 graph-sitter rename src/app.py:helper . --to execute_helper --write +``` + +Use `--subdir` for large repositories: + +```bash +uvx --python 3.13 graph-sitter using src/app.py:handler ./monorepo --subdir packages/app --depth 2 +``` + ## Transform `transform` runs a one-shot Python transform by file path or import path. It diff --git a/src/graph_sitter/cli/cli.py b/src/graph_sitter/cli/cli.py index fc0cd6c14..fa5a7dd47 100644 --- a/src/graph_sitter/cli/cli.py +++ b/src/graph_sitter/cli/cli.py @@ -6,16 +6,20 @@ from graph_sitter.cli.commands.diagnose.main import diagnose_command from graph_sitter.cli.commands.doctor.main import doctor_command from graph_sitter.cli.commands.init.main import init_command +from graph_sitter.cli.commands.inspect.main import inspect_command from graph_sitter.cli.commands.list.main import list_command from graph_sitter.cli.commands.lsp.lsp import lsp_command from graph_sitter.cli.commands.notebook.main import notebook_command from graph_sitter.cli.commands.parse.main import parse_command +from graph_sitter.cli.commands.rename.main import rename_command from graph_sitter.cli.commands.reset.main import reset_command from graph_sitter.cli.commands.run.main import run_command from graph_sitter.cli.commands.start.main import start_command from graph_sitter.cli.commands.style_debug.main import style_debug_command from graph_sitter.cli.commands.transform.main import transform_command from graph_sitter.cli.commands.update.main import update_command +from graph_sitter.cli.commands.usages.main import usages_command +from graph_sitter.cli.commands.using.main import using_command click.rich_click.USE_RICH_MARKUP = True install(show_locals=True) @@ -32,6 +36,10 @@ def main(): main.add_command(doctor_command) main.add_command(diagnose_command) main.add_command(parse_command) +main.add_command(inspect_command) +main.add_command(usages_command) +main.add_command(using_command) +main.add_command(rename_command) main.add_command(run_command) main.add_command(transform_command) main.add_command(create_command) diff --git a/src/graph_sitter/cli/commands/graph/common.py b/src/graph_sitter/cli/commands/graph/common.py new file mode 100644 index 000000000..e77a8adbb --- /dev/null +++ b/src/graph_sitter/cli/commands/graph/common.py @@ -0,0 +1,433 @@ +import json +import logging +from collections.abc import Callable as CallableType +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import rich_click as click + +from graph_sitter.cli.commands.parse.main import _parse_language, _project_for_parse, _suppress_parse_logs +from graph_sitter.configs.models.codebase import CodebaseConfig, GraphBackend, RustFallbackMode +from graph_sitter.core.codebase import Codebase + +GRAPH_COMMAND_JSON_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class ResolvedTarget: + raw: str + file: Any + symbol: Any + + +def graph_options(func: CallableType) -> CallableType: + func = click.option("--subdir", "subdirectories", multiple=True, help="Limit parsing to a repository-relative subdirectory or file. Can be passed more than once.")(func) + func = click.option("--language", type=click.Choice(["auto", "python", "typescript"]), default="auto", show_default=True, help="Project language.")(func) + func = click.option("--fallback", type=click.Choice(["python", "error"]), default="python", show_default=True, help="Fallback behavior when the Rust backend is unavailable.")(func) + func = click.option("--backend", type=click.Choice(["python", "rust", "auto"]), default="python", show_default=True, help="Graph backend to use.")(func) + return func + + +def load_codebase(path: Path, backend: str, fallback: str, language: str, subdirectories: tuple[str, ...], *, quiet: bool = True) -> Codebase: + config = CodebaseConfig( + graph_backend=GraphBackend(backend), + rust_fallback=RustFallbackMode(fallback), + ) + project = _project_for_parse(path.resolve(), _parse_language(language), subdirectories) + + try: + disabled_level = logging.WARNING if quiet else logging.INFO + with _suppress_parse_logs(disabled_level): + return Codebase(projects=[project], config=config) + except RuntimeError as error: + raise click.ClickException(str(error)) from error + + +def emit_json(payload: dict[str, Any]) -> None: + click.echo(json.dumps(payload, sort_keys=True)) + + +def safe_attr(obj: Any, name: str, default: Any = None) -> Any: + try: + return getattr(obj, name) + except Exception: + return default + + +def as_list(value: Any) -> list[Any]: + if value is None: + return [] + try: + return list(value) + except TypeError: + return [] + + +def file_path_of(obj: Any) -> str: + if obj is None: + return "" + if safe_attr(obj, "filepath") is not None: + return str(safe_attr(obj, "filepath")) + if safe_attr(obj, "file_path") is not None: + return str(safe_attr(obj, "file_path")) + file = safe_attr(obj, "file") + if file is not None: + return file_path_of(file) + return "" + + +def line_number(obj: Any) -> int | None: + point = safe_attr(obj, "start_point") + if point is None: + return None + row = safe_attr(point, "row") + if row is not None: + return int(row) + 1 + try: + return int(point[0]) + 1 + except (TypeError, IndexError, ValueError): + return None + + +def location_of(obj: Any) -> str: + path = file_path_of(obj) + line = line_number(obj) + if path and line is not None: + return f"{path}:{line}" + if path: + return path + if line is not None: + return f":{line}" + return "" + + +def symbol_name(symbol: Any) -> str: + name = safe_attr(symbol, "name") + if name: + return str(name) + return type(symbol).__name__ + + +def qualified_name(symbol: Any) -> str: + parent_class = safe_attr(symbol, "parent_class") + if parent_class is not None and safe_attr(parent_class, "name"): + return f"{parent_class.name}.{symbol_name(symbol)}" + full_name = safe_attr(symbol, "full_name") + if full_name: + return str(full_name) + return symbol_name(symbol) + + +def symbol_key(symbol: Any) -> str: + node_id = safe_attr(symbol, "node_id") + if node_id is not None: + return str(node_id) + return f"{file_path_of(symbol)}:{line_number(symbol)}:{qualified_name(symbol)}:{type(symbol).__name__}" + + +def symbol_record(symbol: Any | None) -> dict[str, Any] | None: + if symbol is None: + return None + return { + "name": symbol_name(symbol), + "qualified_name": qualified_name(symbol), + "kind": type(symbol).__name__, + "file": file_path_of(symbol), + "line": line_number(symbol), + "location": location_of(symbol), + } + + +def call_record(call: Any) -> dict[str, Any]: + definitions = [] + for definition in as_list(safe_attr(call, "function_definitions")): + definitions.append(symbol_record(definition)) + return { + "name": str(safe_attr(call, "name", "")), + "source": str(safe_attr(call, "source", "")), + "file": file_path_of(call), + "line": line_number(call), + "location": location_of(call), + "definitions": [definition for definition in definitions if definition is not None], + } + + +def all_functions_in_file(source_file: Any) -> list[Any]: + functions: list[Any] = [] + seen: set[str] = set() + + for function in as_list(safe_attr(source_file, "functions")): + key = symbol_key(function) + if key not in seen: + functions.append(function) + seen.add(key) + + for class_definition in as_list(safe_attr(source_file, "classes")): + for method in as_list(safe_attr(class_definition, "methods")): + key = symbol_key(method) + if key not in seen: + functions.append(method) + seen.add(key) + + return sorted(functions, key=lambda function: line_number(function) or 0) + + +def resolve_file(codebase: Codebase, file_ref: str) -> Any: + candidates = _file_ref_candidates(codebase, file_ref) + for candidate in candidates: + source_file = codebase.get_file(candidate, optional=True, ignore_case=False) + if source_file is not None: + return source_file + + suffix_matches = [source_file for source_file in codebase.files if any(file_path_of(source_file).endswith(candidate) for candidate in candidates)] + if len(suffix_matches) == 1: + return suffix_matches[0] + if suffix_matches: + matches = ", ".join(file_path_of(source_file) for source_file in suffix_matches[:10]) + msg = f"File target is ambiguous: {file_ref}. Matches: {matches}" + raise click.ClickException(msg) + + msg = f"File not found in parsed codebase: {file_ref}" + raise click.ClickException(msg) + + +def resolve_target(codebase: Codebase, raw_target: str) -> ResolvedTarget: + file_ref, symbol_ref = _split_target(codebase, raw_target) + if not symbol_ref: + msg = "Target must include a symbol, for example `src/app.py:handler`." + raise click.ClickException(msg) + + if file_ref is not None: + source_file = resolve_file(codebase, file_ref) + symbol = _resolve_symbol_in_file(source_file, symbol_ref) + return ResolvedTarget(raw=raw_target, file=source_file, symbol=symbol) + + matches = _global_symbol_matches(codebase, symbol_ref) + if len(matches) == 1: + symbol = matches[0] + return ResolvedTarget(raw=raw_target, file=safe_attr(symbol, "file"), symbol=symbol) + if matches: + candidates = ", ".join(_target_label(match) for match in matches[:10]) + msg = f"Symbol target is ambiguous: {symbol_ref}. Matches: {candidates}" + raise click.ClickException(msg) + + msg = f"Symbol not found in parsed codebase: {symbol_ref}" + raise click.ClickException(msg) + + +def caller_for_call(call: Any) -> Any: + parent_function = safe_attr(call, "parent_function") + if parent_function is not None: + return _canonical_function(parent_function, call) or parent_function + parent_symbol = safe_attr(call, "parent_symbol") + if parent_symbol is not None: + return parent_symbol + return safe_attr(call, "file") + + +def outbound_edges(symbol: Any) -> list[dict[str, Any]]: + edges: list[dict[str, Any]] = [] + for call in as_list(safe_attr(symbol, "function_calls")): + definitions = as_list(safe_attr(call, "function_definitions")) + if not definitions: + edges.append({"source": symbol, "target": None, "call": call}) + continue + for definition in definitions: + edges.append({"source": symbol, "target": definition, "call": call}) + return edges + + +def inbound_edges(symbol: Any) -> list[dict[str, Any]]: + edges: list[dict[str, Any]] = [] + for call in as_list(safe_attr(symbol, "call_sites")): + edges.append({"source": caller_for_call(call), "target": symbol, "call": call}) + return edges + + +def edge_record(edge: dict[str, Any], depth: int) -> dict[str, Any]: + return { + "depth": depth, + "source": symbol_record(edge["source"]), + "target": symbol_record(edge["target"]), + "call": call_record(edge["call"]), + } + + +def trace_edges(symbol: Any, *, direction: str, depth: int, max_results: int) -> list[dict[str, Any]]: + queue: list[tuple[Any, int]] = [(symbol, 0)] + visited_nodes = {symbol_key(symbol)} + seen_edges: set[tuple[str, str, str]] = set() + records: list[dict[str, Any]] = [] + + while queue and len(records) < max_results: + current, current_depth = queue.pop(0) + if current_depth >= depth: + continue + + raw_edges = outbound_edges(current) if direction == "outbound" else inbound_edges(current) + for edge in raw_edges: + source_key = symbol_key(edge["source"]) + target_key = symbol_key(edge["target"]) if edge["target"] is not None else f"unresolved:{call_record(edge['call'])['name']}:{location_of(edge['call'])}" + edge_key = (source_key, target_key, location_of(edge["call"])) + if edge_key in seen_edges: + continue + seen_edges.add(edge_key) + records.append(edge_record(edge, current_depth + 1)) + if len(records) >= max_results: + break + + next_symbol = edge["target"] if direction == "outbound" else edge["source"] + if next_symbol is None: + continue + next_key = symbol_key(next_symbol) + if next_key in visited_nodes: + continue + if not as_list(safe_attr(next_symbol, "function_calls")) and not as_list(safe_attr(next_symbol, "call_sites")): + continue + visited_nodes.add(next_key) + queue.append((next_symbol, current_depth + 1)) + + return records + + +def _file_ref_candidates(codebase: Codebase, file_ref: str) -> list[str]: + raw_path = Path(file_ref).expanduser() + candidates: list[str] = [] + if raw_path.is_absolute(): + try: + candidates.append(raw_path.resolve().relative_to(codebase.repo_path.resolve()).as_posix()) + except ValueError: + candidates.append(raw_path.as_posix()) + else: + candidates.append(raw_path.as_posix()) + candidates.append(str(file_ref).replace("\\", "/")) + return list(dict.fromkeys(candidate.removeprefix("./") for candidate in candidates if candidate)) + + +def _split_target(codebase: Codebase, raw_target: str) -> tuple[str | None, str]: + if "::" in raw_target: + file_ref, symbol_ref = raw_target.split("::", 1) + return file_ref, symbol_ref + if ":" in raw_target: + file_ref, symbol_ref = raw_target.split(":", 1) + return file_ref, symbol_ref + + dotted = _split_dotted_file_target(codebase, raw_target) + if dotted is not None: + return dotted + + return None, raw_target + + +def _split_dotted_file_target(codebase: Codebase, raw_target: str) -> tuple[str, str] | None: + candidates: list[tuple[str, str]] = [] + for source_file in codebase.files: + filepath = file_path_of(source_file) + path = Path(filepath) + variants = [filepath, path.with_suffix("").as_posix()] + for variant in variants: + prefix = f"{variant}." + if raw_target.startswith(prefix): + candidates.append((filepath, raw_target[len(prefix) :])) + if not candidates: + return None + candidates.sort(key=lambda candidate: len(candidate[0]), reverse=True) + return candidates[0] + + +def _resolve_symbol_in_file(source_file: Any, symbol_ref: str) -> Any: + direct_candidates: list[Any] = [] + get_function = safe_attr(source_file, "get_function") + if callable(get_function): + function = get_function(symbol_ref) + if function is not None: + direct_candidates.append(function) + + get_symbol = safe_attr(source_file, "get_symbol") + if callable(get_symbol): + symbol = get_symbol(symbol_ref) + if symbol is not None: + direct_candidates.append(symbol) + + if "." in symbol_ref: + class_ref, method_ref = symbol_ref.rsplit(".", 1) + get_class = safe_attr(source_file, "get_class") + if callable(get_class): + class_definition = get_class(class_ref) + get_method = safe_attr(class_definition, "get_method") if class_definition is not None else None + if callable(get_method): + method = get_method(method_ref) + if method is not None: + direct_candidates.append(method) + + if direct_candidates: + return direct_candidates[0] + + matches = [symbol for symbol in _all_symbols_in_file(source_file) if _symbol_matches_ref(symbol, symbol_ref)] + matches = _dedupe_symbols(matches) + if len(matches) == 1: + return matches[0] + if matches: + candidates = ", ".join(_target_label(match) for match in matches[:10]) + msg = f"Symbol target is ambiguous in {file_path_of(source_file)}: {symbol_ref}. Matches: {candidates}" + raise click.ClickException(msg) + + msg = f"Symbol not found in {file_path_of(source_file)}: {symbol_ref}" + raise click.ClickException(msg) + + +def _all_symbols_in_file(source_file: Any) -> list[Any]: + symbols = as_list(safe_attr(source_file, "symbols")) + for function in all_functions_in_file(source_file): + symbols.append(function) + return _dedupe_symbols(symbols) + + +def _global_symbol_matches(codebase: Codebase, symbol_ref: str) -> list[Any]: + matches: list[Any] = [] + for source_file in codebase.files: + for symbol in _all_symbols_in_file(source_file): + if _symbol_matches_ref(symbol, symbol_ref): + matches.append(symbol) + return _dedupe_symbols(matches) + + +def _symbol_matches_ref(symbol: Any, symbol_ref: str) -> bool: + return symbol_name(symbol) == symbol_ref or qualified_name(symbol) == symbol_ref or safe_attr(symbol, "full_name") == symbol_ref + + +def _dedupe_symbols(symbols: list[Any]) -> list[Any]: + result: list[Any] = [] + seen: set[str] = set() + for symbol in symbols: + key = symbol_key(symbol) + if key in seen: + continue + result.append(symbol) + seen.add(key) + return result + + +def _target_label(symbol: Any) -> str: + return f"{file_path_of(symbol)}:{qualified_name(symbol)}" + + +def _canonical_function(function: Any, call: Any) -> Any | None: + source_file = safe_attr(function, "file") or safe_attr(call, "file") + if source_file is None: + return None + + parent_class = safe_attr(function, "parent_class") + if parent_class is not None and safe_attr(parent_class, "name"): + get_class = safe_attr(source_file, "get_class") + class_definition = get_class(parent_class.name) if callable(get_class) else None + get_method = safe_attr(class_definition, "get_method") if class_definition is not None else None + method = get_method(symbol_name(function)) if callable(get_method) else None + if method is not None: + return method + + get_function = safe_attr(source_file, "get_function") + if callable(get_function): + return get_function(symbol_name(function)) + return None diff --git a/src/graph_sitter/cli/commands/inspect/main.py b/src/graph_sitter/cli/commands/inspect/main.py new file mode 100644 index 000000000..90dc9a6df --- /dev/null +++ b/src/graph_sitter/cli/commands/inspect/main.py @@ -0,0 +1,125 @@ +from pathlib import Path +from typing import Any + +import rich +import rich_click as click +from rich.table import Table + +from graph_sitter.cli.commands.graph.common import ( + GRAPH_COMMAND_JSON_SCHEMA_VERSION, + all_functions_in_file, + as_list, + call_record, + emit_json, + file_path_of, + graph_options, + load_codebase, + resolve_file, + safe_attr, + symbol_record, +) + + +@click.command(name="inspect") +@click.argument("file", type=str) +@click.argument("path", required=False, type=click.Path(path_type=Path, exists=True, file_okay=False), default=Path(".")) +@click.option("--level", type=click.Choice(["summary", "functions", "calls", "full"]), default="functions", show_default=True, help="How much detail to print.") +@click.option("--format", "output_format", type=click.Choice(["summary", "json"]), default="summary", show_default=True, help="Output format.") +@click.option("--max-functions", type=click.IntRange(min=1), default=200, show_default=True, help="Maximum functions to include.") +@click.option("--max-calls", type=click.IntRange(min=0), default=20, show_default=True, help="Maximum call names to include per function.") +@graph_options +def inspect_command( + file: str, + path: Path, + level: str, + output_format: str, + max_functions: int, + max_calls: int, + backend: str, + fallback: str, + language: str, + subdirectories: tuple[str, ...], +) -> None: + """Show structure and call stats for a source file.""" + codebase = load_codebase(path, backend, fallback, language, subdirectories, quiet=output_format == "json") + source_file = resolve_file(codebase, file) + payload = _file_payload(source_file, level=level, max_functions=max_functions, max_calls=max_calls) + + if output_format == "json": + emit_json(payload) + return + + _print_summary(payload) + + +def _file_payload(source_file: Any, *, level: str, max_functions: int, max_calls: int) -> dict[str, Any]: + functions = all_functions_in_file(source_file)[:max_functions] + function_payloads = [_function_payload(function, level=level, max_calls=max_calls) for function in functions] + source = safe_attr(source_file, "source", "") or "" + imports = as_list(safe_attr(source_file, "imports")) + classes = as_list(safe_attr(source_file, "classes")) + + payload: dict[str, Any] = { + "schema_version": GRAPH_COMMAND_JSON_SCHEMA_VERSION, + "file": file_path_of(source_file), + "lines": len(str(source).splitlines()), + "imports": len(imports), + "classes": len(classes), + "functions": len(all_functions_in_file(source_file)), + "shown_functions": len(function_payloads), + "level": level, + } + if level != "summary": + payload["function_details"] = function_payloads + return payload + + +def _function_payload(function: Any, *, level: str, max_calls: int) -> dict[str, Any]: + calls = as_list(safe_attr(function, "function_calls")) + payload = { + **(symbol_record(function) or {}), + "calls": len(calls), + } + if level in {"calls", "full"}: + payload["uses"] = [_call_label(call) for call in calls[:max_calls]] + if level == "full": + payload["call_details"] = [call_record(call) for call in calls[:max_calls]] + return payload + + +def _call_label(call: Any) -> str: + definitions = as_list(safe_attr(call, "function_definitions")) + if definitions: + definition = definitions[0] + record = symbol_record(definition) or {} + return str(record.get("qualified_name") or record.get("name") or safe_attr(call, "name", "")) + return str(safe_attr(call, "name", "")) + + +def _print_summary(payload: dict[str, Any]) -> None: + rich.print(f"[bold]Graph-sitter file inspect[/bold] {payload['file']}") + rich.print( + "Lines: {lines} Imports: {imports} Classes: {classes} Functions: {functions}".format( + **payload, + ) + ) + if payload["level"] == "summary": + return + + table = Table(show_header=True, header_style="bold") + table.add_column("Function") + table.add_column("Line", justify="right") + table.add_column("Calls", justify="right") + if payload["level"] in {"calls", "full"}: + table.add_column("Uses") + + for function in payload.get("function_details", []): + row = [ + str(function.get("qualified_name") or function.get("name")), + str(function.get("line") or ""), + str(function.get("calls", 0)), + ] + if payload["level"] in {"calls", "full"}: + row.append(", ".join(function.get("uses", []))) + table.add_row(*row) + rich.print(table) diff --git a/src/graph_sitter/cli/commands/rename/main.py b/src/graph_sitter/cli/commands/rename/main.py new file mode 100644 index 000000000..0360d87aa --- /dev/null +++ b/src/graph_sitter/cli/commands/rename/main.py @@ -0,0 +1,107 @@ +from pathlib import Path +from typing import Any + +import rich +import rich_click as click + +from graph_sitter.cli.commands.graph.common import ( + GRAPH_COMMAND_JSON_SCHEMA_VERSION, + as_list, + emit_json, + graph_options, + load_codebase, + resolve_target, + safe_attr, + symbol_record, +) + + +@click.command(name="rename") +@click.argument("target", type=str) +@click.argument("path", required=False, type=click.Path(path_type=Path, exists=True, file_okay=False), default=Path(".")) +@click.option("--to", "new_name", required=True, help="New symbol name.") +@click.option("--check", is_flag=True, help="Preview the resolved target and reference counts without writing. This is the default.") +@click.option("--write", is_flag=True, help="Apply the rename and write changes to disk.") +@click.option("--format", "output_format", type=click.Choice(["summary", "json"]), default="summary", show_default=True, help="Output format.") +@graph_options +def rename_command( + target: str, + path: Path, + new_name: str, + check: bool, + write: bool, + output_format: str, + backend: str, + fallback: str, + language: str, + subdirectories: tuple[str, ...], +) -> None: + """Rename a function or symbol across its resolved references.""" + if check and write: + msg = "--check and --write cannot be used together" + raise click.ClickException(msg) + + codebase = load_codebase(path, backend, fallback, language, subdirectories, quiet=output_format == "json") + resolved = resolve_target(codebase, target) + symbol = resolved.symbol + old_name = str(safe_attr(symbol, "name", target)) + reference_count = len(as_list(safe_attr(symbol, "usages"))) + call_site_count = len(as_list(safe_attr(symbol, "call_sites"))) + applied = bool(write) + + if write: + rename = safe_attr(symbol, "rename") + if not callable(rename): + msg = f"Resolved target cannot be renamed: {target}" + raise click.ClickException(msg) + rename(new_name) + codebase.commit() + + payload = _payload( + target=target, + old_name=old_name, + new_name=new_name, + symbol=symbol, + reference_count=reference_count, + call_site_count=call_site_count, + applied=applied, + ) + + if output_format == "json": + emit_json(payload) + return + + _print_summary(payload) + + +def _payload( + *, + target: str, + old_name: str, + new_name: str, + symbol: Any, + reference_count: int, + call_site_count: int, + applied: bool, +) -> dict[str, Any]: + return { + "schema_version": GRAPH_COMMAND_JSON_SCHEMA_VERSION, + "target": target, + "old_name": old_name, + "new_name": new_name, + "symbol": symbol_record(symbol), + "references": reference_count, + "call_sites": call_site_count, + "applied": applied, + } + + +def _print_summary(payload: dict[str, Any]) -> None: + symbol = payload.get("symbol") or {} + mode = "Applied" if payload["applied"] else "Dry run" + rich.print(f"[bold]Graph-sitter rename[/bold] {mode}") + rich.print(f"Target: {symbol.get('location') or payload['target']}") + rich.print(f"Rename: {payload['old_name']} -> {payload['new_name']}") + rich.print(f"References: {payload['references']} Call sites: {payload['call_sites']}") + if not payload["applied"]: + rich.print("Pass --write to apply this rename.") diff --git a/src/graph_sitter/cli/commands/usages/main.py b/src/graph_sitter/cli/commands/usages/main.py new file mode 100644 index 000000000..51083edd8 --- /dev/null +++ b/src/graph_sitter/cli/commands/usages/main.py @@ -0,0 +1,79 @@ +from pathlib import Path +from typing import Any + +import rich +import rich_click as click +from rich.table import Table + +from graph_sitter.cli.commands.graph.common import ( + GRAPH_COMMAND_JSON_SCHEMA_VERSION, + emit_json, + graph_options, + load_codebase, + resolve_target, + trace_edges, +) + + +@click.command(name="usages") +@click.argument("target", type=str) +@click.argument("path", required=False, type=click.Path(path_type=Path, exists=True, file_okay=False), default=Path(".")) +@click.option("--depth", type=click.IntRange(min=0), default=1, show_default=True, help="Recursion depth through inbound callers.") +@click.option("--max-results", type=click.IntRange(min=1), default=200, show_default=True, help="Maximum call edges to print.") +@click.option("--format", "output_format", type=click.Choice(["summary", "json"]), default="summary", show_default=True, help="Output format.") +@graph_options +def usages_command( + target: str, + path: Path, + depth: int, + max_results: int, + output_format: str, + backend: str, + fallback: str, + language: str, + subdirectories: tuple[str, ...], +) -> None: + """Trace call sites that use a target.""" + codebase = load_codebase(path, backend, fallback, language, subdirectories, quiet=output_format == "json") + resolved = resolve_target(codebase, target) + edges = trace_edges(resolved.symbol, direction="inbound", depth=depth, max_results=max_results) + payload = { + "schema_version": GRAPH_COMMAND_JSON_SCHEMA_VERSION, + "direction": "inbound", + "target": target, + "depth": depth, + "max_results": max_results, + "edges": edges, + } + + if output_format == "json": + emit_json(payload) + return + + _print_edges("Graph-sitter usages", target, edges) + + +def _print_edges(title: str, target: str, edges: list[dict[str, Any]]) -> None: + rich.print(f"[bold]{title}[/bold] {target}") + if not edges: + rich.print("No inbound call edges found.") + return + + table = Table(show_header=True, header_style="bold") + table.add_column("Depth", justify="right") + table.add_column("Caller") + table.add_column("Target") + table.add_column("Call") + table.add_column("Location") + for edge in edges: + source = edge.get("source") or {} + target_record = edge.get("target") or {} + call = edge.get("call") or {} + table.add_row( + str(edge.get("depth", "")), + str(source.get("qualified_name") or source.get("name") or source.get("file") or ""), + str(target_record.get("qualified_name") or target_record.get("name") or ""), + str(call.get("name") or ""), + str(call.get("location") or ""), + ) + rich.print(table) diff --git a/src/graph_sitter/cli/commands/using/main.py b/src/graph_sitter/cli/commands/using/main.py new file mode 100644 index 000000000..e8d0fd28e --- /dev/null +++ b/src/graph_sitter/cli/commands/using/main.py @@ -0,0 +1,79 @@ +from pathlib import Path +from typing import Any + +import rich +import rich_click as click +from rich.table import Table + +from graph_sitter.cli.commands.graph.common import ( + GRAPH_COMMAND_JSON_SCHEMA_VERSION, + emit_json, + graph_options, + load_codebase, + resolve_target, + trace_edges, +) + + +@click.command(name="using") +@click.argument("target", type=str) +@click.argument("path", required=False, type=click.Path(path_type=Path, exists=True, file_okay=False), default=Path(".")) +@click.option("--depth", type=click.IntRange(min=0), default=1, show_default=True, help="Recursion depth through resolved outbound calls.") +@click.option("--max-results", type=click.IntRange(min=1), default=200, show_default=True, help="Maximum call edges to print.") +@click.option("--format", "output_format", type=click.Choice(["summary", "json"]), default="summary", show_default=True, help="Output format.") +@graph_options +def using_command( + target: str, + path: Path, + depth: int, + max_results: int, + output_format: str, + backend: str, + fallback: str, + language: str, + subdirectories: tuple[str, ...], +) -> None: + """Trace functions and symbols used by a target.""" + codebase = load_codebase(path, backend, fallback, language, subdirectories, quiet=output_format == "json") + resolved = resolve_target(codebase, target) + edges = trace_edges(resolved.symbol, direction="outbound", depth=depth, max_results=max_results) + payload = { + "schema_version": GRAPH_COMMAND_JSON_SCHEMA_VERSION, + "direction": "outbound", + "target": target, + "depth": depth, + "max_results": max_results, + "edges": edges, + } + + if output_format == "json": + emit_json(payload) + return + + _print_edges("Graph-sitter using", target, edges) + + +def _print_edges(title: str, target: str, edges: list[dict[str, Any]]) -> None: + rich.print(f"[bold]{title}[/bold] {target}") + if not edges: + rich.print("No resolved outbound call edges found.") + return + + table = Table(show_header=True, header_style="bold") + table.add_column("Depth", justify="right") + table.add_column("From") + table.add_column("To") + table.add_column("Call") + table.add_column("Location") + for edge in edges: + source = edge.get("source") or {} + target_record = edge.get("target") or {} + call = edge.get("call") or {} + table.add_row( + str(edge.get("depth", "")), + str(source.get("qualified_name") or source.get("name") or source.get("file") or ""), + str(target_record.get("qualified_name") or target_record.get("name") or "unresolved"), + str(call.get("name") or ""), + str(call.get("location") or ""), + ) + rich.print(table) diff --git a/tests/unit/cli/commands/graph/test_graph_commands.py b/tests/unit/cli/commands/graph/test_graph_commands.py new file mode 100644 index 000000000..aa1ba6c06 --- /dev/null +++ b/tests/unit/cli/commands/graph/test_graph_commands.py @@ -0,0 +1,170 @@ +import json +import subprocess +from pathlib import Path + +from click.testing import CliRunner + +from graph_sitter.cli.cli import main + + +def _init_repo(path: Path) -> None: + subprocess.run(["git", "init", str(path)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(path), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(path), "config", "user.name", "Test User"], check=True) + + +def _write_call_graph_repo(path: Path) -> Path: + _init_repo(path) + app = path / "app.py" + app.write_text( + """ +def leaf(): + return 1 + + +def helper(): + return leaf() + + +def entry(): + return helper() +""".lstrip() + ) + return app + + +def test_inspect_command_reports_file_functions_and_calls(tmp_path): + _write_call_graph_repo(tmp_path) + + result = CliRunner().invoke( + main, + [ + "inspect", + "app.py", + str(tmp_path), + "--language", + "python", + "--backend", + "python", + "--format", + "json", + "--level", + "calls", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["schema_version"] == 1 + assert payload["file"] == "app.py" + assert payload["functions"] == 3 + + functions = {function["name"]: function for function in payload["function_details"]} + assert functions["leaf"]["line"] == 1 + assert functions["helper"]["uses"] == ["leaf"] + assert functions["entry"]["uses"] == ["helper"] + + +def test_using_command_traces_outbound_call_graph(tmp_path): + _write_call_graph_repo(tmp_path) + + result = CliRunner().invoke( + main, + [ + "using", + "app.py:entry", + str(tmp_path), + "--language", + "python", + "--backend", + "python", + "--format", + "json", + "--depth", + "2", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + edges = {(edge["source"]["name"], edge["target"]["name"], edge["depth"]) for edge in payload["edges"]} + assert ("entry", "helper", 1) in edges + assert ("helper", "leaf", 2) in edges + + +def test_usages_command_traces_inbound_call_graph(tmp_path): + _write_call_graph_repo(tmp_path) + + result = CliRunner().invoke( + main, + [ + "usages", + "app.py:leaf", + str(tmp_path), + "--language", + "python", + "--backend", + "python", + "--format", + "json", + "--depth", + "2", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + edges = {(edge["source"]["name"], edge["target"]["name"], edge["depth"]) for edge in payload["edges"]} + assert ("helper", "leaf", 1) in edges + assert ("entry", "helper", 2) in edges + + +def test_rename_command_applies_function_rename_when_write_is_passed(tmp_path): + app = _write_call_graph_repo(tmp_path) + + dry_run = CliRunner().invoke( + main, + [ + "rename", + "app.py:leaf", + str(tmp_path), + "--to", + "branch", + "--language", + "python", + "--backend", + "python", + "--format", + "json", + ], + ) + + assert dry_run.exit_code == 0, dry_run.output + dry_run_payload = json.loads(dry_run.output) + assert dry_run_payload["applied"] is False + assert "def leaf()" in app.read_text() + + result = CliRunner().invoke( + main, + [ + "rename", + "app.py:leaf", + str(tmp_path), + "--to", + "branch", + "--language", + "python", + "--backend", + "python", + "--format", + "json", + "--write", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["applied"] is True + source = app.read_text() + assert "def branch():" in source + assert "return branch()" in source