From b6a9bc6693b3a476351f9f742f20a22871850fb7 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Thu, 1 Jan 2026 16:34:38 -0300 Subject: [PATCH] feat: void-type option and optional language for infer --- crates/plotnik-cli/src/cli.rs | 4 ++ crates/plotnik-cli/src/commands/infer.rs | 72 ++++++++++++-------- crates/plotnik-cli/src/main.rs | 1 + crates/plotnik-lib/src/typegen/typescript.rs | 40 ++++++++++- 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/crates/plotnik-cli/src/cli.rs b/crates/plotnik-cli/src/cli.rs index edf6169a..c62a7fbc 100644 --- a/crates/plotnik-cli/src/cli.rs +++ b/crates/plotnik-cli/src/cli.rs @@ -197,6 +197,10 @@ pub struct InferOutputArgs { #[arg(long)] pub no_export: bool, + /// Type for void results: undefined (default) or null + #[arg(long, value_name = "TYPE")] + pub void_type: Option, + /// Write output to file #[arg(short = 'o', long, value_name = "FILE")] pub output: Option, diff --git a/crates/plotnik-cli/src/commands/infer.rs b/crates/plotnik-cli/src/commands/infer.rs index 839201b3..82c917ea 100644 --- a/crates/plotnik-cli/src/commands/infer.rs +++ b/crates/plotnik-cli/src/commands/infer.rs @@ -19,6 +19,7 @@ pub struct InferArgs { pub export: bool, pub output: Option, pub color: bool, + pub void_type: Option, } pub fn run(args: InferArgs) { @@ -45,55 +46,66 @@ pub fn run(args: InferArgs) { std::process::exit(1); } - // Resolve language (required for infer) - let lang = args - .lang - .as_deref() - .map(|name| { - resolve_lang_required(name).unwrap_or_else(|msg| { + // Parse and analyze + let query = match QueryBuilder::new(source_map).parse() { + Ok(parsed) => parsed.analyze(), + Err(e) => { + eprintln!("error: {}", e); + std::process::exit(1); + } + }; + + // Resolve language (optional - enables linking) + let lang = if let Some(lang_name) = &args.lang { + match resolve_lang_required(lang_name) { + Ok(l) => Some(l), + Err(msg) => { eprintln!("error: {}", msg); - if let Some(suggestion) = suggest_language(name) { + if let Some(suggestion) = suggest_language(lang_name) { eprintln!(); eprintln!("Did you mean '{}'?", suggestion); } eprintln!(); eprintln!("Run 'plotnik langs' for the full list."); std::process::exit(1); - }) - }) - .or_else(|| resolve_lang(None, args.query_path.as_deref())); - - let Some(lang) = lang else { - eprintln!("error: --lang is required for type generation"); - std::process::exit(1); + } + } + } else { + resolve_lang(None, args.query_path.as_deref()) }; - // Parse, analyze, and link - let query = match QueryBuilder::new(source_map).parse() { - Ok(parsed) => parsed.analyze().link(&lang), - Err(e) => { - eprintln!("error: {}", e); + let bytecode = if let Some(lang) = lang { + let linked = query.link(&lang); + if !linked.is_valid() { + eprint!( + "{}", + linked.diagnostics().render_colored(linked.source_map(), args.color) + ); + std::process::exit(1); + } + linked.emit().expect("bytecode emission failed") + } else { + if !query.is_valid() { + eprint!( + "{}", + query.diagnostics().render_colored(query.source_map(), args.color) + ); std::process::exit(1); } + query.emit().expect("bytecode emission failed") }; - - if !query.is_valid() { - eprint!( - "{}", - query.diagnostics().render_colored(query.source_map(), args.color) - ); - std::process::exit(1); - } - - // Emit to bytecode - let bytecode = query.emit().expect("bytecode emission failed"); let module = Module::from_bytes(bytecode).expect("module loading failed"); // Emit TypeScript types + let void_type = match args.void_type.as_deref() { + Some("null") => typescript::VoidType::Null, + _ => typescript::VoidType::Undefined, + }; let config = typescript::Config { export: args.export, emit_node_type: !args.no_node_type, verbose_nodes: args.verbose_nodes, + void_type, }; let output = typescript::emit_with_config(&module, config); diff --git a/crates/plotnik-cli/src/main.rs b/crates/plotnik-cli/src/main.rs index 31ef8fe7..b4ece4eb 100644 --- a/crates/plotnik-cli/src/main.rs +++ b/crates/plotnik-cli/src/main.rs @@ -70,6 +70,7 @@ fn main() { export: !infer_output.no_export, output: infer_output.output, color: output.color.should_colorize(), + void_type: infer_output.void_type, }); } Command::Exec { diff --git a/crates/plotnik-lib/src/typegen/typescript.rs b/crates/plotnik-lib/src/typegen/typescript.rs index f01362fd..6d6cc0bd 100644 --- a/crates/plotnik-lib/src/typegen/typescript.rs +++ b/crates/plotnik-lib/src/typegen/typescript.rs @@ -12,6 +12,16 @@ use crate::bytecode::{ EntrypointsView, Module, QTypeId, StringsView, TypeDef, TypeKind, TypesView, }; +/// How to represent the void type in TypeScript. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VoidType { + /// `undefined` - the absence of a value + #[default] + Undefined, + /// `null` - explicit null value + Null, +} + /// Configuration for TypeScript emission. #[derive(Clone, Debug)] pub struct Config { @@ -21,6 +31,8 @@ pub struct Config { pub emit_node_type: bool, /// Use verbose node representation (with kind, text, etc.) pub verbose_nodes: bool, + /// How to represent the void type + pub void_type: VoidType, } impl Default for Config { @@ -29,6 +41,7 @@ impl Default for Config { export: true, emit_node_type: true, verbose_nodes: false, + void_type: VoidType::default(), } } } @@ -109,6 +122,20 @@ impl<'a> Emitter<'a> { } } + // Emit entrypoints with primitive result types (like VOID) + // These are not in to_emit because collect_reachable_types skips primitives + for (&type_id, name) in &primary_names { + if self.emitted.contains(&type_id) { + continue; + } + let Some(type_def) = self.types.get(type_id) else { + continue; + }; + if let Some(kind) = type_def.type_kind() && kind.is_primitive() { + self.emit_type_definition(name, type_id); + } + } + // Emit aliases for (alias_name, type_id) in aliases { if let Some(primary_name) = primary_names.get(&type_id) { @@ -251,7 +278,9 @@ impl<'a> Emitter<'a> { TypeKind::Node => { self.node_referenced = true; } - TypeKind::String | TypeKind::Void => {} + TypeKind::String | TypeKind::Void => { + // No action needed for primitives + } TypeKind::Struct | TypeKind::Enum => { let member_types: Vec<_> = self .types @@ -266,6 +295,7 @@ impl<'a> Emitter<'a> { self.collect_refs_recursive(QTypeId(type_def.data)); } TypeKind::Alias => { + // Alias to Node self.node_referenced = true; } } @@ -361,11 +391,14 @@ impl<'a> Emitter<'a> { return; }; + // For struct payloads, don't add the struct itself (it will be inlined), + // but recurse into its fields to find named types. if type_def.type_kind() == Some(TypeKind::Struct) { for member in self.types.members_of(&type_def) { self.collect_reachable_types(member.type_id, out); } } else { + // For non-struct payloads, fall back to regular collection. self.collect_reachable_types(type_id, out); } } @@ -574,7 +607,10 @@ impl<'a> Emitter<'a> { }; match kind { - TypeKind::Void => "undefined".to_string(), + TypeKind::Void => match self.config.void_type { + VoidType::Undefined => "undefined".to_string(), + VoidType::Null => "null".to_string(), + }, TypeKind::Node => "Node".to_string(), TypeKind::String => "string".to_string(), TypeKind::Struct | TypeKind::Enum => {