diff --git a/crates/plotnik-lib/src/infer/emit/rust.rs b/crates/plotnik-lib/src/infer/emit/rust.rs index 86b0c65c..b3680273 100644 --- a/crates/plotnik-lib/src/infer/emit/rust.rs +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -15,6 +15,8 @@ pub struct RustEmitConfig { pub derive_debug: bool, pub derive_clone: bool, 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. @@ -32,6 +34,7 @@ impl Default for RustEmitConfig { derive_debug: true, derive_clone: true, derive_partial_eq: false, + default_query_name: "QueryResult".to_string(), } } } @@ -67,7 +70,10 @@ fn emit_type_def( table: &TypeTable<'_>, config: &RustEmitConfig, ) -> String { - let name = key.to_pascal_case(); + let name = match key { + TypeKey::DefaultQuery => config.default_query_name.clone(), + _ => key.to_pascal_case(), + }; match value { TypeValue::Node | TypeValue::String | TypeValue::Unit | TypeValue::Invalid => String::new(), @@ -148,7 +154,10 @@ 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 => key.to_pascal_case(), + Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) | None => match key { + TypeKey::DefaultQuery => config.default_query_name.clone(), + _ => key.to_pascal_case(), + }, }; if is_cyclic { diff --git a/crates/plotnik-lib/src/infer/emit/rust_tests.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs index 192d76c1..932d64a1 100644 --- a/crates/plotnik-lib/src/infer/emit/rust_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -537,3 +537,56 @@ fn emit_builtin_value_with_named_key() { "#}; insta::assert_snapshot!(emit(input), @""); } + +// --- DefaultQuery --- + +#[test] +fn emit_default_query_struct() { + let input = "#DefaultQuery = { #Node @value }"; + + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct QueryResult { + pub value: Node, + } + "); +} + +#[test] +fn emit_default_query_custom_name() { + let input = "#DefaultQuery = { #Node @value }"; + let config = RustEmitConfig { + default_query_name: "MyResult".to_string(), + ..Default::default() + }; + + insta::assert_snapshot!(emit_with_config(input, &config), @r" + #[derive(Debug, Clone)] + pub struct MyResult { + pub value: Node, + } + "); +} + +#[test] +fn emit_default_query_referenced() { + let input = indoc! {r#" + Item = { #Node @value } + Items = Item* + #DefaultQuery = { Items @items } + "#}; + + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Item { + pub value: Node, + } + + pub type Items = Vec; + + #[derive(Debug, Clone)] + pub struct QueryResult { + pub items: Vec, + } + "); +} diff --git a/crates/plotnik-lib/src/infer/emit/typescript.rs b/crates/plotnik-lib/src/infer/emit/typescript.rs index d07f2225..72621fd1 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -21,6 +21,8 @@ pub struct TypeScriptEmitConfig { pub node_type_name: String, /// Whether to emit `type Foo = ...` instead of `interface Foo { ... }`. pub use_type_alias: bool, + /// Name for the default (unnamed) query entry point. + pub default_query_name: String, } /// How to represent optional types. @@ -43,6 +45,7 @@ impl Default for TypeScriptEmitConfig { inline_synthetic: true, node_type_name: "SyntaxNode".to_string(), use_type_alias: false, + default_query_name: "QueryResult".to_string(), } } } @@ -83,7 +86,7 @@ fn emit_type_def( table: &TypeTable<'_>, config: &TypeScriptEmitConfig, ) -> String { - let name = key.to_pascal_case(); + let name = type_name(key, config); let export_prefix = if config.export && !matches!(key, TypeKey::Synthetic(_)) { "export " } else { @@ -192,13 +195,13 @@ pub(crate) fn emit_field_type( if config.inline_synthetic && matches!(key, TypeKey::Synthetic(_)) { (emit_inline_struct(fields, table, config), false) } else { - (key.to_pascal_case(), false) + (type_name(key, config), false) } } - Some(TypeValue::TaggedUnion(_)) => (key.to_pascal_case(), false), + Some(TypeValue::TaggedUnion(_)) => (type_name(key, config), false), - None => (key.to_pascal_case(), false), + None => (type_name(key, config), false), } } @@ -231,6 +234,14 @@ pub(crate) fn emit_inline_struct( out } +fn type_name(key: &TypeKey<'_>, config: &TypeScriptEmitConfig) -> String { + if key.is_default_query() { + config.default_query_name.clone() + } else { + key.to_pascal_case() + } +} + pub(crate) fn wrap_if_union(type_str: &str) -> String { if type_str.contains('|') { format!("({})", type_str) diff --git a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs index 13fda721..5aae21dc 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs @@ -447,6 +447,7 @@ fn emit_all_config_options() { inline_synthetic: true, node_type_name: "ASTNode".to_string(), use_type_alias: false, + default_query_name: "QueryResult".to_string(), }; insta::assert_snapshot!(emit_with_config(input, &config), @r" export type MaybeNode = ASTNode; @@ -741,3 +742,52 @@ fn emit_builtin_value_with_named_key() { "#}; insta::assert_snapshot!(emit(input), @""); } + +// --- DefaultQuery --- + +#[test] +fn emit_default_query_interface() { + let input = "#DefaultQuery = { #Node @value }"; + + insta::assert_snapshot!(emit(input), @r" + interface QueryResult { + value: SyntaxNode; + } + "); +} + +#[test] +fn emit_default_query_custom_name() { + let input = "#DefaultQuery = { #Node @value }"; + let config = TypeScriptEmitConfig { + default_query_name: "MyResult".to_string(), + ..Default::default() + }; + + insta::assert_snapshot!(emit_with_config(input, &config), @r" + interface MyResult { + value: SyntaxNode; + } + "); +} + +#[test] +fn emit_default_query_referenced() { + let input = indoc! {r#" + Item = { #Node @value } + Items = Item* + #DefaultQuery = { Items @items } + "#}; + + insta::assert_snapshot!(emit(input), @r" + interface Item { + value: SyntaxNode; + } + + type Items = Item[]; + + interface QueryResult { + items: Item[]; + } + "); +} diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index 9942fe59..6e9081bd 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -35,6 +35,13 @@ //! Built-in types (Node, String, Unit, Invalid) have both a key and value variant for //! consistency—the key is what you reference, the value is what gets stored. //! +//! ## DefaultQuery Key +//! +//! `TypeKey::DefaultQuery` represents the unnamed entry point query (the last definition +//! without a name). It has no corresponding `TypeValue` variant—it's purely a key that +//! maps to a Struct or other value. The emitted name ("QueryResult" by default) is +//! configurable per code generator. +//! //! ## Synthetic Keys //! //! For nested captures like `(function @fn { (param @p) @params })`, we need unique type @@ -55,6 +62,9 @@ pub enum TypeKey<'src> { /// Invalid type for unresolvable conflicts (built-in) /// Emitted same as Unit in code generators. Invalid, + /// The unnamed entry point query (last definition without a name). + /// Default emitted name is "QueryResult", but emitters may override. + DefaultQuery, /// User-provided type name via `:: TypeName` Named(&'src str), /// Path-based synthetic name: ["Foo", "bar"] → FooBar @@ -69,6 +79,7 @@ impl TypeKey<'_> { 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::Named(name) => (*name).to_string(), TypeKey::Synthetic(segments) => segments.iter().map(|s| to_pascal(s)).collect(), } @@ -81,6 +92,11 @@ impl TypeKey<'_> { TypeKey::Node | TypeKey::String | TypeKey::Unit | TypeKey::Invalid ) } + + /// Returns true if this is the default query entry point. + pub fn is_default_query(&self) -> bool { + matches!(self, TypeKey::DefaultQuery) + } } /// Convert snake_case or lowercase to PascalCase. diff --git a/crates/plotnik-lib/src/infer/tyton.rs b/crates/plotnik-lib/src/infer/tyton.rs index d5ed0242..e3a89364 100644 --- a/crates/plotnik-lib/src/infer/tyton.rs +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -23,6 +23,7 @@ //! - `#Node` — built-in node type //! - `#string` — built-in string type //! - `#Invalid` — built-in invalid type +//! - `#DefaultQuery` — unnamed entry point query //! - `()` — built-in unit type //! - `PascalName` — named type //! - `` — synthetic key from path segments @@ -40,6 +41,7 @@ //! - `Name = [ ... ]` — define a tagged union //! - `Name = Other?` — define an optional //! - ` = { ... }` — define with synthetic key +//! - `#DefaultQuery = { ... }` — define unnamed entry point //! - `AliasNode = #Node` — alias to builtin //! //! # Example @@ -70,6 +72,9 @@ enum Token<'src> { #[token("#Invalid")] Invalid, + #[token("#DefaultQuery")] + DefaultQuery, + #[token("()")] Unit, @@ -211,6 +216,10 @@ impl<'src> Parser<'src> { self.advance(); Ok(TypeKey::Invalid) } + Some(Token::DefaultQuery) => { + self.advance(); + Ok(TypeKey::DefaultQuery) + } Some(Token::Unit) => { self.advance(); Ok(TypeKey::Unit) @@ -410,6 +419,10 @@ impl<'src> Parser<'src> { self.advance(); TypeKey::Named(name) } + Some(Token::DefaultQuery) => { + self.advance(); + TypeKey::DefaultQuery + } Some(Token::LAngle) => self.parse_synthetic_key()?, _ => { return Err(ParseError { @@ -475,6 +488,7 @@ fn emit_key(out: &mut String, key: &TypeKey<'_>) { TypeKey::String => out.push_str("#string"), TypeKey::Invalid => out.push_str("#Invalid"), TypeKey::Unit => out.push_str("()"), + TypeKey::DefaultQuery => out.push_str("#DefaultQuery"), TypeKey::Named(name) => out.push_str(name), TypeKey::Synthetic(segments) => { out.push('<');