Skip to content

Commit bff3175

Browse files
committed
Add CLI support for query AST output
1 parent e0d5dd2 commit bff3175

File tree

7 files changed

+519
-12
lines changed

7 files changed

+519
-12
lines changed

AGENTS.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,25 @@
2222
- Basic grammar: tree expressions `(type)`, alternation `[a b]`, wildcards `_`, captures `@name` with types `::T`, fields `field:`, quantifiers `*+?` (and non-greedy), anonymous nodes `"literal"`, supertypes `(a/b)`, special nodes `(ERROR)`, sequences `{...}`, named definitions `Name = expr`, tagged alternations `[A: ... B: ...]`
2323
- Parser accepts UpperIdent in capture/field positions for resilience (validation catches casing errors)
2424

25+
## Error Pipeline
26+
27+
Errors flow through staged analysis:
28+
- `Parse`: Syntax structure errors (lexer/parser)
29+
- `Resolve`: Name resolution errors (undefined references, duplicate definitions)
30+
- `Escape`: Recursive pattern detection errors
31+
32+
Errors are collected, not fail-fast. Use `Query::errors_in_stage()` for filtering, `Query::render_errors_grouped()` for CLI output.
33+
34+
## CLI
35+
36+
Debug flags for introspection (composable, multiple can be enabled):
37+
- `--query-cst`: Concrete syntax tree (all tokens)
38+
- `--query-ast`: Abstract syntax tree (semantic structure, concise)
39+
- `--query-refs`: Name resolution references
40+
- Future: `--query-types`, `--source-ast`, `--result`
41+
42+
Designed for LLM-friendly debugging. Errors render at the end, grouped by stage.
43+
2544
## SyntaxKind naming convention
2645

2746
Short, punchy names for CST node kinds:
@@ -47,7 +66,6 @@ An expression (`Expr`) is one of: `Tree | Alt | Seq | Quantifier | Capture`
4766
## What's NOT yet implemented
4867

4968
- Semantic validation (Phase 5): field value constraints, alternation style mixing, casing rules
50-
- AST layer: typed wrappers over `SyntaxNode` (like `struct Node(SyntaxNode)`)
5169

5270
## Intentionally deferred (post-MVP)
5371

@@ -72,6 +90,21 @@ An expression (`Expr`) is one of: `Tree | Alt | Seq | Quantifier | Capture`
7290
- **Arrays**: Quantifiers `?`, `*`, `+` determine the cardinality (optional, list, non-empty list).
7391
- **Fields**: Captures `@name` create fields within the current scope.
7492

93+
## AST Layer
94+
95+
Typed wrappers over CST (`SyntaxNode`) in `ql/ast.rs`:
96+
- `Root`, `Def`, `Tree`, `Ref`, `Lit`, `Alt`, `Branch`, `Seq`, `Capture`, `Type`, `Quantifier`, `Field`, `NegatedField`, `Wildcard`, `Anchor`
97+
- `Expr` enum for any expression node
98+
- `format_ast()` for concise semantic tree output (vs CST which includes all tokens)
99+
- Tests using AST should use `snapshot_ast()` for cleaner output
100+
101+
## AST Casting Convention
102+
103+
- Use `Option<T>` for casting methods, not `TryFrom`/`TryInto`
104+
- `None` is not always an error—parsing resilience requires flexible conversion
105+
- Validation is a separate layer from casting; don't embed error logic in cast methods
106+
- Consistent with rnix-parser, rowan ecosystem patterns
107+
75108
## General rules
76109

77110
- When the changes are made, propose an update to AGENTS.md file if it provides valuable context for future LLM agent calls

crates/plotnik-cli/src/main.rs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::path::PathBuf;
1212
│ Output │ Needs Query │ Needs Source │
1313
├─────────────────┼─────────────┼──────────────┤
1414
│ --query-cst │ ✓ │ │
15+
│ --query-ast │ ✓ │ │
1516
│ --query-refs │ ✓ │ │
1617
│ --query-types │ ✓ │ │
1718
│ --source-ast │ │ ✓ │
@@ -92,6 +93,10 @@ struct OutputArgs {
9293
#[arg(long)]
9394
query_cst: bool,
9495

96+
/// Show parsed query AST (abstract syntax tree, semantic structure)
97+
#[arg(long)]
98+
query_ast: bool,
99+
95100
/// Show name resolution (definitions and references)
96101
#[arg(long)]
97102
query_refs: bool,
@@ -125,8 +130,15 @@ fn main() {
125130
let has_source = cli.source.source_text.is_some() || cli.source.source_file.is_some();
126131

127132
// Validate output dependencies
128-
if (cli.output.query_cst || cli.output.query_refs || cli.output.query_types) && !has_query {
129-
eprintln!("error: --query-cst, --query-refs, and --query-types require --query-text or --query-file");
133+
if (cli.output.query_cst
134+
|| cli.output.query_ast
135+
|| cli.output.query_refs
136+
|| cli.output.query_types)
137+
&& !has_query
138+
{
139+
eprintln!(
140+
"error: --query-cst, --query-ast, --query-refs, and --query-types require --query-text or --query-file"
141+
);
130142
std::process::exit(1);
131143
}
132144

@@ -150,6 +162,7 @@ fn main() {
150162
|| (has_query
151163
&& has_source
152164
&& !cli.output.query_cst
165+
&& !cli.output.query_ast
153166
&& !cli.output.query_refs
154167
&& !cli.output.query_types
155168
&& !cli.output.source_ast
@@ -177,6 +190,13 @@ fn main() {
177190
}
178191
}
179192

193+
if cli.output.query_ast {
194+
println!("=== QUERY AST ===");
195+
if let Some(ref q) = query {
196+
print!("{}", q.format_ast());
197+
}
198+
}
199+
180200
if cli.output.query_refs {
181201
println!("=== QUERY REFS ===");
182202
if let Some(ref q) = query {
@@ -223,16 +243,16 @@ fn load_query(args: &QueryArgs) -> String {
223243
if let Some(ref path) = args.query_file {
224244
if path.as_os_str() == "-" {
225245
let mut buf = String::new();
226-
io::stdin().read_to_string(&mut buf).expect("failed to read stdin");
246+
io::stdin()
247+
.read_to_string(&mut buf)
248+
.expect("failed to read stdin");
227249
return buf;
228250
}
229251
return fs::read_to_string(path).expect("failed to read query file");
230252
}
231253
unreachable!()
232254
}
233255

234-
235-
236256
fn print_help(topic: Option<&str>) {
237257
match topic {
238258
None => {
@@ -255,4 +275,4 @@ fn print_help(topic: Option<&str>) {
255275
std::process::exit(1);
256276
}
257277
}
258-
}
278+
}

crates/plotnik-lib/src/lib.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
1818
pub mod ql;
1919

20-
use ql::ast::Root;
20+
use ql::ast::{Root, format_ast};
2121
use ql::parser::{self, ErrorStage, Parse, SyntaxError};
2222
use ql::resolve::SymbolTable;
2323
use ql::syntax_kind::SyntaxNode;
@@ -151,6 +151,11 @@ impl<'a> Query<'a> {
151151
out
152152
}
153153

154+
/// Format AST structure (semantic tree without syntactic tokens).
155+
pub fn format_ast(&self) -> String {
156+
format_ast(&self.root())
157+
}
158+
154159
/// Format symbol references (without errors).
155160
pub fn format_refs(&self) -> String {
156161
let mut out = String::new();
@@ -183,6 +188,17 @@ impl<'a> Query<'a> {
183188
out
184189
}
185190

191+
/// Snapshot of AST structure (with errors).
192+
pub fn snapshot_ast(&self) -> String {
193+
let mut out = self.format_ast();
194+
if !self.errors.is_empty() {
195+
out.push_str("---\n");
196+
out.push_str(&self.render_errors());
197+
out.push('\n');
198+
}
199+
out
200+
}
201+
186202
/// Snapshot of CST structure (with trivia, with errors).
187203
pub fn snapshot_cst_raw(&self) -> String {
188204
let mut out = self.format_cst_raw();

crates/plotnik-lib/src/ql/ast.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! Cast is infallible for correct `SyntaxKind` - validation happens elsewhere.
55
66
use crate::ql::syntax_kind::{SyntaxKind, SyntaxNode, SyntaxToken};
7+
use std::fmt::Write;
78

89
macro_rules! ast_node {
910
($name:ident, $kind:ident) => {
@@ -252,3 +253,139 @@ impl Lit {
252253
.find(|t| t.kind() == SyntaxKind::StringLit)
253254
}
254255
}
256+
257+
pub fn format_ast(root: &Root) -> String {
258+
let mut out = String::new();
259+
format_root(root, &mut out);
260+
out
261+
}
262+
263+
fn format_root(root: &Root, out: &mut String) {
264+
out.push_str("Root\n");
265+
for def in root.defs() {
266+
format_def(&def, 1, out);
267+
}
268+
for expr in root.exprs() {
269+
format_expr(&expr, 1, out);
270+
}
271+
}
272+
273+
fn format_def(def: &Def, indent: usize, out: &mut String) {
274+
let prefix = " ".repeat(indent);
275+
let name = def.name().map(|t| t.text().to_string());
276+
match name {
277+
Some(n) => {
278+
let _ = writeln!(out, "{}Def {}", prefix, n);
279+
}
280+
None => {
281+
let _ = writeln!(out, "{}Def", prefix);
282+
}
283+
}
284+
if let Some(body) = def.body() {
285+
format_expr(&body, indent + 1, out);
286+
}
287+
}
288+
289+
fn format_expr(expr: &Expr, indent: usize, out: &mut String) {
290+
let prefix = " ".repeat(indent);
291+
match expr {
292+
Expr::Tree(t) => {
293+
let node_type = t.node_type().map(|tok| tok.text().to_string());
294+
match node_type {
295+
Some(ty) => {
296+
let _ = writeln!(out, "{}Tree {}", prefix, ty);
297+
}
298+
None => {
299+
let _ = writeln!(out, "{}Tree", prefix);
300+
}
301+
}
302+
for child in t.children() {
303+
format_expr(&child, indent + 1, out);
304+
}
305+
}
306+
Expr::Ref(r) => {
307+
let name = r.name().map(|t| t.text().to_string()).unwrap_or_default();
308+
let _ = writeln!(out, "{}Ref {}", prefix, name);
309+
}
310+
Expr::Lit(l) => {
311+
let value = l.value().map(|t| t.text().to_string()).unwrap_or_default();
312+
let _ = writeln!(out, "{}Lit {}", prefix, value);
313+
}
314+
Expr::Alt(a) => {
315+
let _ = writeln!(out, "{}Alt", prefix);
316+
for branch in a.branches() {
317+
format_branch(&branch, indent + 1, out);
318+
}
319+
for expr in a.exprs() {
320+
format_expr(&expr, indent + 1, out);
321+
}
322+
}
323+
Expr::Seq(s) => {
324+
let _ = writeln!(out, "{}Seq", prefix);
325+
for child in s.children() {
326+
format_expr(&child, indent + 1, out);
327+
}
328+
}
329+
Expr::Capture(c) => {
330+
let name = c.name().map(|t| t.text().to_string()).unwrap_or_default();
331+
let type_ann = c
332+
.type_annotation()
333+
.and_then(|t| t.name())
334+
.map(|t| t.text().to_string());
335+
match type_ann {
336+
Some(ty) => {
337+
let _ = writeln!(out, "{}Capture @{} :: {}", prefix, name, ty);
338+
}
339+
None => {
340+
let _ = writeln!(out, "{}Capture @{}", prefix, name);
341+
}
342+
}
343+
if let Some(inner) = c.inner() {
344+
format_expr(&inner, indent + 1, out);
345+
}
346+
}
347+
Expr::Quantifier(q) => {
348+
let op = q
349+
.operator()
350+
.map(|t| t.text().to_string())
351+
.unwrap_or_default();
352+
let _ = writeln!(out, "{}Quantifier {}", prefix, op);
353+
if let Some(inner) = q.inner() {
354+
format_expr(&inner, indent + 1, out);
355+
}
356+
}
357+
Expr::Field(f) => {
358+
let name = f.name().map(|t| t.text().to_string()).unwrap_or_default();
359+
let _ = writeln!(out, "{}Field {}:", prefix, name);
360+
if let Some(value) = f.value() {
361+
format_expr(&value, indent + 1, out);
362+
}
363+
}
364+
Expr::NegatedField(f) => {
365+
let name = f.name().map(|t| t.text().to_string()).unwrap_or_default();
366+
let _ = writeln!(out, "{}NegatedField !{}", prefix, name);
367+
}
368+
Expr::Wildcard(_) => {
369+
let _ = writeln!(out, "{}Wildcard", prefix);
370+
}
371+
Expr::Anchor(_) => {
372+
let _ = writeln!(out, "{}Anchor", prefix);
373+
}
374+
}
375+
}
376+
377+
fn format_branch(branch: &Branch, indent: usize, out: &mut String) {
378+
let prefix = " ".repeat(indent);
379+
let label = branch.label().map(|t| t.text().to_string());
380+
match label {
381+
Some(l) => {
382+
let _ = writeln!(out, "{}Branch {}:", prefix, l);
383+
}
384+
None => {
385+
let _ = writeln!(out, "{}Branch", prefix);
386+
}
387+
}
388+
if let Some(body) = branch.body() {
389+
format_expr(&body, indent + 1, out);
390+
}
391+
}

0 commit comments

Comments
 (0)