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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ Inputs: `-q/--query <Q>`, `--query-file <F>`, `--source <S>`, `-s/--source-file
- `--cst` — Show query CST instead of AST
- `--raw` — Include trivia tokens (whitespace, comments)
- `--spans` — Show source spans
- `--cardinalities` — Show inferred cardinalities
- `--arities` — Show node arities
- `--graph` — Show compiled transition graph
- `--graph-raw` — Show unoptimized graph (before epsilon elimination)
- `--types` — Show inferred types
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ The schema infrastructure is built. Type inference is next.
The CLI foundation exists. The full developer experience is ahead.

- [x] CLI framework with `debug`, `docs`, `langs`, `exec`, `types` commands
- [x] Query inspection: AST dump, symbol table, cardinalities, spans, transition graph, inferred types
- [x] Query inspection: AST dump, symbol table, node arities, spans, transition graph, inferred types
- [x] Source inspection: Tree-sitter parse tree visualization
- [x] Execute queries against source code and output JSON (`exec`)
- [x] Generate TypeScript types from queries (`types`)
Expand Down
4 changes: 2 additions & 2 deletions crates/plotnik-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,9 @@ pub struct OutputArgs {
#[arg(long)]
pub spans: bool,

/// Show inferred cardinalities
/// Show inferred arities
#[arg(long)]
pub cardinalities: bool,
pub arities: bool,

/// Show compiled graph
#[arg(long)]
Expand Down
6 changes: 3 additions & 3 deletions crates/plotnik-cli/src/commands/debug/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub struct DebugArgs {
pub raw: bool,
pub cst: bool,
pub spans: bool,
pub cardinalities: bool,
pub arities: bool,
pub graph: bool,
pub graph_raw: bool,
pub types: bool,
Expand Down Expand Up @@ -65,7 +65,7 @@ pub fn run(args: DebugArgs) {
.raw(args.cst || args.raw)
.with_trivia(args.raw)
.with_spans(args.spans)
.with_cardinalities(args.cardinalities)
.with_arities(args.arities)
.dump()
);
}
Expand All @@ -77,7 +77,7 @@ pub fn run(args: DebugArgs) {
"{}",
q.printer()
.only_symbols(true)
.with_cardinalities(args.cardinalities)
.with_arities(args.arities)
.dump()
);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/plotnik-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ fn main() {
raw: output.raw,
cst: output.cst,
spans: output.spans,
cardinalities: output.cardinalities,
arities: output.arities,
graph: output.graph,
graph_raw: output.graph_raw,
types: output.types,
Expand Down
48 changes: 27 additions & 21 deletions crates/plotnik-lib/src/query/alt_kinds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,43 @@ use rowan::TextRange;

use super::Query;
use super::invariants::ensure_both_branch_kinds;
use crate::diagnostics::DiagnosticKind;
use crate::parser::{AltExpr, AltKind, Branch, Expr};
use super::visitor::{Visitor, walk_alt_expr, walk_root};
use crate::diagnostics::{DiagnosticKind, Diagnostics};
use crate::parser::{AltExpr, AltKind, Branch, Root};

impl Query<'_> {
pub(super) fn validate_alt_kinds(&mut self) {
let defs: Vec<_> = self.ast.defs().collect();
for def in defs {
let Some(body) = def.body() else { continue };
self.validate_alt_expr(&body);
}
let mut visitor = AltKindsValidator {
diagnostics: &mut self.alt_kind_diagnostics,
};
visitor.visit_root(&self.ast);
}
}

struct AltKindsValidator<'a> {
diagnostics: &'a mut Diagnostics,
}

impl Visitor for AltKindsValidator<'_> {
fn visit_root(&mut self, root: &Root) {
assert!(
self.ast.exprs().next().is_none(),
root.exprs().next().is_none(),
"alt_kind: unexpected bare Expr in Root (parser should wrap in Def)"
);
walk_root(self, root);
}

fn validate_alt_expr(&mut self, expr: &Expr) {
if let Expr::AltExpr(alt) = expr {
self.check_mixed_alternation(alt);
assert!(
alt.exprs().next().is_none(),
"alt_kind: unexpected bare Expr in Alt (parser should wrap in Branch)"
);
}

for child in expr.children() {
self.validate_alt_expr(&child);
}
fn visit_alt_expr(&mut self, alt: &AltExpr) {
self.check_mixed_alternation(alt);
assert!(
alt.exprs().next().is_none(),
"alt_kind: unexpected bare Expr in Alt (parser should wrap in Branch)"
);
walk_alt_expr(self, alt);
}
}

impl AltKindsValidator<'_> {
fn check_mixed_alternation(&mut self, alt: &AltExpr) {
if alt.kind() != AltKind::Mixed {
return;
Expand All @@ -57,7 +63,7 @@ impl Query<'_> {

let untagged_range = branch_range(untagged_branch);

self.alt_kind_diagnostics
self.diagnostics
.report(DiagnosticKind::MixedAltBranches, untagged_range)
.related_to("tagged branch here", tagged_range)
.emit();
Expand Down
8 changes: 4 additions & 4 deletions crates/plotnik-lib/src/query/dump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ mod test_helpers {
self.printer().dump()
}

pub fn dump_with_cardinalities(&self) -> String {
self.printer().with_cardinalities(true).dump()
pub fn dump_with_arities(&self) -> String {
self.printer().with_arities(true).dump()
}

pub fn dump_cst_with_cardinalities(&self) -> String {
self.printer().raw(true).with_cardinalities(true).dump()
pub fn dump_cst_with_arities(&self) -> String {
self.printer().raw(true).with_arities(true).dump()
}

pub fn dump_symbols(&self) -> String {
Expand Down
181 changes: 181 additions & 0 deletions crates/plotnik-lib/src/query/expr_arity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! Expression arity analysis for query expressions.
//!
//! Determines whether an expression matches a single node position (`One`)
//! or multiple sequential positions (`Many`). Used to validate field constraints:
//! `field: expr` requires `expr` to have `ExprArity::One`.
//!
//! `Invalid` marks nodes where arity cannot be determined (error nodes,
//! undefined refs, etc.).

use super::Query;
use super::visitor::{Visitor, walk_expr, walk_field_expr};
use crate::diagnostics::DiagnosticKind;
use crate::parser::{Expr, FieldExpr, Ref, SeqExpr, SyntaxKind, SyntaxNode, ast};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExprArity {
One,
Many,
Invalid,
}

impl Query<'_> {
pub(super) fn infer_arities(&mut self) {
let root = self.ast.clone();

let mut computer = ArityComputer { query: self };
computer.visit_root(&root);

let mut validator = ArityValidator { query: self };
validator.visit_root(&root);
}

pub(super) fn get_arity(&self, node: &SyntaxNode) -> Option<ExprArity> {
if node.kind() == SyntaxKind::Error {
return Some(ExprArity::Invalid);
}

// Try casting to Expr first as it's the most common query
if let Some(expr) = ast::Expr::cast(node.clone()) {
return self.expr_arity_table.get(&expr).copied();
}

// Root: arity based on definition count
if let Some(root) = ast::Root::cast(node.clone()) {
return Some(if root.defs().nth(1).is_some() {
ExprArity::Many
} else {
ExprArity::One
});
}

// Def: delegate to body's arity
if let Some(def) = ast::Def::cast(node.clone()) {
return def
.body()
.and_then(|b| self.expr_arity_table.get(&b).copied());
}

// Branch: delegate to body's arity
if let Some(branch) = ast::Branch::cast(node.clone()) {
return branch
.body()
.and_then(|b| self.expr_arity_table.get(&b).copied());
}

None
}
}

struct ArityComputer<'a, 'q> {
query: &'a mut Query<'q>,
}

impl Visitor for ArityComputer<'_, '_> {
fn visit_expr(&mut self, expr: &Expr) {
self.query.compute_arity(expr);
walk_expr(self, expr);
}
}

struct ArityValidator<'a, 'q> {
query: &'a mut Query<'q>,
}

impl Visitor for ArityValidator<'_, '_> {
fn visit_field_expr(&mut self, field: &FieldExpr) {
self.query.validate_field(field);
walk_field_expr(self, field);
}
}

impl Query<'_> {
fn compute_arity(&mut self, expr: &Expr) -> ExprArity {
if let Some(&c) = self.expr_arity_table.get(expr) {
return c;
}
// Insert sentinel to break cycles (e.g., `Foo = (Foo)`)
self.expr_arity_table
.insert(expr.clone(), ExprArity::Invalid);

let c = self.compute_single_arity(expr);
self.expr_arity_table.insert(expr.clone(), c);
c
}

fn compute_single_arity(&mut self, expr: &Expr) -> ExprArity {
match expr {
Expr::NamedNode(_) | Expr::AnonymousNode(_) | Expr::FieldExpr(_) | Expr::AltExpr(_) => {
ExprArity::One
}

Expr::SeqExpr(seq) => self.seq_arity(seq),

Expr::CapturedExpr(cap) => cap
.inner()
.map(|inner| self.compute_arity(&inner))
.unwrap_or(ExprArity::Invalid),

Expr::QuantifiedExpr(q) => q
.inner()
.map(|inner| self.compute_arity(&inner))
.unwrap_or(ExprArity::Invalid),

Expr::Ref(r) => self.ref_arity(r),
}
}

fn seq_arity(&mut self, seq: &SeqExpr) -> ExprArity {
// Avoid collecting into Vec; check if we have 0, 1, or >1 children.
let mut children = seq.children();

match children.next() {
None => ExprArity::One,
Some(first) => {
if children.next().is_some() {
ExprArity::Many
} else {
self.compute_arity(&first)
}
}
}
}

fn ref_arity(&mut self, r: &Ref) -> ExprArity {
let name_tok = r.name().expect(
"expr_arities: Ref without name token \
(parser only creates Ref for PascalCase Id)",
);
let name = name_tok.text();

self.symbol_table
.get(name)
.cloned()
.map(|body| self.compute_arity(&body))
.unwrap_or(ExprArity::Invalid)
}

fn validate_field(&mut self, field: &FieldExpr) {
let Some(value) = field.value() else {
return;
};

let card = self
.expr_arity_table
.get(&value)
.copied()
.unwrap_or(ExprArity::One);

if card == ExprArity::Many {
let field_name = field
.name()
.map(|t| t.text().to_string())
.unwrap_or_else(|| "field".to_string());

self.expr_arity_diagnostics
.report(DiagnosticKind::FieldSequenceValue, value.text_range())
.message(field_name)
.emit();
}
}
}
Loading