Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions ast_rag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
)
from ast_rag.services.embedding_manager import EmbeddingManager
from ast_rag.api import ASTRagAPI
from ast_rag.utils.output import get_formatter
from ast_rag.utils.output import err_console, get_formatter
from ast_rag.services.summarizer_service import SummarizerService

app = typer.Typer(
Expand Down Expand Up @@ -203,10 +203,16 @@ def _describe_ambiguity(name: str, defs: list) -> Optional[str]:


def _warn_if_ambiguous(name: str, defs: list) -> Optional[str]:
"""Print the ambiguity note (if any) and return it for JSON consumers."""
"""Print the ambiguity note (if any) and return it for JSON consumers.

The note goes to stderr: stdout is a JSON document by default, and a
Rich-wrapped line printed into the middle of it is not parseable.
Callers that use a formatter pass the return value on so the note
survives inside the document too.
"""
note = _describe_ambiguity(name, defs)
if note:
console.print(f"[yellow]{note}[/yellow]")
err_console.print(f"[yellow]{note}[/yellow]")
return note


Expand Down Expand Up @@ -539,7 +545,8 @@ def query(
)

if not results:
console.print("[yellow]No results found.[/yellow]")
err_console.print("[yellow]No results found.[/yellow]")
formatter.format_search_results([], text)
raise typer.Exit(0)

formatter.format_search_results(results, text)
Expand Down Expand Up @@ -567,7 +574,8 @@ def goto(
nodes = api.find_definition(qualified_name, kind=kind, lang=lang)

if not nodes:
console.print(f"[yellow]Definition not found for: {qualified_name}[/yellow]")
err_console.print(f"[yellow]Definition not found for: {qualified_name}[/yellow]")
formatter.format_definitions([], api=api, snippet=snippet)
raise typer.Exit(1)

formatter.format_definitions(nodes, api=api, snippet=snippet)
Expand All @@ -593,21 +601,23 @@ def callers(

defs = api.find_definition(qualified_name, lang=lang)
if not defs:
console.print(f"[yellow]Symbol not found: {qualified_name}[/yellow]")
err_console.print(f"[yellow]Symbol not found: {qualified_name}[/yellow]")
formatter.format_callers(qualified_name, [])
raise typer.Exit(1)

_warn_if_ambiguous(qualified_name, defs)
note = _warn_if_ambiguous(qualified_name, defs)
target = defs[0]
if humanize:
console.print(f"Finding callers of [bold]{target.qualified_name}[/bold]...")
err_console.print(f"Finding callers of [bold]{target.qualified_name}[/bold]...")

caller_nodes = api.find_callers(target.id, max_depth=depth)

if not caller_nodes:
console.print("[yellow]No callers found.[/yellow]")
err_console.print("[yellow]No callers found.[/yellow]")
formatter.format_callers(target.qualified_name, [], note=note)
raise typer.Exit(0)

formatter.format_callers(target.qualified_name, caller_nodes)
formatter.format_callers(target.qualified_name, caller_nodes, note=note)


# ---------------------------------------------------------------------------
Expand Down
56 changes: 47 additions & 9 deletions ast_rag/utils/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
Abstraction layer for formatting CLI output. Supports:
- JSON (AI-friendly, default)
- Human-readable (Rich tables, with --humanize flag)

JSON is the default, so a formatter must emit exactly one JSON document on
stdout and nothing else -- including for empty results. Diagnostics belong on
stderr (``err_console``), and anything the caller must know to interpret the
result, such as an ambiguity note, is carried inside the document.
"""

from __future__ import annotations
Expand All @@ -21,32 +26,45 @@

console = Console()

# Diagnostics go here so they never land in the middle of a JSON document.
err_console = Console(stderr=True)


class OutputFormatter(ABC):
"""Abstract base class for output formatters."""

@abstractmethod
def format_search_results(self, results: list[SearchResult], query: str) -> None:
def format_search_results(
self, results: list[SearchResult], query: str, note: Optional[str] = None
) -> None:
"""Format semantic search results."""
pass

@abstractmethod
def format_definitions(
self, nodes: list[ASTNode], api: Optional[Any] = None, snippet: bool = False
self,
nodes: list[ASTNode],
api: Optional[Any] = None,
snippet: bool = False,
note: Optional[str] = None,
) -> None:
"""Format definition lookup results."""
pass

@abstractmethod
def format_callers(self, qualified_name: str, nodes: list[ASTNode]) -> None:
def format_callers(
self, qualified_name: str, nodes: list[ASTNode], note: Optional[str] = None
) -> None:
"""Format callers results."""
pass


class JSONFormatter(OutputFormatter):
"""AI-friendly JSON output (default)."""

def format_search_results(self, results: list[SearchResult], query: str) -> None:
def format_search_results(
self, results: list[SearchResult], query: str, note: Optional[str] = None
) -> None:
output = {
"query": query,
"count": len(results),
Expand All @@ -62,10 +80,16 @@ def format_search_results(self, results: list[SearchResult], query: str) -> None
for r in results
],
}
if note:
output["ambiguous"] = note
print(json.dumps(output, indent=2))

def format_definitions(
self, nodes: list[ASTNode], api: Optional[Any] = None, snippet: bool = False
self,
nodes: list[ASTNode],
api: Optional[Any] = None,
snippet: bool = False,
note: Optional[str] = None,
) -> None:
output = {
"count": len(nodes),
Expand All @@ -82,9 +106,13 @@ def format_definitions(
for n in nodes
],
}
if note:
output["ambiguous"] = note
print(json.dumps(output, indent=2))

def format_callers(self, qualified_name: str, nodes: list[ASTNode]) -> None:
def format_callers(
self, qualified_name: str, nodes: list[ASTNode], note: Optional[str] = None
) -> None:
output = {
"target": qualified_name,
"count": len(nodes),
Expand All @@ -99,6 +127,8 @@ def format_callers(self, qualified_name: str, nodes: list[ASTNode]) -> None:
for n in nodes
],
}
if note:
output["ambiguous"] = note
print(json.dumps(output, indent=2))


Expand All @@ -120,7 +150,9 @@ def _colorize_kind(self, kind: str) -> str:
return kind
return f"[{color}]{kind}[/{color}]"

def format_search_results(self, results: list[SearchResult], query: str) -> None:
def format_search_results(
self, results: list[SearchResult], query: str, note: Optional[str] = None
) -> None:
table = Table(
"Score",
"Kind",
Expand All @@ -143,7 +175,11 @@ def format_search_results(self, results: list[SearchResult], query: str) -> None
console.print(table)

def format_definitions(
self, nodes: list[ASTNode], api: Optional[Any] = None, snippet: bool = False
self,
nodes: list[ASTNode],
api: Optional[Any] = None,
snippet: bool = False,
note: Optional[str] = None,
) -> None:
for node in nodes:
console.print(
Expand Down Expand Up @@ -174,7 +210,9 @@ def format_definitions(
)
)

def format_callers(self, qualified_name: str, nodes: list[ASTNode]) -> None:
def format_callers(
self, qualified_name: str, nodes: list[ASTNode], note: Optional[str] = None
) -> None:
table = Table(
"Kind",
"Lang",
Expand Down
Loading
Loading