Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions crates/plotnik-lib/src/analyze/type_check/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ pub struct TypeContext {
recursive_defs: HashSet<DefId>,

term_info: HashMap<Expr, TermInfo>,

/// 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<TypeId, Symbol>,
}

impl Default for TypeContext {
Expand All @@ -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
Expand Down Expand Up @@ -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<Symbol> {
self.type_names.get(&type_id).copied()
}

/// Iterate over all explicit type names as (TypeId, Symbol).
pub fn iter_type_names(&self) -> impl Iterator<Item = (TypeId, Symbol)> + '_ {
self.type_names.iter().map(|(&id, &sym)| (id, sym))
}
}

#[cfg(test)]
Expand Down
103 changes: 77 additions & 26 deletions crates/plotnik-lib/src/analyze/type_check/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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<TypeId>,
annotation: Option<AnnotationKind>,
) -> 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<TypeId> {
/// 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<AnnotationKind> {
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<AnnotationKind>, 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) {
Expand All @@ -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<TypeId>,
annotation: Option<AnnotationKind>,
) -> 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),
}
}

Expand Down
16 changes: 16 additions & 0 deletions crates/plotnik-lib/src/emit/codegen_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
12 changes: 12 additions & 0 deletions crates/plotnik-lib/src/emit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = <Node>
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: <Node>
M1: S2 → T0 ; num: <Node>
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
Original file line number Diff line number Diff line change
@@ -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 = <Node>
T1 = Struct M0:1 ; { fn }
T2 = Struct M1:1 ; { outer }

[type_members]
M0: S1 → T0 ; fn: <Node>
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 ▶