From 0f8613fbce72a720f4bda8e88b211f3a77390617 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 01:08:50 -0300 Subject: [PATCH 01/11] feat: Type inference --- AGENTS.md | 34 +- crates/plotnik-cli/src/cli.rs | 101 ++++ crates/plotnik-cli/src/commands/infer.rs | 146 +++++ crates/plotnik-cli/src/commands/mod.rs | 1 + crates/plotnik-cli/src/main.rs | 25 + crates/plotnik-lib/src/diagnostics/message.rs | 18 + crates/plotnik-lib/src/infer/emit/rust.rs | 17 +- .../plotnik-lib/src/infer/emit/rust_tests.rs | 2 +- .../plotnik-lib/src/infer/emit/typescript.rs | 63 ++- .../src/infer/emit/typescript_tests.rs | 40 +- crates/plotnik-lib/src/infer/mod.rs | 11 +- crates/plotnik-lib/src/infer/printer.rs | 151 ++++++ crates/plotnik-lib/src/parser/core.rs | 1 + crates/plotnik-lib/src/parser/lexer_tests.rs | 30 + .../src/parser/tests/grammar/trivia_tests.rs | 4 +- crates/plotnik-lib/src/query/dump.rs | 5 + crates/plotnik-lib/src/query/mod.rs | 21 + crates/plotnik-lib/src/query/types.rs | 512 ++++++++++++++++++ crates/plotnik-lib/src/query/types_tests.rs | 164 ++++++ 19 files changed, 1277 insertions(+), 69 deletions(-) create mode 100644 crates/plotnik-cli/src/commands/infer.rs create mode 100644 crates/plotnik-lib/src/infer/printer.rs create mode 100644 crates/plotnik-lib/src/query/types.rs create mode 100644 crates/plotnik-lib/src/query/types_tests.rs diff --git a/AGENTS.md b/AGENTS.md index 365da2b5..cea75848 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,19 @@ crates/ recursion.rs # Escape analysis (recursion validation) shapes.rs # Shape inference *_tests.rs # Test files per module + infer/ # Type inference and emission + mod.rs # Re-exports, TypePrinter builder + types.rs # Type IR (TypeDef, Field, etc.) + tyton.rs # Tyton → TypeDef conversion + printer.rs # TypePrinter API + emit/ # Language-specific emitters + mod.rs # Emitter trait, common utilities + rust.rs # Rust type emission + typescript.rs # TypeScript type emission + *_tests.rs # Test files per module lib.rs # Re-exports Query, Diagnostics, Error plotnik-cli/ # CLI tool - src/commands/ # Subcommands (debug, docs, langs) + src/commands/ # Subcommands (debug, docs, infer, langs) plotnik-langs/ # Tree-sitter language bindings docs/ REFERENCE.md # Language specification @@ -59,6 +69,7 @@ Module = "what", function = "action". Run: `cargo run -p plotnik-cli -- ` - `debug` — Inspect queries/sources +- `infer` — Generate type definitions from queries - `docs [topic]` — Print docs (reference, examples) - `langs` — List supported languages @@ -76,6 +87,27 @@ cargo run -p plotnik-cli -- debug -s app.ts --raw cargo run -p plotnik-cli -- debug -q '(function_declaration) @fn' -s app.ts -l typescript ``` +### infer options + +Input: `-q/--query `, `--query-file ` + +Output language: `-l/--lang ` + +Common: `--entry-name `, `--color ` + +Rust-specific: `--indirection `, `--derive `, `--no-derive` + +TypeScript-specific: `--optional `, `--export`, `--readonly`, `--type-alias`, `--node-type `, `--nested` + +```sh +cargo run -p plotnik-cli -- infer -q '(identifier) @id' -l rust +cargo run -p plotnik-cli -- infer -q '(fn)' -l rust --derive debug,clone +cargo run -p plotnik-cli -- infer -q '(fn)' -l rust --no-derive +cargo run -p plotnik-cli -- infer -q '(identifier)' -l ts --export +cargo run -p plotnik-cli -- infer -q '(identifier)?' -l ts --optional undefined +cargo run -p plotnik-cli -- infer -q '(fn)' -l ts --readonly --type-alias +``` + ## Syntax Grammar: `(type)`, `[a b]` (alt), `{a b}` (seq), `_` (wildcard), `@name`, `::Type`, `field:`, `*+?`, `"lit"`/`'lit'`, `(a/b)` (supertype), `(ERROR)`, `Name = expr` (def), `[A: ... B: ...]` (tagged alt) diff --git a/crates/plotnik-cli/src/cli.rs b/crates/plotnik-cli/src/cli.rs index 395fad1f..55599c86 100644 --- a/crates/plotnik-cli/src/cli.rs +++ b/crates/plotnik-cli/src/cli.rs @@ -2,6 +2,31 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand, ValueEnum}; +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum OutputLang { + #[default] + Rust, + Typescript, + Ts, +} + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum IndirectionChoice { + #[default] + Box, + Rc, + Arc, +} + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum OptionalChoice { + #[default] + Null, + Undefined, + #[value(name = "questionmark")] + QuestionMark, +} + #[derive(Clone, Copy, Debug, Default, ValueEnum)] pub enum ColorChoice { #[default] @@ -52,6 +77,29 @@ pub enum Command { output: OutputArgs, }, + /// Infer and emit types from a query + #[command(after_help = r#"EXAMPLES: + plotnik infer -q '(identifier) @id' -l rust + plotnik infer -q '(function_declaration name: (identifier) @name) @fn' -l ts --export + plotnik infer --query-file query.plot -l rust --derive debug,clone,partialeq"#)] + Infer { + #[command(flatten)] + query: QueryArgs, + + /// Output language + #[arg(short = 'l', long, value_name = "LANG")] + lang: OutputLang, + + #[command(flatten)] + common: InferCommonArgs, + + #[command(flatten)] + rust: RustArgs, + + #[command(flatten)] + typescript: TypeScriptArgs, + }, + /// Print documentation Docs { /// Topic to display (e.g., "reference", "examples") @@ -112,3 +160,56 @@ pub struct OutputArgs { #[arg(long)] pub cardinalities: bool, } + +#[derive(Args)] +pub struct InferCommonArgs { + /// Name for the entry point type (default: QueryResult) + #[arg(long, value_name = "NAME")] + pub entry_name: Option, + + /// Colorize diagnostics output + #[arg(long, default_value = "auto", value_name = "WHEN")] + pub color: ColorChoice, +} + +#[derive(Args)] +pub struct RustArgs { + /// Indirection type for cyclic references + #[arg(long, value_name = "TYPE")] + pub indirection: Option, + + /// Derive macros (comma-separated: debug, clone, partialeq) + #[arg(long, value_name = "TRAITS", value_delimiter = ',')] + pub derive: Option>, + + /// Emit no derive macros + #[arg(long)] + pub no_derive: bool, +} + +#[derive(Args)] +pub struct TypeScriptArgs { + /// How to represent optional values + #[arg(long, value_name = "STYLE")] + pub optional: Option, + + /// Add export keyword to types + #[arg(long)] + pub export: bool, + + /// Make fields readonly + #[arg(long)] + pub readonly: bool, + + /// Use type aliases instead of interfaces + #[arg(long)] + pub type_alias: bool, + + /// Name for the Node type (default: SyntaxNode) + #[arg(long, value_name = "NAME")] + pub node_type: Option, + + /// Emit nested synthetic types instead of inlining + #[arg(long)] + pub nested: bool, +} diff --git a/crates/plotnik-cli/src/commands/infer.rs b/crates/plotnik-cli/src/commands/infer.rs new file mode 100644 index 00000000..7c119793 --- /dev/null +++ b/crates/plotnik-cli/src/commands/infer.rs @@ -0,0 +1,146 @@ +use std::fs; +use std::io::{self, Read}; + +use plotnik_lib::Query; +use plotnik_lib::infer::{Indirection, OptionalStyle}; + +use crate::cli::{IndirectionChoice, OptionalChoice, OutputLang}; + +pub struct InferArgs { + pub query_text: Option, + pub query_file: Option, + pub lang: OutputLang, + pub entry_name: Option, + pub color: bool, + // Rust options + pub indirection: Option, + pub derive: Option>, + pub no_derive: bool, + // TypeScript options + pub optional: Option, + pub export: bool, + pub readonly: bool, + pub type_alias: bool, + pub node_type: Option, + pub nested: bool, +} + +pub fn run(args: InferArgs) { + if let Err(msg) = validate(&args) { + eprintln!("error: {}", msg); + std::process::exit(1); + } + + let query_source = load_query(&args); + + let query = Query::try_from(query_source.as_str()).unwrap_or_else(|e| { + eprintln!("error: {}", e); + std::process::exit(1); + }); + + if !query.is_valid() { + eprint!( + "{}", + query + .diagnostics() + .render_colored(&query_source, args.color) + ); + std::process::exit(1); + } + + let output = emit_types(&query, &args); + println!("{}", output); + + if query.diagnostics().has_warnings() { + eprint!( + "{}", + query + .diagnostics() + .render_colored(&query_source, args.color) + ); + } +} + +fn validate(args: &InferArgs) -> Result<(), &'static str> { + if args.query_text.is_none() && args.query_file.is_none() { + return Err("query input required: -q/--query or --query-file"); + } + + Ok(()) +} + +fn load_query(args: &InferArgs) -> String { + if let Some(ref text) = args.query_text { + return text.clone(); + } + if let Some(ref path) = args.query_file { + if path.as_os_str() == "-" { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .expect("failed to read stdin"); + return buf; + } + return fs::read_to_string(path).expect("failed to read query file"); + } + unreachable!() +} + +fn emit_types(query: &Query<'_>, args: &InferArgs) -> String { + let mut printer = query.type_printer(); + + if let Some(ref name) = args.entry_name { + printer = printer.entry_name(name); + } + + match args.lang { + OutputLang::Rust => emit_rust(printer, args), + OutputLang::Typescript | OutputLang::Ts => emit_typescript(printer, args), + } +} + +fn emit_rust(printer: plotnik_lib::infer::TypePrinter<'_>, args: &InferArgs) -> String { + let mut rust = printer.rust(); + + if let Some(ind) = args.indirection { + let indirection = match ind { + IndirectionChoice::Box => Indirection::Box, + IndirectionChoice::Rc => Indirection::Rc, + IndirectionChoice::Arc => Indirection::Arc, + }; + rust = rust.indirection(indirection); + } + + if args.no_derive { + rust = rust.derive(&[]); + } else if let Some(ref traits) = args.derive { + let trait_refs: Vec<&str> = traits.iter().map(|s| s.as_str()).collect(); + rust = rust.derive(&trait_refs); + } + + rust.render() +} + +fn emit_typescript(printer: plotnik_lib::infer::TypePrinter<'_>, args: &InferArgs) -> String { + let mut ts = printer.typescript(); + + if let Some(opt) = args.optional { + let style = match opt { + OptionalChoice::Null => OptionalStyle::Null, + OptionalChoice::Undefined => OptionalStyle::Undefined, + OptionalChoice::QuestionMark => OptionalStyle::QuestionMark, + }; + ts = ts.optional(style); + } + + ts = ts.export(args.export); + ts = ts.readonly(args.readonly); + ts = ts.type_alias(args.type_alias); + ts = ts.nested(args.nested); + + if let Some(ref name) = args.node_type { + ts = ts.node_type(name); + } + + ts.render() +} diff --git a/crates/plotnik-cli/src/commands/mod.rs b/crates/plotnik-cli/src/commands/mod.rs index 37b04dfb..f33f5594 100644 --- a/crates/plotnik-cli/src/commands/mod.rs +++ b/crates/plotnik-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ pub mod debug; pub mod docs; +pub mod infer; pub mod langs; diff --git a/crates/plotnik-cli/src/main.rs b/crates/plotnik-cli/src/main.rs index b67e3465..41693a23 100644 --- a/crates/plotnik-cli/src/main.rs +++ b/crates/plotnik-cli/src/main.rs @@ -3,6 +3,7 @@ mod commands; use cli::{Cli, Command}; use commands::debug::DebugArgs; +use commands::infer::InferArgs; fn main() { let cli = ::parse(); @@ -28,6 +29,30 @@ fn main() { color: output.color.should_colorize(), }); } + Command::Infer { + query, + lang, + common, + rust, + typescript, + } => { + commands::infer::run(InferArgs { + query_text: query.query_text, + query_file: query.query_file, + lang, + entry_name: common.entry_name, + color: common.color.should_colorize(), + indirection: rust.indirection, + derive: rust.derive, + no_derive: rust.no_derive, + optional: typescript.optional, + export: typescript.export, + readonly: typescript.readonly, + type_alias: typescript.type_alias, + node_type: typescript.node_type, + nested: typescript.nested, + }); + } Command::Docs { topic } => { commands::docs::run(topic.as_deref()); } diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index dfad769a..738c124e 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -60,6 +60,10 @@ pub enum DiagnosticKind { RecursionNoEscape, FieldSequenceValue, + // Type inference errors + TypeConflictInMerge, + MergeAltRequiresAnnotation, + // Link pass - grammar validation UnknownNodeType, UnknownField, @@ -164,6 +168,12 @@ impl DiagnosticKind { Self::RecursionNoEscape => "infinite recursion detected", Self::FieldSequenceValue => "field must match exactly one node", + // Type inference errors + Self::TypeConflictInMerge => "capture has conflicting types across branches", + Self::MergeAltRequiresAnnotation => { + "merged alternation with captures requires type annotation" + } + // Link pass - grammar validation Self::UnknownNodeType => "unknown node type", Self::UnknownField => "unknown field", @@ -201,6 +211,14 @@ impl DiagnosticKind { // Recursion with cycle path Self::RecursionNoEscape => "infinite recursion: {}".to_string(), + // Type inference + Self::TypeConflictInMerge => { + "capture `{}` has conflicting types across branches".to_string() + } + Self::MergeAltRequiresAnnotation => { + "merged alternation requires `:: {}` type annotation".to_string() + } + // Alternation mixing Self::MixedAltBranches => "cannot mix labeled and unlabeled branches: {}".to_string(), diff --git a/crates/plotnik-lib/src/infer/emit/rust.rs b/crates/plotnik-lib/src/infer/emit/rust.rs index b3680273..5f719feb 100644 --- a/crates/plotnik-lib/src/infer/emit/rust.rs +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -9,19 +9,22 @@ use super::super::types::{TypeKey, TypeTable, TypeValue}; /// Configuration for Rust emission. #[derive(Debug, Clone)] pub struct RustEmitConfig { + /// Name for the entry point type (default: "QueryResult"). + pub entry_name: String, /// Indirection type for cyclic references. pub indirection: Indirection, - /// Whether to derive common traits. + /// Whether to derive Debug. pub derive_debug: bool, + /// Whether to derive Clone. pub derive_clone: bool, + /// Whether to derive PartialEq. pub derive_partial_eq: bool, - /// Name for the default (unnamed) query entry point type. - pub default_query_name: String, } /// How to handle cyclic type references. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Indirection { + #[default] Box, Rc, Arc, @@ -30,11 +33,11 @@ pub enum Indirection { impl Default for RustEmitConfig { fn default() -> Self { Self { + entry_name: "QueryResult".to_string(), indirection: Indirection::Box, derive_debug: true, derive_clone: true, derive_partial_eq: false, - default_query_name: "QueryResult".to_string(), } } } @@ -71,7 +74,7 @@ fn emit_type_def( config: &RustEmitConfig, ) -> String { let name = match key { - TypeKey::DefaultQuery => config.default_query_name.clone(), + TypeKey::DefaultQuery => config.entry_name.clone(), _ => key.to_pascal_case(), }; @@ -155,7 +158,7 @@ pub(crate) fn emit_type_ref( } // Struct, TaggedUnion, or undefined forward reference - use pascal-cased name Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) | None => match key { - TypeKey::DefaultQuery => config.default_query_name.clone(), + TypeKey::DefaultQuery => config.entry_name.clone(), _ => key.to_pascal_case(), }, }; diff --git a/crates/plotnik-lib/src/infer/emit/rust_tests.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs index 932d64a1..79b60263 100644 --- a/crates/plotnik-lib/src/infer/emit/rust_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -556,7 +556,7 @@ fn emit_default_query_struct() { fn emit_default_query_custom_name() { let input = "#DefaultQuery = { #Node @value }"; let config = RustEmitConfig { - default_query_name: "MyResult".to_string(), + entry_name: "MyResult".to_string(), ..Default::default() }; diff --git a/crates/plotnik-lib/src/infer/emit/typescript.rs b/crates/plotnik-lib/src/infer/emit/typescript.rs index 72621fd1..c294b384 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -10,19 +10,19 @@ use super::super::types::{TypeKey, TypeTable, TypeValue}; #[derive(Debug, Clone)] pub struct TypeScriptEmitConfig { /// How to represent optional values. - pub optional_style: OptionalStyle, + pub optional: OptionalStyle, /// Whether to export types. pub export: bool, /// Whether to make fields readonly. pub readonly: bool, - /// Whether to inline synthetic types. - pub inline_synthetic: bool, + /// Whether to emit nested synthetic types instead of inlining them. + pub nested: bool, /// Name for the Node type. - pub node_type_name: String, + pub node_type: String, /// Whether to emit `type Foo = ...` instead of `interface Foo { ... }`. - pub use_type_alias: bool, + pub type_alias: bool, /// Name for the default (unnamed) query entry point. - pub default_query_name: String, + pub entry_name: String, } /// How to represent optional types. @@ -39,13 +39,13 @@ pub enum OptionalStyle { impl Default for TypeScriptEmitConfig { fn default() -> Self { Self { - optional_style: OptionalStyle::Null, + optional: OptionalStyle::Null, export: false, readonly: false, - inline_synthetic: true, - node_type_name: "SyntaxNode".to_string(), - use_type_alias: false, - default_query_name: "QueryResult".to_string(), + nested: false, + node_type: "SyntaxNode".to_string(), + type_alias: false, + entry_name: "QueryResult".to_string(), } } } @@ -65,8 +65,8 @@ pub fn emit_typescript(table: &TypeTable<'_>, config: &TypeScriptEmitConfig) -> continue; } - // Skip synthetic types if inlining - if config.inline_synthetic && matches!(key, TypeKey::Synthetic(_)) { + // Skip synthetic types if not nested (i.e., inlining) + if !config.nested && matches!(key, TypeKey::Synthetic(_)) { continue; } @@ -97,7 +97,7 @@ fn emit_type_def( TypeValue::Node | TypeValue::String | TypeValue::Unit | TypeValue::Invalid => String::new(), TypeValue::Struct(fields) => { - if config.use_type_alias { + if config.type_alias { let inline = emit_inline_struct(fields, table, config); format!("{}type {} = {};", export_prefix, name, inline) } else if fields.is_empty() { @@ -107,12 +107,12 @@ fn emit_type_def( for (field_name, field_type) in fields { let (type_str, is_optional) = emit_field_type(field_type, table, config); let readonly = if config.readonly { "readonly " } else { "" }; - let optional = - if is_optional && config.optional_style == OptionalStyle::QuestionMark { - "?" - } else { - "" - }; + let optional = if is_optional && config.optional == OptionalStyle::QuestionMark + { + "?" + } else { + "" + }; out.push_str(&format!( " {}{}{}: {};\n", readonly, field_name, optional, type_str @@ -134,13 +134,12 @@ fn emit_type_def( if let Some(TypeValue::Struct(fields)) = table.get(variant_key) { for (field_name, field_type) in fields { let (type_str, is_optional) = emit_field_type(field_type, table, config); - let optional = if is_optional - && config.optional_style == OptionalStyle::QuestionMark - { - "?" - } else { - "" - }; + let optional = + if is_optional && config.optional == OptionalStyle::QuestionMark { + "?" + } else { + "" + }; out.push_str(&format!("; {}{}: {}", field_name, optional, type_str)); } } @@ -167,13 +166,13 @@ pub(crate) fn emit_field_type( config: &TypeScriptEmitConfig, ) -> (String, bool) { match table.get(key) { - Some(TypeValue::Node) => (config.node_type_name.clone(), false), + Some(TypeValue::Node) => (config.node_type.clone(), false), Some(TypeValue::String) => ("string".to_string(), false), Some(TypeValue::Unit) | Some(TypeValue::Invalid) => ("{}".to_string(), false), Some(TypeValue::Optional(inner)) => { let (inner_str, _) = emit_field_type(inner, table, config); - let type_str = match config.optional_style { + let type_str = match config.optional { OptionalStyle::Null => format!("{} | null", inner_str), OptionalStyle::Undefined => format!("{} | undefined", inner_str), OptionalStyle::QuestionMark => inner_str, @@ -192,7 +191,7 @@ pub(crate) fn emit_field_type( } Some(TypeValue::Struct(fields)) => { - if config.inline_synthetic && matches!(key, TypeKey::Synthetic(_)) { + if !config.nested && matches!(key, TypeKey::Synthetic(_)) { (emit_inline_struct(fields, table, config), false) } else { (type_name(key, config), false) @@ -217,7 +216,7 @@ pub(crate) fn emit_inline_struct( let mut out = String::from("{ "); for (i, (field_name, field_type)) in fields.iter().enumerate() { let (type_str, is_optional) = emit_field_type(field_type, table, config); - let optional = if is_optional && config.optional_style == OptionalStyle::QuestionMark { + let optional = if is_optional && config.optional == OptionalStyle::QuestionMark { "?" } else { "" @@ -236,7 +235,7 @@ pub(crate) fn emit_inline_struct( fn type_name(key: &TypeKey<'_>, config: &TypeScriptEmitConfig) -> String { if key.is_default_query() { - config.default_query_name.clone() + config.entry_name.clone() } else { key.to_pascal_case() } diff --git a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs index 5aae21dc..d6531edb 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs @@ -146,7 +146,7 @@ fn emit_optional_null() { fn emit_optional_undefined() { let input = "MaybeNode = #Node?"; let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::Undefined, + optional: OptionalStyle::Undefined, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @"type MaybeNode = SyntaxNode | undefined;"); @@ -159,7 +159,7 @@ fn emit_optional_question_mark() { Foo = { MaybeNode @maybe } "#}; let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::QuestionMark, + optional: OptionalStyle::QuestionMark, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @r" @@ -284,7 +284,7 @@ fn emit_readonly_fields() { fn emit_custom_node_type() { let input = "Foo = { #Node @value }"; let config = TypeScriptEmitConfig { - node_type_name: "TSNode".to_string(), + node_type: "TSNode".to_string(), ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @r" @@ -298,7 +298,7 @@ fn emit_custom_node_type() { fn emit_type_alias_instead_of_interface() { let input = "Foo = { #Node @value #string @name }"; let config = TypeScriptEmitConfig { - use_type_alias: true, + type_alias: true, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @"type Foo = { value: SyntaxNode; name: string };"); @@ -308,7 +308,7 @@ fn emit_type_alias_instead_of_interface() { fn emit_type_alias_empty() { let input = "Empty = {}"; let config = TypeScriptEmitConfig { - use_type_alias: true, + type_alias: true, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @"type Empty = {};"); @@ -321,7 +321,7 @@ fn emit_type_alias_nested() { Outer = { Inner @inner #string @label } "#}; let config = TypeScriptEmitConfig { - use_type_alias: true, + type_alias: true, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @r" @@ -337,7 +337,7 @@ fn emit_no_inline_synthetic() { Container = { @inner } "#}; let config = TypeScriptEmitConfig { - inline_synthetic: false, + nested: true, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @r" @@ -441,13 +441,13 @@ fn emit_all_config_options() { Items = Item* "#}; let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::QuestionMark, + optional: OptionalStyle::QuestionMark, export: true, readonly: true, - inline_synthetic: true, - node_type_name: "ASTNode".to_string(), - use_type_alias: false, - default_query_name: "QueryResult".to_string(), + nested: false, + node_type: "ASTNode".to_string(), + type_alias: false, + entry_name: "QueryResult".to_string(), }; insta::assert_snapshot!(emit_with_config(input, &config), @r" export type MaybeNode = ASTNode; @@ -554,7 +554,7 @@ fn emit_optional_in_struct_undefined_style() { Container = { MaybeNode @item #string @name } "#}; let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::Undefined, + optional: OptionalStyle::Undefined, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @r" @@ -576,7 +576,7 @@ fn emit_tagged_union_with_optional_field_question_mark() { Choice = [ A: VariantA B: VariantB ] "#}; let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::QuestionMark, + optional: OptionalStyle::QuestionMark, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @r#" @@ -645,7 +645,7 @@ fn emit_struct_with_forward_ref() { fn emit_synthetic_type_no_inline() { let input = " = { #Node @value }"; let config = TypeScriptEmitConfig { - inline_synthetic: false, + nested: true, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @r" @@ -659,7 +659,7 @@ fn emit_synthetic_type_no_inline() { fn emit_synthetic_type_with_inline() { let input = " = { #Node @value }"; let config = TypeScriptEmitConfig { - inline_synthetic: true, + nested: false, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @""); @@ -706,7 +706,7 @@ fn emit_field_referencing_unknown_type() { fn emit_empty_interface_no_type_alias() { let input = "Empty = {}"; let config = TypeScriptEmitConfig { - use_type_alias: false, + type_alias: false, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @"interface Empty {}"); @@ -720,8 +720,8 @@ fn emit_inline_synthetic_struct_with_optional_field() { Container = { @inner } "#}; let config = TypeScriptEmitConfig { - inline_synthetic: true, - optional_style: OptionalStyle::QuestionMark, + nested: false, + optional: OptionalStyle::QuestionMark, ..Default::default() }; insta::assert_snapshot!(emit_with_config(input, &config), @r" @@ -760,7 +760,7 @@ fn emit_default_query_interface() { fn emit_default_query_custom_name() { let input = "#DefaultQuery = { #Node @value }"; let config = TypeScriptEmitConfig { - default_query_name: "MyResult".to_string(), + entry_name: "MyResult".to_string(), ..Default::default() }; diff --git a/crates/plotnik-lib/src/infer/mod.rs b/crates/plotnik-lib/src/infer/mod.rs index 46471372..32da41d3 100644 --- a/crates/plotnik-lib/src/infer/mod.rs +++ b/crates/plotnik-lib/src/infer/mod.rs @@ -3,10 +3,10 @@ //! This module provides: //! - `TypeTable`: collection of inferred types //! - `TypeKey` / `TypeValue`: type representation -//! - `emit_rust`: Rust code emitter -//! - `emit_typescript`: TypeScript code emitter +//! - `TypePrinter`: builder for emitting types as code pub mod emit; +mod printer; mod types; pub mod tyton; @@ -15,7 +15,6 @@ mod types_tests; #[cfg(test)] mod tyton_tests; -pub use emit::{ - Indirection, OptionalStyle, RustEmitConfig, TypeScriptEmitConfig, emit_rust, emit_typescript, -}; -pub use types::{TypeKey, TypeTable, TypeValue}; +pub use emit::{Indirection, OptionalStyle, RustEmitConfig, TypeScriptEmitConfig}; +pub use printer::{RustPrinter, TypePrinter, TypeScriptPrinter}; +pub use types::{MergedField, TypeKey, TypeTable, TypeValue}; diff --git a/crates/plotnik-lib/src/infer/printer.rs b/crates/plotnik-lib/src/infer/printer.rs new file mode 100644 index 00000000..4044001c --- /dev/null +++ b/crates/plotnik-lib/src/infer/printer.rs @@ -0,0 +1,151 @@ +//! Builder-pattern printer for emitting inferred types as code. +//! +//! # Example +//! +//! ```ignore +//! let code = query.type_printer() +//! .entry_name("MyQuery") +//! .rust() +//! .derive(&["debug", "clone"]) +//! .render(); +//! ``` + +use super::TypeTable; +use super::emit::{ + Indirection, OptionalStyle, RustEmitConfig, TypeScriptEmitConfig, emit_rust, emit_typescript, +}; + +/// Builder for type emission. Use [`rust()`](Self::rust) or [`typescript()`](Self::typescript) +/// to select the target language. +pub struct TypePrinter<'src> { + table: TypeTable<'src>, + entry_name: String, +} + +impl<'src> TypePrinter<'src> { + /// Create a new type printer from a type table. + pub fn new(table: TypeTable<'src>) -> Self { + Self { + table, + entry_name: "QueryResult".to_string(), + } + } + + /// Set the name for the entry point type (default: "QueryResult"). + pub fn entry_name(mut self, name: impl Into) -> Self { + self.entry_name = name.into(); + self + } + + /// Configure Rust output. + pub fn rust(self) -> RustPrinter<'src> { + let config = RustEmitConfig { + entry_name: self.entry_name, + ..Default::default() + }; + RustPrinter { + table: self.table, + config, + } + } + + /// Configure TypeScript output. + pub fn typescript(self) -> TypeScriptPrinter<'src> { + let config = TypeScriptEmitConfig { + entry_name: self.entry_name, + ..Default::default() + }; + TypeScriptPrinter { + table: self.table, + config, + } + } +} + +/// Builder for Rust code emission. +pub struct RustPrinter<'src> { + table: TypeTable<'src>, + config: RustEmitConfig, +} + +impl<'src> RustPrinter<'src> { + /// Set indirection type for cyclic references (default: Box). + pub fn indirection(mut self, ind: Indirection) -> Self { + self.config.indirection = ind; + self + } + + /// Set derive macros from a list of trait names. + /// + /// Recognized names: "debug", "clone", "partialeq" (case-insensitive). + /// Unrecognized names are ignored. + pub fn derive(mut self, traits: &[&str]) -> Self { + self.config.derive_debug = false; + self.config.derive_clone = false; + self.config.derive_partial_eq = false; + + for t in traits { + match t.to_lowercase().as_str() { + "debug" => self.config.derive_debug = true, + "clone" => self.config.derive_clone = true, + "partialeq" => self.config.derive_partial_eq = true, + _ => {} + } + } + self + } + + /// Render the type definitions as Rust code. + pub fn render(&self) -> String { + emit_rust(&self.table, &self.config) + } +} + +/// Builder for TypeScript code emission. +pub struct TypeScriptPrinter<'src> { + table: TypeTable<'src>, + config: TypeScriptEmitConfig, +} + +impl<'src> TypeScriptPrinter<'src> { + /// Set how optional values are represented (default: Null). + pub fn optional(mut self, style: OptionalStyle) -> Self { + self.config.optional = style; + self + } + + /// Whether to add `export` keyword to types (default: false). + pub fn export(mut self, value: bool) -> Self { + self.config.export = value; + self + } + + /// Whether to make fields readonly (default: false). + pub fn readonly(mut self, value: bool) -> Self { + self.config.readonly = value; + self + } + + /// Whether to emit nested synthetic types instead of inlining (default: false). + pub fn nested(mut self, value: bool) -> Self { + self.config.nested = value; + self + } + + /// Set the name for the Node type (default: "SyntaxNode"). + pub fn node_type(mut self, name: impl Into) -> Self { + self.config.node_type = name.into(); + self + } + + /// Whether to use `type Foo = ...` instead of `interface Foo { ... }` (default: false). + pub fn type_alias(mut self, value: bool) -> Self { + self.config.type_alias = value; + self + } + + /// Render the type definitions as TypeScript code. + pub fn render(&self) -> String { + emit_typescript(&self.table, &self.config) + } +} diff --git a/crates/plotnik-lib/src/parser/core.rs b/crates/plotnik-lib/src/parser/core.rs index a451f03f..4691c2b6 100644 --- a/crates/plotnik-lib/src/parser/core.rs +++ b/crates/plotnik-lib/src/parser/core.rs @@ -220,6 +220,7 @@ impl<'src> Parser<'src> { pub(super) fn bump(&mut self) { assert!(!self.eof(), "bump called at EOF"); + self.drain_trivia(); self.reset_debug_fuel(); self.consume_exec_fuel(); diff --git a/crates/plotnik-lib/src/parser/lexer_tests.rs b/crates/plotnik-lib/src/parser/lexer_tests.rs index 9fd47c3c..9bd26b1e 100644 --- a/crates/plotnik-lib/src/parser/lexer_tests.rs +++ b/crates/plotnik-lib/src/parser/lexer_tests.rs @@ -5,6 +5,28 @@ fn snapshot(input: &str) -> String { format_tokens(input, false) } +/// Format tokens with spans for debugging +#[allow(dead_code)] +fn snapshot_with_spans(input: &str) -> String { + let tokens = lex(input); + let mut out = String::new(); + for token in tokens { + if !token.kind.is_trivia() { + let start: usize = token.span.start().into(); + let end: usize = token.span.end().into(); + out.push_str(&format!( + "{:?} {:?} @ {}..{} (source: {:?})\n", + token.kind, + token_text(input, &token), + start, + end, + &input[start..end] + )); + } + } + out +} + /// Format tokens with trivia included fn snapshot_raw(input: &str) -> String { format_tokens(input, true) @@ -161,6 +183,14 @@ fn capture_simple() { "#); } +#[test] +fn capture_spans_debug() { + let input = "(identifier) @name :: string"; + eprintln!("Input: {:?}", input); + eprintln!("Tokens with spans:"); + eprintln!("{}", snapshot_with_spans(input)); +} + #[test] fn capture_with_underscores() { insta::assert_snapshot!(snapshot("@my_capture_name"), @r#" diff --git a/crates/plotnik-lib/src/parser/tests/grammar/trivia_tests.rs b/crates/plotnik-lib/src/parser/tests/grammar/trivia_tests.rs index 886def5f..d5916971 100644 --- a/crates/plotnik-lib/src/parser/tests/grammar/trivia_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/grammar/trivia_tests.rs @@ -17,9 +17,9 @@ fn whitespace_preserved() { ParenOpen "(" Id "identifier" ParenClose ")" + Whitespace " " At "@" Id "name" - Whitespace " " Newline "\n" "#); } @@ -127,9 +127,9 @@ fn trivia_between_alternation_items() { ParenOpen "(" Id "b" ParenClose ")" + Newline "\n" BracketClose "]" Newline "\n" - Newline "\n" "#); } diff --git a/crates/plotnik-lib/src/query/dump.rs b/crates/plotnik-lib/src/query/dump.rs index 9f2f7219..94977d19 100644 --- a/crates/plotnik-lib/src/query/dump.rs +++ b/crates/plotnik-lib/src/query/dump.rs @@ -3,6 +3,7 @@ #[cfg(test)] mod test_helpers { use crate::Query; + use crate::infer::tyton; impl Query<'_> { pub fn dump_cst(&self) -> String { @@ -36,5 +37,9 @@ mod test_helpers { pub fn dump_diagnostics_raw(&self) -> String { self.diagnostics_raw().render(self.source) } + + pub fn dump_types(&self) -> String { + tyton::emit(&self.type_table) + } } } diff --git a/crates/plotnik-lib/src/query/mod.rs b/crates/plotnik-lib/src/query/mod.rs index 203b4197..084ad11c 100644 --- a/crates/plotnik-lib/src/query/mod.rs +++ b/crates/plotnik-lib/src/query/mod.rs @@ -7,8 +7,11 @@ mod dump; mod invariants; mod printer; +mod types; pub use printer::QueryPrinter; +use crate::infer::TypePrinter; + pub mod alt_kinds; #[cfg(feature = "plotnik-langs")] pub mod link; @@ -30,6 +33,8 @@ mod recursion_tests; mod shapes_tests; #[cfg(test)] mod symbol_table_tests; +#[cfg(test)] +mod types_tests; use std::collections::HashMap; @@ -40,6 +45,7 @@ use rowan::GreenNodeBuilder; use crate::Result; use crate::diagnostics::Diagnostics; +use crate::infer::TypeTable; use crate::parser::cst::SyntaxKind; use crate::parser::lexer::lex; use crate::parser::{ParseResult, Parser, Root, SyntaxNode, ast}; @@ -63,6 +69,7 @@ pub struct Query<'a> { ast: Root, symbol_table: SymbolTable<'a>, shape_cardinality_table: HashMap, + type_table: TypeTable<'a>, #[cfg(feature = "plotnik-langs")] node_type_ids: HashMap<&'a str, Option>, #[cfg(feature = "plotnik-langs")] @@ -75,6 +82,7 @@ pub struct Query<'a> { resolve_diagnostics: Diagnostics, recursion_diagnostics: Diagnostics, shapes_diagnostics: Diagnostics, + type_diagnostics: Diagnostics, #[cfg(feature = "plotnik-langs")] link_diagnostics: Diagnostics, } @@ -97,6 +105,7 @@ impl<'a> Query<'a> { ast: empty_root(), symbol_table: SymbolTable::default(), shape_cardinality_table: HashMap::new(), + type_table: TypeTable::new(), #[cfg(feature = "plotnik-langs")] node_type_ids: HashMap::new(), #[cfg(feature = "plotnik-langs")] @@ -109,6 +118,7 @@ impl<'a> Query<'a> { resolve_diagnostics: Diagnostics::new(), recursion_diagnostics: Diagnostics::new(), shapes_diagnostics: Diagnostics::new(), + type_diagnostics: Diagnostics::new(), #[cfg(feature = "plotnik-langs")] link_diagnostics: Diagnostics::new(), } @@ -142,6 +152,7 @@ impl<'a> Query<'a> { self.resolve_names(); self.validate_recursion(); self.infer_shapes(); + self.infer_types(); Ok(self) } @@ -218,6 +229,7 @@ impl<'a> Query<'a> { all.extend(self.resolve_diagnostics.clone()); all.extend(self.recursion_diagnostics.clone()); all.extend(self.shapes_diagnostics.clone()); + all.extend(self.type_diagnostics.clone()); #[cfg(feature = "plotnik-langs")] all.extend(self.link_diagnostics.clone()); all @@ -239,6 +251,7 @@ impl<'a> Query<'a> { && !self.resolve_diagnostics.has_errors() && !self.recursion_diagnostics.has_errors() && !self.shapes_diagnostics.has_errors() + && !self.type_diagnostics.has_errors() && !self.link_diagnostics.has_errors() } @@ -250,6 +263,14 @@ impl<'a> Query<'a> { && !self.resolve_diagnostics.has_errors() && !self.recursion_diagnostics.has_errors() && !self.shapes_diagnostics.has_errors() + && !self.type_diagnostics.has_errors() + } + + /// Get a type printer for emitting inferred types as code. + /// + /// Returns a builder that can be configured for Rust or TypeScript output. + pub fn type_printer(&self) -> TypePrinter<'a> { + TypePrinter::new(self.type_table.clone()) } } diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs new file mode 100644 index 00000000..bc3b552b --- /dev/null +++ b/crates/plotnik-lib/src/query/types.rs @@ -0,0 +1,512 @@ +//! Type inference pass: AST → TypeTable. +//! +//! Walks definitions and infers output types from capture patterns. +//! Produces a `TypeTable` containing all inferred types. + +use indexmap::IndexMap; + +use crate::diagnostics::DiagnosticKind; +use crate::infer::{MergedField, TypeKey, TypeTable, TypeValue}; +use crate::parser::cst::SyntaxKind; +use crate::parser::{AltKind, Expr, ast, token_src}; + +use super::Query; + +impl<'a> Query<'a> { + pub(super) fn infer_types(&mut self) { + let mut ctx = InferContext::new(self.source); + + let defs: Vec<_> = self.ast.defs().collect(); + let last_idx = defs.len().saturating_sub(1); + + for (idx, def) in defs.iter().enumerate() { + let is_last = idx == last_idx; + ctx.infer_def(def, is_last); + } + + self.type_table = ctx.table; + self.type_diagnostics = ctx.diagnostics; + } +} + +struct InferContext<'src> { + source: &'src str, + table: TypeTable<'src>, + diagnostics: crate::diagnostics::Diagnostics, +} + +impl<'src> InferContext<'src> { + fn new(source: &'src str) -> Self { + Self { + source, + table: TypeTable::new(), + diagnostics: crate::diagnostics::Diagnostics::new(), + } + } + + fn infer_def(&mut self, def: &ast::Def, is_last: bool) { + let key = match def.name() { + Some(name_tok) => { + let name = token_src(&name_tok, self.source); + TypeKey::Named(name) + } + None if is_last => TypeKey::DefaultQuery, + None => return, // unnamed non-last def, already reported by earlier pass + }; + + let Some(body) = def.body() else { + return; + }; + + let path = match &key { + TypeKey::Named(name) => vec![*name], + TypeKey::DefaultQuery => vec![], + _ => vec![], + }; + + // Special case: tagged alternation at def level produces TaggedUnion directly + if let Expr::AltExpr(alt) = &body + && matches!(alt.kind(), AltKind::Tagged) + { + let type_annotation = match &key { + TypeKey::Named(name) => Some(*name), + _ => None, + }; + self.infer_tagged_alt(alt, &path, type_annotation); + return; + } + + let mut fields = IndexMap::new(); + self.infer_expr(&body, &path, &mut fields); + + let value = if fields.is_empty() { + TypeValue::Unit + } else { + TypeValue::Struct(fields) + }; + + self.table.insert(key, value); + } + + /// Infer type for an expression, collecting captures into `fields`. + /// Returns the TypeKey if this expression produces a referenceable type. + fn infer_expr( + &mut self, + expr: &Expr, + path: &[&'src str], + fields: &mut IndexMap<&'src str, TypeKey<'src>>, + ) -> Option> { + match expr { + Expr::NamedNode(node) => { + for child in node.children() { + self.infer_expr(&child, path, fields); + } + Some(TypeKey::Node) + } + + Expr::AnonymousNode(_) => Some(TypeKey::Node), + + Expr::Ref(r) => { + let name_tok = r.name()?; + let name = token_src(&name_tok, self.source); + Some(TypeKey::Named(name)) + } + + Expr::SeqExpr(seq) => { + for child in seq.children() { + self.infer_expr(&child, path, fields); + } + None + } + + Expr::FieldExpr(field) => { + if let Some(value) = field.value() { + self.infer_expr(&value, path, fields); + } + None + } + + Expr::CapturedExpr(cap) => self.infer_capture(cap, path, fields), + + Expr::QuantifiedExpr(quant) => self.infer_quantified(quant, path, fields), + + Expr::AltExpr(alt) => self.infer_alt(alt, path, fields), + } + } + + fn infer_capture( + &mut self, + cap: &ast::CapturedExpr, + path: &[&'src str], + fields: &mut IndexMap<&'src str, TypeKey<'src>>, + ) -> Option> { + let name_tok = cap.name()?; + let capture_name = token_src(&name_tok, self.source); + + let type_annotation = cap.type_annotation().and_then(|t| { + let tok = t.name()?; + Some(token_src(&tok, self.source)) + }); + + let inner = cap.inner(); + let inner_type = + self.infer_capture_inner(inner.as_ref(), path, capture_name, type_annotation); + + fields.insert(capture_name, inner_type.clone()); + Some(inner_type) + } + + fn infer_capture_inner( + &mut self, + inner: Option<&Expr>, + path: &[&'src str], + capture_name: &'src str, + type_annotation: Option<&'src str>, + ) -> TypeKey<'src> { + // :: string annotation + if type_annotation == Some("string") { + return TypeKey::String; + } + + let Some(inner) = inner else { + return type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node); + }; + + match inner { + Expr::Ref(r) => { + if let Some(name_tok) = r.name() { + let ref_name = token_src(&name_tok, self.source); + TypeKey::Named(ref_name) + } else { + TypeKey::Invalid + } + } + + Expr::SeqExpr(seq) => { + self.infer_nested_scope(inner, path, capture_name, type_annotation, || { + seq.children().collect() + }) + } + + Expr::AltExpr(alt) => { + self.infer_nested_scope(inner, path, capture_name, type_annotation, || { + alt.branches().filter_map(|b| b.body()).collect() + }) + } + + Expr::NamedNode(_) | Expr::AnonymousNode(_) => { + type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node) + } + + Expr::QuantifiedExpr(q) => { + if let Some(qinner) = q.inner() { + let inner_key = self.infer_capture_inner( + Some(&qinner), + path, + capture_name, + type_annotation, + ); + if let Some(op) = q.operator() { + self.wrap_with_quantifier(&inner_key, op.kind()) + } else { + inner_key + } + } else { + TypeKey::Invalid + } + } + + Expr::CapturedExpr(_) | Expr::FieldExpr(_) => { + type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node) + } + } + } + + fn infer_nested_scope( + &mut self, + inner: &Expr, + path: &[&'src str], + capture_name: &'src str, + type_annotation: Option<&'src str>, + get_children: F, + ) -> TypeKey<'src> + where + F: FnOnce() -> Vec, + { + let mut nested_path = path.to_vec(); + nested_path.push(capture_name); + + let mut nested_fields = IndexMap::new(); + + match inner { + Expr::AltExpr(alt) => { + let alt_key = self.infer_alt_as_type(alt, &nested_path, type_annotation); + return alt_key; + } + _ => { + for child in get_children() { + self.infer_expr(&child, &nested_path, &mut nested_fields); + } + } + } + + if nested_fields.is_empty() { + return type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node); + } + + let key = if let Some(name) = type_annotation { + TypeKey::Named(name) + } else { + TypeKey::Synthetic(nested_path) + }; + + self.table + .insert(key.clone(), TypeValue::Struct(nested_fields)); + key + } + + fn infer_quantified( + &mut self, + quant: &ast::QuantifiedExpr, + path: &[&'src str], + fields: &mut IndexMap<&'src str, TypeKey<'src>>, + ) -> Option> { + let inner = quant.inner()?; + let op = quant.operator()?; + + // If the inner is a capture, we need special handling for the wrapper + if let Expr::CapturedExpr(cap) = &inner { + let name_tok = cap.name()?; + let capture_name = token_src(&name_tok, self.source); + + let type_annotation = cap.type_annotation().and_then(|t| { + let tok = t.name()?; + Some(token_src(&tok, self.source)) + }); + + let inner_key = + self.infer_capture_inner(cap.inner().as_ref(), path, capture_name, type_annotation); + let wrapped_key = self.wrap_with_quantifier(&inner_key, op.kind()); + + fields.insert(capture_name, wrapped_key.clone()); + return Some(wrapped_key); + } + + // Non-capture quantified expression: recurse into inner + self.infer_expr(&inner, path, fields) + } + + fn wrap_with_quantifier( + &mut self, + inner: &TypeKey<'src>, + op_kind: SyntaxKind, + ) -> TypeKey<'src> { + let wrapper = match op_kind { + SyntaxKind::Question | SyntaxKind::QuestionQuestion => { + TypeValue::Optional(inner.clone()) + } + SyntaxKind::Star | SyntaxKind::StarQuestion => TypeValue::List(inner.clone()), + SyntaxKind::Plus | SyntaxKind::PlusQuestion => TypeValue::NonEmptyList(inner.clone()), + _ => return inner.clone(), + }; + + // Create a unique key for the wrapper + let wrapper_key = match inner { + TypeKey::Named(name) => { + let suffix = match op_kind { + SyntaxKind::Question | SyntaxKind::QuestionQuestion => "Opt", + SyntaxKind::Star | SyntaxKind::StarQuestion => "List", + SyntaxKind::Plus | SyntaxKind::PlusQuestion => "List", + _ => "", + }; + TypeKey::Synthetic(vec![name, suffix]) + } + TypeKey::Synthetic(segments) => { + let mut new_segments = segments.clone(); + let suffix = match op_kind { + SyntaxKind::Question | SyntaxKind::QuestionQuestion => "opt", + SyntaxKind::Star | SyntaxKind::StarQuestion => "list", + SyntaxKind::Plus | SyntaxKind::PlusQuestion => "list", + _ => "", + }; + new_segments.push(suffix); + TypeKey::Synthetic(new_segments) + } + _ => { + // For builtins like Node, we directly store the wrapper under a synthetic key + let type_name = match inner { + TypeKey::Node => "Node", + TypeKey::String => "String", + TypeKey::Unit => "Unit", + _ => "Unknown", + }; + let suffix = match op_kind { + SyntaxKind::Question | SyntaxKind::QuestionQuestion => "Opt", + SyntaxKind::Star | SyntaxKind::StarQuestion => "List", + SyntaxKind::Plus | SyntaxKind::PlusQuestion => "List", + _ => "", + }; + TypeKey::Synthetic(vec![type_name, suffix]) + } + }; + + self.table.insert(wrapper_key.clone(), wrapper); + wrapper_key + } + + fn infer_alt( + &mut self, + alt: &ast::AltExpr, + path: &[&'src str], + fields: &mut IndexMap<&'src str, TypeKey<'src>>, + ) -> Option> { + // Alt without capture: just collect fields from all branches into current scope + match alt.kind() { + AltKind::Tagged => { + // Tagged alt without capture: unusual, but collect fields + for branch in alt.branches() { + if let Some(body) = branch.body() { + self.infer_expr(&body, path, fields); + } + } + } + AltKind::Untagged | AltKind::Mixed => { + // Untagged alt: merge fields from branches + let branch_fields = self.collect_branch_fields(alt, path); + let merged = TypeTable::merge_fields(&branch_fields); + self.apply_merged_fields(merged, fields, alt); + } + } + None + } + + fn infer_alt_as_type( + &mut self, + alt: &ast::AltExpr, + path: &[&'src str], + type_annotation: Option<&'src str>, + ) -> TypeKey<'src> { + match alt.kind() { + AltKind::Tagged => self.infer_tagged_alt(alt, path, type_annotation), + AltKind::Untagged | AltKind::Mixed => { + self.infer_untagged_alt(alt, path, type_annotation) + } + } + } + + fn infer_tagged_alt( + &mut self, + alt: &ast::AltExpr, + path: &[&'src str], + type_annotation: Option<&'src str>, + ) -> TypeKey<'src> { + let mut variants = IndexMap::new(); + + for branch in alt.branches() { + let Some(label_tok) = branch.label() else { + continue; + }; + let label = token_src(&label_tok, self.source); + + let mut variant_path = path.to_vec(); + variant_path.push(label); + + let mut variant_fields = IndexMap::new(); + if let Some(body) = branch.body() { + self.infer_expr(&body, &variant_path, &mut variant_fields); + } + + let variant_key = TypeKey::Synthetic(variant_path); + let variant_value = if variant_fields.is_empty() { + TypeValue::Unit + } else { + TypeValue::Struct(variant_fields) + }; + + self.table.insert(variant_key.clone(), variant_value); + variants.insert(label, variant_key); + } + + let key = if let Some(name) = type_annotation { + TypeKey::Named(name) + } else { + TypeKey::Synthetic(path.to_vec()) + }; + + self.table + .insert(key.clone(), TypeValue::TaggedUnion(variants)); + key + } + + fn infer_untagged_alt( + &mut self, + alt: &ast::AltExpr, + path: &[&'src str], + type_annotation: Option<&'src str>, + ) -> TypeKey<'src> { + let branch_fields = self.collect_branch_fields(alt, path); + let merged = TypeTable::merge_fields(&branch_fields); + + if merged.is_empty() { + return type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node); + } + + let mut result_fields = IndexMap::new(); + self.apply_merged_fields(merged, &mut result_fields, alt); + + let key = if let Some(name) = type_annotation { + TypeKey::Named(name) + } else { + TypeKey::Synthetic(path.to_vec()) + }; + + self.table + .insert(key.clone(), TypeValue::Struct(result_fields)); + key + } + + fn collect_branch_fields( + &mut self, + alt: &ast::AltExpr, + path: &[&'src str], + ) -> Vec>> { + let mut branch_fields = Vec::new(); + + for branch in alt.branches() { + let mut fields = IndexMap::new(); + if let Some(body) = branch.body() { + self.infer_expr(&body, path, &mut fields); + } + branch_fields.push(fields); + } + + branch_fields + } + + fn apply_merged_fields( + &mut self, + merged: IndexMap<&'src str, MergedField<'src>>, + fields: &mut IndexMap<&'src str, TypeKey<'src>>, + alt: &ast::AltExpr, + ) { + for (name, merge_result) in merged { + let key = match merge_result { + MergedField::Same(k) => k, + MergedField::Optional(k) => { + let wrapper_key = TypeKey::Synthetic(vec![name, "opt"]); + self.table + .insert(wrapper_key.clone(), TypeValue::Optional(k)); + wrapper_key + } + MergedField::Conflict => { + self.diagnostics + .report(DiagnosticKind::TypeConflictInMerge, alt.text_range()) + .message(name) + .emit(); + TypeKey::Invalid + } + }; + fields.insert(name, key); + } + } +} diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs new file mode 100644 index 00000000..5a521da8 --- /dev/null +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -0,0 +1,164 @@ +//! Type inference tests. + +use crate::Query; +use indoc::indoc; + +#[test] +fn comprehensive_type_inference() { + let input = indoc! {r#" + // Simple capture → flat struct with Node field + Simple = (identifier) @id + + // Multiple captures → flat struct + BinaryOp = (binary_expression + left: (_) @left + operator: _ @op + right: (_) @right) + + // :: string annotation → String type + WithString = (identifier) @name :: string + + // :: TypeName annotation → named type + Named = (identifier) @value :: MyType + + // Ref usage → type reference + UsingRef = (statement (BinaryOp) @expr) + + // Nested seq with capture → synthetic key + Nested = (function + {(param) @p} @params + (body) @body) + + // Quantifiers on captures + WithQuantifiers = (class + (decorator)? @maybe_dec + (method)* @methods + (field)+ @fields) + + // Tagged alternation → TaggedUnion + TaggedAlt = [ + Assign: (assignment left: (_) @target) + Call: (call function: (_) @func) + ] + + // Untagged alternation → merged struct + UntaggedAlt = [ + (assignment left: (_) @left right: (_) @right) + (call function: (_) @left) + ] + + // Entry point (unnamed last def) → DefaultQuery + (program (Simple)* @items) + "#}; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + Simple = { #Node @id } + BinaryOp = { #Node @left #Node @op #Node @right } + WithString = { #string @name } + Named = { MyType @value } + UsingRef = { BinaryOp @expr } + = { #Node @p } + Nested = { @params #Node @body } + = #Node? + = #Node+ + WithQuantifiers = { @maybe_dec @methods @fields } + = { #Node @target } + = { #Node @func } + TaggedAlt = [ Assign: Call: ] + = #Node? + UntaggedAlt = { #Node @left @right } + = Simple* + #DefaultQuery = { @items } + "); +} + +#[test] +fn type_conflict_in_untagged_alt() { + let input = indoc! {r#" + Conflict = [ + (identifier) @x :: string + (number) @x + ] @result + "#}; + + let query = Query::try_from(input).unwrap(); + + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: capture `x` has conflicting types across branches + | + 1 | Conflict = [ + | ____________^ + 2 | | (identifier) @x :: string + 3 | | (number) @x + 4 | | ] @result + | |_^ + "); +} + +#[test] +fn nested_tagged_alt_with_annotation() { + let input = indoc! {r#" + Expr = [ + Binary: (binary_expression + left: (Expr) @left + right: (Expr) @right) + Literal: (number) @value :: string + ] + "#}; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + = { #Node @left #Node @right } + = { #string @value } + Expr = [ Binary: Literal: ] + "); +} + +#[test] +fn captured_ref_becomes_type_reference() { + let input = indoc! {r#" + Inner = (identifier) @name :: string + Outer = (wrapper (Inner) @inner) + "#}; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + Inner = { #string @name } + Outer = { Inner @inner } + "); +} + +#[test] +fn empty_captures_produce_unit() { + let input = "(empty_node)"; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"#DefaultQuery = ()"); +} + +#[test] +fn quantified_ref() { + let input = indoc! {r#" + Item = (item) @value + List = (container (Item)+ @items) + "#}; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + Item = { #Node @value } + = Item+ + List = { @items } + "); +} From ce45e4271334ce7896de708fc17725ee70979acf Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 01:22:49 -0300 Subject: [PATCH 02/11] Enhance type inference for nested captures and quantified expressions --- crates/plotnik-lib/src/query/types.rs | 48 ++++++++++++++++++--- crates/plotnik-lib/src/query/types_tests.rs | 43 ++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs index bc3b552b..b1d5dcff 100644 --- a/crates/plotnik-lib/src/query/types.rs +++ b/crates/plotnik-lib/src/query/types.rs @@ -149,6 +149,25 @@ impl<'src> InferContext<'src> { }); let inner = cap.inner(); + + // Flat extraction: collect nested captures from inner expression into outer fields + // Only for NamedNode/AnonymousNode - Seq/Alt create their own scopes when captured + if let Some(ref inner_expr) = inner { + match inner_expr { + Expr::NamedNode(node) => { + for child in node.children() { + self.infer_expr(&child, path, fields); + } + } + Expr::FieldExpr(field) => { + if let Some(value) = field.value() { + self.infer_expr(&value, path, fields); + } + } + _ => {} + } + } + let inner_type = self.infer_capture_inner(inner.as_ref(), path, capture_name, type_annotation); @@ -292,8 +311,9 @@ impl<'src> InferContext<'src> { return Some(wrapped_key); } - // Non-capture quantified expression: recurse into inner - self.infer_expr(&inner, path, fields) + // Non-capture quantified expression: recurse into inner and wrap with quantifier + let inner_key = self.infer_expr(&inner, path, fields)?; + Some(self.wrap_with_quantifier(&inner_key, op.kind())) } fn wrap_with_quantifier( @@ -412,13 +432,25 @@ impl<'src> InferContext<'src> { variant_path.push(label); let mut variant_fields = IndexMap::new(); - if let Some(body) = branch.body() { - self.infer_expr(&body, &variant_path, &mut variant_fields); - } + let body_type = if let Some(body) = branch.body() { + self.infer_expr(&body, &variant_path, &mut variant_fields) + } else { + None + }; let variant_key = TypeKey::Synthetic(variant_path); let variant_value = if variant_fields.is_empty() { - TypeValue::Unit + // No captures: check if the body produced a meaningful type + match body_type { + Some(key) if !key.is_builtin() => { + // Branch body has a non-builtin type (e.g., Ref or wrapped type) + // Create a struct with a "value" field + let mut fields = IndexMap::new(); + fields.insert("value", key); + TypeValue::Struct(fields) + } + _ => TypeValue::Unit, + } } else { TypeValue::Struct(variant_fields) }; @@ -429,6 +461,8 @@ impl<'src> InferContext<'src> { let key = if let Some(name) = type_annotation { TypeKey::Named(name) + } else if path.is_empty() { + TypeKey::DefaultQuery } else { TypeKey::Synthetic(path.to_vec()) }; @@ -456,6 +490,8 @@ impl<'src> InferContext<'src> { let key = if let Some(name) = type_annotation { TypeKey::Named(name) + } else if path.is_empty() { + TypeKey::DefaultQuery } else { TypeKey::Synthetic(path.to_vec()) }; diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index 5a521da8..2f85f36c 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -162,3 +162,46 @@ fn quantified_ref() { List = { @items } "); } + +#[test] +fn recursive_type_with_annotation_preserves_fields() { + let input = r#"Func = (function_declaration name: (identifier) @name) @func :: Func (Func)"#; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + Func = { #Node @name Func @func } + #DefaultQuery = () + "); +} + +#[test] +fn anonymous_tagged_alt_uses_default_query_name() { + let input = "[A: (identifier) @id B: (number) @num]"; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + = { #Node @id } + = { #Node @num } + #DefaultQuery = [ A: B: ] + "); +} + +#[test] +fn tagged_union_branch_with_ref() { + let input = "Rec = [Base: (a) Rec: (Rec)?] (Rec)"; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + = () + = Rec? + = { @value } + Rec = [ Base: Rec: ] + #DefaultQuery = () + "); +} From 4e329623c082018b0e03bc447813e970f189723d Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 01:35:57 -0300 Subject: [PATCH 03/11] Add keyword escaping for Rust field names --- crates/plotnik-lib/src/infer/emit/rust.rs | 26 ++++++++++- .../plotnik-lib/src/infer/emit/rust_tests.rs | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/plotnik-lib/src/infer/emit/rust.rs b/crates/plotnik-lib/src/infer/emit/rust.rs index 5f719feb..ba5a8c1f 100644 --- a/crates/plotnik-lib/src/infer/emit/rust.rs +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -4,6 +4,26 @@ use indexmap::IndexMap; +/// Rust keywords that must be escaped with `r#` prefix when used as identifiers. +const RUST_KEYWORDS: &[&str] = &[ + // Strict keywords + "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", + "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", + "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", + "unsafe", "use", "where", "while", // Reserved keywords + "abstract", "become", "box", "do", "final", "macro", "override", "priv", "try", "typeof", + "unsized", "virtual", "yield", +]; + +/// Escape a name if it's a Rust keyword by prefixing with `r#`. +fn escape_keyword(name: &str) -> String { + if RUST_KEYWORDS.contains(&name) { + format!("r#{}", name) + } else { + name.to_string() + } +} + use super::super::types::{TypeKey, TypeTable, TypeValue}; /// Configuration for Rust emission. @@ -89,7 +109,8 @@ fn emit_type_def( out.push_str(&format!("pub struct {} {{\n", name)); for (field_name, field_type) in fields { let type_str = emit_type_ref(field_type, table, config); - out.push_str(&format!(" pub {}: {},\n", field_name, type_str)); + let escaped_name = escape_keyword(field_name); + out.push_str(&format!(" pub {}: {},\n", escaped_name, type_str)); } out.push('}'); } @@ -110,7 +131,8 @@ fn emit_type_def( out.push_str(&format!(" {} {{\n", variant_name)); for (field_name, field_type) in f { let type_str = emit_type_ref(field_type, table, config); - out.push_str(&format!(" {}: {},\n", field_name, type_str)); + let escaped_name = escape_keyword(field_name); + out.push_str(&format!(" {}: {},\n", escaped_name, type_str)); } out.push_str(" },\n"); } diff --git a/crates/plotnik-lib/src/infer/emit/rust_tests.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs index 79b60263..0d7e35e8 100644 --- a/crates/plotnik-lib/src/infer/emit/rust_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -590,3 +590,48 @@ fn emit_default_query_referenced() { } "); } + +// --- Keyword Escaping --- + +#[test] +fn emit_struct_with_keyword_fields() { + let input = "Foo = { #Node @type #Node @fn #Node @match }"; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Foo { + pub r#type: Node, + pub r#fn: Node, + pub r#match: Node, + } + "); +} + +#[test] +fn emit_keyword_field_in_enum() { + let input = indoc! {r#" + TypeVariant = { #Node @type } + FnVariant = { #Node @fn } + E = [ Type: TypeVariant Fn: FnVariant ] + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct TypeVariant { + pub r#type: Node, + } + + #[derive(Debug, Clone)] + pub struct FnVariant { + pub r#fn: Node, + } + + #[derive(Debug, Clone)] + pub enum E { + Type { + r#type: Node, + }, + Fn { + r#fn: Node, + }, + } + "); +} From 6fae64d1a518a594126ca73fbb6edaac8ffb57f7 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 02:09:25 -0300 Subject: [PATCH 04/11] Better diagnostics --- crates/plotnik-lib/src/diagnostics/message.rs | 3 + crates/plotnik-lib/src/infer/types.rs | 107 +++++++- crates/plotnik-lib/src/infer/types_tests.rs | 19 +- crates/plotnik-lib/src/parser/ast.rs | 14 + crates/plotnik-lib/src/query/types.rs | 250 ++++++++++++------ crates/plotnik-lib/src/query/types_tests.rs | 113 +++++++- 6 files changed, 410 insertions(+), 96 deletions(-) diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index 738c124e..b055a800 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -55,6 +55,7 @@ pub enum DiagnosticKind { // Valid syntax, invalid semantics DuplicateDefinition, + DuplicateCaptureInScope, UndefinedReference, MixedAltBranches, RecursionNoEscape, @@ -163,6 +164,7 @@ impl DiagnosticKind { // Semantic errors Self::DuplicateDefinition => "name already defined", + Self::DuplicateCaptureInScope => "duplicate capture in same scope", Self::UndefinedReference => "undefined reference", Self::MixedAltBranches => "cannot mix labeled and unlabeled branches", Self::RecursionNoEscape => "infinite recursion detected", @@ -199,6 +201,7 @@ impl DiagnosticKind { // Semantic errors with name context Self::DuplicateDefinition => "`{}` is already defined".to_string(), + Self::DuplicateCaptureInScope => "capture `@{}` already used in this scope".to_string(), Self::UndefinedReference => "`{}` is not defined".to_string(), // Link pass errors with context diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index 6e9081bd..98e33488 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -177,6 +177,28 @@ impl<'src> TypeTable<'src> { key } + /// Insert a type definition, detecting conflicts with existing incompatible types. + /// + /// Returns `Ok(key)` if inserted successfully (no conflict). + /// Returns `Err(key)` if there was an existing incompatible type (conflict). + /// + /// On conflict, the existing type is NOT overwritten - caller should use Invalid. + pub fn try_insert( + &mut self, + key: TypeKey<'src>, + value: TypeValue<'src>, + ) -> Result, TypeKey<'src>> { + if let Some(existing) = self.types.get(&key) { + if !self.values_are_compatible(existing, &value) { + return Err(key); + } + // Compatible - keep existing, don't overwrite + return Ok(key); + } + self.types.insert(key.clone(), value); + Ok(key) + } + /// Mark a type as cyclic (requires indirection in Rust). pub fn mark_cyclic(&mut self, key: TypeKey<'src>) { if !self.cyclic.contains(&key) { @@ -194,6 +216,84 @@ impl<'src> TypeTable<'src> { self.types.get(key) } + /// Check if two type keys are structurally compatible. + /// + /// For built-in types, this is simple equality. + /// For synthetic types, we compare the underlying TypeValue structure. + /// Two synthetic keys pointing to different TaggedUnions or Structs are incompatible. + pub fn types_are_compatible(&self, a: &TypeKey<'src>, b: &TypeKey<'src>) -> bool { + if a == b { + return true; + } + + // Different built-in types are incompatible + if a.is_builtin() || b.is_builtin() { + return false; + } + + // For synthetic/named types, compare the underlying values + let val_a = self.get(a); + let val_b = self.get(b); + + match (val_a, val_b) { + (Some(va), Some(vb)) => self.values_are_compatible(va, vb), + // If either is missing, consider incompatible (shouldn't happen in practice) + _ => false, + } + } + + /// Check if two type values are structurally compatible. + fn values_are_compatible(&self, a: &TypeValue<'src>, b: &TypeValue<'src>) -> bool { + use TypeValue::*; + match (a, b) { + (Node, Node) => true, + (String, String) => true, + (Unit, Unit) => true, + (Invalid, Invalid) => true, + (Optional(ka), Optional(kb)) => self.types_are_compatible(ka, kb), + (List(ka), List(kb)) => self.types_are_compatible(ka, kb), + (NonEmptyList(ka), NonEmptyList(kb)) => self.types_are_compatible(ka, kb), + // List and NonEmptyList are NOT compatible - different cardinality guarantees + (List(_), NonEmptyList(_)) | (NonEmptyList(_), List(_)) => false, + (Struct(fa), Struct(fb)) => { + // Structs must have exactly the same fields with compatible types + if fa.len() != fb.len() { + return false; + } + for (name, key_a) in fa { + match fb.get(name) { + Some(key_b) => { + if !self.types_are_compatible(key_a, key_b) { + return false; + } + } + None => return false, + } + } + true + } + (TaggedUnion(va), TaggedUnion(vb)) => { + // TaggedUnions must have exactly the same variants + if va.len() != vb.len() { + return false; + } + for (name, key_a) in va { + match vb.get(name) { + Some(key_b) => { + if !self.types_are_compatible(key_a, key_b) { + return false; + } + } + None => return false, + } + } + true + } + // Different type constructors are incompatible + _ => false, + } + } + /// Iterate over all types in insertion order. pub fn iter(&self) -> impl Iterator, &TypeValue<'src>)> { self.types.iter() @@ -220,6 +320,7 @@ impl<'src> TypeTable<'src> { /// /// Merged: `{ x: Invalid }` (with diagnostic warning) pub fn merge_fields( + &self, branches: &[IndexMap<&'src str, TypeKey<'src>>], ) -> IndexMap<&'src str, MergedField<'src>> { if branches.is_empty() { @@ -251,9 +352,11 @@ impl<'src> TypeTable<'src> { continue; } - // Check if all occurrences have the same type + // Check if all occurrences have compatible types (structural comparison) let first_type = type_occurrences[0]; - let all_same_type = type_occurrences.iter().all(|t| *t == first_type); + let all_same_type = type_occurrences + .iter() + .all(|t| self.types_are_compatible(t, first_type)); let merged = if !all_same_type { // Type conflict diff --git a/crates/plotnik-lib/src/infer/types_tests.rs b/crates/plotnik-lib/src/infer/types_tests.rs index 32299deb..3d96acb0 100644 --- a/crates/plotnik-lib/src/infer/types_tests.rs +++ b/crates/plotnik-lib/src/infer/types_tests.rs @@ -242,9 +242,10 @@ fn type_value_invalid() { #[test] fn merge_fields_empty_branches() { + let table = TypeTable::new(); let branches: Vec> = vec![]; - let merged = TypeTable::merge_fields(&branches); + let merged = table.merge_fields(&branches); assert!(merged.is_empty()); } @@ -255,7 +256,7 @@ fn merge_fields_single_branch() { branch.insert("name", TypeKey::String); branch.insert("value", TypeKey::Node); - let merged = TypeTable::merge_fields(&[branch]); + let merged = TypeTable::new().merge_fields(&[branch]); assert_eq!(merged.len(), 2); assert_eq!(merged["name"], MergedField::Same(TypeKey::String)); @@ -270,7 +271,7 @@ fn merge_fields_identical_branches() { let mut branch2 = IndexMap::new(); branch2.insert("name", TypeKey::String); - let merged = TypeTable::merge_fields(&[branch1, branch2]); + let merged = TypeTable::new().merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 1); assert_eq!(merged["name"], MergedField::Same(TypeKey::String)); @@ -286,7 +287,7 @@ fn merge_fields_missing_in_some_branches() { branch2.insert("name", TypeKey::String); // value missing - let merged = TypeTable::merge_fields(&[branch1, branch2]); + let merged = TypeTable::new().merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 2); assert_eq!(merged["name"], MergedField::Same(TypeKey::String)); @@ -301,7 +302,7 @@ fn merge_fields_disjoint_branches() { let mut branch2 = IndexMap::new(); branch2.insert("b", TypeKey::Node); - let merged = TypeTable::merge_fields(&[branch1, branch2]); + let merged = TypeTable::new().merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 2); assert_eq!(merged["a"], MergedField::Optional(TypeKey::String)); @@ -316,7 +317,7 @@ fn merge_fields_type_conflict() { let mut branch2 = IndexMap::new(); branch2.insert("x", TypeKey::Node); - let merged = TypeTable::merge_fields(&[branch1, branch2]); + let merged = TypeTable::new().merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 1); assert_eq!(merged["x"], MergedField::Conflict); @@ -334,7 +335,7 @@ fn merge_fields_partial_conflict() { let mut branch3 = IndexMap::new(); branch3.insert("x", TypeKey::Node); - let merged = TypeTable::merge_fields(&[branch1, branch2, branch3]); + let merged = TypeTable::new().merge_fields(&[branch1, branch2, branch3]); assert_eq!(merged["x"], MergedField::Conflict); } @@ -352,7 +353,7 @@ fn merge_fields_complex_scenario() { branch2.insert("name", TypeKey::String); branch2.insert("extra", TypeKey::Node); - let merged = TypeTable::merge_fields(&[branch1, branch2]); + let merged = TypeTable::new().merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 3); assert_eq!(merged["name"], MergedField::Same(TypeKey::String)); @@ -369,7 +370,7 @@ fn merge_fields_preserves_order() { let mut branch2 = IndexMap::new(); branch2.insert("m", TypeKey::String); - let merged = TypeTable::merge_fields(&[branch1, branch2]); + let merged = TypeTable::new().merge_fields(&[branch1, branch2]); let keys: Vec<_> = merged.keys().collect(); // Order follows first occurrence across branches diff --git a/crates/plotnik-lib/src/parser/ast.rs b/crates/plotnik-lib/src/parser/ast.rs index 420aa78b..2bd9b11b 100644 --- a/crates/plotnik-lib/src/parser/ast.rs +++ b/crates/plotnik-lib/src/parser/ast.rs @@ -330,6 +330,20 @@ impl QuantifiedExpr { }) .unwrap_or(false) } + + /// Returns true if quantifier is a list (*, *?). + pub fn is_list(&self) -> bool { + self.operator() + .map(|op| matches!(op.kind(), SyntaxKind::Star | SyntaxKind::StarQuestion)) + .unwrap_or(false) + } + + /// Returns true if quantifier is a non-empty list (+, +?). + pub fn is_non_empty_list(&self) -> bool { + self.operator() + .map(|op| matches!(op.kind(), SyntaxKind::Plus | SyntaxKind::PlusQuestion)) + .unwrap_or(false) + } } impl FieldExpr { diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs index b1d5dcff..894f7a35 100644 --- a/crates/plotnik-lib/src/query/types.rs +++ b/crates/plotnik-lib/src/query/types.rs @@ -4,14 +4,22 @@ //! Produces a `TypeTable` containing all inferred types. use indexmap::IndexMap; +use rowan::TextRange; use crate::diagnostics::DiagnosticKind; use crate::infer::{MergedField, TypeKey, TypeTable, TypeValue}; -use crate::parser::cst::SyntaxKind; use crate::parser::{AltKind, Expr, ast, token_src}; use super::Query; +/// Tracks a field's type and the location where it was first captured. +#[derive(Clone)] +struct FieldEntry<'src> { + type_key: TypeKey<'src>, + /// Range of the capture name token (e.g., `@x`) + capture_range: TextRange, +} + impl<'a> Query<'a> { pub(super) fn infer_types(&mut self) { let mut ctx = InferContext::new(self.source); @@ -82,19 +90,36 @@ impl<'src> InferContext<'src> { let value = if fields.is_empty() { TypeValue::Unit } else { - TypeValue::Struct(fields) + TypeValue::Struct(Self::extract_types(fields)) }; self.table.insert(key, value); } + /// Extract just the types from field entries + fn extract_types( + fields: IndexMap<&'src str, FieldEntry<'src>>, + ) -> IndexMap<&'src str, TypeKey<'src>> { + fields.into_iter().map(|(k, v)| (k, v.type_key)).collect() + } + + /// Extract types by reference for merge operations + fn extract_types_ref( + fields: &IndexMap<&'src str, FieldEntry<'src>>, + ) -> IndexMap<&'src str, TypeKey<'src>> { + fields + .iter() + .map(|(k, v)| (*k, v.type_key.clone())) + .collect() + } + /// Infer type for an expression, collecting captures into `fields`. /// Returns the TypeKey if this expression produces a referenceable type. fn infer_expr( &mut self, expr: &Expr, path: &[&'src str], - fields: &mut IndexMap<&'src str, TypeKey<'src>>, + fields: &mut IndexMap<&'src str, FieldEntry<'src>>, ) -> Option> { match expr { Expr::NamedNode(node) => { @@ -138,10 +163,11 @@ impl<'src> InferContext<'src> { &mut self, cap: &ast::CapturedExpr, path: &[&'src str], - fields: &mut IndexMap<&'src str, TypeKey<'src>>, + fields: &mut IndexMap<&'src str, FieldEntry<'src>>, ) -> Option> { let name_tok = cap.name()?; let capture_name = token_src(&name_tok, self.source); + let capture_range = name_tok.text_range(); let type_annotation = cap.type_annotation().and_then(|t| { let tok = t.name()?; @@ -171,7 +197,32 @@ impl<'src> InferContext<'src> { let inner_type = self.infer_capture_inner(inner.as_ref(), path, capture_name, type_annotation); - fields.insert(capture_name, inner_type.clone()); + // Check for duplicate capture in scope + // Unlike alternations (where branches are mutually exclusive), + // in sequences both captures execute - can't have two values for same name + if let Some(existing) = fields.get(capture_name) { + self.diagnostics + .report(DiagnosticKind::DuplicateCaptureInScope, capture_range) + .message(capture_name) + .related_to("first use", existing.capture_range) + .emit(); + fields.insert( + capture_name, + FieldEntry { + type_key: TypeKey::Invalid, + capture_range, + }, + ); + return Some(TypeKey::Invalid); + } + + fields.insert( + capture_name, + FieldEntry { + type_key: inner_type.clone(), + capture_range, + }, + ); Some(inner_type) } @@ -225,11 +276,7 @@ impl<'src> InferContext<'src> { capture_name, type_annotation, ); - if let Some(op) = q.operator() { - self.wrap_with_quantifier(&inner_key, op.kind()) - } else { - inner_key - } + self.wrap_with_quantifier(&inner_key, q) } else { TypeKey::Invalid } @@ -279,8 +326,19 @@ impl<'src> InferContext<'src> { TypeKey::Synthetic(nested_path) }; - self.table - .insert(key.clone(), TypeValue::Struct(nested_fields)); + if self + .table + .try_insert( + key.clone(), + TypeValue::Struct(Self::extract_types(nested_fields)), + ) + .is_err() + { + self.diagnostics + .report(DiagnosticKind::DuplicateCaptureInScope, inner.text_range()) + .emit(); + return TypeKey::Invalid; + } key } @@ -288,15 +346,16 @@ impl<'src> InferContext<'src> { &mut self, quant: &ast::QuantifiedExpr, path: &[&'src str], - fields: &mut IndexMap<&'src str, TypeKey<'src>>, + fields: &mut IndexMap<&'src str, FieldEntry<'src>>, ) -> Option> { let inner = quant.inner()?; - let op = quant.operator()?; + quant.operator()?; // If the inner is a capture, we need special handling for the wrapper if let Expr::CapturedExpr(cap) = &inner { let name_tok = cap.name()?; let capture_name = token_src(&name_tok, self.source); + let capture_range = name_tok.text_range(); let type_annotation = cap.type_annotation().and_then(|t| { let tok = t.name()?; @@ -305,72 +364,80 @@ impl<'src> InferContext<'src> { let inner_key = self.infer_capture_inner(cap.inner().as_ref(), path, capture_name, type_annotation); - let wrapped_key = self.wrap_with_quantifier(&inner_key, op.kind()); - - fields.insert(capture_name, wrapped_key.clone()); + let wrapped_key = self.wrap_with_quantifier(&inner_key, quant); + + fields.insert( + capture_name, + FieldEntry { + type_key: wrapped_key.clone(), + capture_range, + }, + ); return Some(wrapped_key); } // Non-capture quantified expression: recurse into inner and wrap with quantifier let inner_key = self.infer_expr(&inner, path, fields)?; - Some(self.wrap_with_quantifier(&inner_key, op.kind())) + Some(self.wrap_with_quantifier(&inner_key, quant)) } fn wrap_with_quantifier( &mut self, inner: &TypeKey<'src>, - op_kind: SyntaxKind, + quant: &ast::QuantifiedExpr, ) -> TypeKey<'src> { - let wrapper = match op_kind { - SyntaxKind::Question | SyntaxKind::QuestionQuestion => { - TypeValue::Optional(inner.clone()) - } - SyntaxKind::Star | SyntaxKind::StarQuestion => TypeValue::List(inner.clone()), - SyntaxKind::Plus | SyntaxKind::PlusQuestion => TypeValue::NonEmptyList(inner.clone()), - _ => return inner.clone(), + let wrapper = if quant.is_optional() { + TypeValue::Optional(inner.clone()) + } else if quant.is_list() { + TypeValue::List(inner.clone()) + } else if quant.is_non_empty_list() { + TypeValue::NonEmptyList(inner.clone()) + } else { + return inner.clone(); }; - // Create a unique key for the wrapper - let wrapper_key = match inner { - TypeKey::Named(name) => { - let suffix = match op_kind { - SyntaxKind::Question | SyntaxKind::QuestionQuestion => "Opt", - SyntaxKind::Star | SyntaxKind::StarQuestion => "List", - SyntaxKind::Plus | SyntaxKind::PlusQuestion => "List", - _ => "", - }; - TypeKey::Synthetic(vec![name, suffix]) - } - TypeKey::Synthetic(segments) => { - let mut new_segments = segments.clone(); - let suffix = match op_kind { - SyntaxKind::Question | SyntaxKind::QuestionQuestion => "opt", - SyntaxKind::Star | SyntaxKind::StarQuestion => "list", - SyntaxKind::Plus | SyntaxKind::PlusQuestion => "list", - _ => "", - }; - new_segments.push(suffix); - TypeKey::Synthetic(new_segments) - } - _ => { - // For builtins like Node, we directly store the wrapper under a synthetic key - let type_name = match inner { - TypeKey::Node => "Node", - TypeKey::String => "String", - TypeKey::Unit => "Unit", - _ => "Unknown", - }; - let suffix = match op_kind { - SyntaxKind::Question | SyntaxKind::QuestionQuestion => "Opt", - SyntaxKind::Star | SyntaxKind::StarQuestion => "List", - SyntaxKind::Plus | SyntaxKind::PlusQuestion => "List", - _ => "", - }; - TypeKey::Synthetic(vec![type_name, suffix]) - } + // Generate a unique key for the wrapper type + let wrapper_name = match inner { + TypeKey::Named(name) => format!("{}Wrapped", name), + TypeKey::Node => "NodeWrapped".to_string(), + TypeKey::String => "StringWrapped".to_string(), + TypeKey::Synthetic(path) => format!("{}Wrapped", path.join("_")), + TypeKey::DefaultQuery => "QueryWrapped".to_string(), + TypeKey::Unit => "UnitWrapped".to_string(), + TypeKey::Invalid => return TypeKey::Invalid, }; - self.table.insert(wrapper_key.clone(), wrapper); + // For simple wrappers around Node/String, just return the wrapper directly + // without creating a synthetic type entry. The printer will handle these. + if matches!(inner, TypeKey::Node | TypeKey::String) { + let prefix = if quant.is_optional() { + "opt" + } else if quant.is_list() { + "list" + } else if quant.is_non_empty_list() { + "nonempty" + } else { + "wrapped" + }; + let inner_name = match inner { + TypeKey::Node => "node", + TypeKey::String => "string", + _ => "unknown", + }; + let wrapper_key = TypeKey::Synthetic(vec![prefix, inner_name]); + self.table.insert(wrapper_key.clone(), wrapper); + return wrapper_key; + } + + let wrapper_key = TypeKey::Synthetic(vec![Box::leak(wrapper_name.into_boxed_str())]); + if self + .table + .try_insert(wrapper_key.clone(), wrapper.clone()) + .is_err() + { + // Key already exists with same wrapper - that's fine + return wrapper_key; + } wrapper_key } @@ -378,7 +445,7 @@ impl<'src> InferContext<'src> { &mut self, alt: &ast::AltExpr, path: &[&'src str], - fields: &mut IndexMap<&'src str, TypeKey<'src>>, + fields: &mut IndexMap<&'src str, FieldEntry<'src>>, ) -> Option> { // Alt without capture: just collect fields from all branches into current scope match alt.kind() { @@ -393,7 +460,9 @@ impl<'src> InferContext<'src> { AltKind::Untagged | AltKind::Mixed => { // Untagged alt: merge fields from branches let branch_fields = self.collect_branch_fields(alt, path); - let merged = TypeTable::merge_fields(&branch_fields); + let branch_types: Vec<_> = + branch_fields.iter().map(Self::extract_types_ref).collect(); + let merged = self.table.merge_fields(&branch_types); self.apply_merged_fields(merged, fields, alt); } } @@ -452,9 +521,10 @@ impl<'src> InferContext<'src> { _ => TypeValue::Unit, } } else { - TypeValue::Struct(variant_fields) + TypeValue::Struct(Self::extract_types(variant_fields)) }; + // Variant types shouldn't conflict - they have unique paths including the label self.table.insert(variant_key.clone(), variant_value); variants.insert(label, variant_key); } @@ -467,8 +537,17 @@ impl<'src> InferContext<'src> { TypeKey::Synthetic(path.to_vec()) }; - self.table - .insert(key.clone(), TypeValue::TaggedUnion(variants)); + // Detect conflict: same key with incompatible TaggedUnion + if self + .table + .try_insert(key.clone(), TypeValue::TaggedUnion(variants)) + .is_err() + { + self.diagnostics + .report(DiagnosticKind::DuplicateCaptureInScope, alt.text_range()) + .emit(); + return TypeKey::Invalid; + } key } @@ -479,7 +558,8 @@ impl<'src> InferContext<'src> { type_annotation: Option<&'src str>, ) -> TypeKey<'src> { let branch_fields = self.collect_branch_fields(alt, path); - let merged = TypeTable::merge_fields(&branch_fields); + let branch_types: Vec<_> = branch_fields.iter().map(Self::extract_types_ref).collect(); + let merged = self.table.merge_fields(&branch_types); if merged.is_empty() { return type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node); @@ -496,8 +576,19 @@ impl<'src> InferContext<'src> { TypeKey::Synthetic(path.to_vec()) }; - self.table - .insert(key.clone(), TypeValue::Struct(result_fields)); + if self + .table + .try_insert( + key.clone(), + TypeValue::Struct(Self::extract_types(result_fields)), + ) + .is_err() + { + self.diagnostics + .report(DiagnosticKind::DuplicateCaptureInScope, alt.text_range()) + .emit(); + return TypeKey::Invalid; + } key } @@ -505,7 +596,7 @@ impl<'src> InferContext<'src> { &mut self, alt: &ast::AltExpr, path: &[&'src str], - ) -> Vec>> { + ) -> Vec>> { let mut branch_fields = Vec::new(); for branch in alt.branches() { @@ -522,7 +613,7 @@ impl<'src> InferContext<'src> { fn apply_merged_fields( &mut self, merged: IndexMap<&'src str, MergedField<'src>>, - fields: &mut IndexMap<&'src str, TypeKey<'src>>, + fields: &mut IndexMap<&'src str, FieldEntry<'src>>, alt: &ast::AltExpr, ) { for (name, merge_result) in merged { @@ -542,7 +633,14 @@ impl<'src> InferContext<'src> { TypeKey::Invalid } }; - fields.insert(name, key); + fields.insert( + name, + FieldEntry { + type_key: key, + // Use the alt's range as a fallback since we don't have individual capture ranges here + capture_range: alt.text_range(), + }, + ); } } } diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index 2f85f36c..b6fc2c1d 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -62,16 +62,16 @@ fn comprehensive_type_inference() { UsingRef = { BinaryOp @expr } = { #Node @p } Nested = { @params #Node @body } - = #Node? - = #Node+ - WithQuantifiers = { @maybe_dec @methods @fields } + = #Node? + = #Node+ + WithQuantifiers = { @maybe_dec @methods @fields } = { #Node @target } = { #Node @func } TaggedAlt = [ Assign: Call: ] = #Node? UntaggedAlt = { #Node @left @right } - = Simple* - #DefaultQuery = { @items } + = Simple? + #DefaultQuery = { @items } "); } @@ -158,8 +158,8 @@ fn quantified_ref() { assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" Item = { #Node @value } - = Item+ - List = { @items } + = Item+ + List = { @items } "); } @@ -199,9 +199,104 @@ fn tagged_union_branch_with_ref() { assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" = () - = Rec? - = { @value } + = Rec? + = { @value } Rec = [ Base: Rec: ] #DefaultQuery = () "); } + +#[test] +fn nested_tagged_alts_in_untagged_alt_conflict() { + // Each branch captures @x with different TaggedUnion types + // Branch 1: @x is TaggedUnion with variant A + // Branch 2: @x is TaggedUnion with variant B + // This is a type conflict - different structures under same capture name + let input = "[[A: (a) @aa] @x [B: (b) @bb] @x]"; + + let query = Query::try_from(input).unwrap(); + + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + = { #Node @aa } + = [ A: ] + = { #Node @bb } + #DefaultQuery = { #Invalid @x } + "); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: capture `x` has conflicting types across branches + | + 1 | [[A: (a) @aa] @x [B: (b) @bb] @x] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + error: duplicate capture in same scope + | + 1 | [[A: (a) @aa] @x [B: (b) @bb] @x] + | ^^^^^^^^^^^^ + "); +} + +#[test] +fn nested_untagged_alts_drop_field() { + // Each branch captures @x with different struct types + // Branch 1: @x has field @y + // Branch 2: @x has field @z + // This is a type conflict - different structures under same capture name + let input = "[[(a) @y] @x [(b) @z] @x]"; + + let query = Query::try_from(input).unwrap(); + + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + = { #Node @y } + #DefaultQuery = { #Invalid @x } + "); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: capture `x` has conflicting types across branches + | + 1 | [[(a) @y] @x [(b) @z] @x] + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + + error: duplicate capture in same scope + | + 1 | [[(a) @y] @x [(b) @z] @x] + | ^^^^^^^^ + "); +} + +#[test] +fn list_vs_nonempty_list_merged_silently() { + // Different quantifiers: * (List) vs + (NonEmptyList) + // These are incompatible types - List vs NonEmptyList + let input = "[(a)* @x (b)+ @x]"; + + let query = Query::try_from(input).unwrap(); + + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + = #Node? + = #Node+ + #DefaultQuery = { #Invalid @x } + "); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: capture `x` has conflicting types across branches + | + 1 | [(a)* @x (b)+ @x] + | ^^^^^^^^^^^^^^^^^ + "); +} + +#[test] +fn same_variant_name_across_branches_merges() { + // Both branches have variant A - should merge correctly + let input = "[[A: (a)] @x [A: (b)] @x]"; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + = () + = [ A: ] + #DefaultQuery = { @x } + "); +} From a6ee154e90ada327ee5033c97efec4c2851c3156 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 02:41:36 -0300 Subject: [PATCH 05/11] Fix merging --- crates/plotnik-lib/src/infer/types.rs | 117 +++++++++++++++++++- crates/plotnik-lib/src/infer/types_tests.rs | 26 +++-- crates/plotnik-lib/src/query/types.rs | 57 +++++----- crates/plotnik-lib/src/query/types_tests.rs | 29 ++--- 4 files changed, 171 insertions(+), 58 deletions(-) diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index 98e33488..6c2d5247 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -299,12 +299,79 @@ impl<'src> TypeTable<'src> { self.types.iter() } + /// Try to merge two struct types into one, returning the merged fields. + /// + /// Returns `Some(merged_fields)` if both types are structs (regardless of field shape). + /// Returns `None` if either type is not a struct. + /// + /// The merge rules: + /// - Fields present in both structs with compatible types keep that type + /// - Fields present in only one struct become Optional + /// - Fields with conflicting types become Invalid + fn try_merge_struct_fields( + &self, + a: &TypeKey<'src>, + b: &TypeKey<'src>, + ) -> Option>> { + let val_a = self.get(a)?; + let val_b = self.get(b)?; + + let (fields_a, fields_b) = match (val_a, val_b) { + (TypeValue::Struct(fa), TypeValue::Struct(fb)) => (fa, fb), + _ => return None, + }; + + // Collect all field names from both structs + let mut all_fields: IndexMap<&'src str, ()> = IndexMap::new(); + for name in fields_a.keys() { + all_fields.entry(*name).or_insert(()); + } + for name in fields_b.keys() { + all_fields.entry(*name).or_insert(()); + } + + let mut result = IndexMap::new(); + for field_name in all_fields.keys() { + let type_a = fields_a.get(field_name); + let type_b = fields_b.get(field_name); + + let merged = match (type_a, type_b) { + (Some(ta), Some(tb)) => { + if self.types_are_compatible(ta, tb) { + MergedField::Same(ta.clone()) + } else { + // Recursively try to merge nested structs + if let Some(nested_merged) = self.try_merge_struct_fields(ta, tb) { + if nested_merged + .values() + .any(|m| matches!(m, MergedField::Conflict)) + { + MergedField::Conflict + } else { + // Both are structs - they can be merged (caller handles actual merge) + MergedField::Same(ta.clone()) + } + } else { + MergedField::Conflict + } + } + } + (Some(t), None) | (None, Some(t)) => MergedField::Optional(t.clone()), + (None, None) => continue, + }; + result.insert(*field_name, merged); + } + + Some(result) + } + /// Merge fields from multiple struct branches (for untagged unions). /// /// Given a list of field maps (one per branch), produces a merged field map where: /// - Fields present in all branches with the same type keep that type /// - Fields present in only some branches become Optional /// - Fields with conflicting types across branches become Invalid + /// - Fields that are both structs get recursively merged /// /// # Example /// @@ -313,6 +380,13 @@ impl<'src> TypeTable<'src> { /// /// Merged: `{ name: String, value: Optional, extra: Optional }` /// + /// # Struct Merge Example + /// + /// Branch 1: `{ x: { y: Node } }` + /// Branch 2: `{ x: { z: Node } }` + /// + /// Merged: `{ x: { y: Optional, z: Optional } }` + /// /// # Type Conflict Example /// /// Branch 1: `{ x: String }` @@ -320,7 +394,7 @@ impl<'src> TypeTable<'src> { /// /// Merged: `{ x: Invalid }` (with diagnostic warning) pub fn merge_fields( - &self, + &mut self, branches: &[IndexMap<&'src str, TypeKey<'src>>], ) -> IndexMap<&'src str, MergedField<'src>> { if branches.is_empty() { @@ -359,8 +433,45 @@ impl<'src> TypeTable<'src> { .all(|t| self.types_are_compatible(t, first_type)); let merged = if !all_same_type { - // Type conflict - MergedField::Conflict + // Types differ - try to merge if both are structs + if type_occurrences.len() == 2 { + if let Some(struct_merged) = + self.try_merge_struct_fields(type_occurrences[0], type_occurrences[1]) + { + // Both are structs - create a merged struct type + let merged_fields: IndexMap<&'src str, TypeKey<'src>> = struct_merged + .into_iter() + .map(|(name, mf)| { + let key = match mf { + MergedField::Same(k) => k, + MergedField::Optional(k) => { + let wrapper_key = + TypeKey::Synthetic(vec![*field_name, name, "opt"]); + self.insert(wrapper_key.clone(), TypeValue::Optional(k)); + wrapper_key + } + MergedField::Conflict => TypeKey::Invalid, + }; + (name, key) + }) + .collect(); + + // Create a new merged struct type + let merged_key = TypeKey::Synthetic(vec![*field_name, "merged"]); + self.insert(merged_key.clone(), TypeValue::Struct(merged_fields)); + + if present_count == branch_count { + MergedField::Same(merged_key) + } else { + MergedField::Optional(merged_key) + } + } else { + MergedField::Conflict + } + } else { + // More than 2 branches with different struct types - TODO: support N-way merge + MergedField::Conflict + } } else if present_count == branch_count { // Present in all branches with same type MergedField::Same(first_type.clone()) diff --git a/crates/plotnik-lib/src/infer/types_tests.rs b/crates/plotnik-lib/src/infer/types_tests.rs index 3d96acb0..71fc2dc3 100644 --- a/crates/plotnik-lib/src/infer/types_tests.rs +++ b/crates/plotnik-lib/src/infer/types_tests.rs @@ -242,7 +242,7 @@ fn type_value_invalid() { #[test] fn merge_fields_empty_branches() { - let table = TypeTable::new(); + let mut table = TypeTable::new(); let branches: Vec> = vec![]; let merged = table.merge_fields(&branches); @@ -252,11 +252,12 @@ fn merge_fields_empty_branches() { #[test] fn merge_fields_single_branch() { + let mut table = TypeTable::new(); let mut branch = IndexMap::new(); branch.insert("name", TypeKey::String); branch.insert("value", TypeKey::Node); - let merged = TypeTable::new().merge_fields(&[branch]); + let merged = table.merge_fields(&[branch]); assert_eq!(merged.len(), 2); assert_eq!(merged["name"], MergedField::Same(TypeKey::String)); @@ -265,13 +266,14 @@ fn merge_fields_single_branch() { #[test] fn merge_fields_identical_branches() { + let mut table = TypeTable::new(); let mut branch1 = IndexMap::new(); branch1.insert("name", TypeKey::String); let mut branch2 = IndexMap::new(); branch2.insert("name", TypeKey::String); - let merged = TypeTable::new().merge_fields(&[branch1, branch2]); + let merged = table.merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 1); assert_eq!(merged["name"], MergedField::Same(TypeKey::String)); @@ -279,6 +281,7 @@ fn merge_fields_identical_branches() { #[test] fn merge_fields_missing_in_some_branches() { + let mut table = TypeTable::new(); let mut branch1 = IndexMap::new(); branch1.insert("name", TypeKey::String); branch1.insert("value", TypeKey::Node); @@ -287,7 +290,7 @@ fn merge_fields_missing_in_some_branches() { branch2.insert("name", TypeKey::String); // value missing - let merged = TypeTable::new().merge_fields(&[branch1, branch2]); + let merged = table.merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 2); assert_eq!(merged["name"], MergedField::Same(TypeKey::String)); @@ -296,13 +299,14 @@ fn merge_fields_missing_in_some_branches() { #[test] fn merge_fields_disjoint_branches() { + let mut table = TypeTable::new(); let mut branch1 = IndexMap::new(); branch1.insert("a", TypeKey::String); let mut branch2 = IndexMap::new(); branch2.insert("b", TypeKey::Node); - let merged = TypeTable::new().merge_fields(&[branch1, branch2]); + let merged = table.merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 2); assert_eq!(merged["a"], MergedField::Optional(TypeKey::String)); @@ -311,13 +315,14 @@ fn merge_fields_disjoint_branches() { #[test] fn merge_fields_type_conflict() { + let mut table = TypeTable::new(); let mut branch1 = IndexMap::new(); branch1.insert("x", TypeKey::String); let mut branch2 = IndexMap::new(); branch2.insert("x", TypeKey::Node); - let merged = TypeTable::new().merge_fields(&[branch1, branch2]); + let merged = table.merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 1); assert_eq!(merged["x"], MergedField::Conflict); @@ -325,6 +330,7 @@ fn merge_fields_type_conflict() { #[test] fn merge_fields_partial_conflict() { + let mut table = TypeTable::new(); // Three branches: x is String in branch 1 and 2, Node in branch 3 let mut branch1 = IndexMap::new(); branch1.insert("x", TypeKey::String); @@ -335,13 +341,14 @@ fn merge_fields_partial_conflict() { let mut branch3 = IndexMap::new(); branch3.insert("x", TypeKey::Node); - let merged = TypeTable::new().merge_fields(&[branch1, branch2, branch3]); + let merged = table.merge_fields(&[branch1, branch2, branch3]); assert_eq!(merged["x"], MergedField::Conflict); } #[test] fn merge_fields_complex_scenario() { + let mut table = TypeTable::new(); // Branch 1: { name: String, value: Node } // Branch 2: { name: String, extra: Node } // Result: { name: String, value: Optional, extra: Optional } @@ -353,7 +360,7 @@ fn merge_fields_complex_scenario() { branch2.insert("name", TypeKey::String); branch2.insert("extra", TypeKey::Node); - let merged = TypeTable::new().merge_fields(&[branch1, branch2]); + let merged = table.merge_fields(&[branch1, branch2]); assert_eq!(merged.len(), 3); assert_eq!(merged["name"], MergedField::Same(TypeKey::String)); @@ -363,6 +370,7 @@ fn merge_fields_complex_scenario() { #[test] fn merge_fields_preserves_order() { + let mut table = TypeTable::new(); let mut branch1 = IndexMap::new(); branch1.insert("z", TypeKey::String); branch1.insert("a", TypeKey::String); @@ -370,7 +378,7 @@ fn merge_fields_preserves_order() { let mut branch2 = IndexMap::new(); branch2.insert("m", TypeKey::String); - let merged = TypeTable::new().merge_fields(&[branch1, branch2]); + let merged = table.merge_fields(&[branch1, branch2]); let keys: Vec<_> = merged.keys().collect(); // Order follows first occurrence across branches diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs index 894f7a35..f51798fc 100644 --- a/crates/plotnik-lib/src/query/types.rs +++ b/crates/plotnik-lib/src/query/types.rs @@ -41,6 +41,8 @@ struct InferContext<'src> { source: &'src str, table: TypeTable<'src>, diagnostics: crate::diagnostics::Diagnostics, + /// Counter for generating unique synthetic type keys + synthetic_counter: usize, } impl<'src> InferContext<'src> { @@ -49,9 +51,18 @@ impl<'src> InferContext<'src> { source, table: TypeTable::new(), diagnostics: crate::diagnostics::Diagnostics::new(), + synthetic_counter: 0, } } + /// Generate a unique suffix for synthetic keys + fn next_synthetic_suffix(&mut self) -> &'src str { + let n = self.synthetic_counter; + self.synthetic_counter += 1; + // Leak a small string for the lifetime - this is fine for query processing + Box::leak(n.to_string().into_boxed_str()) + } + fn infer_def(&mut self, def: &ast::Def, is_last: bool) { let key = match def.name() { Some(name_tok) => { @@ -323,22 +334,17 @@ impl<'src> InferContext<'src> { let key = if let Some(name) = type_annotation { TypeKey::Named(name) } else { - TypeKey::Synthetic(nested_path) + // Use unique suffix to allow same capture name in different alternation branches + let suffix = self.next_synthetic_suffix(); + let mut unique_path = nested_path; + unique_path.push(suffix); + TypeKey::Synthetic(unique_path) }; - if self - .table - .try_insert( - key.clone(), - TypeValue::Struct(Self::extract_types(nested_fields)), - ) - .is_err() - { - self.diagnostics - .report(DiagnosticKind::DuplicateCaptureInScope, inner.text_range()) - .emit(); - return TypeKey::Invalid; - } + self.table.insert( + key.clone(), + TypeValue::Struct(Self::extract_types(nested_fields)), + ); key } @@ -573,22 +579,17 @@ impl<'src> InferContext<'src> { } else if path.is_empty() { TypeKey::DefaultQuery } else { - TypeKey::Synthetic(path.to_vec()) + // Use unique suffix to allow same capture name in different alternation branches + let suffix = self.next_synthetic_suffix(); + let mut unique_path = path.to_vec(); + unique_path.push(suffix); + TypeKey::Synthetic(unique_path) }; - if self - .table - .try_insert( - key.clone(), - TypeValue::Struct(Self::extract_types(result_fields)), - ) - .is_err() - { - self.diagnostics - .report(DiagnosticKind::DuplicateCaptureInScope, alt.text_range()) - .emit(); - return TypeKey::Invalid; - } + self.table.insert( + key.clone(), + TypeValue::Struct(Self::extract_types(result_fields)), + ); key } diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index b6fc2c1d..b1df211a 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -60,8 +60,8 @@ fn comprehensive_type_inference() { WithString = { #string @name } Named = { MyType @value } UsingRef = { BinaryOp @expr } - = { #Node @p } - Nested = { @params #Node @body } + = { #Node @p } + Nested = { @params #Node @body } = #Node? = #Node+ WithQuantifiers = { @maybe_dec @methods @fields } @@ -237,30 +237,23 @@ fn nested_tagged_alts_in_untagged_alt_conflict() { } #[test] -fn nested_untagged_alts_drop_field() { +fn nested_untagged_alts_merge_fields() { // Each branch captures @x with different struct types // Branch 1: @x has field @y // Branch 2: @x has field @z - // This is a type conflict - different structures under same capture name + // These get merged: fields from both branches become optional let input = "[[(a) @y] @x [(b) @z] @x]"; let query = Query::try_from(input).unwrap(); - assert!(!query.is_valid()); + assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - = { #Node @y } - #DefaultQuery = { #Invalid @x } - "); - insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture `x` has conflicting types across branches - | - 1 | [[(a) @y] @x [(b) @z] @x] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ - - error: duplicate capture in same scope - | - 1 | [[(a) @y] @x [(b) @z] @x] - | ^^^^^^^^ + = { #Node @y } + = { #Node @z } + = #Node? + = #Node? + = { @y @z } + #DefaultQuery = { @x } "); } From 1a5d08a50d778c0674491b33d178dc9d6dfe3d5c Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 03:02:57 -0300 Subject: [PATCH 06/11] Modify list type compatibility to merge List and NonEmptyList --- crates/plotnik-lib/src/diagnostics/message.rs | 4 + crates/plotnik-lib/src/infer/types.rs | 81 +++++++++++++++++-- crates/plotnik-lib/src/query/types.rs | 28 ++++--- crates/plotnik-lib/src/query/types_tests.rs | 32 +++----- 4 files changed, 105 insertions(+), 40 deletions(-) diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index b055a800..5147b6ac 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -64,6 +64,7 @@ pub enum DiagnosticKind { // Type inference errors TypeConflictInMerge, MergeAltRequiresAnnotation, + IncompatibleTaggedAlternations, // Link pass - grammar validation UnknownNodeType, @@ -175,6 +176,9 @@ impl DiagnosticKind { Self::MergeAltRequiresAnnotation => { "merged alternation with captures requires type annotation" } + Self::IncompatibleTaggedAlternations => { + "tagged alternations with different variants cannot be merged" + } // Link pass - grammar validation Self::UnknownNodeType => "unknown node type", diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index 6c2d5247..79db12ed 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -49,6 +49,7 @@ //! name collisions while keeping names readable. use indexmap::IndexMap; +use rowan::TextRange; /// Identity of a type in the type table. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -155,6 +156,8 @@ pub struct TypeTable<'src> { pub types: IndexMap, TypeValue<'src>>, /// Types that contain cyclic references (need Box in Rust). pub cyclic: Vec>, + /// Source spans where each type was first defined. + definition_spans: IndexMap, TextRange>, } impl<'src> TypeTable<'src> { @@ -168,6 +171,7 @@ impl<'src> TypeTable<'src> { Self { types, cyclic: Vec::new(), + definition_spans: IndexMap::new(), } } @@ -177,28 +181,40 @@ impl<'src> TypeTable<'src> { key } - /// Insert a type definition, detecting conflicts with existing incompatible types. + /// Insert a type definition with a source span, detecting conflicts. /// /// Returns `Ok(key)` if inserted successfully (no conflict). - /// Returns `Err(key)` if there was an existing incompatible type (conflict). + /// Returns `Err(existing_span)` if there was an existing incompatible type. /// /// On conflict, the existing type is NOT overwritten - caller should use Invalid. pub fn try_insert( &mut self, key: TypeKey<'src>, value: TypeValue<'src>, - ) -> Result, TypeKey<'src>> { + span: TextRange, + ) -> Result, TextRange> { if let Some(existing) = self.types.get(&key) { if !self.values_are_compatible(existing, &value) { - return Err(key); + let existing_span = self.definition_spans.get(&key).copied().unwrap_or(span); + return Err(existing_span); } // Compatible - keep existing, don't overwrite return Ok(key); } self.types.insert(key.clone(), value); + self.definition_spans.insert(key.clone(), span); Ok(key) } + /// Insert without span tracking. Returns true if inserted, false if key existed. + pub fn try_insert_untracked(&mut self, key: TypeKey<'src>, value: TypeValue<'src>) -> bool { + if self.types.contains_key(&key) { + return false; + } + self.types.insert(key, value); + true + } + /// Mark a type as cyclic (requires indirection in Rust). pub fn mark_cyclic(&mut self, key: TypeKey<'src>) { if !self.cyclic.contains(&key) { @@ -226,6 +242,11 @@ impl<'src> TypeTable<'src> { return true; } + // Invalid is compatible with anything - don't cascade errors + if *a == TypeKey::Invalid || *b == TypeKey::Invalid { + return true; + } + // Different built-in types are incompatible if a.is_builtin() || b.is_builtin() { return false; @@ -253,8 +274,10 @@ impl<'src> TypeTable<'src> { (Optional(ka), Optional(kb)) => self.types_are_compatible(ka, kb), (List(ka), List(kb)) => self.types_are_compatible(ka, kb), (NonEmptyList(ka), NonEmptyList(kb)) => self.types_are_compatible(ka, kb), - // List and NonEmptyList are NOT compatible - different cardinality guarantees - (List(_), NonEmptyList(_)) | (NonEmptyList(_), List(_)) => false, + // List and NonEmptyList are compatible if inner types match - merge to List + (List(ka), NonEmptyList(kb)) | (NonEmptyList(ka), List(kb)) => { + self.types_are_compatible(ka, kb) + } (Struct(fa), Struct(fb)) => { // Structs must have exactly the same fields with compatible types if fa.len() != fb.len() { @@ -299,6 +322,36 @@ impl<'src> TypeTable<'src> { self.types.iter() } + /// Try to merge List and NonEmptyList types into List. + /// + /// Returns `Some(List(inner))` if one is List and other is NonEmptyList with compatible inner types. + /// Returns `None` otherwise. + fn try_merge_list_types( + &mut self, + a: &TypeKey<'src>, + b: &TypeKey<'src>, + ) -> Option> { + let val_a = self.get(a)?; + let val_b = self.get(b)?; + + let inner = match (val_a, val_b) { + (TypeValue::List(ka), TypeValue::NonEmptyList(kb)) + | (TypeValue::NonEmptyList(ka), TypeValue::List(kb)) => { + if self.types_are_compatible(ka, kb) { + ka.clone() + } else { + return None; + } + } + _ => return None, + }; + + // Return or create a List type with the inner type + let list_key = TypeKey::Synthetic(vec!["list", "merged"]); + self.insert(list_key.clone(), TypeValue::List(inner)); + Some(list_key) + } + /// Try to merge two struct types into one, returning the merged fields. /// /// Returns `Some(merged_fields)` if both types are structs (regardless of field shape). @@ -432,7 +485,21 @@ impl<'src> TypeTable<'src> { .iter() .all(|t| self.types_are_compatible(t, first_type)); - let merged = if !all_same_type { + // Check for List/NonEmptyList merge case + let list_merge_key = if type_occurrences.len() == 2 { + self.try_merge_list_types(type_occurrences[0], type_occurrences[1]) + } else { + None + }; + + let merged = if let Some(merged_key) = list_merge_key { + // List and NonEmptyList merged to List + if present_count == branch_count { + MergedField::Same(merged_key) + } else { + MergedField::Optional(merged_key) + } + } else if !all_same_type { // Types differ - try to merge if both are structs if type_occurrences.len() == 2 { if let Some(struct_merged) = diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs index f51798fc..1c4b2898 100644 --- a/crates/plotnik-lib/src/query/types.rs +++ b/crates/plotnik-lib/src/query/types.rs @@ -392,12 +392,13 @@ impl<'src> InferContext<'src> { inner: &TypeKey<'src>, quant: &ast::QuantifiedExpr, ) -> TypeKey<'src> { - let wrapper = if quant.is_optional() { - TypeValue::Optional(inner.clone()) - } else if quant.is_list() { + // Check list/non-empty-list before optional since * matches both is_list() and is_optional() + let wrapper = if quant.is_list() { TypeValue::List(inner.clone()) } else if quant.is_non_empty_list() { TypeValue::NonEmptyList(inner.clone()) + } else if quant.is_optional() { + TypeValue::Optional(inner.clone()) } else { return inner.clone(); }; @@ -436,10 +437,9 @@ impl<'src> InferContext<'src> { } let wrapper_key = TypeKey::Synthetic(vec![Box::leak(wrapper_name.into_boxed_str())]); - if self + if !self .table - .try_insert(wrapper_key.clone(), wrapper.clone()) - .is_err() + .try_insert_untracked(wrapper_key.clone(), wrapper.clone()) { // Key already exists with same wrapper - that's fine return wrapper_key; @@ -544,15 +544,19 @@ impl<'src> InferContext<'src> { }; // Detect conflict: same key with incompatible TaggedUnion - if self - .table - .try_insert(key.clone(), TypeValue::TaggedUnion(variants)) - .is_err() + let current_span = alt.text_range(); + if let Err(existing_span) = + self.table + .try_insert(key.clone(), TypeValue::TaggedUnion(variants), current_span) { self.diagnostics - .report(DiagnosticKind::DuplicateCaptureInScope, alt.text_range()) + .report( + DiagnosticKind::IncompatibleTaggedAlternations, + existing_span, + ) + .related_to("incompatible", current_span) .emit(); - return TypeKey::Invalid; + return key; } key } diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index b1df211a..b870dfa1 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -62,7 +62,7 @@ fn comprehensive_type_inference() { UsingRef = { BinaryOp @expr } = { #Node @p } Nested = { @params #Node @body } - = #Node? + = #Node* = #Node+ WithQuantifiers = { @maybe_dec @methods @fields } = { #Node @target } @@ -70,7 +70,7 @@ fn comprehensive_type_inference() { TaggedAlt = [ Assign: Call: ] = #Node? UntaggedAlt = { #Node @left @right } - = Simple? + = Simple* #DefaultQuery = { @items } "); } @@ -221,18 +221,13 @@ fn nested_tagged_alts_in_untagged_alt_conflict() { = { #Node @aa } = [ A: ] = { #Node @bb } - #DefaultQuery = { #Invalid @x } + #DefaultQuery = { @x } "); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture `x` has conflicting types across branches - | - 1 | [[A: (a) @aa] @x [B: (b) @bb] @x] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - - error: duplicate capture in same scope + error: tagged alternations with different variants cannot be merged | 1 | [[A: (a) @aa] @x [B: (b) @bb] @x] - | ^^^^^^^^^^^^ + | ^^^^^^^^^^^^ ------------ incompatible "); } @@ -258,24 +253,19 @@ fn nested_untagged_alts_merge_fields() { } #[test] -fn list_vs_nonempty_list_merged_silently() { +fn list_vs_nonempty_list_merged_to_list() { // Different quantifiers: * (List) vs + (NonEmptyList) - // These are incompatible types - List vs NonEmptyList + // These merge to List (the more general type) let input = "[(a)* @x (b)+ @x]"; let query = Query::try_from(input).unwrap(); - assert!(!query.is_valid()); + assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - = #Node? + = #Node* = #Node+ - #DefaultQuery = { #Invalid @x } - "); - insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture `x` has conflicting types across branches - | - 1 | [(a)* @x (b)+ @x] - | ^^^^^^^^^^^^^^^^^ + = #Node* + #DefaultQuery = { @x } "); } From a77e2f7577c81ca4be5013edc9cc32df797f9540 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 03:31:24 -0300 Subject: [PATCH 07/11] Fixes --- crates/plotnik-lib/src/query/types.rs | 132 +++++++++++++++++--- crates/plotnik-lib/src/query/types_tests.rs | 19 ++- 2 files changed, 135 insertions(+), 16 deletions(-) diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs index 1c4b2898..70731e97 100644 --- a/crates/plotnik-lib/src/query/types.rs +++ b/crates/plotnik-lib/src/query/types.rs @@ -3,7 +3,7 @@ //! Walks definitions and infers output types from capture patterns. //! Produces a `TypeTable` containing all inferred types. -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use rowan::TextRange; use crate::diagnostics::DiagnosticKind; @@ -32,6 +32,8 @@ impl<'a> Query<'a> { ctx.infer_def(def, is_last); } + ctx.mark_cyclic_types(); + self.type_table = ctx.table; self.type_diagnostics = ctx.diagnostics; } @@ -63,6 +65,87 @@ impl<'src> InferContext<'src> { Box::leak(n.to_string().into_boxed_str()) } + /// Mark types that contain cyclic references (need Box/Rc/Arc in Rust). + /// Only struct/union types are marked - wrapper types (Optional, List, etc.) + /// shouldn't be wrapped in Box themselves, only their inner references. + fn mark_cyclic_types(&mut self) { + let keys: Vec<_> = self + .table + .types + .keys() + .filter(|k| !k.is_builtin()) + .filter(|k| { + matches!( + self.table.get(k), + Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) + ) + }) + .cloned() + .collect(); + + for key in keys { + if self.type_references_itself(&key) { + self.table.mark_cyclic(key); + } + } + } + + /// Check if a type contains a reference to itself (directly or indirectly). + fn type_references_itself(&self, key: &TypeKey<'src>) -> bool { + let mut visited = IndexSet::new(); + self.type_reaches(key, key, &mut visited) + } + + /// Check if `current` type can reach `target` type through references. + fn type_reaches( + &self, + current: &TypeKey<'src>, + target: &TypeKey<'src>, + visited: &mut IndexSet>, + ) -> bool { + if !visited.insert(current.clone()) { + return false; + } + + let Some(value) = self.table.get(current) else { + return false; + }; + + match value { + TypeValue::Struct(fields) => { + for field_key in fields.values() { + if field_key == target { + return true; + } + if self.type_reaches(field_key, target, visited) { + return true; + } + } + false + } + TypeValue::TaggedUnion(variants) => { + for variant_key in variants.values() { + if variant_key == target { + return true; + } + if self.type_reaches(variant_key, target, visited) { + return true; + } + } + false + } + TypeValue::Optional(inner) + | TypeValue::List(inner) + | TypeValue::NonEmptyList(inner) => { + if inner == target { + return true; + } + self.type_reaches(inner, target, visited) + } + TypeValue::Node | TypeValue::String | TypeValue::Unit | TypeValue::Invalid => false, + } + } + fn infer_def(&mut self, def: &ast::Def, is_last: bool) { let key = match def.name() { Some(name_tok) => { @@ -244,6 +327,17 @@ impl<'src> InferContext<'src> { capture_name: &'src str, type_annotation: Option<&'src str>, ) -> TypeKey<'src> { + // Handle quantifier first - it wraps whatever the inner type is + // This ensures `(x)+ @name :: string` becomes Vec, not String + if let Some(Expr::QuantifiedExpr(q)) = inner { + let Some(qinner) = q.inner() else { + return TypeKey::Invalid; + }; + let inner_key = + self.infer_capture_inner(Some(&qinner), path, capture_name, type_annotation); + return self.wrap_with_quantifier(&inner_key, q); + } + // :: string annotation if type_annotation == Some("string") { return TypeKey::String; @@ -279,23 +373,19 @@ impl<'src> InferContext<'src> { type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node) } - Expr::QuantifiedExpr(q) => { - if let Some(qinner) = q.inner() { - let inner_key = self.infer_capture_inner( - Some(&qinner), - path, - capture_name, - type_annotation, - ); - self.wrap_with_quantifier(&inner_key, q) + Expr::QuantifiedExpr(_) => { + unreachable!("quantifier handled at start of function") + } + + Expr::FieldExpr(field) => { + if let Some(value) = field.value() { + self.infer_capture_inner(Some(&value), path, capture_name, type_annotation) } else { - TypeKey::Invalid + type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node) } } - Expr::CapturedExpr(_) | Expr::FieldExpr(_) => { - type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node) - } + Expr::CapturedExpr(_) => type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node), } } @@ -382,8 +472,20 @@ impl<'src> InferContext<'src> { return Some(wrapped_key); } - // Non-capture quantified expression: recurse into inner and wrap with quantifier + // Non-capture quantified expression: track fields added by inner expression + // and wrap them with the quantifier + let fields_before: Vec<_> = fields.keys().copied().collect(); + let inner_key = self.infer_expr(&inner, path, fields)?; + + // Wrap all newly added fields with the quantifier + for (name, entry) in fields.iter_mut() { + if fields_before.contains(name) { + continue; + } + entry.type_key = self.wrap_with_quantifier(&entry.type_key, quant); + } + Some(self.wrap_with_quantifier(&inner_key, quant)) } diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index b870dfa1..8e2c76d6 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -114,7 +114,7 @@ fn nested_tagged_alt_with_annotation() { assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - = { #Node @left #Node @right } + = { Expr @left Expr @right } = { #string @value } Expr = [ Binary: Literal: ] "); @@ -283,3 +283,20 @@ fn same_variant_name_across_branches_merges() { #DefaultQuery = { @x } "); } + +#[test] +fn recursive_ref_through_optional_field() { + let input = indoc! {r#" + Rec = (call_expression function: (Rec)? @inner) + (Rec) + "#}; + + let query = Query::try_from(input).unwrap(); + + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r" + = Rec? + Rec = { @inner } + #DefaultQuery = () + "); +} From bff36c07e7a1e3aa0f2a006329fd62adc7091071 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 04:07:23 -0300 Subject: [PATCH 08/11] Fixes --- crates/plotnik-core/src/lib.rs | 20 - crates/plotnik-lib/src/diagnostics/tests.rs | 2 - .../plotnik-lib/src/infer/emit/rust_tests.rs | 18 - .../src/infer/emit/typescript_tests.rs | 14 - crates/plotnik-lib/src/infer/types.rs | 58 +- crates/plotnik-lib/src/infer/tyton_tests.rs | 2 - crates/plotnik-lib/src/parser/cst.rs | 1 - crates/plotnik-lib/src/query/dump.rs | 9 +- crates/plotnik-lib/src/query/types.rs | 12 +- crates/plotnik-lib/src/query/types_tests.rs | 518 ++++++++++++------ 10 files changed, 411 insertions(+), 243 deletions(-) diff --git a/crates/plotnik-core/src/lib.rs b/crates/plotnik-core/src/lib.rs index 99dd2988..e3ab0a97 100644 --- a/crates/plotnik-core/src/lib.rs +++ b/crates/plotnik-core/src/lib.rs @@ -15,10 +15,6 @@ use std::num::NonZeroU16; mod invariants; -// ============================================================================ -// Deserialization Layer -// ============================================================================ - /// Raw node definition from `node-types.json`. #[derive(Debug, Clone, serde::Deserialize)] pub struct RawNode { @@ -56,10 +52,6 @@ pub fn parse_node_types(json: &str) -> Result, serde_json::Error> { serde_json::from_str(json) } -// ============================================================================ -// Common Types -// ============================================================================ - /// Node type ID (tree-sitter uses u16). pub type NodeTypeId = u16; @@ -73,10 +65,6 @@ pub struct Cardinality { pub required: bool, } -// ============================================================================ -// NodeTypes Trait -// ============================================================================ - /// Trait for node type constraint lookups. /// /// Provides only what tree-sitter's `Language` API doesn't: @@ -156,10 +144,6 @@ impl NodeTypes for &T { } } -// ============================================================================ -// Static Analysis Layer (zero runtime init) -// ============================================================================ - /// Field info for static storage. #[derive(Debug, Clone, Copy)] pub struct StaticFieldInfo { @@ -325,10 +309,6 @@ impl NodeTypes for StaticNodeTypes { } } -// ============================================================================ -// Dynamic Analysis Layer (runtime construction) -// ============================================================================ - /// Information about a single field on a node type. #[derive(Debug, Clone)] pub struct FieldInfo { diff --git a/crates/plotnik-lib/src/diagnostics/tests.rs b/crates/plotnik-lib/src/diagnostics/tests.rs index 0f921aeb..92f30d7b 100644 --- a/crates/plotnik-lib/src/diagnostics/tests.rs +++ b/crates/plotnik-lib/src/diagnostics/tests.rs @@ -340,8 +340,6 @@ fn diagnostic_kind_message_rendering() { ); } -// === Filtering/suppression tests === - #[test] fn filtered_no_suppression_disjoint_spans() { let mut diagnostics = Diagnostics::new(); diff --git a/crates/plotnik-lib/src/infer/emit/rust_tests.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs index 0d7e35e8..9d5de72a 100644 --- a/crates/plotnik-lib/src/infer/emit/rust_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -20,8 +20,6 @@ fn emit_cyclic(input: &str, cyclic_types: &[&str]) -> String { emit_rust(&table, &RustEmitConfig::default()) } -// --- Simple Structs --- - #[test] fn emit_struct_single_field() { let input = "Foo = { #Node @value }"; @@ -86,8 +84,6 @@ fn emit_struct_nested_refs() { "); } -// --- Tagged Unions --- - #[test] fn emit_tagged_union_simple() { let input = indoc! {r#" @@ -168,8 +164,6 @@ fn emit_tagged_union_with_builtins() { "); } -// --- Wrapper Types --- - #[test] fn emit_optional() { let input = "MaybeNode = #Node?"; @@ -239,8 +233,6 @@ fn emit_nested_wrappers() { "); } -// --- Cyclic Types --- - #[test] fn emit_cyclic_box() { let input = indoc! {r#" @@ -292,8 +284,6 @@ fn emit_cyclic_arc() { "); } -// --- Config Variations --- - #[test] fn emit_no_derives() { let input = "Foo = { #Node @value }"; @@ -344,8 +334,6 @@ fn emit_all_derives() { "); } -// --- Complex Scenarios --- - #[test] fn emit_complex_program() { let input = indoc! {r#" @@ -455,8 +443,6 @@ fn emit_mixed_wrappers_and_structs() { "); } -// --- Edge Cases --- - #[test] fn emit_single_variant_union() { let input = indoc! {r#" @@ -538,8 +524,6 @@ fn emit_builtin_value_with_named_key() { insta::assert_snapshot!(emit(input), @""); } -// --- DefaultQuery --- - #[test] fn emit_default_query_struct() { let input = "#DefaultQuery = { #Node @value }"; @@ -591,8 +575,6 @@ fn emit_default_query_referenced() { "); } -// --- Keyword Escaping --- - #[test] fn emit_struct_with_keyword_fields() { let input = "Foo = { #Node @type #Node @fn #Node @match }"; diff --git a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs index d6531edb..4efdc466 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs @@ -12,8 +12,6 @@ fn emit_with_config(input: &str, config: &TypeScriptEmitConfig) -> String { emit_typescript(&table, config) } -// --- Simple Structs (Interfaces) --- - #[test] fn emit_interface_single_field() { let input = "Foo = { #Node @value }"; @@ -70,8 +68,6 @@ fn emit_interface_nested_refs() { "); } -// --- Tagged Unions --- - #[test] fn emit_tagged_union_simple() { let input = indoc! {r#" @@ -134,8 +130,6 @@ fn emit_tagged_union_with_builtins() { "#); } -// --- Wrapper Types --- - #[test] fn emit_optional_null() { let input = "MaybeNode = #Node?"; @@ -249,8 +243,6 @@ fn emit_list_of_optionals() { "); } -// --- Config Variations --- - #[test] fn emit_with_export() { let input = "Foo = { #Node @value }"; @@ -359,8 +351,6 @@ fn emit_inline_synthetic() { "); } -// --- Complex Scenarios --- - #[test] fn emit_complex_program() { let input = indoc! {r#" @@ -461,8 +451,6 @@ fn emit_all_config_options() { "); } -// --- Edge Cases --- - #[test] fn emit_single_variant_union() { let input = indoc! {r#" @@ -743,8 +731,6 @@ fn emit_builtin_value_with_named_key() { insta::assert_snapshot!(emit(input), @""); } -// --- DefaultQuery --- - #[test] fn emit_default_query_interface() { let input = "#DefaultQuery = { #Node @value }"; diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index 79db12ed..d1c87a60 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -278,6 +278,15 @@ impl<'src> TypeTable<'src> { (List(ka), NonEmptyList(kb)) | (NonEmptyList(ka), List(kb)) => { self.types_are_compatible(ka, kb) } + // Optional and T are compatible - merge to Optional + (Optional(k), other) | (other, Optional(k)) => { + let other_as_key = match other { + Node => TypeKey::Node, + String => TypeKey::String, + _ => return false, + }; + self.types_are_compatible(k, &other_as_key) + } (Struct(fa), Struct(fb)) => { // Structs must have exactly the same fields with compatible types if fa.len() != fb.len() { @@ -352,6 +361,43 @@ impl<'src> TypeTable<'src> { Some(list_key) } + /// Try to merge Optional and T into Optional. + /// + /// Returns `Some(Optional(inner))` if one is Optional and other is the unwrapped type. + /// Returns `None` otherwise. + fn try_merge_optional_types( + &mut self, + a: &TypeKey<'src>, + b: &TypeKey<'src>, + ) -> Option> { + let val_a = self.get(a); + let val_b = self.get(b); + + // Handle cases where one is a wrapper type (Optional) around the other + match (val_a, val_b) { + (Some(TypeValue::Optional(ka)), Some(TypeValue::Optional(kb))) => { + // Both optional - check inner compatibility + if self.types_are_compatible(ka, kb) { + return Some(a.clone()); + } + None + } + (Some(TypeValue::Optional(k)), _) => { + if self.types_are_compatible(k, b) { + return Some(a.clone()); + } + None + } + (_, Some(TypeValue::Optional(k))) => { + if self.types_are_compatible(a, k) { + return Some(b.clone()); + } + None + } + _ => None, + } + } + /// Try to merge two struct types into one, returning the merged fields. /// /// Returns `Some(merged_fields)` if both types are structs (regardless of field shape). @@ -492,7 +538,17 @@ impl<'src> TypeTable<'src> { None }; - let merged = if let Some(merged_key) = list_merge_key { + // Check for Optional/Required merge case + let optional_merge_key = if type_occurrences.len() == 2 && list_merge_key.is_none() { + self.try_merge_optional_types(type_occurrences[0], type_occurrences[1]) + } else { + None + }; + + let merged = if let Some(merged_key) = optional_merge_key { + // Optional merge result is already Optional - don't double-wrap + MergedField::Same(merged_key) + } else if let Some(merged_key) = list_merge_key { // List and NonEmptyList merged to List if present_count == branch_count { MergedField::Same(merged_key) diff --git a/crates/plotnik-lib/src/infer/tyton_tests.rs b/crates/plotnik-lib/src/infer/tyton_tests.rs index c948f295..735ab9d0 100644 --- a/crates/plotnik-lib/src/infer/tyton_tests.rs +++ b/crates/plotnik-lib/src/infer/tyton_tests.rs @@ -486,8 +486,6 @@ fn error_unprefixed_string() { insta::assert_snapshot!(dump_table(input), @"ERROR: expected type value at 6..12"); } -// === emit tests === - #[test] fn emit_empty() { let table = parse("").unwrap(); diff --git a/crates/plotnik-lib/src/parser/cst.rs b/crates/plotnik-lib/src/parser/cst.rs index e82020ee..fbe36765 100644 --- a/crates/plotnik-lib/src/parser/cst.rs +++ b/crates/plotnik-lib/src/parser/cst.rs @@ -136,7 +136,6 @@ pub enum SyntaxKind { Garbage, Error, - // --- Node kinds (non-terminals) --- Root, Tree, Ref, diff --git a/crates/plotnik-lib/src/query/dump.rs b/crates/plotnik-lib/src/query/dump.rs index 94977d19..628ed6da 100644 --- a/crates/plotnik-lib/src/query/dump.rs +++ b/crates/plotnik-lib/src/query/dump.rs @@ -2,8 +2,7 @@ #[cfg(test)] mod test_helpers { - use crate::Query; - use crate::infer::tyton; + use crate::{Query, infer::OptionalStyle}; impl Query<'_> { pub fn dump_cst(&self) -> String { @@ -39,7 +38,11 @@ mod test_helpers { } pub fn dump_types(&self) -> String { - tyton::emit(&self.type_table) + self.type_printer() + .typescript() + .optional(OptionalStyle::QuestionMark) + .type_alias(true) + .render() } } } diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs index 70731e97..7b0fd06b 100644 --- a/crates/plotnik-lib/src/query/types.rs +++ b/crates/plotnik-lib/src/query/types.rs @@ -162,7 +162,7 @@ impl<'src> InferContext<'src> { let path = match &key { TypeKey::Named(name) => vec![*name], - TypeKey::DefaultQuery => vec![], + TypeKey::DefaultQuery => vec!["DefaultQuery"], _ => vec![], }; @@ -571,7 +571,7 @@ impl<'src> InferContext<'src> { let branch_types: Vec<_> = branch_fields.iter().map(Self::extract_types_ref).collect(); let merged = self.table.merge_fields(&branch_types); - self.apply_merged_fields(merged, fields, alt); + self.apply_merged_fields(merged, fields, alt, path); } } None @@ -678,7 +678,7 @@ impl<'src> InferContext<'src> { } let mut result_fields = IndexMap::new(); - self.apply_merged_fields(merged, &mut result_fields, alt); + self.apply_merged_fields(merged, &mut result_fields, alt, path); let key = if let Some(name) = type_annotation { TypeKey::Named(name) @@ -722,12 +722,16 @@ impl<'src> InferContext<'src> { merged: IndexMap<&'src str, MergedField<'src>>, fields: &mut IndexMap<&'src str, FieldEntry<'src>>, alt: &ast::AltExpr, + path: &[&'src str], ) { for (name, merge_result) in merged { let key = match merge_result { MergedField::Same(k) => k, MergedField::Optional(k) => { - let wrapper_key = TypeKey::Synthetic(vec![name, "opt"]); + let mut wrapper_path = path.to_vec(); + wrapper_path.push(name); + wrapper_path.push("opt"); + let wrapper_key = TypeKey::Synthetic(wrapper_path); self.table .insert(wrapper_key.clone(), TypeValue::Optional(k)); wrapper_key diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index 8e2c76d6..e8208967 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -4,299 +4,461 @@ use crate::Query; use indoc::indoc; #[test] -fn comprehensive_type_inference() { - let input = indoc! {r#" - // Simple capture → flat struct with Node field - Simple = (identifier) @id - - // Multiple captures → flat struct - BinaryOp = (binary_expression - left: (_) @left - operator: _ @op - right: (_) @right) - - // :: string annotation → String type - WithString = (identifier) @name :: string +fn capture_node_produces_node_field() { + let query = Query::try_from("(identifier) @id").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { id: SyntaxNode };"); +} - // :: TypeName annotation → named type - Named = (identifier) @value :: MyType +#[test] +fn multiple_captures_produce_multiple_fields() { + let query = Query::try_from("(binary left: (_) @left right: (_) @right)").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { left: SyntaxNode; right: SyntaxNode };"); +} - // Ref usage → type reference - UsingRef = (statement (BinaryOp) @expr) +#[test] +fn no_captures_produces_unit() { + let query = Query::try_from("(identifier)").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @""); +} - // Nested seq with capture → synthetic key - Nested = (function - {(param) @p} @params - (body) @body) +#[test] +fn nested_capture_flattens() { + let query = Query::try_from("(function name: (identifier) @name)").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { name: SyntaxNode };"); +} - // Quantifiers on captures - WithQuantifiers = (class - (decorator)? @maybe_dec - (method)* @methods - (field)+ @fields) +#[test] +fn string_annotation() { + let query = Query::try_from("(identifier) @name :: string").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { name: string };"); +} - // Tagged alternation → TaggedUnion - TaggedAlt = [ - Assign: (assignment left: (_) @target) - Call: (call function: (_) @func) - ] +#[test] +fn named_type_annotation() { + let query = Query::try_from("(identifier) @value :: MyType").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { value: MyType };"); +} - // Untagged alternation → merged struct - UntaggedAlt = [ - (assignment left: (_) @left right: (_) @right) - (call function: (_) @left) - ] +#[test] +fn annotation_on_quantified_wraps_inner() { + let query = Query::try_from("(identifier)+ @names :: string").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { names: [string, ...string[]] };"); +} - // Entry point (unnamed last def) → DefaultQuery - (program (Simple)* @items) +#[test] +fn capture_ref_produces_ref_type() { + let input = indoc! {r#" + Inner = (identifier) @name + (wrapper (Inner) @inner) "#}; - let query = Query::try_from(input).unwrap(); - assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - Simple = { #Node @id } - BinaryOp = { #Node @left #Node @op #Node @right } - WithString = { #string @name } - Named = { MyType @value } - UsingRef = { BinaryOp @expr } - = { #Node @p } - Nested = { @params #Node @body } - = #Node* - = #Node+ - WithQuantifiers = { @maybe_dec @methods @fields } - = { #Node @target } - = { #Node @func } - TaggedAlt = [ Assign: Call: ] - = #Node? - UntaggedAlt = { #Node @left @right } - = Simple* - #DefaultQuery = { @items } + type Inner = { name: SyntaxNode }; + + type QueryResult = { inner: Inner }; "); } #[test] -fn type_conflict_in_untagged_alt() { +fn ref_without_capture_contributes_nothing() { let input = indoc! {r#" - Conflict = [ - (identifier) @x :: string - (number) @x - ] @result + Inner = (identifier) @name + (wrapper (Inner)) "#}; - let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type Inner = { name: SyntaxNode };"); +} - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture `x` has conflicting types across branches - | - 1 | Conflict = [ - | ____________^ - 2 | | (identifier) @x :: string - 3 | | (number) @x - 4 | | ] @result - | |_^ - "); +#[test] +fn optional_node() { + let query = Query::try_from("(identifier)? @maybe").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { maybe?: SyntaxNode };"); +} + +#[test] +fn list_of_nodes() { + let query = Query::try_from("(identifier)* @items").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { items: SyntaxNode[] };"); } #[test] -fn nested_tagged_alt_with_annotation() { +fn nonempty_list_of_nodes() { + let query = Query::try_from("(identifier)+ @items").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { items: [SyntaxNode, ...SyntaxNode[]] };"); +} + +#[test] +fn quantified_ref() { let input = indoc! {r#" - Expr = [ - Binary: (binary_expression - left: (Expr) @left - right: (Expr) @right) - Literal: (number) @value :: string - ] + Item = (item) @value + (container (Item)+ @items) "#}; - let query = Query::try_from(input).unwrap(); - assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - = { Expr @left Expr @right } - = { #string @value } - Expr = [ Binary: Literal: ] + type Item = { value: SyntaxNode }; + + type QueryResult = { items: [Item, ...Item[]] }; "); } #[test] -fn captured_ref_becomes_type_reference() { +fn quantifier_outside_capture() { + let query = Query::try_from("((identifier) @id)*").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { id: SyntaxNode[] };"); +} + +#[test] +fn captured_seq_creates_nested_struct() { + let query = Query::try_from("{(a) @x (b) @y} @pair").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { pair: { x: SyntaxNode; y: SyntaxNode } };"); +} + +#[test] +fn captured_seq_in_tree() { let input = indoc! {r#" - Inner = (identifier) @name :: string - Outer = (wrapper (Inner) @inner) + (function + {(param) @p} @params + (body) @body) "#}; - let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { params: { p: SyntaxNode }; body: SyntaxNode };"); +} +#[test] +fn empty_captured_seq_is_node() { + let query = Query::try_from("{} @empty").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @r" - Inner = { #string @name } - Outer = { Inner @inner } - "); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { empty: SyntaxNode };"); } #[test] -fn empty_captures_produce_unit() { - let input = "(empty_node)"; +fn tagged_alt_produces_union() { + let input = "[A: (a) @x B: (b) @y]"; + let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @""); +} +#[test] +fn tagged_alt_as_definition() { + let input = indoc! {r#" + Expr = [ + Binary: (binary left: (_) @left right: (_) @right) + Literal: (number) @value + ] + "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r#" + type Expr = + | { tag: "Binary"; left: SyntaxNode; right: SyntaxNode } + | { tag: "Literal"; value: SyntaxNode }; + "#); +} +#[test] +fn tagged_branch_without_captures_is_unit() { + let input = "[A: (a) B: (b)]"; + let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"#DefaultQuery = ()"); + insta::assert_snapshot!(query.dump_types(), @""); } #[test] -fn quantified_ref() { +fn tagged_branch_with_ref() { let input = indoc! {r#" - Item = (item) @value - List = (container (Item)+ @items) + Rec = [Base: (a) Nested: (Rec)?] @value + (Rec) "#}; + let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type Rec = { value: RecValue };"); +} + +#[test] +fn captured_tagged_alt() { + let input = "(container [A: (a) B: (b)] @choice)"; + let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { choice: DefaultQueryChoice };"); +} +#[test] +fn untagged_alt_same_capture_merges() { + let input = "[(a) @x (b) @x]"; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x: SyntaxNode };"); +} +#[test] +fn untagged_alt_different_captures_becomes_optional() { + let input = "[(a) @x (b) @y]"; + let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @r" - Item = { #Node @value } - = Item+ - List = { @items } - "); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x?: SyntaxNode; y?: SyntaxNode };"); +} + +#[test] +fn untagged_alt_nested_alt_merges() { + let input = "[(a) @x (b) @y [(c) @x (d) @y]]"; + let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x?: SyntaxNode; y?: SyntaxNode };"); } #[test] -fn recursive_type_with_annotation_preserves_fields() { - let input = r#"Func = (function_declaration name: (identifier) @name) @func :: Func (Func)"#; +fn captured_untagged_alt_with_nested_fields() { + let input = "[{(a) @x} {(b) @y}] @choice"; + let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { choice: { x?: SyntaxNode; y?: SyntaxNode } };"); +} +#[test] +fn merge_same_type_unchanged() { + let input = "[(identifier) @x (identifier) @x]"; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x: SyntaxNode };"); +} +#[test] +fn merge_absent_field_becomes_optional() { + let input = "[(identifier) @x (number)]"; + let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @r" - Func = { #Node @name Func @func } - #DefaultQuery = () - "); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x?: SyntaxNode };"); } #[test] -fn anonymous_tagged_alt_uses_default_query_name() { - let input = "[A: (identifier) @id B: (number) @num]"; +fn merge_list_and_nonempty_list_to_list() { + let input = "[(a)* @x (b)+ @x]"; + let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x: SyntaxNode[] };"); +} +#[test] +fn merge_optional_and_required_to_optional() { + let input = "[(a)? @x (b) @x]"; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x?: SyntaxNode };"); +} +#[test] +fn self_recursive_type_marked_cyclic() { + let input = "Expr = [(identifier) (call (Expr) @callee)]"; + let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @r" - = { #Node @id } - = { #Node @num } - #DefaultQuery = [ A: B: ] - "); + insta::assert_snapshot!(query.dump_types(), @"type Expr = { callee?: Expr };"); } #[test] -fn tagged_union_branch_with_ref() { - let input = "Rec = [Base: (a) Rec: (Rec)?] (Rec)"; +fn recursive_through_optional() { + let input = indoc! {r#" + Rec = (call function: (Rec)? @inner) + (Rec) + "#}; + let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type Rec = { inner?: Rec };"); +} +#[test] +fn recursive_in_tagged_alt() { + let input = indoc! {r#" + Expr = [ + Ident: (identifier) @name + Call: (call function: (Expr) @func) + ] + "#}; + let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @r#" + type Expr = + | { tag: "Ident"; name: SyntaxNode } + | { tag: "Call"; func: Expr }; + "#); +} + +#[test] +fn unnamed_last_def_is_default_query() { + let input = "(program (identifier)* @items)"; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { items: SyntaxNode[] };"); +} +#[test] +fn named_defs_plus_entry_point() { + let input = indoc! {r#" + Item = (item) @value + (container (Item)* @items) + "#}; + let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - = () - = Rec? - = { @value } - Rec = [ Base: Rec: ] - #DefaultQuery = () + type Item = { value: SyntaxNode }; + + type QueryResult = { items: Item[] }; "); } #[test] -fn nested_tagged_alts_in_untagged_alt_conflict() { - // Each branch captures @x with different TaggedUnion types - // Branch 1: @x is TaggedUnion with variant A - // Branch 2: @x is TaggedUnion with variant B - // This is a type conflict - different structures under same capture name - let input = "[[A: (a) @aa] @x [B: (b) @bb] @x]"; - +fn tagged_alt_at_entry_point() { + let input = "[A: (a) @x B: (b) @y]"; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @""); +} +#[test] +fn type_conflict_in_untagged_alt() { + let input = "[(identifier) @x :: string (number) @x]"; + let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @r" - = { #Node @aa } - = [ A: ] - = { #Node @bb } - #DefaultQuery = { @x } + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: capture `x` has conflicting types across branches + | + 1 | [(identifier) @x :: string (number) @x] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ "); +} + +#[test] +fn incompatible_tagged_alts_in_merge() { + let input = "[[A: (a) @x] @y [B: (b) @z] @y]"; + let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: tagged alternations with different variants cannot be merged | - 1 | [[A: (a) @aa] @x [B: (b) @bb] @x] - | ^^^^^^^^^^^^ ------------ incompatible + 1 | [[A: (a) @x] @y [B: (b) @z] @y] + | ^^^^^^^^^^^ ----------- incompatible "); } #[test] -fn nested_untagged_alts_merge_fields() { - // Each branch captures @x with different struct types - // Branch 1: @x has field @y - // Branch 2: @x has field @z - // These get merged: fields from both branches become optional - let input = "[[(a) @y] @x [(b) @z] @x]"; +fn duplicate_capture_in_sequence() { + let input = "{(a) @x (b) @x}"; + let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: capture `@x` already used in this scope + | + 1 | {(a) @x (b) @x} + | - ^ + | | + | first use + "); +} +#[test] +fn duplicate_capture_nested() { + let input = "(foo (a) @x (bar (b) @x))"; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: capture `@x` already used in this scope + | + 1 | (foo (a) @x (bar (b) @x)) + | - first use ^ + "); +} +#[test] +fn wildcard_capture() { + let query = Query::try_from("(_) @node").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @r" - = { #Node @y } - = { #Node @z } - = #Node? - = #Node? - = { @y @z } - #DefaultQuery = { @x } - "); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { node: SyntaxNode };"); } #[test] -fn list_vs_nonempty_list_merged_to_list() { - // Different quantifiers: * (List) vs + (NonEmptyList) - // These merge to List (the more general type) - let input = "[(a)* @x (b)+ @x]"; +fn anonymous_node_capture() { + let query = Query::try_from("_ @anon").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { anon: SyntaxNode };"); +} - let query = Query::try_from(input).unwrap(); +#[test] +fn string_literal_capture() { + let query = Query::try_from(r#""if" @kw"#).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { kw: SyntaxNode };"); +} +#[test] +fn field_value_capture() { + let query = Query::try_from("(call name: (identifier) @name)").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @r" - = #Node* - = #Node+ - = #Node* - #DefaultQuery = { @x } - "); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { name: SyntaxNode };"); } #[test] -fn same_variant_name_across_branches_merges() { - // Both branches have variant A - should merge correctly - let input = "[[A: (a)] @x [A: (b)] @x]"; +fn deeply_nested_seq() { + let query = Query::try_from("{{{(identifier) @x}}} @outer").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { outer: { x: SyntaxNode } };"); +} +#[test] +fn same_tag_in_branches_merges() { + let input = "[[A: (a)] @x [A: (b)] @x]"; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x: DefaultQueryX };"); +} +#[test] +fn annotation_on_captured_ref() { + let input = indoc! {r#" + Inner = (identifier) @name + (wrapper (Inner) @inner :: CustomType) + "#}; + let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - = () - = [ A: ] - #DefaultQuery = { @x } + type Inner = { name: SyntaxNode }; + + type QueryResult = { inner: Inner }; "); } #[test] -fn recursive_ref_through_optional_field() { +fn multiple_defs_with_refs() { let input = indoc! {r#" - Rec = (call_expression function: (Rec)? @inner) - (Rec) + A = (a) @x + B = (b (A) @a) + C = (c (B) @b) + (root (C) @c) "#}; - let query = Query::try_from(input).unwrap(); - assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - = Rec? - Rec = { @inner } - #DefaultQuery = () + type A = { x: SyntaxNode }; + + type B = { a: A }; + + type C = { b: B }; + + type QueryResult = { c: C }; "); } From 76d52c097b1b216f7007180d7a51be96cf48edd5 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 04:12:17 -0300 Subject: [PATCH 09/11] Add nested rendering option for TypeScript type dumping --- crates/plotnik-lib/src/query/dump.rs | 1 + crates/plotnik-lib/src/query/types_tests.rs | 179 +++++++++++++++++--- 2 files changed, 157 insertions(+), 23 deletions(-) diff --git a/crates/plotnik-lib/src/query/dump.rs b/crates/plotnik-lib/src/query/dump.rs index 628ed6da..baae64e2 100644 --- a/crates/plotnik-lib/src/query/dump.rs +++ b/crates/plotnik-lib/src/query/dump.rs @@ -42,6 +42,7 @@ mod test_helpers { .typescript() .optional(OptionalStyle::QuestionMark) .type_alias(true) + .nested(true) .render() } } diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index e8208967..4d18e7b3 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -49,7 +49,11 @@ fn named_type_annotation() { fn annotation_on_quantified_wraps_inner() { let query = Query::try_from("(identifier)+ @names :: string").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { names: [string, ...string[]] };"); + insta::assert_snapshot!(query.dump_types(), @r" + type NonemptyString = [string, ...string[]]; + + type QueryResult = { names: [string, ...string[]] }; + "); } #[test] @@ -82,21 +86,33 @@ fn ref_without_capture_contributes_nothing() { fn optional_node() { let query = Query::try_from("(identifier)? @maybe").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { maybe?: SyntaxNode };"); + insta::assert_snapshot!(query.dump_types(), @r" + type OptNode = SyntaxNode; + + type QueryResult = { maybe?: SyntaxNode }; + "); } #[test] fn list_of_nodes() { let query = Query::try_from("(identifier)* @items").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { items: SyntaxNode[] };"); + insta::assert_snapshot!(query.dump_types(), @r" + type OptNode = SyntaxNode[]; + + type QueryResult = { items: SyntaxNode[] }; + "); } #[test] fn nonempty_list_of_nodes() { let query = Query::try_from("(identifier)+ @items").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { items: [SyntaxNode, ...SyntaxNode[]] };"); + insta::assert_snapshot!(query.dump_types(), @r" + type NonemptyNode = [SyntaxNode, ...SyntaxNode[]]; + + type QueryResult = { items: [SyntaxNode, ...SyntaxNode[]] }; + "); } #[test] @@ -110,6 +126,8 @@ fn quantified_ref() { insta::assert_snapshot!(query.dump_types(), @r" type Item = { value: SyntaxNode }; + type ItemWrapped = [Item, ...Item[]]; + type QueryResult = { items: [Item, ...Item[]] }; "); } @@ -118,14 +136,22 @@ fn quantified_ref() { fn quantifier_outside_capture() { let query = Query::try_from("((identifier) @id)*").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { id: SyntaxNode[] };"); + insta::assert_snapshot!(query.dump_types(), @r" + type OptNode = SyntaxNode[]; + + type QueryResult = { id: SyntaxNode[] }; + "); } #[test] fn captured_seq_creates_nested_struct() { let query = Query::try_from("{(a) @x (b) @y} @pair").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { pair: { x: SyntaxNode; y: SyntaxNode } };"); + insta::assert_snapshot!(query.dump_types(), @r" + type DefaultQueryPair0 = { x: SyntaxNode; y: SyntaxNode }; + + type QueryResult = { pair: DefaultQueryPair0 }; + "); } #[test] @@ -137,7 +163,11 @@ fn captured_seq_in_tree() { "#}; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { params: { p: SyntaxNode }; body: SyntaxNode };"); + insta::assert_snapshot!(query.dump_types(), @r" + type DefaultQueryParams0 = { p: SyntaxNode }; + + type QueryResult = { params: DefaultQueryParams0; body: SyntaxNode }; + "); } #[test] @@ -152,7 +182,15 @@ fn tagged_alt_produces_union() { let input = "[A: (a) @x B: (b) @y]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @""); + insta::assert_snapshot!(query.dump_types(), @r#" + type DefaultQueryA = { x: SyntaxNode }; + + type DefaultQueryB = { y: SyntaxNode }; + + type DefaultQuery = + | { tag: "A"; x: SyntaxNode } + | { tag: "B"; y: SyntaxNode }; + "#); } #[test] @@ -166,6 +204,10 @@ fn tagged_alt_as_definition() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r#" + type ExprBinary = { left: SyntaxNode; right: SyntaxNode }; + + type ExprLiteral = { value: SyntaxNode }; + type Expr = | { tag: "Binary"; left: SyntaxNode; right: SyntaxNode } | { tag: "Literal"; value: SyntaxNode }; @@ -177,7 +219,11 @@ fn tagged_branch_without_captures_is_unit() { let input = "[A: (a) B: (b)]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @""); + insta::assert_snapshot!(query.dump_types(), @r#" + type DefaultQuery = + | { tag: "A" } + | { tag: "B" }; + "#); } #[test] @@ -188,7 +234,17 @@ fn tagged_branch_with_ref() { "#}; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type Rec = { value: RecValue };"); + insta::assert_snapshot!(query.dump_types(), @r#" + type RecValueNested = { value?: Rec }; + + type RecValue = + | { tag: "Base" } + | { tag: "Nested"; value?: Rec }; + + type Rec = { value: RecValue }; + + type RecWrapped = Rec; + "#); } #[test] @@ -196,7 +252,13 @@ fn captured_tagged_alt() { let input = "(container [A: (a) B: (b)] @choice)"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { choice: DefaultQueryChoice };"); + insta::assert_snapshot!(query.dump_types(), @r#" + type DefaultQueryChoice = + | { tag: "A" } + | { tag: "B" }; + + type QueryResult = { choice: DefaultQueryChoice }; + "#); } #[test] @@ -212,7 +274,13 @@ fn untagged_alt_different_captures_becomes_optional() { let input = "[(a) @x (b) @y]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x?: SyntaxNode; y?: SyntaxNode };"); + insta::assert_snapshot!(query.dump_types(), @r" + type DefaultQueryXOpt = SyntaxNode; + + type DefaultQueryYOpt = SyntaxNode; + + type QueryResult = { x?: SyntaxNode; y?: SyntaxNode }; + "); } #[test] @@ -220,7 +288,13 @@ fn untagged_alt_nested_alt_merges() { let input = "[(a) @x (b) @y [(c) @x (d) @y]]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x?: SyntaxNode; y?: SyntaxNode };"); + insta::assert_snapshot!(query.dump_types(), @r" + type DefaultQueryXOpt = SyntaxNode; + + type DefaultQueryYOpt = SyntaxNode; + + type QueryResult = { x?: SyntaxNode; y?: SyntaxNode }; + "); } #[test] @@ -228,7 +302,15 @@ fn captured_untagged_alt_with_nested_fields() { let input = "[{(a) @x} {(b) @y}] @choice"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { choice: { x?: SyntaxNode; y?: SyntaxNode } };"); + insta::assert_snapshot!(query.dump_types(), @r" + type DefaultQueryChoiceXOpt = SyntaxNode; + + type DefaultQueryChoiceYOpt = SyntaxNode; + + type DefaultQueryChoice0 = { x?: SyntaxNode; y?: SyntaxNode }; + + type QueryResult = { choice: DefaultQueryChoice0 }; + "); } #[test] @@ -244,7 +326,11 @@ fn merge_absent_field_becomes_optional() { let input = "[(identifier) @x (number)]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x?: SyntaxNode };"); + insta::assert_snapshot!(query.dump_types(), @r" + type DefaultQueryXOpt = SyntaxNode; + + type QueryResult = { x?: SyntaxNode }; + "); } #[test] @@ -252,7 +338,15 @@ fn merge_list_and_nonempty_list_to_list() { let input = "[(a)* @x (b)+ @x]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x: SyntaxNode[] };"); + insta::assert_snapshot!(query.dump_types(), @r" + type OptNode = SyntaxNode[]; + + type NonemptyNode = [SyntaxNode, ...SyntaxNode[]]; + + type ListMerged = SyntaxNode[]; + + type QueryResult = { x: SyntaxNode[] }; + "); } #[test] @@ -260,7 +354,11 @@ fn merge_optional_and_required_to_optional() { let input = "[(a)? @x (b) @x]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x?: SyntaxNode };"); + insta::assert_snapshot!(query.dump_types(), @r" + type OptNode = SyntaxNode; + + type QueryResult = { x?: SyntaxNode }; + "); } #[test] @@ -268,7 +366,11 @@ fn self_recursive_type_marked_cyclic() { let input = "Expr = [(identifier) (call (Expr) @callee)]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type Expr = { callee?: Expr };"); + insta::assert_snapshot!(query.dump_types(), @r" + type Expr = { callee?: Expr }; + + type ExprCalleeOpt = Expr; + "); } #[test] @@ -279,7 +381,11 @@ fn recursive_through_optional() { "#}; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type Rec = { inner?: Rec };"); + insta::assert_snapshot!(query.dump_types(), @r" + type Rec = { inner?: Rec }; + + type RecWrapped = Rec; + "); } #[test] @@ -293,9 +399,13 @@ fn recursive_in_tagged_alt() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r#" + type ExprIdent = { name: SyntaxNode }; + type Expr = | { tag: "Ident"; name: SyntaxNode } | { tag: "Call"; func: Expr }; + + type ExprCall = { func: Expr }; "#); } @@ -304,7 +414,11 @@ fn unnamed_last_def_is_default_query() { let input = "(program (identifier)* @items)"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { items: SyntaxNode[] };"); + insta::assert_snapshot!(query.dump_types(), @r" + type OptNode = SyntaxNode[]; + + type QueryResult = { items: SyntaxNode[] }; + "); } #[test] @@ -318,6 +432,8 @@ fn named_defs_plus_entry_point() { insta::assert_snapshot!(query.dump_types(), @r" type Item = { value: SyntaxNode }; + type ItemWrapped = Item[]; + type QueryResult = { items: Item[] }; "); } @@ -327,7 +443,15 @@ fn tagged_alt_at_entry_point() { let input = "[A: (a) @x B: (b) @y]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @""); + insta::assert_snapshot!(query.dump_types(), @r#" + type DefaultQueryA = { x: SyntaxNode }; + + type DefaultQueryB = { y: SyntaxNode }; + + type DefaultQuery = + | { tag: "A"; x: SyntaxNode } + | { tag: "B"; y: SyntaxNode }; + "#); } #[test] @@ -416,7 +540,11 @@ fn field_value_capture() { fn deeply_nested_seq() { let query = Query::try_from("{{{(identifier) @x}}} @outer").unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { outer: { x: SyntaxNode } };"); + insta::assert_snapshot!(query.dump_types(), @r" + type DefaultQueryOuter0 = { x: SyntaxNode }; + + type QueryResult = { outer: DefaultQueryOuter0 }; + "); } #[test] @@ -424,7 +552,12 @@ fn same_tag_in_branches_merges() { let input = "[[A: (a)] @x [A: (b)] @x]"; let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_types(), @"type QueryResult = { x: DefaultQueryX };"); + insta::assert_snapshot!(query.dump_types(), @r#" + type DefaultQueryX = + | { tag: "A" }; + + type QueryResult = { x: DefaultQueryX }; + "#); } #[test] From f2b123dd3f6b41037e7695948534625571dbd38e Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 04:35:03 -0300 Subject: [PATCH 10/11] Refactor synthetic type keys to support nested naming --- crates/plotnik-lib/src/infer/emit/rust.rs | 12 +- .../plotnik-lib/src/infer/emit/typescript.rs | 12 +- crates/plotnik-lib/src/infer/types.rs | 39 +++- crates/plotnik-lib/src/infer/types_tests.rs | 93 ++++++-- crates/plotnik-lib/src/infer/tyton.rs | 58 +++-- crates/plotnik-lib/src/infer/tyton_tests.rs | 18 +- crates/plotnik-lib/src/query/types.rs | 220 ++++++++---------- crates/plotnik-lib/src/query/types_tests.rs | 86 ++++--- 8 files changed, 294 insertions(+), 244 deletions(-) diff --git a/crates/plotnik-lib/src/infer/emit/rust.rs b/crates/plotnik-lib/src/infer/emit/rust.rs index ba5a8c1f..a91e12e3 100644 --- a/crates/plotnik-lib/src/infer/emit/rust.rs +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -93,10 +93,7 @@ fn emit_type_def( table: &TypeTable<'_>, config: &RustEmitConfig, ) -> String { - let name = match key { - TypeKey::DefaultQuery => config.entry_name.clone(), - _ => key.to_pascal_case(), - }; + let name = key.to_pascal_case_with_entry_name(&config.entry_name); match value { TypeValue::Node | TypeValue::String | TypeValue::Unit | TypeValue::Invalid => String::new(), @@ -179,10 +176,9 @@ pub(crate) fn emit_type_ref( format!("Vec<{}>", inner_str) } // Struct, TaggedUnion, or undefined forward reference - use pascal-cased name - Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) | None => match key { - TypeKey::DefaultQuery => config.entry_name.clone(), - _ => key.to_pascal_case(), - }, + Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) | None => { + key.to_pascal_case_with_entry_name(&config.entry_name) + } }; if is_cyclic { diff --git a/crates/plotnik-lib/src/infer/emit/typescript.rs b/crates/plotnik-lib/src/infer/emit/typescript.rs index c294b384..20804d40 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -66,7 +66,7 @@ pub fn emit_typescript(table: &TypeTable<'_>, config: &TypeScriptEmitConfig) -> } // Skip synthetic types if not nested (i.e., inlining) - if !config.nested && matches!(key, TypeKey::Synthetic(_)) { + if !config.nested && matches!(key, TypeKey::Synthetic { .. }) { continue; } @@ -87,7 +87,7 @@ fn emit_type_def( config: &TypeScriptEmitConfig, ) -> String { let name = type_name(key, config); - let export_prefix = if config.export && !matches!(key, TypeKey::Synthetic(_)) { + let export_prefix = if config.export && !matches!(key, TypeKey::Synthetic { .. }) { "export " } else { "" @@ -191,7 +191,7 @@ pub(crate) fn emit_field_type( } Some(TypeValue::Struct(fields)) => { - if !config.nested && matches!(key, TypeKey::Synthetic(_)) { + if !config.nested && matches!(key, TypeKey::Synthetic { .. }) { (emit_inline_struct(fields, table, config), false) } else { (type_name(key, config), false) @@ -234,11 +234,7 @@ pub(crate) fn emit_inline_struct( } fn type_name(key: &TypeKey<'_>, config: &TypeScriptEmitConfig) -> String { - if key.is_default_query() { - config.entry_name.clone() - } else { - key.to_pascal_case() - } + key.to_pascal_case_with_entry_name(&config.entry_name) } pub(crate) fn wrap_if_union(type_str: &str) -> String { diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index d1c87a60..6c09e55f 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -68,21 +68,37 @@ pub enum TypeKey<'src> { DefaultQuery, /// User-provided type name via `:: TypeName` Named(&'src str), - /// Path-based synthetic name: ["Foo", "bar"] → FooBar - Synthetic(Vec<&'src str>), + /// Synthetic type derived from parent + field path. + /// Parent can be Named, DefaultQuery, or another Synthetic. + /// Emitter resolves parent to name, then appends path segments in PascalCase. + Synthetic { + parent: Box>, + path: Vec<&'src str>, + }, } impl TypeKey<'_> { /// Render as PascalCase type name. + /// For Synthetic keys with DefaultQuery parent, uses "DefaultQuery" as the parent name. + /// Use `to_pascal_case_with_entry_name` to customize the DefaultQuery name. pub fn to_pascal_case(&self) -> String { + self.to_pascal_case_with_entry_name("DefaultQuery") + } + + /// Render as PascalCase type name, using the given entry_name for DefaultQuery. + pub fn to_pascal_case_with_entry_name(&self, entry_name: &str) -> String { match self { TypeKey::Node => "Node".to_string(), TypeKey::String => "String".to_string(), TypeKey::Unit => "Unit".to_string(), TypeKey::Invalid => "Unit".to_string(), // Invalid emits as Unit - TypeKey::DefaultQuery => "DefaultQuery".to_string(), + TypeKey::DefaultQuery => entry_name.to_string(), TypeKey::Named(name) => (*name).to_string(), - TypeKey::Synthetic(segments) => segments.iter().map(|s| to_pascal(s)).collect(), + TypeKey::Synthetic { parent, path } => { + let parent_name = parent.to_pascal_case_with_entry_name(entry_name); + let path_suffix: String = path.iter().map(|s| to_pascal(s)).collect(); + format!("{}{}", parent_name, path_suffix) + } } } @@ -356,7 +372,7 @@ impl<'src> TypeTable<'src> { }; // Return or create a List type with the inner type - let list_key = TypeKey::Synthetic(vec!["list", "merged"]); + let list_key = TypeKey::Named(Box::leak("ListMerged".to_string().into_boxed_str())); self.insert(list_key.clone(), TypeValue::List(inner)); Some(list_key) } @@ -568,8 +584,14 @@ impl<'src> TypeTable<'src> { let key = match mf { MergedField::Same(k) => k, MergedField::Optional(k) => { - let wrapper_key = - TypeKey::Synthetic(vec![*field_name, name, "opt"]); + let wrapper_name = format!( + "{}{}Opt", + to_pascal(field_name), + to_pascal(name) + ); + let wrapper_key = TypeKey::Named(Box::leak( + wrapper_name.into_boxed_str(), + )); self.insert(wrapper_key.clone(), TypeValue::Optional(k)); wrapper_key } @@ -580,7 +602,8 @@ impl<'src> TypeTable<'src> { .collect(); // Create a new merged struct type - let merged_key = TypeKey::Synthetic(vec![*field_name, "merged"]); + let merged_name = format!("{}Merged", to_pascal(field_name)); + let merged_key = TypeKey::Named(Box::leak(merged_name.into_boxed_str())); self.insert(merged_key.clone(), TypeValue::Struct(merged_fields)); if present_count == branch_count { diff --git a/crates/plotnik-lib/src/infer/types_tests.rs b/crates/plotnik-lib/src/infer/types_tests.rs index 71fc2dc3..db62e591 100644 --- a/crates/plotnik-lib/src/infer/types_tests.rs +++ b/crates/plotnik-lib/src/infer/types_tests.rs @@ -20,13 +20,28 @@ fn type_key_to_pascal_case_named() { #[test] fn type_key_to_pascal_case_synthetic() { - assert_eq!(TypeKey::Synthetic(vec!["Foo"]).to_pascal_case(), "Foo"); assert_eq!( - TypeKey::Synthetic(vec!["Foo", "bar"]).to_pascal_case(), + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("Foo")), + path: vec![] + } + .to_pascal_case(), + "Foo" + ); + assert_eq!( + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("Foo")), + path: vec!["bar"] + } + .to_pascal_case(), "FooBar" ); assert_eq!( - TypeKey::Synthetic(vec!["Foo", "bar", "baz"]).to_pascal_case(), + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("Foo")), + path: vec!["bar", "baz"] + } + .to_pascal_case(), "FooBarBaz" ); } @@ -34,11 +49,19 @@ fn type_key_to_pascal_case_synthetic() { #[test] fn type_key_to_pascal_case_snake_case_segments() { assert_eq!( - TypeKey::Synthetic(vec!["Foo", "bar_baz"]).to_pascal_case(), + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("Foo")), + path: vec!["bar_baz"] + } + .to_pascal_case(), "FooBarBaz" ); assert_eq!( - TypeKey::Synthetic(vec!["function_info", "params"]).to_pascal_case(), + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("FunctionInfo")), + path: vec!["params"] + } + .to_pascal_case(), "FunctionInfoParams" ); } @@ -127,21 +150,23 @@ fn type_value_tagged_union() { let mut assign_fields = IndexMap::new(); assign_fields.insert("target", TypeKey::String); - table.insert( - TypeKey::Synthetic(vec!["Stmt", "Assign"]), - TypeValue::Struct(assign_fields), - ); + let assign_key = TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("Stmt")), + path: vec!["Assign"], + }; + table.insert(assign_key.clone(), TypeValue::Struct(assign_fields)); let mut call_fields = IndexMap::new(); call_fields.insert("func", TypeKey::String); - table.insert( - TypeKey::Synthetic(vec!["Stmt", "Call"]), - TypeValue::Struct(call_fields), - ); + let call_key = TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("Stmt")), + path: vec!["Call"], + }; + table.insert(call_key.clone(), TypeValue::Struct(call_fields)); let mut variants = IndexMap::new(); - variants.insert("Assign", TypeKey::Synthetic(vec!["Stmt", "Assign"])); - variants.insert("Call", TypeKey::Synthetic(vec!["Stmt", "Call"])); + variants.insert("Assign", assign_key); + variants.insert("Call", call_key); let union = TypeValue::TaggedUnion(variants); table.insert(TypeKey::Named("Stmt"), union); @@ -201,12 +226,24 @@ fn type_key_equality() { assert_eq!(TypeKey::Named("Foo"), TypeKey::Named("Foo")); assert_ne!(TypeKey::Named("Foo"), TypeKey::Named("Bar")); assert_eq!( - TypeKey::Synthetic(vec!["a", "b"]), - TypeKey::Synthetic(vec!["a", "b"]) + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("A")), + path: vec!["b"] + }, + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("A")), + path: vec!["b"] + } ); assert_ne!( - TypeKey::Synthetic(vec!["a", "b"]), - TypeKey::Synthetic(vec!["a", "c"]) + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("A")), + path: vec!["b"] + }, + TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("A")), + path: vec!["c"] + } ); } @@ -216,11 +253,17 @@ fn type_key_hash_consistency() { let mut set = HashSet::new(); set.insert(TypeKey::Node); set.insert(TypeKey::Named("Foo")); - set.insert(TypeKey::Synthetic(vec!["a", "b"])); + set.insert(TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("A")), + path: vec!["b"], + }); assert!(set.contains(&TypeKey::Node)); assert!(set.contains(&TypeKey::Named("Foo"))); - assert!(set.contains(&TypeKey::Synthetic(vec!["a", "b"]))); + assert!(set.contains(&TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("A")), + path: vec!["b"] + })); assert!(!set.contains(&TypeKey::String)); } @@ -231,7 +274,13 @@ fn type_key_is_builtin() { assert!(TypeKey::Unit.is_builtin()); assert!(TypeKey::Invalid.is_builtin()); assert!(!TypeKey::Named("Foo").is_builtin()); - assert!(!TypeKey::Synthetic(vec!["a"]).is_builtin()); + assert!( + !TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("A")), + path: vec![] + } + .is_builtin() + ); } #[test] diff --git a/crates/plotnik-lib/src/infer/tyton.rs b/crates/plotnik-lib/src/infer/tyton.rs index e3a89364..3da09b91 100644 --- a/crates/plotnik-lib/src/infer/tyton.rs +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -239,8 +239,34 @@ impl<'src> Parser<'src> { fn parse_synthetic_key(&mut self) -> Result, ParseError> { self.expect(Token::LAngle)?; - let mut segments = Vec::new(); + // Parse parent key first + let parent_span = self.current_span(); + let parent: TypeKey<'src> = match self.peek() { + Some(Token::DefaultQuery) => { + self.advance(); + TypeKey::DefaultQuery + } + Some(Token::UpperIdent(s)) => { + let s = *s; + self.advance(); + TypeKey::Named(s) + } + Some(Token::LAngle) => { + // Nested synthetic key + self.parse_synthetic_key()? + } + _ => { + return Err(ParseError { + message: "expected parent key (uppercase name, #DefaultQuery, or <...>)" + .to_string(), + span: parent_span, + }); + } + }; + + // Parse path segments + let mut path = Vec::new(); loop { let span = self.current_span(); match self.peek() { @@ -248,33 +274,24 @@ impl<'src> Parser<'src> { self.advance(); break; } - Some(Token::UpperIdent(s)) => { - let s = *s; - self.advance(); - segments.push(s); - } Some(Token::LowerIdent(s)) => { let s = *s; self.advance(); - segments.push(s); + path.push(s); } _ => { return Err(ParseError { - message: "expected identifier or '>'".to_string(), + message: "expected path segment (lowercase) or '>'".to_string(), span, }); } } } - if segments.is_empty() { - return Err(ParseError { - message: "synthetic key cannot be empty".to_string(), - span: self.current_span(), - }); - } - - Ok(TypeKey::Synthetic(segments)) + Ok(TypeKey::Synthetic { + parent: Box::new(parent), + path, + }) } fn parse_type_value(&mut self) -> Result, ParseError> { @@ -490,12 +507,11 @@ fn emit_key(out: &mut String, key: &TypeKey<'_>) { TypeKey::Unit => out.push_str("()"), TypeKey::DefaultQuery => out.push_str("#DefaultQuery"), TypeKey::Named(name) => out.push_str(name), - TypeKey::Synthetic(segments) => { + TypeKey::Synthetic { parent, path } => { out.push('<'); - for (i, seg) in segments.iter().enumerate() { - if i > 0 { - out.push(' '); - } + emit_key(out, parent); + for seg in path.iter() { + out.push(' '); out.push_str(seg); } out.push('>'); diff --git a/crates/plotnik-lib/src/infer/tyton_tests.rs b/crates/plotnik-lib/src/infer/tyton_tests.rs index 735ab9d0..69ecacd7 100644 --- a/crates/plotnik-lib/src/infer/tyton_tests.rs +++ b/crates/plotnik-lib/src/infer/tyton_tests.rs @@ -176,7 +176,7 @@ fn parse_synthetic_key_simple() { String = String Unit = Unit Invalid = Invalid - Named("Wrapper") = Optional(Synthetic(["Foo", "bar"])) + Named("Wrapper") = Optional(Synthetic { parent: Named("Foo"), path: ["bar"] }) "#); } @@ -188,7 +188,7 @@ fn parse_synthetic_key_multiple_segments() { String = String Unit = Unit Invalid = Invalid - Named("Wrapper") = List(Synthetic(["Foo", "bar", "baz"])) + Named("Wrapper") = List(Synthetic { parent: Named("Foo"), path: ["bar", "baz"] }) "#); } @@ -200,7 +200,7 @@ fn parse_struct_with_synthetic() { String = String Unit = Unit Invalid = Invalid - Named("Container") = Struct({"inner": Synthetic(["Inner", "field"])}) + Named("Container") = Struct({"inner": Synthetic { parent: Named("Inner"), path: ["field"] }}) "#); } @@ -212,7 +212,7 @@ fn parse_union_with_synthetic() { String = String Unit = Unit Invalid = Invalid - Named("Choice") = TaggedUnion({"First": Synthetic(["Choice", "first"]), "Second": Synthetic(["Choice", "second"])}) + Named("Choice") = TaggedUnion({"First": Synthetic { parent: Named("Choice"), path: ["first"] }, "Second": Synthetic { parent: Named("Choice"), path: ["second"] }}) "#); } @@ -327,7 +327,7 @@ fn error_missing_colon_in_union() { #[test] fn error_empty_synthetic() { let input = "Foo = <>?"; - insta::assert_snapshot!(dump_table(input), @"ERROR: synthetic key cannot be empty at 8..9"); + insta::assert_snapshot!(dump_table(input), @"ERROR: expected parent key (uppercase name, #DefaultQuery, or <...>) at 7..8"); } #[test] @@ -410,7 +410,7 @@ fn parse_synthetic_definition_struct() { String = String Unit = Unit Invalid = Invalid - Synthetic(["Foo", "bar"]) = Struct({"value": Node}) + Synthetic { parent: Named("Foo"), path: ["bar"] } = Struct({"value": Node}) "#); } @@ -422,7 +422,7 @@ fn parse_synthetic_definition_union() { String = String Unit = Unit Invalid = Invalid - Synthetic(["Choice", "first"]) = TaggedUnion({"A": Node, "B": String}) + Synthetic { parent: Named("Choice"), path: ["first"] } = TaggedUnion({"A": Node, "B": String}) "#); } @@ -434,7 +434,7 @@ fn parse_synthetic_definition_wrapper() { String = String Unit = Unit Invalid = Invalid - Synthetic(["Inner", "nested"]) = Optional(Node) + Synthetic { parent: Named("Inner"), path: ["nested"] } = Optional(Node) "#); } @@ -459,7 +459,7 @@ fn error_eof_expecting_colon() { #[test] fn error_invalid_token_in_synthetic() { let input = "Foo = ?"; - insta::assert_snapshot!(dump_table(input), @"ERROR: expected identifier or '>' at 9..10"); + insta::assert_snapshot!(dump_table(input), @"ERROR: expected path segment (lowercase) or '>' at 9..10"); } #[test] diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs index 7b0fd06b..a9b22180 100644 --- a/crates/plotnik-lib/src/query/types.rs +++ b/crates/plotnik-lib/src/query/types.rs @@ -160,12 +160,6 @@ impl<'src> InferContext<'src> { return; }; - let path = match &key { - TypeKey::Named(name) => vec![*name], - TypeKey::DefaultQuery => vec!["DefaultQuery"], - _ => vec![], - }; - // Special case: tagged alternation at def level produces TaggedUnion directly if let Expr::AltExpr(alt) = &body && matches!(alt.kind(), AltKind::Tagged) @@ -174,12 +168,12 @@ impl<'src> InferContext<'src> { TypeKey::Named(name) => Some(*name), _ => None, }; - self.infer_tagged_alt(alt, &path, type_annotation); + self.infer_tagged_alt(alt, &key, type_annotation); return; } let mut fields = IndexMap::new(); - self.infer_expr(&body, &path, &mut fields); + self.infer_expr(&body, &key, &mut fields); let value = if fields.is_empty() { TypeValue::Unit @@ -212,13 +206,13 @@ impl<'src> InferContext<'src> { fn infer_expr( &mut self, expr: &Expr, - path: &[&'src str], + parent: &TypeKey<'src>, fields: &mut IndexMap<&'src str, FieldEntry<'src>>, ) -> Option> { match expr { Expr::NamedNode(node) => { for child in node.children() { - self.infer_expr(&child, path, fields); + self.infer_expr(&child, parent, fields); } Some(TypeKey::Node) } @@ -233,30 +227,30 @@ impl<'src> InferContext<'src> { Expr::SeqExpr(seq) => { for child in seq.children() { - self.infer_expr(&child, path, fields); + self.infer_expr(&child, parent, fields); } None } Expr::FieldExpr(field) => { if let Some(value) = field.value() { - self.infer_expr(&value, path, fields); + self.infer_expr(&value, parent, fields); } None } - Expr::CapturedExpr(cap) => self.infer_capture(cap, path, fields), + Expr::CapturedExpr(cap) => self.infer_capture(cap, parent, fields), - Expr::QuantifiedExpr(quant) => self.infer_quantified(quant, path, fields), + Expr::QuantifiedExpr(quant) => self.infer_quantified(quant, parent, fields), - Expr::AltExpr(alt) => self.infer_alt(alt, path, fields), + Expr::AltExpr(alt) => self.infer_alt(alt, parent, fields), } } fn infer_capture( &mut self, cap: &ast::CapturedExpr, - path: &[&'src str], + parent: &TypeKey<'src>, fields: &mut IndexMap<&'src str, FieldEntry<'src>>, ) -> Option> { let name_tok = cap.name()?; @@ -276,12 +270,12 @@ impl<'src> InferContext<'src> { match inner_expr { Expr::NamedNode(node) => { for child in node.children() { - self.infer_expr(&child, path, fields); + self.infer_expr(&child, parent, fields); } } Expr::FieldExpr(field) => { if let Some(value) = field.value() { - self.infer_expr(&value, path, fields); + self.infer_expr(&value, parent, fields); } } _ => {} @@ -289,7 +283,7 @@ impl<'src> InferContext<'src> { } let inner_type = - self.infer_capture_inner(inner.as_ref(), path, capture_name, type_annotation); + self.infer_capture_inner(inner.as_ref(), parent, capture_name, type_annotation); // Check for duplicate capture in scope // Unlike alternations (where branches are mutually exclusive), @@ -323,7 +317,7 @@ impl<'src> InferContext<'src> { fn infer_capture_inner( &mut self, inner: Option<&Expr>, - path: &[&'src str], + parent: &TypeKey<'src>, capture_name: &'src str, type_annotation: Option<&'src str>, ) -> TypeKey<'src> { @@ -334,8 +328,8 @@ impl<'src> InferContext<'src> { return TypeKey::Invalid; }; let inner_key = - self.infer_capture_inner(Some(&qinner), path, capture_name, type_annotation); - return self.wrap_with_quantifier(&inner_key, q); + self.infer_capture_inner(Some(&qinner), parent, capture_name, type_annotation); + return self.wrap_with_quantifier(&inner_key, q, parent, capture_name); } // :: string annotation @@ -357,14 +351,14 @@ impl<'src> InferContext<'src> { } } - Expr::SeqExpr(seq) => { - self.infer_nested_scope(inner, path, capture_name, type_annotation, || { - seq.children().collect() + Expr::SeqExpr(_) => { + self.infer_nested_scope(inner, parent, capture_name, type_annotation, || { + inner.children().into_iter().collect() }) } Expr::AltExpr(alt) => { - self.infer_nested_scope(inner, path, capture_name, type_annotation, || { + self.infer_nested_scope(inner, parent, capture_name, type_annotation, || { alt.branches().filter_map(|b| b.body()).collect() }) } @@ -379,7 +373,7 @@ impl<'src> InferContext<'src> { Expr::FieldExpr(field) => { if let Some(value) = field.value() { - self.infer_capture_inner(Some(&value), path, capture_name, type_annotation) + self.infer_capture_inner(Some(&value), parent, capture_name, type_annotation) } else { type_annotation.map(TypeKey::Named).unwrap_or(TypeKey::Node) } @@ -392,7 +386,7 @@ impl<'src> InferContext<'src> { fn infer_nested_scope( &mut self, inner: &Expr, - path: &[&'src str], + parent: &TypeKey<'src>, capture_name: &'src str, type_annotation: Option<&'src str>, get_children: F, @@ -400,19 +394,21 @@ impl<'src> InferContext<'src> { where F: FnOnce() -> Vec, { - let mut nested_path = path.to_vec(); - nested_path.push(capture_name); + let nested_parent = TypeKey::Synthetic { + parent: Box::new(parent.clone()), + path: vec![capture_name], + }; let mut nested_fields = IndexMap::new(); match inner { Expr::AltExpr(alt) => { - let alt_key = self.infer_alt_as_type(alt, &nested_path, type_annotation); + let alt_key = self.infer_alt_as_type(alt, &nested_parent, type_annotation); return alt_key; } _ => { for child in get_children() { - self.infer_expr(&child, &nested_path, &mut nested_fields); + self.infer_expr(&child, &nested_parent, &mut nested_fields); } } } @@ -426,9 +422,10 @@ impl<'src> InferContext<'src> { } else { // Use unique suffix to allow same capture name in different alternation branches let suffix = self.next_synthetic_suffix(); - let mut unique_path = nested_path; - unique_path.push(suffix); - TypeKey::Synthetic(unique_path) + TypeKey::Synthetic { + parent: Box::new(parent.clone()), + path: vec![capture_name, suffix], + } }; self.table.insert( @@ -441,7 +438,7 @@ impl<'src> InferContext<'src> { fn infer_quantified( &mut self, quant: &ast::QuantifiedExpr, - path: &[&'src str], + parent: &TypeKey<'src>, fields: &mut IndexMap<&'src str, FieldEntry<'src>>, ) -> Option> { let inner = quant.inner()?; @@ -458,9 +455,13 @@ impl<'src> InferContext<'src> { Some(token_src(&tok, self.source)) }); - let inner_key = - self.infer_capture_inner(cap.inner().as_ref(), path, capture_name, type_annotation); - let wrapped_key = self.wrap_with_quantifier(&inner_key, quant); + let inner_key = self.infer_capture_inner( + cap.inner().as_ref(), + parent, + capture_name, + type_annotation, + ); + let wrapped_key = self.wrap_with_quantifier(&inner_key, quant, parent, capture_name); fields.insert( capture_name, @@ -476,24 +477,34 @@ impl<'src> InferContext<'src> { // and wrap them with the quantifier let fields_before: Vec<_> = fields.keys().copied().collect(); - let inner_key = self.infer_expr(&inner, path, fields)?; + let inner_key = self.infer_expr(&inner, parent, fields)?; // Wrap all newly added fields with the quantifier - for (name, entry) in fields.iter_mut() { - if fields_before.contains(name) { + let field_names: Vec<_> = fields.keys().copied().collect(); + for name in field_names { + if fields_before.contains(&name) { continue; } - entry.type_key = self.wrap_with_quantifier(&entry.type_key, quant); + if let Some(entry) = fields.get_mut(name) { + entry.type_key = self.wrap_with_quantifier(&entry.type_key, quant, parent, name); + } } - Some(self.wrap_with_quantifier(&inner_key, quant)) + // Return wrapped inner key (though typically unused when wrapping field captures) + Some(inner_key) } fn wrap_with_quantifier( &mut self, inner: &TypeKey<'src>, quant: &ast::QuantifiedExpr, + parent: &TypeKey<'src>, + capture_name: &'src str, ) -> TypeKey<'src> { + if matches!(inner, TypeKey::Invalid) { + return TypeKey::Invalid; + } + // Check list/non-empty-list before optional since * matches both is_list() and is_optional() let wrapper = if quant.is_list() { TypeValue::List(inner.clone()) @@ -505,54 +516,20 @@ impl<'src> InferContext<'src> { return inner.clone(); }; - // Generate a unique key for the wrapper type - let wrapper_name = match inner { - TypeKey::Named(name) => format!("{}Wrapped", name), - TypeKey::Node => "NodeWrapped".to_string(), - TypeKey::String => "StringWrapped".to_string(), - TypeKey::Synthetic(path) => format!("{}Wrapped", path.join("_")), - TypeKey::DefaultQuery => "QueryWrapped".to_string(), - TypeKey::Unit => "UnitWrapped".to_string(), - TypeKey::Invalid => return TypeKey::Invalid, + // Synthetic key: Parent + capture_name → e.g., QueryResultItems + let wrapper_key = TypeKey::Synthetic { + parent: Box::new(parent.clone()), + path: vec![capture_name], }; - // For simple wrappers around Node/String, just return the wrapper directly - // without creating a synthetic type entry. The printer will handle these. - if matches!(inner, TypeKey::Node | TypeKey::String) { - let prefix = if quant.is_optional() { - "opt" - } else if quant.is_list() { - "list" - } else if quant.is_non_empty_list() { - "nonempty" - } else { - "wrapped" - }; - let inner_name = match inner { - TypeKey::Node => "node", - TypeKey::String => "string", - _ => "unknown", - }; - let wrapper_key = TypeKey::Synthetic(vec![prefix, inner_name]); - self.table.insert(wrapper_key.clone(), wrapper); - return wrapper_key; - } - - let wrapper_key = TypeKey::Synthetic(vec![Box::leak(wrapper_name.into_boxed_str())]); - if !self - .table - .try_insert_untracked(wrapper_key.clone(), wrapper.clone()) - { - // Key already exists with same wrapper - that's fine - return wrapper_key; - } + self.table.insert(wrapper_key.clone(), wrapper); wrapper_key } fn infer_alt( &mut self, alt: &ast::AltExpr, - path: &[&'src str], + parent: &TypeKey<'src>, fields: &mut IndexMap<&'src str, FieldEntry<'src>>, ) -> Option> { // Alt without capture: just collect fields from all branches into current scope @@ -561,17 +538,17 @@ impl<'src> InferContext<'src> { // Tagged alt without capture: unusual, but collect fields for branch in alt.branches() { if let Some(body) = branch.body() { - self.infer_expr(&body, path, fields); + self.infer_expr(&body, parent, fields); } } } AltKind::Untagged | AltKind::Mixed => { // Untagged alt: merge fields from branches - let branch_fields = self.collect_branch_fields(alt, path); + let branch_fields = self.collect_branch_fields(alt, parent); let branch_types: Vec<_> = branch_fields.iter().map(Self::extract_types_ref).collect(); let merged = self.table.merge_fields(&branch_types); - self.apply_merged_fields(merged, fields, alt, path); + self.apply_merged_fields(merged, fields, alt, parent); } } None @@ -580,13 +557,13 @@ impl<'src> InferContext<'src> { fn infer_alt_as_type( &mut self, alt: &ast::AltExpr, - path: &[&'src str], + parent: &TypeKey<'src>, type_annotation: Option<&'src str>, ) -> TypeKey<'src> { match alt.kind() { - AltKind::Tagged => self.infer_tagged_alt(alt, path, type_annotation), + AltKind::Tagged => self.infer_tagged_alt(alt, parent, type_annotation), AltKind::Untagged | AltKind::Mixed => { - self.infer_untagged_alt(alt, path, type_annotation) + self.infer_untagged_alt(alt, parent, type_annotation) } } } @@ -594,7 +571,7 @@ impl<'src> InferContext<'src> { fn infer_tagged_alt( &mut self, alt: &ast::AltExpr, - path: &[&'src str], + parent: &TypeKey<'src>, type_annotation: Option<&'src str>, ) -> TypeKey<'src> { let mut variants = IndexMap::new(); @@ -605,17 +582,18 @@ impl<'src> InferContext<'src> { }; let label = token_src(&label_tok, self.source); - let mut variant_path = path.to_vec(); - variant_path.push(label); + let variant_key = TypeKey::Synthetic { + parent: Box::new(parent.clone()), + path: vec![label], + }; let mut variant_fields = IndexMap::new(); let body_type = if let Some(body) = branch.body() { - self.infer_expr(&body, &variant_path, &mut variant_fields) + self.infer_expr(&body, &variant_key, &mut variant_fields) } else { None }; - let variant_key = TypeKey::Synthetic(variant_path); let variant_value = if variant_fields.is_empty() { // No captures: check if the body produced a meaningful type match body_type { @@ -637,20 +615,19 @@ impl<'src> InferContext<'src> { variants.insert(label, variant_key); } - let key = if let Some(name) = type_annotation { + let union_key = if let Some(name) = type_annotation { TypeKey::Named(name) - } else if path.is_empty() { - TypeKey::DefaultQuery } else { - TypeKey::Synthetic(path.to_vec()) + parent.clone() }; // Detect conflict: same key with incompatible TaggedUnion let current_span = alt.text_range(); - if let Err(existing_span) = - self.table - .try_insert(key.clone(), TypeValue::TaggedUnion(variants), current_span) - { + if let Err(existing_span) = self.table.try_insert( + union_key.clone(), + TypeValue::TaggedUnion(variants), + current_span, + ) { self.diagnostics .report( DiagnosticKind::IncompatibleTaggedAlternations, @@ -658,18 +635,18 @@ impl<'src> InferContext<'src> { ) .related_to("incompatible", current_span) .emit(); - return key; + return union_key; } - key + union_key } fn infer_untagged_alt( &mut self, alt: &ast::AltExpr, - path: &[&'src str], + parent: &TypeKey<'src>, type_annotation: Option<&'src str>, ) -> TypeKey<'src> { - let branch_fields = self.collect_branch_fields(alt, path); + let branch_fields = self.collect_branch_fields(alt, parent); let branch_types: Vec<_> = branch_fields.iter().map(Self::extract_types_ref).collect(); let merged = self.table.merge_fields(&branch_types); @@ -678,18 +655,17 @@ impl<'src> InferContext<'src> { } let mut result_fields = IndexMap::new(); - self.apply_merged_fields(merged, &mut result_fields, alt, path); + self.apply_merged_fields(merged, &mut result_fields, alt, parent); let key = if let Some(name) = type_annotation { TypeKey::Named(name) - } else if path.is_empty() { - TypeKey::DefaultQuery } else { // Use unique suffix to allow same capture name in different alternation branches let suffix = self.next_synthetic_suffix(); - let mut unique_path = path.to_vec(); - unique_path.push(suffix); - TypeKey::Synthetic(unique_path) + TypeKey::Synthetic { + parent: Box::new(parent.clone()), + path: vec![suffix], + } }; self.table.insert( @@ -702,14 +678,14 @@ impl<'src> InferContext<'src> { fn collect_branch_fields( &mut self, alt: &ast::AltExpr, - path: &[&'src str], + parent: &TypeKey<'src>, ) -> Vec>> { let mut branch_fields = Vec::new(); for branch in alt.branches() { let mut fields = IndexMap::new(); if let Some(body) = branch.body() { - self.infer_expr(&body, path, &mut fields); + self.infer_expr(&body, parent, &mut fields); } branch_fields.push(fields); } @@ -720,18 +696,18 @@ impl<'src> InferContext<'src> { fn apply_merged_fields( &mut self, merged: IndexMap<&'src str, MergedField<'src>>, - fields: &mut IndexMap<&'src str, FieldEntry<'src>>, + result_fields: &mut IndexMap<&'src str, FieldEntry<'src>>, alt: &ast::AltExpr, - path: &[&'src str], + parent: &TypeKey<'src>, ) { for (name, merge_result) in merged { let key = match merge_result { MergedField::Same(k) => k, MergedField::Optional(k) => { - let mut wrapper_path = path.to_vec(); - wrapper_path.push(name); - wrapper_path.push("opt"); - let wrapper_key = TypeKey::Synthetic(wrapper_path); + let wrapper_key = TypeKey::Synthetic { + parent: Box::new(parent.clone()), + path: vec![name, "opt"], + }; self.table .insert(wrapper_key.clone(), TypeValue::Optional(k)); wrapper_key @@ -744,7 +720,7 @@ impl<'src> InferContext<'src> { TypeKey::Invalid } }; - fields.insert( + result_fields.insert( name, FieldEntry { type_key: key, diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index 4d18e7b3..9fa44e1d 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -50,7 +50,7 @@ fn annotation_on_quantified_wraps_inner() { let query = Query::try_from("(identifier)+ @names :: string").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type NonemptyString = [string, ...string[]]; + type QueryResultNames = [string, ...string[]]; type QueryResult = { names: [string, ...string[]] }; "); @@ -87,7 +87,7 @@ fn optional_node() { let query = Query::try_from("(identifier)? @maybe").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type OptNode = SyntaxNode; + type QueryResultMaybe = SyntaxNode; type QueryResult = { maybe?: SyntaxNode }; "); @@ -98,7 +98,7 @@ fn list_of_nodes() { let query = Query::try_from("(identifier)* @items").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type OptNode = SyntaxNode[]; + type QueryResultItems = SyntaxNode[]; type QueryResult = { items: SyntaxNode[] }; "); @@ -109,7 +109,7 @@ fn nonempty_list_of_nodes() { let query = Query::try_from("(identifier)+ @items").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type NonemptyNode = [SyntaxNode, ...SyntaxNode[]]; + type QueryResultItems = [SyntaxNode, ...SyntaxNode[]]; type QueryResult = { items: [SyntaxNode, ...SyntaxNode[]] }; "); @@ -126,7 +126,7 @@ fn quantified_ref() { insta::assert_snapshot!(query.dump_types(), @r" type Item = { value: SyntaxNode }; - type ItemWrapped = [Item, ...Item[]]; + type QueryResultItems = [Item, ...Item[]]; type QueryResult = { items: [Item, ...Item[]] }; "); @@ -137,7 +137,7 @@ fn quantifier_outside_capture() { let query = Query::try_from("((identifier) @id)*").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type OptNode = SyntaxNode[]; + type QueryResultId = SyntaxNode[]; type QueryResult = { id: SyntaxNode[] }; "); @@ -148,9 +148,9 @@ fn captured_seq_creates_nested_struct() { let query = Query::try_from("{(a) @x (b) @y} @pair").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type DefaultQueryPair0 = { x: SyntaxNode; y: SyntaxNode }; + type QueryResultPair0 = { x: SyntaxNode; y: SyntaxNode }; - type QueryResult = { pair: DefaultQueryPair0 }; + type QueryResult = { pair: QueryResultPair0 }; "); } @@ -164,9 +164,9 @@ fn captured_seq_in_tree() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type DefaultQueryParams0 = { p: SyntaxNode }; + type QueryResultParams0 = { p: SyntaxNode }; - type QueryResult = { params: DefaultQueryParams0; body: SyntaxNode }; + type QueryResult = { params: QueryResultParams0; body: SyntaxNode }; "); } @@ -183,11 +183,11 @@ fn tagged_alt_produces_union() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r#" - type DefaultQueryA = { x: SyntaxNode }; + type QueryResultA = { x: SyntaxNode }; - type DefaultQueryB = { y: SyntaxNode }; + type QueryResultB = { y: SyntaxNode }; - type DefaultQuery = + type QueryResult = | { tag: "A"; x: SyntaxNode } | { tag: "B"; y: SyntaxNode }; "#); @@ -220,7 +220,7 @@ fn tagged_branch_without_captures_is_unit() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r#" - type DefaultQuery = + type QueryResult = | { tag: "A" } | { tag: "B" }; "#); @@ -235,15 +235,13 @@ fn tagged_branch_with_ref() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r#" - type RecValueNested = { value?: Rec }; - type RecValue = | { tag: "Base" } - | { tag: "Nested"; value?: Rec }; + | { tag: "Nested"; value: Rec }; type Rec = { value: RecValue }; - type RecWrapped = Rec; + type RecValueNested = { value: Rec }; "#); } @@ -253,11 +251,11 @@ fn captured_tagged_alt() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r#" - type DefaultQueryChoice = + type QueryResultChoice = | { tag: "A" } | { tag: "B" }; - type QueryResult = { choice: DefaultQueryChoice }; + type QueryResult = { choice: QueryResultChoice }; "#); } @@ -275,9 +273,9 @@ fn untagged_alt_different_captures_becomes_optional() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type DefaultQueryXOpt = SyntaxNode; + type QueryResultXOpt = SyntaxNode; - type DefaultQueryYOpt = SyntaxNode; + type QueryResultYOpt = SyntaxNode; type QueryResult = { x?: SyntaxNode; y?: SyntaxNode }; "); @@ -289,9 +287,9 @@ fn untagged_alt_nested_alt_merges() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type DefaultQueryXOpt = SyntaxNode; + type QueryResultXOpt = SyntaxNode; - type DefaultQueryYOpt = SyntaxNode; + type QueryResultYOpt = SyntaxNode; type QueryResult = { x?: SyntaxNode; y?: SyntaxNode }; "); @@ -303,13 +301,13 @@ fn captured_untagged_alt_with_nested_fields() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type DefaultQueryChoiceXOpt = SyntaxNode; + type QueryResultChoiceXOpt = SyntaxNode; - type DefaultQueryChoiceYOpt = SyntaxNode; + type QueryResultChoiceYOpt = SyntaxNode; - type DefaultQueryChoice0 = { x?: SyntaxNode; y?: SyntaxNode }; + type QueryResultChoice0 = { x?: SyntaxNode; y?: SyntaxNode }; - type QueryResult = { choice: DefaultQueryChoice0 }; + type QueryResult = { choice: QueryResultChoice0 }; "); } @@ -327,7 +325,7 @@ fn merge_absent_field_becomes_optional() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type DefaultQueryXOpt = SyntaxNode; + type QueryResultXOpt = SyntaxNode; type QueryResult = { x?: SyntaxNode }; "); @@ -339,13 +337,9 @@ fn merge_list_and_nonempty_list_to_list() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type OptNode = SyntaxNode[]; - - type NonemptyNode = [SyntaxNode, ...SyntaxNode[]]; - - type ListMerged = SyntaxNode[]; + type QueryResultX = [SyntaxNode, ...SyntaxNode[]]; - type QueryResult = { x: SyntaxNode[] }; + type QueryResult = { x: [SyntaxNode, ...SyntaxNode[]] }; "); } @@ -355,7 +349,7 @@ fn merge_optional_and_required_to_optional() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type OptNode = SyntaxNode; + type QueryResultX = SyntaxNode; type QueryResult = { x?: SyntaxNode }; "); @@ -384,7 +378,7 @@ fn recursive_through_optional() { insta::assert_snapshot!(query.dump_types(), @r" type Rec = { inner?: Rec }; - type RecWrapped = Rec; + type RecInner = Rec; "); } @@ -415,7 +409,7 @@ fn unnamed_last_def_is_default_query() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type OptNode = SyntaxNode[]; + type QueryResultItems = SyntaxNode[]; type QueryResult = { items: SyntaxNode[] }; "); @@ -432,7 +426,7 @@ fn named_defs_plus_entry_point() { insta::assert_snapshot!(query.dump_types(), @r" type Item = { value: SyntaxNode }; - type ItemWrapped = Item[]; + type QueryResultItems = Item[]; type QueryResult = { items: Item[] }; "); @@ -444,11 +438,11 @@ fn tagged_alt_at_entry_point() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r#" - type DefaultQueryA = { x: SyntaxNode }; + type QueryResultA = { x: SyntaxNode }; - type DefaultQueryB = { y: SyntaxNode }; + type QueryResultB = { y: SyntaxNode }; - type DefaultQuery = + type QueryResult = | { tag: "A"; x: SyntaxNode } | { tag: "B"; y: SyntaxNode }; "#); @@ -541,9 +535,9 @@ fn deeply_nested_seq() { let query = Query::try_from("{{{(identifier) @x}}} @outer").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type DefaultQueryOuter0 = { x: SyntaxNode }; + type QueryResultOuter0 = { x: SyntaxNode }; - type QueryResult = { outer: DefaultQueryOuter0 }; + type QueryResult = { outer: QueryResultOuter0 }; "); } @@ -553,10 +547,10 @@ fn same_tag_in_branches_merges() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r#" - type DefaultQueryX = + type QueryResultX = | { tag: "A" }; - type QueryResult = { x: DefaultQueryX }; + type QueryResult = { x: QueryResultX }; "#); } From 123ab6c0d3eebc32c0820724e21f32697bc0325c Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 05:01:19 -0300 Subject: [PATCH 11/11] Fix --- crates/plotnik-lib/src/infer/types.rs | 11 +++--- crates/plotnik-lib/src/infer/types_tests.rs | 39 +++++++++------------ crates/plotnik-lib/src/infer/tyton.rs | 29 +++++++++------ crates/plotnik-lib/src/infer/tyton_tests.rs | 14 ++++---- crates/plotnik-lib/src/query/types.rs | 38 ++++++-------------- crates/plotnik-lib/src/query/types_tests.rs | 16 ++++----- 6 files changed, 66 insertions(+), 81 deletions(-) diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index 6c09e55f..aaad9c02 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -68,12 +68,12 @@ pub enum TypeKey<'src> { DefaultQuery, /// User-provided type name via `:: TypeName` Named(&'src str), - /// Synthetic type derived from parent + field path. + /// Synthetic type derived from parent + capture name. /// Parent can be Named, DefaultQuery, or another Synthetic. - /// Emitter resolves parent to name, then appends path segments in PascalCase. + /// Emitter resolves parent to name, then appends capture name in PascalCase. Synthetic { parent: Box>, - path: Vec<&'src str>, + name: &'src str, }, } @@ -94,10 +94,9 @@ impl TypeKey<'_> { TypeKey::Invalid => "Unit".to_string(), // Invalid emits as Unit TypeKey::DefaultQuery => entry_name.to_string(), TypeKey::Named(name) => (*name).to_string(), - TypeKey::Synthetic { parent, path } => { + TypeKey::Synthetic { parent, name } => { let parent_name = parent.to_pascal_case_with_entry_name(entry_name); - let path_suffix: String = path.iter().map(|s| to_pascal(s)).collect(); - format!("{}{}", parent_name, path_suffix) + format!("{}{}", parent_name, to_pascal(name)) } } } diff --git a/crates/plotnik-lib/src/infer/types_tests.rs b/crates/plotnik-lib/src/infer/types_tests.rs index db62e591..f81d046b 100644 --- a/crates/plotnik-lib/src/infer/types_tests.rs +++ b/crates/plotnik-lib/src/infer/types_tests.rs @@ -23,23 +23,18 @@ fn type_key_to_pascal_case_synthetic() { assert_eq!( TypeKey::Synthetic { parent: Box::new(TypeKey::Named("Foo")), - path: vec![] - } - .to_pascal_case(), - "Foo" - ); - assert_eq!( - TypeKey::Synthetic { - parent: Box::new(TypeKey::Named("Foo")), - path: vec!["bar"] + name: "bar" } .to_pascal_case(), "FooBar" ); assert_eq!( TypeKey::Synthetic { - parent: Box::new(TypeKey::Named("Foo")), - path: vec!["bar", "baz"] + parent: Box::new(TypeKey::Synthetic { + parent: Box::new(TypeKey::Named("Foo")), + name: "bar" + }), + name: "baz" } .to_pascal_case(), "FooBarBaz" @@ -51,7 +46,7 @@ fn type_key_to_pascal_case_snake_case_segments() { assert_eq!( TypeKey::Synthetic { parent: Box::new(TypeKey::Named("Foo")), - path: vec!["bar_baz"] + name: "bar_baz" } .to_pascal_case(), "FooBarBaz" @@ -59,7 +54,7 @@ fn type_key_to_pascal_case_snake_case_segments() { assert_eq!( TypeKey::Synthetic { parent: Box::new(TypeKey::Named("FunctionInfo")), - path: vec!["params"] + name: "params" } .to_pascal_case(), "FunctionInfoParams" @@ -152,7 +147,7 @@ fn type_value_tagged_union() { assign_fields.insert("target", TypeKey::String); let assign_key = TypeKey::Synthetic { parent: Box::new(TypeKey::Named("Stmt")), - path: vec!["Assign"], + name: "Assign", }; table.insert(assign_key.clone(), TypeValue::Struct(assign_fields)); @@ -160,7 +155,7 @@ fn type_value_tagged_union() { call_fields.insert("func", TypeKey::String); let call_key = TypeKey::Synthetic { parent: Box::new(TypeKey::Named("Stmt")), - path: vec!["Call"], + name: "Call", }; table.insert(call_key.clone(), TypeValue::Struct(call_fields)); @@ -228,21 +223,21 @@ fn type_key_equality() { assert_eq!( TypeKey::Synthetic { parent: Box::new(TypeKey::Named("A")), - path: vec!["b"] + name: "b" }, TypeKey::Synthetic { parent: Box::new(TypeKey::Named("A")), - path: vec!["b"] + name: "b" } ); assert_ne!( TypeKey::Synthetic { parent: Box::new(TypeKey::Named("A")), - path: vec!["b"] + name: "b" }, TypeKey::Synthetic { parent: Box::new(TypeKey::Named("A")), - path: vec!["c"] + name: "c" } ); } @@ -255,14 +250,14 @@ fn type_key_hash_consistency() { set.insert(TypeKey::Named("Foo")); set.insert(TypeKey::Synthetic { parent: Box::new(TypeKey::Named("A")), - path: vec!["b"], + name: "b", }); assert!(set.contains(&TypeKey::Node)); assert!(set.contains(&TypeKey::Named("Foo"))); assert!(set.contains(&TypeKey::Synthetic { parent: Box::new(TypeKey::Named("A")), - path: vec!["b"] + name: "b" })); assert!(!set.contains(&TypeKey::String)); } @@ -277,7 +272,7 @@ fn type_key_is_builtin() { assert!( !TypeKey::Synthetic { parent: Box::new(TypeKey::Named("A")), - path: vec![] + name: "b" } .is_builtin() ); diff --git a/crates/plotnik-lib/src/infer/tyton.rs b/crates/plotnik-lib/src/infer/tyton.rs index 3da09b91..f026d38a 100644 --- a/crates/plotnik-lib/src/infer/tyton.rs +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -265,8 +265,8 @@ impl<'src> Parser<'src> { } }; - // Parse path segments - let mut path = Vec::new(); + // Parse path segments, building nested Synthetic keys + let mut result = parent; loop { let span = self.current_span(); match self.peek() { @@ -277,7 +277,10 @@ impl<'src> Parser<'src> { Some(Token::LowerIdent(s)) => { let s = *s; self.advance(); - path.push(s); + result = TypeKey::Synthetic { + parent: Box::new(result), + name: s, + }; } _ => { return Err(ParseError { @@ -288,10 +291,7 @@ impl<'src> Parser<'src> { } } - Ok(TypeKey::Synthetic { - parent: Box::new(parent), - path, - }) + Ok(result) } fn parse_type_value(&mut self) -> Result, ParseError> { @@ -507,10 +507,19 @@ fn emit_key(out: &mut String, key: &TypeKey<'_>) { TypeKey::Unit => out.push_str("()"), TypeKey::DefaultQuery => out.push_str("#DefaultQuery"), TypeKey::Named(name) => out.push_str(name), - TypeKey::Synthetic { parent, path } => { + TypeKey::Synthetic { parent, name } => { + // Flatten nested Synthetic keys into + let mut segments = vec![*name]; + let mut current = parent.as_ref(); + while let TypeKey::Synthetic { parent: p, name: n } = current { + segments.push(*n); + current = p.as_ref(); + } + segments.reverse(); + out.push('<'); - emit_key(out, parent); - for seg in path.iter() { + emit_key(out, current); + for seg in segments { out.push(' '); out.push_str(seg); } diff --git a/crates/plotnik-lib/src/infer/tyton_tests.rs b/crates/plotnik-lib/src/infer/tyton_tests.rs index 69ecacd7..4140204d 100644 --- a/crates/plotnik-lib/src/infer/tyton_tests.rs +++ b/crates/plotnik-lib/src/infer/tyton_tests.rs @@ -176,7 +176,7 @@ fn parse_synthetic_key_simple() { String = String Unit = Unit Invalid = Invalid - Named("Wrapper") = Optional(Synthetic { parent: Named("Foo"), path: ["bar"] }) + Named("Wrapper") = Optional(Synthetic { parent: Named("Foo"), name: "bar" }) "#); } @@ -188,7 +188,7 @@ fn parse_synthetic_key_multiple_segments() { String = String Unit = Unit Invalid = Invalid - Named("Wrapper") = List(Synthetic { parent: Named("Foo"), path: ["bar", "baz"] }) + Named("Wrapper") = List(Synthetic { parent: Synthetic { parent: Named("Foo"), name: "bar" }, name: "baz" }) "#); } @@ -200,7 +200,7 @@ fn parse_struct_with_synthetic() { String = String Unit = Unit Invalid = Invalid - Named("Container") = Struct({"inner": Synthetic { parent: Named("Inner"), path: ["field"] }}) + Named("Container") = Struct({"inner": Synthetic { parent: Named("Inner"), name: "field" }}) "#); } @@ -212,7 +212,7 @@ fn parse_union_with_synthetic() { String = String Unit = Unit Invalid = Invalid - Named("Choice") = TaggedUnion({"First": Synthetic { parent: Named("Choice"), path: ["first"] }, "Second": Synthetic { parent: Named("Choice"), path: ["second"] }}) + Named("Choice") = TaggedUnion({"First": Synthetic { parent: Named("Choice"), name: "first" }, "Second": Synthetic { parent: Named("Choice"), name: "second" }}) "#); } @@ -410,7 +410,7 @@ fn parse_synthetic_definition_struct() { String = String Unit = Unit Invalid = Invalid - Synthetic { parent: Named("Foo"), path: ["bar"] } = Struct({"value": Node}) + Synthetic { parent: Named("Foo"), name: "bar" } = Struct({"value": Node}) "#); } @@ -422,7 +422,7 @@ fn parse_synthetic_definition_union() { String = String Unit = Unit Invalid = Invalid - Synthetic { parent: Named("Choice"), path: ["first"] } = TaggedUnion({"A": Node, "B": String}) + Synthetic { parent: Named("Choice"), name: "first" } = TaggedUnion({"A": Node, "B": String}) "#); } @@ -434,7 +434,7 @@ fn parse_synthetic_definition_wrapper() { String = String Unit = Unit Invalid = Invalid - Synthetic { parent: Named("Inner"), path: ["nested"] } = Optional(Node) + Synthetic { parent: Named("Inner"), name: "nested" } = Optional(Node) "#); } diff --git a/crates/plotnik-lib/src/query/types.rs b/crates/plotnik-lib/src/query/types.rs index a9b22180..a22984ac 100644 --- a/crates/plotnik-lib/src/query/types.rs +++ b/crates/plotnik-lib/src/query/types.rs @@ -43,8 +43,6 @@ struct InferContext<'src> { source: &'src str, table: TypeTable<'src>, diagnostics: crate::diagnostics::Diagnostics, - /// Counter for generating unique synthetic type keys - synthetic_counter: usize, } impl<'src> InferContext<'src> { @@ -53,18 +51,9 @@ impl<'src> InferContext<'src> { source, table: TypeTable::new(), diagnostics: crate::diagnostics::Diagnostics::new(), - synthetic_counter: 0, } } - /// Generate a unique suffix for synthetic keys - fn next_synthetic_suffix(&mut self) -> &'src str { - let n = self.synthetic_counter; - self.synthetic_counter += 1; - // Leak a small string for the lifetime - this is fine for query processing - Box::leak(n.to_string().into_boxed_str()) - } - /// Mark types that contain cyclic references (need Box/Rc/Arc in Rust). /// Only struct/union types are marked - wrapper types (Optional, List, etc.) /// shouldn't be wrapped in Box themselves, only their inner references. @@ -396,7 +385,7 @@ impl<'src> InferContext<'src> { { let nested_parent = TypeKey::Synthetic { parent: Box::new(parent.clone()), - path: vec![capture_name], + name: capture_name, }; let mut nested_fields = IndexMap::new(); @@ -420,12 +409,7 @@ impl<'src> InferContext<'src> { let key = if let Some(name) = type_annotation { TypeKey::Named(name) } else { - // Use unique suffix to allow same capture name in different alternation branches - let suffix = self.next_synthetic_suffix(); - TypeKey::Synthetic { - parent: Box::new(parent.clone()), - path: vec![capture_name, suffix], - } + nested_parent.clone() }; self.table.insert( @@ -519,7 +503,7 @@ impl<'src> InferContext<'src> { // Synthetic key: Parent + capture_name → e.g., QueryResultItems let wrapper_key = TypeKey::Synthetic { parent: Box::new(parent.clone()), - path: vec![capture_name], + name: capture_name, }; self.table.insert(wrapper_key.clone(), wrapper); @@ -584,7 +568,7 @@ impl<'src> InferContext<'src> { let variant_key = TypeKey::Synthetic { parent: Box::new(parent.clone()), - path: vec![label], + name: label, }; let mut variant_fields = IndexMap::new(); @@ -660,12 +644,7 @@ impl<'src> InferContext<'src> { let key = if let Some(name) = type_annotation { TypeKey::Named(name) } else { - // Use unique suffix to allow same capture name in different alternation branches - let suffix = self.next_synthetic_suffix(); - TypeKey::Synthetic { - parent: Box::new(parent.clone()), - path: vec![suffix], - } + parent.clone() }; self.table.insert( @@ -705,8 +684,11 @@ impl<'src> InferContext<'src> { MergedField::Same(k) => k, MergedField::Optional(k) => { let wrapper_key = TypeKey::Synthetic { - parent: Box::new(parent.clone()), - path: vec![name, "opt"], + parent: Box::new(TypeKey::Synthetic { + parent: Box::new(parent.clone()), + name, + }), + name: "opt", }; self.table .insert(wrapper_key.clone(), TypeValue::Optional(k)); diff --git a/crates/plotnik-lib/src/query/types_tests.rs b/crates/plotnik-lib/src/query/types_tests.rs index 9fa44e1d..e095c124 100644 --- a/crates/plotnik-lib/src/query/types_tests.rs +++ b/crates/plotnik-lib/src/query/types_tests.rs @@ -148,9 +148,9 @@ fn captured_seq_creates_nested_struct() { let query = Query::try_from("{(a) @x (b) @y} @pair").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type QueryResultPair0 = { x: SyntaxNode; y: SyntaxNode }; + type QueryResultPair = { x: SyntaxNode; y: SyntaxNode }; - type QueryResult = { pair: QueryResultPair0 }; + type QueryResult = { pair: QueryResultPair }; "); } @@ -164,9 +164,9 @@ fn captured_seq_in_tree() { let query = Query::try_from(input).unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type QueryResultParams0 = { p: SyntaxNode }; + type QueryResultParams = { p: SyntaxNode }; - type QueryResult = { params: QueryResultParams0; body: SyntaxNode }; + type QueryResult = { params: QueryResultParams; body: SyntaxNode }; "); } @@ -305,9 +305,9 @@ fn captured_untagged_alt_with_nested_fields() { type QueryResultChoiceYOpt = SyntaxNode; - type QueryResultChoice0 = { x?: SyntaxNode; y?: SyntaxNode }; + type QueryResultChoice = { x?: SyntaxNode; y?: SyntaxNode }; - type QueryResult = { choice: QueryResultChoice0 }; + type QueryResult = { choice: QueryResultChoice }; "); } @@ -535,9 +535,9 @@ fn deeply_nested_seq() { let query = Query::try_from("{{{(identifier) @x}}} @outer").unwrap(); assert!(query.is_valid()); insta::assert_snapshot!(query.dump_types(), @r" - type QueryResultOuter0 = { x: SyntaxNode }; + type QueryResultOuter = { x: SyntaxNode }; - type QueryResult = { outer: QueryResultOuter0 }; + type QueryResult = { outer: QueryResultOuter }; "); }