From 4c89c807489e5198d85c10eed3d971a7950bfb79 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sun, 8 Feb 2026 15:45:30 +0000 Subject: [PATCH] feat(resolver): implement cross-file template resolution Enable characters to inherit from templates defined in different files across the project structure. - Add file_index field to NameEntry to track declaration source files - Update NameTable::from_files() to set file indices when merging tables - Change conversion pipeline to pass &[ast::File] instead of flat arrays - Update merge functions to use two-level indexing: all_files[entry.file_index].declarations[entry.decl_index] - Update all affected tests to use new signatures --- src/lib.rs | 10 ++-- src/resolve/convert.rs | 72 ++++++++++++++++-------- src/resolve/convert_integration_tests.rs | 10 ++-- src/resolve/convert_prop_tests.rs | 23 ++++++-- src/resolve/integration_tests.rs | 2 +- src/resolve/links_prop_tests.rs | 2 +- src/resolve/merge.rs | 48 +++++++++------- src/resolve/names.rs | 27 ++++++++- src/resolve/prop_tests.rs | 4 ++ 9 files changed, 136 insertions(+), 62 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 74e0145..046bf84 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -173,12 +173,14 @@ impl Project { // Validate and convert all files let mut resolved_files = Vec::new(); - for file in parsed_files { + for file in &parsed_files { // First validate - validate::validate_file(&file, &action_registry)?; + validate::validate_file(file, &action_registry)?; - // Then convert AST to resolved types - let declarations = resolve::convert::convert_file(&file)?; + // Then convert AST to resolved types using the global name table and all parsed + // files + let declarations = + resolve::convert::convert_file_with_all_files(file, &name_table, &parsed_files)?; resolved_files.push(ResolvedFile { declarations }); } diff --git a/src/resolve/convert.rs b/src/resolve/convert.rs index be1241f..207d7f2 100644 --- a/src/resolve/convert.rs +++ b/src/resolve/convert.rs @@ -29,31 +29,45 @@ use crate::{ /// Convert a parsed file into resolved declarations /// /// This is the old version that doesn't handle template composition. -/// Use `convert_file_with_templates` for full template support. +/// Use `convert_file_with_name_table` for full template support. pub fn convert_file(file: &ast::File) -> Result> { - // Use the template-aware version - convert_file_with_templates(file) + // Build name table for this file only (for backward compatibility) + let name_table = NameTable::from_file(file)?; + convert_file_with_name_table(file, &name_table) } -/// Convert a parsed file into resolved declarations with template composition -/// support -pub fn convert_file_with_templates(file: &ast::File) -> Result> { - // Build name table for template lookups - let name_table = NameTable::from_file(file)?; +/// Convert a parsed file into resolved declarations with a provided name table +/// +/// This allows using a global name table that contains declarations from +/// multiple files. +pub fn convert_file_with_name_table( + file: &ast::File, + name_table: &NameTable, +) -> Result> { + // For backward compatibility, use only this file (wrapped in a Vec for the API) + convert_file_with_all_files(file, name_table, std::slice::from_ref(file)) +} + +/// Convert a parsed file with access to all files +/// +/// This is needed for cross-file template resolution. +pub fn convert_file_with_all_files( + file: &ast::File, + name_table: &NameTable, + all_files: &[ast::File], +) -> Result> { let mut resolved = Vec::new(); for decl in &file.declarations { match decl { | ast::Declaration::Character(c) => { // Use template-aware conversion - let resolved_char = - convert_character_with_templates(c, &file.declarations, &name_table)?; + let resolved_char = convert_character_with_templates(c, all_files, name_table)?; resolved.push(ResolvedDeclaration::Character(resolved_char)); }, | ast::Declaration::Template(t) => { // Use include-aware conversion - let resolved_template = - convert_template_with_includes(t, &file.declarations, &name_table)?; + let resolved_template = convert_template_with_includes(t, all_files, name_table)?; resolved.push(ResolvedDeclaration::Template(resolved_template)); }, | ast::Declaration::LifeArc(la) => { @@ -96,6 +110,7 @@ pub fn convert_character(character: &ast::Character) -> Result Result Result { // Merge character templates if any let merged_fields = if character.template.is_some() { - merge::merge_character_templates(character, declarations, name_table)? + merge::merge_character_templates(character, all_files, name_table)? } else { character.fields.clone() }; @@ -126,6 +141,7 @@ pub fn convert_character_with_templates( Ok(ResolvedCharacter { name: character.name.clone(), + species: None, fields, prose_blocks, span: character.span.clone(), @@ -151,13 +167,13 @@ pub fn convert_template(template: &ast::Template) -> Result { /// 3. Adding template's own fields on top pub fn convert_template_with_includes( template: &ast::Template, - declarations: &[ast::Declaration], + all_files: &[ast::File], name_table: &NameTable, ) -> Result { // Resolve template includes if any let merged_fields = if !template.includes.is_empty() { let mut visited = std::collections::HashSet::new(); - merge::resolve_template_includes(template, declarations, name_table, &mut visited)? + merge::resolve_template_includes(template, all_files, name_table, &mut visited)? } else { template.fields.clone() }; @@ -328,6 +344,7 @@ mod tests { fn test_convert_simple_character() { let character = Character { name: "Martha".to_string(), + species: None, fields: vec![ Field { name: "age".to_string(), @@ -363,6 +380,7 @@ mod tests { let character = Character { name: "Martha".to_string(), + species: None, fields: vec![ Field { name: "age".to_string(), @@ -392,6 +410,7 @@ mod tests { fn test_convert_character_duplicate_field_fails() { let character = Character { name: "Martha".to_string(), + species: None, fields: vec![ Field { name: "age".to_string(), @@ -434,6 +453,7 @@ mod tests { declarations: vec![ ast::Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![Field { name: "age".to_string(), value: Value::Int(34), @@ -474,6 +494,7 @@ mod tests { }), ast::Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -555,6 +576,7 @@ mod tests { let character = Character { name: "Martha".to_string(), + species: None, fields: vec![Field { name: "age".to_string(), value: Value::Int(34), @@ -574,8 +596,8 @@ mod tests { }; let name_table = NameTable::from_file(&file).unwrap(); - let resolved = - convert_character_with_templates(&character, &declarations, &name_table).unwrap(); + let files = vec![file.clone()]; + let resolved = convert_character_with_templates(&character, &files, &name_table).unwrap(); assert_eq!(resolved.name, "Martha"); assert_eq!(resolved.fields.len(), 2); @@ -616,6 +638,7 @@ mod tests { let character = Character { name: "Martha".to_string(), + species: None, fields: vec![ Field { name: "height".to_string(), @@ -643,8 +666,8 @@ mod tests { }; let name_table = NameTable::from_file(&file).unwrap(); - let resolved = - convert_character_with_templates(&character, &declarations, &name_table).unwrap(); + let files = vec![file.clone()]; + let resolved = convert_character_with_templates(&character, &files, &name_table).unwrap(); assert_eq!(resolved.name, "Martha"); assert_eq!(resolved.fields.len(), 2); @@ -690,8 +713,8 @@ mod tests { }; let name_table = NameTable::from_file(&file).unwrap(); - let resolved = - convert_template_with_includes(&derived, &declarations, &name_table).unwrap(); + let files = vec![file.clone()]; + let resolved = convert_template_with_includes(&derived, &files, &name_table).unwrap(); assert_eq!(resolved.name, "Person"); assert_eq!(resolved.fields.len(), 2); @@ -706,6 +729,7 @@ mod tests { fn test_convert_character_reserved_keyword_fails() { let character = Character { name: "Martha".to_string(), + species: None, fields: vec![Field { name: "species".to_string(), // Reserved keyword! value: Value::String("human".to_string()), @@ -744,6 +768,7 @@ mod tests { let character = Character { name: "Martha".to_string(), + species: None, fields: vec![], // No fields - inherits range from template template: Some(vec!["Person".to_string()]), span: Span::new(0, 100), @@ -759,7 +784,8 @@ mod tests { }; let name_table = NameTable::from_file(&file).unwrap(); - let result = convert_character_with_templates(&character, &declarations, &name_table); + let files = vec![file.clone()]; + let result = convert_character_with_templates(&character, &files, &name_table); assert!(result.is_err()); if let Err(ResolveError::ValidationError { message, .. }) = result { assert!(message.contains("strict template")); diff --git a/src/resolve/convert_integration_tests.rs b/src/resolve/convert_integration_tests.rs index 9a03ac5..a839eac 100644 --- a/src/resolve/convert_integration_tests.rs +++ b/src/resolve/convert_integration_tests.rs @@ -182,9 +182,9 @@ fn test_behavior_tree_end_to_end() { fn test_schedule_end_to_end() { let source = r#" schedule DailyRoutine { - 08:00 -> 12:00: work - 12:00 -> 13:00: lunch - 13:00 -> 17:00: work + 08:00 -> 12:00: work { } + 12:00 -> 13:00: lunch { } + 13:00 -> 17:00: work { } } "#; @@ -335,8 +335,8 @@ Martha grew up in a small town. } schedule DailyRoutine { - 08:00 -> 12:00: work - 12:00 -> 13:00: lunch + 08:00 -> 12:00: work { } + 12:00 -> 13:00: lunch { } } "#; diff --git a/src/resolve/convert_prop_tests.rs b/src/resolve/convert_prop_tests.rs index 22ef00c..f2571bb 100644 --- a/src/resolve/convert_prop_tests.rs +++ b/src/resolve/convert_prop_tests.rs @@ -89,9 +89,9 @@ fn valid_unique_fields() -> impl Strategy> { fn valid_character() -> impl Strategy { (valid_ident(), valid_unique_fields()).prop_map(|(name, fields)| Character { name, + species: None, fields, template: None, - span: Span::new(0, 100), }) } @@ -192,7 +192,8 @@ proptest! { val2 in valid_value() ) { let character = Character { - name, + name: name.clone(), + species: None, fields: vec![ Field { name: field_name.clone(), @@ -239,6 +240,7 @@ proptest! { fn test_empty_character_converts(name in valid_ident()) { let character = Character { name: name.clone(), + species: None, fields: vec![], template: None, @@ -268,7 +270,7 @@ proptest! { #[test] fn test_file_with_use_declarations_skips_them( - characters in prop::collection::vec(valid_character(), 1..5), + char_count in 1usize..5, use_count in 0usize..5 ) { let mut declarations = vec![]; @@ -282,9 +284,16 @@ proptest! { })); } - // Add characters - let char_count = characters.len(); - declarations.extend(characters.into_iter().map(Declaration::Character)); + // Add characters with unique names to avoid duplicate definition errors + for i in 0..char_count { + declarations.push(Declaration::Character(Character { + name: format!("Char{}", i), + species: None, + fields: vec![], + template: None, + span: Span::new(0, 100), + })); + } let file = File { declarations }; let resolved = convert_file(&file).unwrap(); @@ -308,6 +317,7 @@ mod edge_cases { ) { let character = Character { name: "Test".to_string(), + species: None, fields: vec![ Field { name: "int_field".to_string(), @@ -351,6 +361,7 @@ mod edge_cases { ) { let character = Character { name: name.clone(), + species: None, fields: vec![Field { name: field_name.clone(), value: Value::String(string_val.clone()), diff --git a/src/resolve/integration_tests.rs b/src/resolve/integration_tests.rs index 9decf0c..da77f1e 100644 --- a/src/resolve/integration_tests.rs +++ b/src/resolve/integration_tests.rs @@ -123,7 +123,7 @@ fn test_all_declaration_kinds() { state s {} } schedule S { - 10:00 -> 11:00: activity + 10:00 -> 11:00: activity { } } behavior B { action diff --git a/src/resolve/links_prop_tests.rs b/src/resolve/links_prop_tests.rs index 7fc8868..83ad1e6 100644 --- a/src/resolve/links_prop_tests.rs +++ b/src/resolve/links_prop_tests.rs @@ -458,9 +458,9 @@ proptest! { prop_oneof![ valid_ident().prop_map(|name| Declaration::Character(Character { name, + species: None, fields: vec![], template: None, - span: Span::new(0, 10), })), valid_ident().prop_map(|name| Declaration::Template(Template { diff --git a/src/resolve/merge.rs b/src/resolve/merge.rs index 5e7682c..630e799 100644 --- a/src/resolve/merge.rs +++ b/src/resolve/merge.rs @@ -42,7 +42,7 @@ use crate::{ /// Returns the fully merged fields for this template pub fn resolve_template_includes( template: &Template, - declarations: &[Declaration], + all_files: &[crate::syntax::ast::File], name_table: &NameTable, visited: &mut HashSet, ) -> Result> { @@ -69,8 +69,8 @@ pub fn resolve_template_includes( suggestion: name_table.find_suggestion(include_name), })?; - // Get the template declaration - let included_template = match &declarations[entry.decl_index] { + // Get the template declaration using two-level indexing + let included_template = match &all_files[entry.file_index].declarations[entry.decl_index] { | Declaration::Template(t) => t, | _ => { return Err(ResolveError::ValidationError { @@ -88,7 +88,7 @@ pub fn resolve_template_includes( // Recursively resolve the included template let included_fields = - resolve_template_includes(included_template, declarations, name_table, visited)?; + resolve_template_includes(included_template, all_files, name_table, visited)?; // Merge included fields (replacing any existing fields with same name) merged_fields = merge_field_lists(merged_fields, included_fields); @@ -114,7 +114,7 @@ pub fn resolve_template_includes( /// Returns the fully merged fields for this character pub fn merge_character_templates( character: &Character, - declarations: &[Declaration], + all_files: &[crate::syntax::ast::File], name_table: &NameTable, ) -> Result> { let mut merged_fields = Vec::new(); @@ -131,8 +131,8 @@ pub fn merge_character_templates( suggestion: name_table.find_suggestion(template_name), })?; - // Get the template declaration - let template = match &declarations[entry.decl_index] { + // Get the template declaration using two-level indexing + let template = match &all_files[entry.file_index].declarations[entry.decl_index] { | Declaration::Template(t) => t, | _ => { return Err(ResolveError::ValidationError { @@ -156,7 +156,7 @@ pub fn merge_character_templates( // Resolve template (which handles includes recursively) let mut visited = HashSet::new(); let template_fields = - resolve_template_includes(template, declarations, name_table, &mut visited)?; + resolve_template_includes(template, all_files, name_table, &mut visited)?; // Merge template fields into accumulated fields merged_fields = merge_field_lists(merged_fields, template_fields); @@ -506,6 +506,7 @@ mod tests { fn make_character(name: &str, fields: Vec, templates: Vec<&str>) -> Character { Character { name: name.to_string(), + species: None, fields, template: if templates.is_empty() { None @@ -523,8 +524,9 @@ mod tests { let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); let mut visited = HashSet::new(); + let files = vec![make_file(declarations.clone())]; let result = - resolve_template_includes(&template, &declarations, &name_table, &mut visited).unwrap(); + resolve_template_includes(&template, &files, &name_table, &mut visited).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].name, "age"); @@ -543,8 +545,9 @@ mod tests { let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); let mut visited = HashSet::new(); + let files = vec![make_file(declarations.clone())]; let result = - resolve_template_includes(&derived, &declarations, &name_table, &mut visited).unwrap(); + resolve_template_includes(&derived, &files, &name_table, &mut visited).unwrap(); assert_eq!(result.len(), 2); assert!(result.iter().any(|f| f.name == "age")); @@ -565,8 +568,8 @@ mod tests { let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); let mut visited = HashSet::new(); - let result = - resolve_template_includes(&top, &declarations, &name_table, &mut visited).unwrap(); + let files = vec![make_file(declarations.clone())]; + let result = resolve_template_includes(&top, &files, &name_table, &mut visited).unwrap(); assert_eq!(result.len(), 3); assert!(result.iter().any(|f| f.name == "alive")); @@ -591,8 +594,9 @@ mod tests { let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); let mut visited = HashSet::new(); + let files = vec![make_file(declarations.clone())]; let result = - resolve_template_includes(&derived, &declarations, &name_table, &mut visited).unwrap(); + resolve_template_includes(&derived, &files, &name_table, &mut visited).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].name, "age"); @@ -611,7 +615,8 @@ mod tests { ]; let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); - let result = merge_character_templates(&character, &declarations, &name_table).unwrap(); + let files = vec![make_file(declarations.clone())]; + let result = merge_character_templates(&character, &files, &name_table).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].name, "age"); @@ -636,7 +641,8 @@ mod tests { ]; let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); - let result = merge_character_templates(&character, &declarations, &name_table).unwrap(); + let files = vec![make_file(declarations.clone())]; + let result = merge_character_templates(&character, &files, &name_table).unwrap(); assert_eq!(result.len(), 2); assert!(result @@ -664,7 +670,8 @@ mod tests { ]; let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); - let result = merge_character_templates(&character, &declarations, &name_table).unwrap(); + let files = vec![make_file(declarations.clone())]; + let result = merge_character_templates(&character, &files, &name_table).unwrap(); assert_eq!(result.len(), 2); assert!(result @@ -686,7 +693,8 @@ mod tests { ]; let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); - let result = merge_character_templates(&character, &declarations, &name_table); + let files = vec![make_file(declarations.clone())]; + let result = merge_character_templates(&character, &files, &name_table); assert!(result.is_ok()); } @@ -710,7 +718,8 @@ mod tests { ]; let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); - let result = merge_character_templates(&character, &declarations, &name_table); + let files = vec![make_file(declarations.clone())]; + let result = merge_character_templates(&character, &files, &name_table); assert!(result.is_err()); if let Err(ResolveError::ValidationError { message, .. }) = result { assert!(message.contains("strict template")); @@ -727,7 +736,8 @@ mod tests { let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap(); let mut visited = HashSet::new(); - let result = resolve_template_includes(&a, &declarations, &name_table, &mut visited); + let files = vec![make_file(declarations.clone())]; + let result = resolve_template_includes(&a, &files, &name_table, &mut visited); assert!(result.is_err()); if let Err(ResolveError::CircularDependency { .. }) = result { // Expected diff --git a/src/resolve/names.rs b/src/resolve/names.rs index 97abfb0..449b1ab 100644 --- a/src/resolve/names.rs +++ b/src/resolve/names.rs @@ -45,7 +45,10 @@ pub struct NameEntry { pub kind: DeclKind, pub qualified_path: QualifiedPath, pub span: Span, - /// Index into the original declarations vector + /// Index of the file this declaration came from (when building from + /// multiple files) + pub file_index: usize, + /// Index into the file's declarations vector pub decl_index: usize, } @@ -157,6 +160,7 @@ impl NameTable { kind, qualified_path, span, + file_index: 0, // Single file, so file_index is always 0 decl_index: index, }, ); @@ -282,8 +286,12 @@ impl NameTable { pub fn from_files(files: &[File]) -> Result { let mut combined = NameTable::new(); - for file in files { - let table = NameTable::from_file(file)?; + for (file_index, file) in files.iter().enumerate() { + let mut table = NameTable::from_file(file)?; + // Update all entries to have the correct file_index + for entry in table.entries.values_mut() { + entry.file_index = file_index; + } combined.merge(table)?; } @@ -311,6 +319,7 @@ mod tests { declarations: vec![ Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -339,6 +348,7 @@ mod tests { declarations: vec![ Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -346,6 +356,7 @@ mod tests { }), Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -363,6 +374,7 @@ mod tests { let file = File { declarations: vec![Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -393,6 +405,7 @@ mod tests { }), Declaration::Character(Character { name: "characters".to_string(), + species: None, fields: vec![], template: None, @@ -467,6 +480,7 @@ mod tests { let file1 = File { declarations: vec![Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -477,6 +491,7 @@ mod tests { let file2 = File { declarations: vec![Declaration::Character(Character { name: "David".to_string(), + species: None, fields: vec![], template: None, @@ -499,6 +514,7 @@ mod tests { let file1 = File { declarations: vec![Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -509,6 +525,7 @@ mod tests { let file2 = File { declarations: vec![Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -529,6 +546,7 @@ mod tests { File { declarations: vec![Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -538,6 +556,7 @@ mod tests { File { declarations: vec![Declaration::Character(Character { name: "David".to_string(), + species: None, fields: vec![], template: None, @@ -558,6 +577,7 @@ mod tests { File { declarations: vec![Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, @@ -567,6 +587,7 @@ mod tests { File { declarations: vec![Declaration::Character(Character { name: "Martha".to_string(), + species: None, fields: vec![], template: None, diff --git a/src/resolve/prop_tests.rs b/src/resolve/prop_tests.rs index 252fb24..f996d0a 100644 --- a/src/resolve/prop_tests.rs +++ b/src/resolve/prop_tests.rs @@ -52,6 +52,7 @@ fn valid_character_decl() -> impl Strategy { valid_ident().prop_map(|name| { let decl = Declaration::Character(Character { name: name.clone(), + species: None, fields: vec![], template: None, @@ -157,6 +158,7 @@ proptest! { let declarations: Vec<_> = (0..count).map(|i| { Declaration::Character(Character { name: name.clone(), + species: None, fields: vec![], template: None, @@ -177,6 +179,7 @@ proptest! { declarations: vec![ Declaration::Character(Character { name: name.clone(), + species: None, fields: vec![], template: None, @@ -254,6 +257,7 @@ proptest! { declarations: vec![ Declaration::Character(Character { name: name.clone(), + species: None, fields: vec![], template: None,