diff --git a/crates/plotnik-lib/src/analyze/type_check/context.rs b/crates/plotnik-lib/src/analyze/type_check/context.rs index fa5685ea..630a9584 100644 --- a/crates/plotnik-lib/src/analyze/type_check/context.rs +++ b/crates/plotnik-lib/src/analyze/type_check/context.rs @@ -27,6 +27,10 @@ pub struct TypeContext { recursive_defs: HashSet, term_info: HashMap, + + /// Explicit type names from annotations like `{...} @x :: TypeName`. + /// Maps a struct/enum TypeId to the name it should have in generated code. + type_names: HashMap, } impl Default for TypeContext { @@ -45,6 +49,7 @@ impl TypeContext { def_types: HashMap::new(), recursive_defs: HashSet::new(), term_info: HashMap::new(), + type_names: HashMap::new(), }; // Pre-register builtin types at their expected IDs @@ -226,6 +231,21 @@ impl TypeContext { pub fn def_count(&self) -> usize { self.def_names.len() } + + /// Associate an explicit name with a type (from `@x :: TypeName` on struct captures). + pub fn set_type_name(&mut self, type_id: TypeId, name: Symbol) { + self.type_names.insert(type_id, name); + } + + /// Get the explicit name for a type, if any. + pub fn get_type_name(&self, type_id: TypeId) -> Option { + self.type_names.get(&type_id).copied() + } + + /// Iterate over all explicit type names as (TypeId, Symbol). + pub fn iter_type_names(&self) -> impl Iterator + '_ { + self.type_names.iter().map(|(&id, &sym)| (id, sym)) + } } #[cfg(test)] diff --git a/crates/plotnik-lib/src/analyze/type_check/infer.rs b/crates/plotnik-lib/src/analyze/type_check/infer.rs index 5b112a4b..3893cda4 100644 --- a/crates/plotnik-lib/src/analyze/type_check/infer.rs +++ b/crates/plotnik-lib/src/analyze/type_check/infer.rs @@ -27,6 +27,19 @@ use crate::parser::ast::{ use crate::parser::cst::SyntaxKind; use crate::query::source_map::SourceId; +/// Type annotation kind from `@capture :: Type` syntax. +/// +/// The caller decides how to use the annotation based on context: +/// - `String`: always converts the capture to string type +/// - `TypeName`: either names a struct (for scope-creating captures) or creates a Node alias +#[derive(Clone, Copy, Debug)] +enum AnnotationKind { + /// `:: string` - extract text as string + String, + /// `:: TypeName` - custom type name + TypeName(Symbol), +} + /// Inference context for a single pass over the AST. pub struct InferenceVisitor<'a, 'd> { pub ctx: &'a mut TypeContext, @@ -286,10 +299,10 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { }; let capture_name = self.interner.intern(&name_tok.text()[1..]); // Strip @ prefix - let annotation_type = self.resolve_annotation(cap); + let annotation = self.resolve_annotation(cap); let Some(inner) = cap.inner() else { - // Capture without inner -> creates a Node field - let type_id = annotation_type.unwrap_or(TYPE_NODE); + // Capture without inner -> creates a Node field with optional annotation + let type_id = self.annotation_to_alias(annotation, TYPE_NODE); let field = FieldInfo::required(type_id); return TermInfo::new( Arity::One, @@ -309,7 +322,7 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { if should_merge_fields { // Named node/ref/etc with bubbling fields: capture adds a field, // inner fields bubble up alongside. - let captured_type = self.determine_non_scope_captured_type(&inner, annotation_type); + let captured_type = self.determine_non_scope_captured_type(&inner, annotation); let field_info = if is_optional { FieldInfo::optional(captured_type) } else { @@ -334,7 +347,7 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { } else { // All other cases: scope-creating captures, scalar flows, void flows. // Inner becomes the captured type (if applicable). - let captured_type = self.determine_captured_type(&inner, &inner_info, annotation_type); + let captured_type = self.determine_captured_type(&inner, &inner_info, annotation); let field_info = if is_optional { FieldInfo::optional(captured_type) } else { @@ -369,33 +382,46 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { } /// Determines captured type for non-scope-creating expressions. + /// + /// For non-scope captures, fields bubble up alongside the capture field. + /// The annotation applies to the capture's type (usually Node or a recursive ref). fn determine_non_scope_captured_type( &mut self, inner: &Expr, - annotation: Option, + annotation: Option, ) -> TypeId { - if let Some(ref_type) = self.get_recursive_ref_type(inner) { - annotation.unwrap_or(ref_type) - } else { - annotation.unwrap_or(TYPE_NODE) - } + let base_type = self.get_recursive_ref_type(inner).unwrap_or(TYPE_NODE); + self.annotation_to_alias(annotation, base_type) } - /// Resolves explicit type annotation like `@foo: string`. - fn resolve_annotation(&mut self, cap: &CapturedExpr) -> Option { + /// Resolves explicit type annotation like `@foo :: string` or `@foo :: TypeName`. + /// + /// Returns the annotation kind without creating types - the caller decides + /// how to use the annotation based on the capture's flow. + fn resolve_annotation(&mut self, cap: &CapturedExpr) -> Option { cap.type_annotation().and_then(|t| { t.name().map(|n| { let text = n.text(); if text == "string" { - TYPE_STRING + AnnotationKind::String } else { - let sym = self.interner.intern(text); - self.ctx.intern_type(TypeShape::Custom(sym)) + AnnotationKind::TypeName(self.interner.intern(text)) } }) }) } + /// Converts annotation to a type, creating a Node alias for custom type names. + /// + /// Used for non-struct contexts where TypeName should create an alias to Node. + fn annotation_to_alias(&mut self, annotation: Option, base: TypeId) -> TypeId { + match annotation { + Some(AnnotationKind::String) => TYPE_STRING, + Some(AnnotationKind::TypeName(name)) => self.ctx.intern_type(TypeShape::Custom(name)), + None => base, + } + } + /// Logic for how quantifier on the inner expression affects the capture field. /// Returns (Info, is_optional). fn resolve_capture_inner(&mut self, inner: &Expr) -> (TermInfo, bool) { @@ -415,34 +441,59 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { } /// Transforms the inner flow into a specific TypeId for the field. + /// + /// Handles type annotation semantics based on the flow: + /// - Void/Scalar + TypeName: creates a Node alias (current Custom behavior) + /// - Bubble + TypeName: names the struct type instead of replacing it fn determine_captured_type( &mut self, inner: &Expr, inner_info: &TermInfo, - annotation: Option, + annotation: Option, ) -> TypeId { match &inner_info.flow { TypeFlow::Void => { - if let Some(ref_type) = self.get_recursive_ref_type(inner) { - annotation.unwrap_or(ref_type) - } else { - annotation.unwrap_or(TYPE_NODE) - } + let base_type = self.get_recursive_ref_type(inner).unwrap_or(TYPE_NODE); + self.annotation_to_alias(annotation, base_type) } TypeFlow::Scalar(type_id) => { // For array types with annotation, replace the element type // e.g., `(identifier)* @names :: string` → string[] not string - if let Some(ann) = annotation + if let Some(AnnotationKind::String) = annotation && let Some(TypeShape::Array { non_empty, .. }) = self.ctx.get_type(*type_id) { return self.ctx.intern_type(TypeShape::Array { - element: ann, + element: TYPE_STRING, non_empty: *non_empty, }); } - annotation.unwrap_or(*type_id) + match annotation { + Some(AnnotationKind::String) => TYPE_STRING, + Some(AnnotationKind::TypeName(name)) => { + // For enum types, name the enum instead of creating an alias + if matches!(self.ctx.get_type(*type_id), Some(TypeShape::Enum(_))) { + self.ctx.set_type_name(*type_id, name); + *type_id + } else { + self.ctx.intern_type(TypeShape::Custom(name)) + } + } + None => *type_id, + } + } + TypeFlow::Bubble(type_id) => { + // Bubble flow means inner has struct fields (scope-creating capture). + // TypeName annotation should NAME the struct, not replace it with an alias. + match annotation { + Some(AnnotationKind::String) => TYPE_STRING, + Some(AnnotationKind::TypeName(name)) => { + // Register the name for this struct type + self.ctx.set_type_name(*type_id, name); + *type_id + } + None => *type_id, + } } - TypeFlow::Bubble(type_id) => annotation.unwrap_or(*type_id), } } diff --git a/crates/plotnik-lib/src/emit/codegen_tests.rs b/crates/plotnik-lib/src/emit/codegen_tests.rs index 78ae4b00..2d0c5350 100644 --- a/crates/plotnik-lib/src/emit/codegen_tests.rs +++ b/crates/plotnik-lib/src/emit/codegen_tests.rs @@ -131,6 +131,22 @@ fn captures_optional_wrapper_struct() { "#}); } +#[test] +fn captures_struct_with_type_annotation() { + // Type annotation on struct capture should name the struct, not create an alias + snap!(indoc! {r#" + Test = {(identifier) @fn} @outer :: FunctionInfo + "#}); +} + +#[test] +fn captures_enum_with_type_annotation() { + // Type annotation on tagged alternation should name the enum + snap!(indoc! {r#" + Test = [A: (identifier) @id B: (number) @num] @expr :: Expression + "#}); +} + // ============================================================================ // 3. FIELDS // ============================================================================ diff --git a/crates/plotnik-lib/src/emit/mod.rs b/crates/plotnik-lib/src/emit/mod.rs index f66a55f3..f0930881 100644 --- a/crates/plotnik-lib/src/emit/mod.rs +++ b/crates/plotnik-lib/src/emit/mod.rs @@ -317,6 +317,18 @@ impl TypeTableBuilder { }); } + // Collect TypeName entries for explicit type annotations on struct captures + // e.g., `{(fn) @fn} @outer :: FunctionInfo` names the struct "FunctionInfo" + for (type_id, name_sym) in type_ctx.iter_type_names() { + if let Some(&bc_type_id) = self.mapping.get(&type_id) { + let name = strings.get_or_intern(name_sym, interner)?; + self.type_names.push(TypeName { + name, + type_id: bc_type_id, + }); + } + } + Ok(()) } diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_enum_with_type_annotation.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_enum_with_type_annotation.snap new file mode 100644 index 00000000..9133bcfa --- /dev/null +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_enum_with_type_annotation.snap @@ -0,0 +1,58 @@ +--- +source: crates/plotnik-lib/src/emit/codegen_tests.rs +--- +Test = [A: (identifier) @id B: (number) @num] @expr :: Expression +--- +[flags] +linked = false + +[strings] +S0 "Beauty will save the world" +S1 "id" +S2 "num" +S3 "A" +S4 "B" +S5 "expr" +S6 "Test" +S7 "Expression" +S8 "identifier" +S9 "number" + +[type_defs] +T0 = +T1 = Struct M0:1 ; { id } +T2 = Struct M1:1 ; { num } +T3 = Enum M2:2 ; A | B +T4 = Struct M4:1 ; { expr } + +[type_members] +M0: S1 → T0 ; id: +M1: S2 → T0 ; num: +M2: S3 → T1 ; A: T1 +M3: S4 → T2 ; B: T2 +M4: S5 → T3 ; expr: Expression + +[type_names] +N0: S6 → T4 ; Test +N1: S7 → T3 ; Expression + +[entrypoints] +Test = 06 :: T4 + +[transitions] +_ObjWrap: + 00 ε [Obj] 02 + 02 Trampoline 03 + 03 ε [EndObj] 05 + 05 ▶ + +Test: + 06 ε 07 + 07 ε 14, 20 + 09 ▶ + 10 ε [EndEnum Set(M4)] 09 + 12 ! (identifier) [Node Set(M0)] 10 + 14 ε [Enum(M2)] 12 + 16 ε [EndEnum Set(M4)] 09 + 18 ! (number) [Node Set(M1)] 16 + 20 ε [Enum(M3)] 18 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_struct_with_type_annotation.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_struct_with_type_annotation.snap new file mode 100644 index 00000000..d9aef9d0 --- /dev/null +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_struct_with_type_annotation.snap @@ -0,0 +1,45 @@ +--- +source: crates/plotnik-lib/src/emit/codegen_tests.rs +--- +Test = {(identifier) @fn} @outer :: FunctionInfo +--- +[flags] +linked = false + +[strings] +S0 "Beauty will save the world" +S1 "fn" +S2 "outer" +S3 "Test" +S4 "FunctionInfo" +S5 "identifier" + +[type_defs] +T0 = +T1 = Struct M0:1 ; { fn } +T2 = Struct M1:1 ; { outer } + +[type_members] +M0: S1 → T0 ; fn: +M1: S2 → T1 ; outer: FunctionInfo + +[type_names] +N0: S3 → T2 ; Test +N1: S4 → T1 ; FunctionInfo + +[entrypoints] +Test = 06 :: T2 + +[transitions] +_ObjWrap: + 00 ε [Obj] 02 + 02 Trampoline 03 + 03 ε [EndObj] 05 + 05 ▶ + +Test: + 06 ε 07 + 07 ε [Obj] 09 + 09 ! (identifier) [Node Set(M0)] 11 + 11 ε [EndObj Set(M1)] 13 + 13 ▶