From bb6d515715dc3e1c8beabb2a6de7ac5d75dcdd5e Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 01:19:55 +0000 Subject: [PATCH 01/18] fix: surface hyperstack macro validation failures during expansion --- Cargo.lock | 74 +- hyperstack-idl/src/error.rs | 30 +- hyperstack-idl/src/search.rs | 151 ++++ hyperstack-macros/Cargo.toml | 3 + hyperstack-macros/README.md | 31 + hyperstack-macros/src/ast/types.rs | 54 +- hyperstack-macros/src/ast/writer.rs | 22 +- hyperstack-macros/src/codegen/bytecode.rs | 10 +- hyperstack-macros/src/codegen/handlers.rs | 6 +- hyperstack-macros/src/codegen/multi_entity.rs | 36 +- hyperstack-macros/src/diagnostic.rs | 155 +++++ hyperstack-macros/src/lib.rs | 24 +- hyperstack-macros/src/parse/attributes.rs | 545 +++++++++++---- hyperstack-macros/src/parse/conditions.rs | 166 ++++- hyperstack-macros/src/parse/pda_validation.rs | 44 +- .../src/stream_spec/ast_writer.rs | 261 ++++--- hyperstack-macros/src/stream_spec/entity.rs | 612 ++++++++-------- hyperstack-macros/src/stream_spec/handlers.rs | 60 +- hyperstack-macros/src/stream_spec/idl_spec.rs | 213 +++--- hyperstack-macros/src/stream_spec/mod.rs | 2 +- hyperstack-macros/src/stream_spec/module.rs | 117 ++-- .../src/stream_spec/proto_struct.rs | 223 +++--- hyperstack-macros/src/stream_spec/sections.rs | 651 ++++++++---------- hyperstack-macros/src/validation/idl_refs.rs | 211 ++++++ hyperstack-macros/src/validation/mod.rs | 605 ++++++++++++++++ hyperstack-macros/tests/phase0_dynamic.rs | 140 ++++ hyperstack-macros/tests/phase1_runtime.rs | 90 +++ hyperstack-macros/tests/phase2_dynamic.rs | 205 ++++++ hyperstack-macros/tests/phase3_dynamic.rs | 150 ++++ hyperstack-macros/tests/phase4_dynamic.rs | 196 ++++++ hyperstack-macros/tests/phase5_dynamic.rs | 137 ++++ hyperstack-macros/tests/ui.rs | 9 + .../ui/map_errors/malformed_map_attribute.rs | 9 + .../map_errors/malformed_map_attribute.stderr | 5 + .../nested_malformed_map_attribute.rs | 16 + .../nested_malformed_map_attribute.stderr | 5 + .../tests/ui/pass/empty_module.rs | 6 + hyperstack-macros/tests/ui/pass/proto_flag.rs | 6 + hyperstack-macros/tests/ui/pass/proto_list.rs | 6 + .../malformed_resolve_attribute.rs | 9 + .../malformed_resolve_attribute.stderr | 5 + .../resolve_errors/malformed_url_template.rs | 12 + .../malformed_url_template.stderr | 5 + .../ui/resolve_errors/unknown_resolver.rs | 13 + .../ui/resolve_errors/unknown_resolver.stderr | 5 + .../unsupported_resolver_type.rs | 12 + .../unsupported_resolver_type.stderr | 5 + .../ui/validation_errors/computed_cycle.rs | 14 + .../validation_errors/computed_cycle.stderr | 5 + .../validation_errors/invalid_idl_literal.rs | 6 + .../invalid_idl_literal.stderr | 5 + .../invalid_proto_literal.rs | 6 + .../invalid_proto_literal.stderr | 5 + .../invalid_skip_decoders_assignment.rs | 6 + .../invalid_skip_decoders_assignment.stderr | 5 + .../validation_errors/missing_proto_file.rs | 6 + .../missing_proto_file.stderr | 5 + .../validation_errors/missing_proto_value.rs | 6 + .../missing_proto_value.stderr | 7 + .../struct_mode_args_not_supported.rs | 8 + .../struct_mode_args_not_supported.stderr | 5 + .../unknown_top_level_arg.rs | 6 + .../unknown_top_level_arg.stderr | 5 + .../ui/view_errors/invalid_view_sort_field.rs | 12 + .../invalid_view_sort_field.stderr | 5 + 65 files changed, 4168 insertions(+), 1301 deletions(-) create mode 100644 hyperstack-macros/src/diagnostic.rs create mode 100644 hyperstack-macros/src/validation/idl_refs.rs create mode 100644 hyperstack-macros/src/validation/mod.rs create mode 100644 hyperstack-macros/tests/phase0_dynamic.rs create mode 100644 hyperstack-macros/tests/phase1_runtime.rs create mode 100644 hyperstack-macros/tests/phase2_dynamic.rs create mode 100644 hyperstack-macros/tests/phase3_dynamic.rs create mode 100644 hyperstack-macros/tests/phase4_dynamic.rs create mode 100644 hyperstack-macros/tests/phase5_dynamic.rs create mode 100644 hyperstack-macros/tests/ui.rs create mode 100644 hyperstack-macros/tests/ui/map_errors/malformed_map_attribute.rs create mode 100644 hyperstack-macros/tests/ui/map_errors/malformed_map_attribute.stderr create mode 100644 hyperstack-macros/tests/ui/map_errors/nested_malformed_map_attribute.rs create mode 100644 hyperstack-macros/tests/ui/map_errors/nested_malformed_map_attribute.stderr create mode 100644 hyperstack-macros/tests/ui/pass/empty_module.rs create mode 100644 hyperstack-macros/tests/ui/pass/proto_flag.rs create mode 100644 hyperstack-macros/tests/ui/pass/proto_list.rs create mode 100644 hyperstack-macros/tests/ui/resolve_errors/malformed_resolve_attribute.rs create mode 100644 hyperstack-macros/tests/ui/resolve_errors/malformed_resolve_attribute.stderr create mode 100644 hyperstack-macros/tests/ui/resolve_errors/malformed_url_template.rs create mode 100644 hyperstack-macros/tests/ui/resolve_errors/malformed_url_template.stderr create mode 100644 hyperstack-macros/tests/ui/resolve_errors/unknown_resolver.rs create mode 100644 hyperstack-macros/tests/ui/resolve_errors/unknown_resolver.stderr create mode 100644 hyperstack-macros/tests/ui/resolve_errors/unsupported_resolver_type.rs create mode 100644 hyperstack-macros/tests/ui/resolve_errors/unsupported_resolver_type.stderr create mode 100644 hyperstack-macros/tests/ui/validation_errors/computed_cycle.rs create mode 100644 hyperstack-macros/tests/ui/validation_errors/computed_cycle.stderr create mode 100644 hyperstack-macros/tests/ui/validation_errors/invalid_idl_literal.rs create mode 100644 hyperstack-macros/tests/ui/validation_errors/invalid_idl_literal.stderr create mode 100644 hyperstack-macros/tests/ui/validation_errors/invalid_proto_literal.rs create mode 100644 hyperstack-macros/tests/ui/validation_errors/invalid_proto_literal.stderr create mode 100644 hyperstack-macros/tests/ui/validation_errors/invalid_skip_decoders_assignment.rs create mode 100644 hyperstack-macros/tests/ui/validation_errors/invalid_skip_decoders_assignment.stderr create mode 100644 hyperstack-macros/tests/ui/validation_errors/missing_proto_file.rs create mode 100644 hyperstack-macros/tests/ui/validation_errors/missing_proto_file.stderr create mode 100644 hyperstack-macros/tests/ui/validation_errors/missing_proto_value.rs create mode 100644 hyperstack-macros/tests/ui/validation_errors/missing_proto_value.stderr create mode 100644 hyperstack-macros/tests/ui/validation_errors/struct_mode_args_not_supported.rs create mode 100644 hyperstack-macros/tests/ui/validation_errors/struct_mode_args_not_supported.stderr create mode 100644 hyperstack-macros/tests/ui/validation_errors/unknown_top_level_arg.rs create mode 100644 hyperstack-macros/tests/ui/validation_errors/unknown_top_level_arg.stderr create mode 100644 hyperstack-macros/tests/ui/view_errors/invalid_view_sort_field.rs create mode 100644 hyperstack-macros/tests/ui/view_errors/invalid_view_sort_field.stderr diff --git a/Cargo.lock b/Cargo.lock index 8c9afc8f..d3993b57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1731,6 +1731,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "syn 2.0.113", + "trybuild", ] [[package]] @@ -3505,6 +3506,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5239,6 +5249,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.24.0" @@ -5252,6 +5268,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminal_size" version = "0.4.3" @@ -5486,11 +5511,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +dependencies = [ + "indexmap 2.12.1", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -5517,7 +5557,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.12.1", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow", @@ -5550,6 +5590,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" + [[package]] name = "tonic" version = "0.11.0" @@ -5905,6 +5951,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.0.6+spec-1.1.0", +] + [[package]] name = "tungstenite" version = "0.21.0" @@ -6208,6 +6269,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/hyperstack-idl/src/error.rs b/hyperstack-idl/src/error.rs index bd515fcc..87c20f2b 100644 --- a/hyperstack-idl/src/error.rs +++ b/hyperstack-idl/src/error.rs @@ -30,20 +30,44 @@ impl std::fmt::Display for IdlSearchError { input, section, suggestions, - .. + available, } => { write!(f, "Not found: '{}' in {}", input, section)?; if !suggestions.is_empty() { write!(f, ". Did you mean: {}?", suggestions[0].candidate)?; + } else if !available.is_empty() { + let preview = available + .iter() + .take(5) + .cloned() + .collect::>() + .join(", "); + write!(f, ". {}: {}", available_label(section), preview)?; } Ok(()) } IdlSearchError::ParseError { path, source } => { - write!(f, "Parse error in {}: {}", path, source) + write!(f, "Failed to parse '{}': {}", path, source) + } + IdlSearchError::InvalidPath { path } => { + write!( + f, + "Invalid path '{}'. Expected a Rust path like foo::bar::Baz", + path + ) } - IdlSearchError::InvalidPath { path } => write!(f, "Invalid path: {}", path), } } } impl std::error::Error for IdlSearchError {} + +fn available_label(section: &str) -> String { + if section.starts_with("instruction fields") { + "Available instruction fields".to_string() + } else if section.starts_with("account fields") { + "Available account fields".to_string() + } else { + format!("Available {}", section) + } +} diff --git a/hyperstack-idl/src/search.rs b/hyperstack-idl/src/search.rs index 7459078b..c2a8810f 100644 --- a/hyperstack-idl/src/search.rs +++ b/hyperstack-idl/src/search.rs @@ -1,6 +1,8 @@ //! Search utilities for IDL specs with fuzzy matching +use crate::error::IdlSearchError; use crate::types::IdlSpec; +use crate::types::{IdlAccount, IdlInstruction, IdlTypeDef}; use strsim::levenshtein; /// A fuzzy match suggestion with candidate name and edit distance. @@ -38,6 +40,109 @@ pub struct SearchResult { pub match_type: MatchType, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstructionFieldKind { + Account, + Arg, +} + +#[derive(Debug, Clone, Copy)] +pub struct InstructionFieldLookup<'a> { + pub instruction: &'a IdlInstruction, + pub kind: InstructionFieldKind, +} + +fn build_not_found_error(input: &str, section: String, available: Vec) -> IdlSearchError { + let candidate_refs: Vec<&str> = available.iter().map(String::as_str).collect(); + let suggestions = suggest_similar(input, &candidate_refs, 3); + IdlSearchError::NotFound { + input: input.to_string(), + section, + suggestions, + available, + } +} + +pub fn lookup_instruction<'a>( + idl: &'a IdlSpec, + instruction_name: &str, +) -> Result<&'a IdlInstruction, IdlSearchError> { + let available: Vec = idl.instructions.iter().map(|ix| ix.name.clone()).collect(); + idl.instructions + .iter() + .find(|ix| ix.name.eq_ignore_ascii_case(instruction_name)) + .ok_or_else(|| { + build_not_found_error(instruction_name, "instructions".to_string(), available) + }) +} + +pub fn lookup_account<'a>( + idl: &'a IdlSpec, + account_name: &str, +) -> Result<&'a IdlAccount, IdlSearchError> { + let available: Vec = idl + .accounts + .iter() + .map(|account| account.name.clone()) + .collect(); + idl.accounts + .iter() + .find(|account| account.name.eq_ignore_ascii_case(account_name)) + .ok_or_else(|| build_not_found_error(account_name, "accounts".to_string(), available)) +} + +pub fn lookup_type<'a>( + idl: &'a IdlSpec, + type_name: &str, +) -> Result<&'a IdlTypeDef, IdlSearchError> { + let available: Vec = idl.types.iter().map(|ty| ty.name.clone()).collect(); + idl.types + .iter() + .find(|ty| ty.name.eq_ignore_ascii_case(type_name)) + .ok_or_else(|| build_not_found_error(type_name, "types".to_string(), available)) +} + +pub fn lookup_instruction_field<'a>( + idl: &'a IdlSpec, + instruction_name: &str, + field_name: &str, +) -> Result, IdlSearchError> { + let instruction = lookup_instruction(idl, instruction_name)?; + if instruction + .accounts + .iter() + .any(|account| account.name.eq_ignore_ascii_case(field_name)) + { + return Ok(InstructionFieldLookup { + instruction, + kind: InstructionFieldKind::Account, + }); + } + + if instruction + .args + .iter() + .any(|arg| arg.name.eq_ignore_ascii_case(field_name)) + { + return Ok(InstructionFieldLookup { + instruction, + kind: InstructionFieldKind::Arg, + }); + } + + let mut available: Vec = instruction + .accounts + .iter() + .map(|acc| acc.name.clone()) + .collect(); + available.extend(instruction.args.iter().map(|arg| arg.name.clone())); + Err(build_not_found_error( + field_name, + format!("instruction fields for '{}'", instruction.name), + available, + )) +} + /// Suggest similar names from a list of candidates using fuzzy matching. /// /// Returns candidates sorted by edit distance (closest first). @@ -195,4 +300,50 @@ mod tests { let results = search_idl(&idl, "swap"); assert!(!results.is_empty(), "should find results for 'swap'"); } + + #[test] + fn test_lookup_instruction_with_suggestion() { + use crate::parse::parse_idl_file; + use std::path::PathBuf; + + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json"); + let idl = parse_idl_file(&path).expect("should parse"); + + let error = lookup_instruction(&idl, "initialise").expect_err("lookup should fail"); + match error { + IdlSearchError::NotFound { suggestions, .. } => { + assert_eq!(suggestions[0].candidate, "initialize"); + } + other => panic!("expected NotFound, got {other:?}"), + } + } + + #[test] + fn test_lookup_instruction_field_with_suggestion() { + use crate::parse::parse_idl_file; + use std::path::PathBuf; + + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json"); + let idl = parse_idl_file(&path).expect("should parse"); + + let error = lookup_instruction_field(&idl, "buy", "usr").expect_err("lookup should fail"); + match error { + IdlSearchError::NotFound { suggestions, .. } => { + assert_eq!(suggestions[0].candidate, "user"); + } + other => panic!("expected NotFound, got {other:?}"), + } + } + + #[test] + fn test_lookup_account_success() { + use crate::parse::parse_idl_file; + use std::path::PathBuf; + + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pump.json"); + let idl = parse_idl_file(&path).expect("should parse"); + + let account = lookup_account(&idl, "BondingCurve").expect("account should exist"); + assert_eq!(account.name, "BondingCurve"); + } } diff --git a/hyperstack-macros/Cargo.toml b/hyperstack-macros/Cargo.toml index 19194036..bc1ae5bb 100644 --- a/hyperstack-macros/Cargo.toml +++ b/hyperstack-macros/Cargo.toml @@ -24,3 +24,6 @@ sha2 = "0.10" hex = "0.4" bs58 = "0.5" hyperstack-idl = { path = "../hyperstack-idl", version = "0.1.5" } + +[dev-dependencies] +trybuild = "1.0" diff --git a/hyperstack-macros/README.md b/hyperstack-macros/README.md index 658a08f5..fcb46282 100644 --- a/hyperstack-macros/README.md +++ b/hyperstack-macros/README.md @@ -73,6 +73,37 @@ The macro generates: - `create_spec()` function returning `TypedStreamSpec` - Handler creation functions for each source +## Diagnostics + +The macro now validates most authoring mistakes before code generation. Common failures include: + +- unknown account, instruction, or field references in `#[map]`, `#[event]`, and `#[derive_from]` +- invalid resolver inputs, unsupported resolver-backed field types, and malformed URL templates +- invalid view `sort_by` fields and computed-field dependency cycles +- invalid `pdas!` programs, seed accounts, and seed argument types + +Most diagnostics include either a `Did you mean: ...?` suggestion or a short list of available values. + +## Troubleshooting + +- `unknown ... on entity ...`: check the field path against the generated state shape; nested fields must use `section.field` +- `unknown ... in instructions/accounts/...`: the IDL lookup failed; verify the SDK path or instruction/account spelling +- `invalid strategy ...`: use one of the listed strategy values exactly as shown in the error +- `unknown resolver ...` or `unknown resolver-backed type ...`: use a supported resolver name or change the target field type to a supported resolver-backed type +- `computed fields contain a dependency cycle ...`: break the cycle by making one field depend only on stored state, not another computed field in the loop + +## Testing + +Useful commands while working on macro diagnostics: + +```bash +cargo test -p hyperstack-macros +cargo test -p hyperstack-idl +cargo check --manifest-path stacks/ore/Cargo.toml +``` + +The macro crate includes both `trybuild` UI tests under `hyperstack-macros/tests/ui/` and higher-level dynamic compile-failure tests under `hyperstack-macros/tests/phase*_dynamic.rs`. + ## License Apache-2.0 diff --git a/hyperstack-macros/src/ast/types.rs b/hyperstack-macros/src/ast/types.rs index 0024c11e..5c6b9036 100644 --- a/hyperstack-macros/src/ast/types.rs +++ b/hyperstack-macros/src/ast/types.rs @@ -753,24 +753,26 @@ impl SerializableStreamSpec { /// The hash is computed over the entire spec except the content_hash field itself, /// ensuring the same AST always produces the same hash regardless of when it was /// generated or by whom. - pub fn compute_content_hash(&self) -> String { + #[allow(dead_code)] + pub fn try_compute_content_hash(&self) -> Result { use sha2::{Digest, Sha256}; - // Clone and clear the hash field for computation let mut spec_for_hash = self.clone(); spec_for_hash.content_hash = None; - // Serialize to JSON (serde_json produces consistent output for the same struct) - let json = - serde_json::to_string(&spec_for_hash).expect("Failed to serialize spec for hashing"); + let json = serde_json::to_string(&spec_for_hash)?; - // Compute SHA256 hash let mut hasher = Sha256::new(); hasher.update(json.as_bytes()); let result = hasher.finalize(); - // Return hex-encoded hash - hex::encode(result) + Ok(hex::encode(result)) + } + + #[allow(dead_code)] + pub fn compute_content_hash(&self) -> String { + self.try_compute_content_hash() + .expect("Failed to serialize spec for hashing") } /// Verify that the content_hash matches the computed hash. @@ -778,15 +780,21 @@ impl SerializableStreamSpec { #[allow(dead_code)] pub fn verify_content_hash(&self) -> bool { match &self.content_hash { - Some(hash) => { - let computed = self.compute_content_hash(); - hash == &computed - } + Some(hash) => self + .try_compute_content_hash() + .map(|computed| hash == &computed) + .unwrap_or(false), None => true, // No hash to verify } } /// Set the content_hash field to the computed hash. + #[allow(dead_code)] + pub fn try_with_content_hash(mut self) -> Result { + self.content_hash = Some(self.try_compute_content_hash()?); + Ok(self) + } + #[allow(dead_code)] pub fn with_content_hash(mut self) -> Self { self.content_hash = Some(self.compute_content_hash()); @@ -909,17 +917,31 @@ pub struct SerializableStackSpec { impl SerializableStackSpec { /// Compute deterministic content hash (SHA256 of canonical JSON). - pub fn compute_content_hash(&self) -> String { + #[allow(dead_code)] + pub fn try_compute_content_hash(&self) -> Result { use sha2::{Digest, Sha256}; + let mut spec_for_hash = self.clone(); spec_for_hash.content_hash = None; - let json = serde_json::to_string(&spec_for_hash) - .expect("Failed to serialize stack spec for hashing"); + let json = serde_json::to_string(&spec_for_hash)?; let mut hasher = Sha256::new(); hasher.update(json.as_bytes()); - hex::encode(hasher.finalize()) + Ok(hex::encode(hasher.finalize())) + } + + #[allow(dead_code)] + pub fn compute_content_hash(&self) -> String { + self.try_compute_content_hash() + .expect("Failed to serialize stack spec for hashing") } + #[allow(dead_code)] + pub fn try_with_content_hash(mut self) -> Result { + self.content_hash = Some(self.try_compute_content_hash()?); + Ok(self) + } + + #[allow(dead_code)] pub fn with_content_hash(mut self) -> Self { self.content_hash = Some(self.compute_content_hash()); self diff --git a/hyperstack-macros/src/ast/writer.rs b/hyperstack-macros/src/ast/writer.rs index 37ab0252..ceeddb3a 100644 --- a/hyperstack-macros/src/ast/writer.rs +++ b/hyperstack-macros/src/ast/writer.rs @@ -12,7 +12,6 @@ use std::collections::{BTreeMap, HashMap}; use super::types::*; use crate::parse; -use crate::parse::conditions as condition_parser; use crate::parse::idl as idl_parser; /// Write a SerializableStreamSpec to a JSON file. @@ -349,7 +348,7 @@ pub fn convert_idl_type(idl_type: &idl_parser::IdlType) -> IdlTypeSnapshot { pub fn build_handlers_from_sources( sources_by_type: &HashMap>, _events_by_instruction: &HashMap>, - aggregate_conditions: &HashMap, + aggregate_conditions: &HashMap, idl: Option<&idl_parser::IdlSpec>, ) -> Vec { let program_name = idl.map(|idl| idl.get_name()); @@ -462,10 +461,7 @@ pub fn build_handlers_from_sources( let population = parse_population_strategy(&mapping.strategy); - let condition = mapping.condition.as_ref().map(|cond| ConditionExpr { - expression: cond.clone(), - parsed: condition_parser::parse_condition_expression(cond), - }); + let condition = mapping.condition.clone(); let when = mapping.when.as_ref().map(|when_path| { let instr_type = path_to_string(when_path); @@ -689,7 +685,7 @@ pub fn build_resolver_hooks( pub fn build_instruction_hooks( pda_registrations: &[parse::RegisterPdaAttribute], derive_from_mappings: &HashMap>, - aggregate_conditions: &HashMap, + aggregate_conditions: &HashMap, sources_by_type: &HashMap>, program_name: Option<&str>, ) -> Vec { @@ -767,10 +763,7 @@ pub fn build_instruction_hooks( } }; - let condition = derive_attr.condition.as_ref().map(|cond| ConditionExpr { - expression: cond.clone(), - parsed: condition_parser::parse_condition_expression(cond), - }); + let condition = derive_attr.condition.clone(); let action = HookAction::SetField { target_field: derive_attr.target_field_name.clone(), @@ -804,7 +797,7 @@ pub fn build_instruction_hooks( sorted_aggregate_conditions.sort_by_key(|(k, _)| *k); let mut sorted_sources: Vec<_> = sources_by_type.iter().collect(); sorted_sources.sort_by_key(|(k, _)| *k); - for (field_path, condition_str) in sorted_aggregate_conditions { + for (field_path, condition_expr) in sorted_aggregate_conditions { for (source_type, mappings) in &sorted_sources { for mapping in *mappings { if &mapping.target_field_name == field_path @@ -821,10 +814,7 @@ pub fn build_instruction_hooks( format!("{}IxState", instr_base) }; - let condition = ConditionExpr { - expression: condition_str.clone(), - parsed: condition_parser::parse_condition_expression(condition_str), - }; + let condition = condition_expr.clone(); if mapping.strategy == "Count" { let action = HookAction::IncrementField { diff --git a/hyperstack-macros/src/codegen/bytecode.rs b/hyperstack-macros/src/codegen/bytecode.rs index 8fb729bb..a5c08831 100644 --- a/hyperstack-macros/src/codegen/bytecode.rs +++ b/hyperstack-macros/src/codegen/bytecode.rs @@ -6,7 +6,15 @@ use quote::quote; use crate::ast::SerializableStreamSpec; pub fn generate_bytecode_from_spec(spec: &SerializableStreamSpec) -> TokenStream { - let spec_json = serde_json::to_string(spec).unwrap_or_else(|_| "{}".to_string()); + let spec_json = match serde_json::to_string(spec) { + Ok(json) => json, + Err(error) => { + let error_message = format!("failed to serialize embedded AST JSON: {error}"); + return quote! { + compile_error!(#error_message); + }; + } + }; let entity_name = &spec.state_name; quote! { diff --git a/hyperstack-macros/src/codegen/handlers.rs b/hyperstack-macros/src/codegen/handlers.rs index 08112665..b81c140e 100644 --- a/hyperstack-macros/src/codegen/handlers.rs +++ b/hyperstack-macros/src/codegen/handlers.rs @@ -8,7 +8,7 @@ //! and generates the corresponding Rust code for creating a `TypedHandlerSpec`. use proc_macro2::TokenStream; -use quote::{format_ident, quote}; +use quote::{format_ident, quote, quote_spanned}; use crate::ast::{ ComparisonOp, ConditionExpr, FieldPath, IdlSerializationSnapshot, KeyResolutionStrategy, @@ -49,7 +49,7 @@ pub fn build_handler_code( let emit = handler.emit; - quote! { + quote_spanned! { state_name.span()=> hyperstack::runtime::hyperstack_interpreter::ast::TypedHandlerSpec::<#state_name>::new( #source_code, #key_resolution_code, @@ -77,7 +77,7 @@ pub fn build_handler_fn( ) -> TokenStream { let handler_code = build_handler_code(handler, state_name); - quote! { + quote_spanned! { handler_name.span()=> fn #handler_name() -> hyperstack::runtime::hyperstack_interpreter::ast::TypedHandlerSpec<#state_name> { #handler_code } diff --git a/hyperstack-macros/src/codegen/multi_entity.rs b/hyperstack-macros/src/codegen/multi_entity.rs index 0623a530..a5f86d7e 100644 --- a/hyperstack-macros/src/codegen/multi_entity.rs +++ b/hyperstack-macros/src/codegen/multi_entity.rs @@ -13,7 +13,8 @@ pub fn generate_multi_entity_builder( entity_names: &[String], proto_analyses: &[(String, ProtoAnalysis)], skip_decoders: bool, - stack_name: &str, + _stack_name: &str, + stack_spec_json: &str, ) -> TokenStream { let mut builder_calls = Vec::new(); @@ -52,13 +53,12 @@ pub fn generate_multi_entity_builder( quote! {} }; - let stack_file_name = format!("{}.stack.json", stack_name); let view_extraction = quote! { { - let stack_json = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/.hyperstack/", #stack_file_name)); + let stack_json = #stack_spec_json; let stack_spec: hyperstack::runtime::hyperstack_interpreter::ast::SerializableStackSpec = hyperstack::runtime::serde_json::from_str(stack_json) - .expect("Failed to parse stack AST file"); + .unwrap_or_else(|error| panic!("embedded stack spec is invalid: {}", error)); for entity_spec in &stack_spec.entities { all_views.extend(entity_spec.views.clone()); } @@ -87,31 +87,3 @@ pub fn generate_multi_entity_builder( } } } - -pub fn generate_entity_spec_loader(entity_name: &str, stack_name: &str) -> TokenStream { - let spec_fn_name = format_ident!("create_{}_spec", to_snake_case(entity_name)); - let state_name = format_ident!("{}", entity_name); - let stack_file_name = format!("{}.stack.json", stack_name); - - quote! { - pub fn #spec_fn_name() -> hyperstack::runtime::hyperstack_interpreter::ast::TypedStreamSpec<#state_name> { - let stack_json = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/.hyperstack/", #stack_file_name)); - let stack_spec: hyperstack::runtime::hyperstack_interpreter::ast::SerializableStackSpec = - hyperstack::runtime::serde_json::from_str(stack_json) - .expect("Failed to parse stack AST file"); - - let entity_spec = stack_spec - .entities - .iter() - .find(|e| e.state_name == #entity_name) - .expect(&format!("Entity {} not found in stack AST", #entity_name)); - - let mut spec = entity_spec.clone(); - if spec.idl.is_none() { - spec.idl = stack_spec.idls.first().cloned(); - } - - hyperstack::runtime::hyperstack_interpreter::ast::TypedStreamSpec::from_serializable(spec) - } - } -} diff --git a/hyperstack-macros/src/diagnostic.rs b/hyperstack-macros/src/diagnostic.rs new file mode 100644 index 00000000..03d63a32 --- /dev/null +++ b/hyperstack-macros/src/diagnostic.rs @@ -0,0 +1,155 @@ +use hyperstack_idl::error::IdlSearchError; +use hyperstack_idl::search::suggest_similar; +use proc_macro2::{Span, TokenStream}; +use syn::Item; + +// Error message style guide: +// - start with a lowercase problem statement (`invalid`, `unknown`, `missing`) +// - name the DSL concept directly (`strategy`, `resolver`, `view field`) +// - include what was provided and what was expected +// - add a single best suggestion when possible, otherwise preview available values + +pub fn combine_errors(errors: Vec) -> syn::Error { + let mut iter = errors.into_iter(); + let mut combined = iter.next().unwrap_or_else(|| { + internal_codegen_error(Span::call_site(), "attempted to combine zero errors") + }); + + for error in iter { + combined.combine(error); + } + + combined +} + +#[derive(Default)] +pub struct ErrorCollector { + errors: Vec, +} + +impl ErrorCollector { + pub fn push(&mut self, error: syn::Error) { + self.errors.push(error); + } + + pub fn finish(self) -> syn::Result<()> { + if self.errors.is_empty() { + Ok(()) + } else { + Err(combine_errors(self.errors)) + } + } +} + +pub fn internal_codegen_error(span: Span, msg: impl Into) -> syn::Error { + syn::Error::new( + span, + format!("internal code generation error: {}", msg.into()), + ) +} + +pub fn preview_values(values: &[String], limit: usize) -> String { + values + .iter() + .take(limit) + .cloned() + .collect::>() + .join(", ") +} + +pub fn suggestion_or_available_suffix( + input: &str, + available: &[String], + available_label: &str, +) -> String { + let candidate_refs: Vec<&str> = available.iter().map(String::as_str).collect(); + let suggestions = suggest_similar(input, &candidate_refs, 3); + + if let Some(suggestion) = suggestions.first() { + format!(". Did you mean: {}?", suggestion.candidate) + } else if !available.is_empty() { + format!(". {}: {}", available_label, preview_values(available, 6)) + } else { + String::new() + } +} + +pub fn invalid_choice_message( + choice_kind: &str, + actual: &str, + context: &str, + expected: &[&str], +) -> String { + let available = expected + .iter() + .map(|value| value.to_string()) + .collect::>(); + format!( + "invalid {} '{}' for {}. Expected one of: {}{}", + choice_kind, + actual, + context, + expected.join(", "), + suggestion_or_available_suffix(actual, &available, "Available values") + ) +} + +pub fn unknown_value_message( + value_kind: &str, + actual: &str, + available_label: &str, + available: &[String], +) -> String { + format!( + "unknown {} '{}'{}", + value_kind, + actual, + suggestion_or_available_suffix(actual, available, available_label) + ) +} + +pub fn idl_error_to_syn(span: Span, error: IdlSearchError) -> syn::Error { + syn::Error::new(span, error.to_string()) +} + +pub fn parse_generated_items( + tokens: TokenStream, + span: Span, + context: &str, +) -> syn::Result> { + syn::parse2::(tokens) + .map(|file| file.items) + .map_err(|error| { + internal_codegen_error(span, format!("{context} generated invalid Rust: {error}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_choice_message_suggests_nearest_value() { + let message = + invalid_choice_message("strategy", "LastWrit", "#[map]", &["SetOnce", "LastWrite"]); + + assert!(message.contains("invalid strategy 'LastWrit' for #[map]")); + assert!(message.contains("Expected one of: SetOnce, LastWrite")); + assert!(message.contains("Did you mean: LastWrite?")); + } + + #[test] + fn unknown_value_message_falls_back_to_available_values() { + let message = unknown_value_message( + "resolver-backed type", + "u64", + "Available types", + &["TokenMetadata".to_string()], + ); + + assert_eq!( + message, + "unknown resolver-backed type 'u64'. Available types: TokenMetadata" + ); + } +} diff --git a/hyperstack-macros/src/lib.rs b/hyperstack-macros/src/lib.rs index 8e2fab90..0e1e1c4e 100644 --- a/hyperstack-macros/src/lib.rs +++ b/hyperstack-macros/src/lib.rs @@ -38,6 +38,7 @@ // Public modules - AST types needed for SDK generation pub(crate) mod ast; +mod diagnostic; pub(crate) mod event_type_helpers; // Internal modules - not exposed publicly @@ -49,10 +50,11 @@ mod parse; mod proto_codegen; mod stream_spec; mod utils; +mod validation; use proc_macro::TokenStream; use std::collections::HashMap; -use syn::{parse_macro_input, ItemMod, ItemStruct}; +use syn::{ItemMod, ItemStruct}; // Use the stream_spec module functions use stream_spec::{process_module, process_struct_with_context}; @@ -85,11 +87,29 @@ use stream_spec::{process_module, process_struct_with_context}; /// ``` #[proc_macro_attribute] pub fn hyperstack(attr: TokenStream, item: TokenStream) -> TokenStream { + expand_hyperstack(attr, item) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +fn expand_hyperstack( + attr: TokenStream, + item: TokenStream, +) -> syn::Result { if let Ok(module) = syn::parse::(item.clone()) { return process_module(module, attr); } - let input = parse_macro_input!(item as ItemStruct); + let input = syn::parse::(item)?; + + let config = parse::parse_stream_spec_attribute(attr)?; + if !config.proto_files.is_empty() || !config.idl_files.is_empty() || config.skip_decoders { + return Err(syn::Error::new( + input.ident.span(), + "#[hyperstack(...)] arguments are only supported on modules", + )); + } + process_struct_with_context(input, HashMap::new(), false) } diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index 4a605a12..4ac5530e 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -4,11 +4,15 @@ #![allow(dead_code)] +use proc_macro2::Span; use std::collections::HashMap; use syn::parse::{Parse, ParseStream}; +use syn::spanned::Spanned; use syn::{Attribute, Path, Token}; -use crate::ast::ResolverType; +use crate::ast::{ConditionExpr, FieldPath, ResolverCondition, ResolverType}; +use crate::diagnostic::{invalid_choice_message, ErrorCollector}; +use crate::parse::conditions as condition_parser; #[derive(Debug, Clone)] pub struct RegisterFromSpec { @@ -19,6 +23,9 @@ pub struct RegisterFromSpec { #[derive(Debug, Clone)] pub struct MapAttribute { + pub attr_span: Span, + pub source_type_span: Span, + pub source_field_span: Span, pub source_type_path: Path, pub source_field_name: String, pub target_field_name: String, @@ -35,7 +42,7 @@ pub struct MapAttribute { pub is_instruction: bool, pub is_whole_source: bool, pub lookup_by: Option, - pub condition: Option, + pub condition: Option, pub when: Option, pub stop: Option, pub stop_lookup_by: Option, @@ -53,6 +60,8 @@ pub struct ResolverTransformSpec { #[derive(Debug, Clone)] pub struct EventAttribute { + pub attr_span: Span, + pub instruction_span: Option, // New type-safe fields pub from_instruction: Option, // Explicit source via `from = ...` pub inferred_instruction: Option, // Inferred from field type @@ -73,6 +82,7 @@ pub struct EventAttribute { #[derive(Debug, Clone)] pub struct CaptureAttribute { + pub attr_span: Span, // Type-safe fields for account capture pub from_account: Option, // Explicit source via `from = ...` pub inferred_account: Option, // Inferred from field type @@ -91,6 +101,19 @@ pub struct CaptureAttribute { pub when: Option, } +#[derive(Debug, Clone)] +pub struct ValidatedFieldPath { + pub raw: String, + pub parsed: FieldPath, + pub span: Span, +} + +#[derive(Debug, Clone)] +pub struct ValidatedResolverCondition { + pub expression: String, + pub parsed: ResolverCondition, +} + #[derive(Debug, Clone)] pub struct FieldSpec { pub ident: syn::Ident, @@ -103,6 +126,68 @@ pub enum FieldLocation { Account, } +fn validate_strategy( + attr_name: &str, + strategy: String, + tokens: &T, + allowed: &[&str], +) -> syn::Result { + if allowed.contains(&strategy.as_str()) { + Ok(strategy) + } else { + Err(syn::Error::new_spanned( + tokens, + invalid_choice_message("strategy", &strategy, attr_name, allowed), + )) + } +} + +fn parse_condition_literal(literal: &syn::LitStr) -> syn::Result { + let expression = literal.value(); + let parsed = condition_parser::parse_condition_expression_strict(&expression) + .map_err(|error| syn::Error::new_spanned(literal, error))?; + + Ok(ConditionExpr { + expression, + parsed: Some(parsed), + }) +} + +fn parse_resolver_condition_literal( + literal: &syn::LitStr, +) -> syn::Result { + let expression = literal.value(); + let parsed = condition_parser::parse_resolver_condition_expression(&expression) + .map_err(|error| syn::Error::new_spanned(literal, error))?; + + Ok(ValidatedResolverCondition { expression, parsed }) +} + +fn field_path_to_string(path: &FieldPath) -> String { + path.segments.join(".") +} + +fn parse_validated_field_path(input: ParseStream) -> syn::Result { + let mut segments = Vec::new(); + let first: syn::Ident = input.parse()?; + let span = first.span(); + segments.push(first.to_string()); + + while input.peek(Token![.]) { + input.parse::()?; + let next: syn::Ident = input.parse()?; + segments.push(next.to_string()); + } + + let refs: Vec<&str> = segments.iter().map(String::as_str).collect(); + let parsed = FieldPath::new(&refs); + Ok(ValidatedFieldPath { + raw: field_path_to_string(&parsed), + parsed, + span, + }) +} + impl MapAttribute { pub fn source_type_string(&self) -> String { self.source_type_path @@ -125,7 +210,7 @@ struct MapAttributeArgs { join_on: Option, transform: Option, resolver_transform: Option, - condition: Option, + condition: Option, when: Option, stop: Option, stop_lookup_by: Option, @@ -217,7 +302,7 @@ impl Parse for MapAttributeArgs { } else if ident_str == "condition" { input.parse::()?; let condition_lit: syn::LitStr = input.parse()?; - condition = Some(condition_lit.value()); + condition = Some(condition_lit); } else if ident_str == "when" { input.parse::()?; let when_path: Path = input.parse()?; @@ -281,26 +366,25 @@ pub fn parse_map_attribute( )); } - let strategy = args.strategy.unwrap_or_else(|| "SetOnce".to_string()); - if strategy != "SetOnce" && strategy != "LastWrite" { - return Err(syn::Error::new_spanned( - attr, - format!( - "Invalid strategy '{}' for #[resolve]. Only 'SetOnce' or 'LastWrite' are allowed.", - strategy - ), - )); - } + let strategy = validate_strategy( + "#[map]", + args.strategy.unwrap_or_else(|| "SetOnce".to_string()), + attr, + &["SetOnce", "LastWrite"], + )?; let target_name = args.rename.unwrap_or_else(|| target_field_name.to_string()); let emit = args.emit.unwrap_or(true); let mut results = Vec::new(); for source_path in args.source_paths { - let (source_type_path, source_field_name) = split_source_path(&source_path)?; + let split = split_source_path(&source_path)?; results.push(MapAttribute { - source_type_path, - source_field_name, + attr_span: attr.span(), + source_type_span: split.source_type_span, + source_field_span: split.source_field_span, + source_type_path: split.source_type_path, + source_field_name: split.source_field_name, target_field_name: target_name.clone(), is_primary_key: args.is_primary_key, is_lookup_index: args.is_lookup_index, @@ -313,7 +397,11 @@ pub fn parse_map_attribute( is_instruction: false, is_whole_source: false, lookup_by: None, - condition: args.condition.clone(), + condition: args + .condition + .as_ref() + .map(parse_condition_literal) + .transpose()?, when: args.when.clone(), stop: args.stop.clone(), stop_lookup_by: args.stop_lookup_by.clone(), @@ -341,17 +429,25 @@ pub fn parse_from_instruction_attribute( )); } - let strategy = args.strategy.unwrap_or_else(|| "SetOnce".to_string()); + let strategy = validate_strategy( + "#[from_instruction]", + args.strategy.unwrap_or_else(|| "SetOnce".to_string()), + attr, + &["SetOnce", "LastWrite"], + )?; let target_name = args.rename.unwrap_or_else(|| target_field_name.to_string()); let emit = args.emit.unwrap_or(true); let mut results = Vec::new(); for source_path in args.source_paths { - let (source_type_path, source_field_name) = split_source_path(&source_path)?; + let split = split_source_path(&source_path)?; results.push(MapAttribute { - source_type_path, - source_field_name, + attr_span: attr.span(), + source_type_span: split.source_type_span, + source_field_span: split.source_field_span, + source_type_path: split.source_type_path, + source_field_name: split.source_field_name, target_field_name: target_name.clone(), is_primary_key: args.is_primary_key, is_lookup_index: args.is_lookup_index, @@ -364,7 +460,11 @@ pub fn parse_from_instruction_attribute( is_instruction: true, is_whole_source: false, lookup_by: None, - condition: args.condition.clone(), + condition: args + .condition + .as_ref() + .map(parse_condition_literal) + .transpose()?, when: args.when.clone(), stop: args.stop.clone(), stop_lookup_by: args.stop_lookup_by.clone(), @@ -375,7 +475,14 @@ pub fn parse_from_instruction_attribute( Ok(Some(results)) } -fn split_source_path(path: &Path) -> syn::Result<(Path, String)> { +struct SplitSourcePath { + source_type_path: Path, + source_type_span: Span, + source_field_name: String, + source_field_span: Span, +} + +fn split_source_path(path: &Path) -> syn::Result { if path.segments.len() < 2 { return Err(syn::Error::new_spanned( path, @@ -383,22 +490,37 @@ fn split_source_path(path: &Path) -> syn::Result<(Path, String)> { )); } - let field_name = path.segments.last().unwrap().ident.to_string(); + let field_segment = path.segments.last().unwrap(); + let field_name = field_segment.ident.to_string(); + let field_span = field_segment.ident.span(); let mut type_path = path.clone(); type_path.segments.pop(); - Ok((type_path, field_name)) + let type_span = type_path + .segments + .last() + .map(|segment| segment.ident.span()) + .unwrap_or_else(Span::call_site); + + Ok(SplitSourcePath { + source_type_path: type_path, + source_type_span: type_span, + source_field_name: field_name, + source_field_span: field_span, + }) } struct EventAttributeArgs { // New type-safe syntax from: Option, + from_span: Option, fields: Option>, transforms: Option>, // Backward compatibility instruction: Option, + instruction_span: Option, capture: Option>, transforms_legacy: Option>, @@ -417,10 +539,12 @@ struct FieldTransform { impl Parse for EventAttributeArgs { fn parse(input: ParseStream) -> syn::Result { let mut from = None; + let mut from_span = None; let mut fields = None; let mut transforms = None; let mut transforms_legacy = None; let mut instruction = None; + let mut instruction_span = None; let mut capture = None; let mut strategy = None; let mut rename = None; @@ -435,7 +559,9 @@ impl Parse for EventAttributeArgs { if ident_str == "from" { // New: from = InstructionType - from = Some(input.parse()?); + let parsed: Path = input.parse()?; + from_span = Some(parsed.span()); + from = Some(parsed); } else if ident_str == "fields" { // New: fields = [ident1, ident2] or fields = [accounts::ident1, args::ident2] let content; @@ -453,6 +579,7 @@ impl Parse for EventAttributeArgs { } else if ident_str == "instruction" { // Legacy: instruction = "string" let lit: syn::LitStr = input.parse()?; + instruction_span = Some(lit.span()); instruction = Some(lit.value()); } else if ident_str == "capture" { // Legacy/Alias for fields: capture = ["string1", "string2"] @@ -569,9 +696,11 @@ impl Parse for EventAttributeArgs { Ok(EventAttributeArgs { from, + from_span, fields, transforms, instruction, + instruction_span, capture, transforms_legacy, strategy, @@ -651,15 +780,21 @@ pub fn parse_event_attribute( let field_transforms_legacy = args.transforms_legacy.unwrap_or_default(); // Determine strategy - let strategy = args - .strategy - .map(|s| s.to_string()) - .unwrap_or_else(|| "SetOnce".to_string()); + let strategy = validate_strategy( + "#[event]", + args.strategy + .map(|s| s.to_string()) + .unwrap_or_else(|| "SetOnce".to_string()), + attr, + &["SetOnce", "LastWrite"], + )?; // Handle legacy instruction string let instruction_str = args.instruction.unwrap_or_default(); Ok(Some(EventAttribute { + attr_span: attr.span(), + instruction_span: args.from_span.or(args.instruction_span), from_instruction: args.from, inferred_instruction: None, // Will be filled in later from field type capture_fields: args.fields.unwrap_or_default(), @@ -793,23 +928,18 @@ pub fn parse_snapshot_attribute( let target_name = args.rename.unwrap_or_else(|| target_field_name.to_string()); // Determine strategy - only SetOnce or LastWrite allowed - let strategy = args - .strategy - .as_ref() - .map(|s| s.to_string()) - .unwrap_or_else(|| "SetOnce".to_string()); - - // Validate strategy - if strategy != "SetOnce" && strategy != "LastWrite" { - if let Some(ref strategy_ident) = args.strategy { - return Err(syn::Error::new_spanned( - strategy_ident, - format!("Invalid strategy '{}' for #[snapshot]. Only 'SetOnce' or 'LastWrite' are allowed. Account snapshots cannot use 'Append'.", strategy) - )); - } - } + let strategy = validate_strategy( + "#[snapshot]", + args.strategy + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "SetOnce".to_string()), + attr, + &["SetOnce", "LastWrite"], + )?; Ok(Some(CaptureAttribute { + attr_span: attr.span(), from_account: args.from, inferred_account: None, // Will be filled in later from field type field: args.field, @@ -828,6 +958,7 @@ pub fn parse_snapshot_attribute( #[derive(Debug, Clone)] pub struct AggregateAttribute { + pub attr_span: Span, /// Instruction type(s) to aggregate from pub from_instructions: Vec, /// Field to aggregate (optional - if omitted, just count occurrences) @@ -843,7 +974,7 @@ pub struct AggregateAttribute { /// Lookup field for key resolution pub lookup_by: Option, /// Condition expression for conditional aggregation (Level 1) - pub condition: Option, + pub condition: Option, } struct AggregateAttributeArgs { @@ -854,7 +985,7 @@ struct AggregateAttributeArgs { rename: Option, join_on: Option, lookup_by: Option, - condition: Option, + condition: Option, } impl Parse for AggregateAttributeArgs { @@ -921,7 +1052,7 @@ impl Parse for AggregateAttributeArgs { } } else if ident_str == "condition" { let condition_lit: syn::LitStr = input.parse()?; - condition = Some(condition_lit.value()); + condition = Some(condition_lit); } else { return Err(syn::Error::new( ident.span(), @@ -968,22 +1099,12 @@ pub fn parse_aggregate_attribute( // Determine strategy - default to Count if no field specified, Sum otherwise let strategy = if let Some(ref strategy_ident) = args.strategy { - let strategy_str = strategy_ident.to_string(); - - // Validate strategy - let valid_strategies = ["Sum", "Count", "Min", "Max", "UniqueCount"]; - if !valid_strategies.contains(&strategy_str.as_str()) { - return Err(syn::Error::new_spanned( - strategy_ident, - format!( - "Invalid aggregation strategy '{}'. Valid strategies: {}", - strategy_str, - valid_strategies.join(", ") - ), - )); - } - - strategy_str + validate_strategy( + "#[aggregate]", + strategy_ident.to_string(), + strategy_ident, + &["Sum", "Count", "Min", "Max", "UniqueCount"], + )? } else { // Default strategy based on whether field is specified if args.field.is_none() { @@ -994,6 +1115,7 @@ pub fn parse_aggregate_attribute( }; Ok(Some(AggregateAttribute { + attr_span: attr.span(), from_instructions: args.from, field: args.field, strategy, @@ -1001,7 +1123,11 @@ pub fn parse_aggregate_attribute( target_field_name: target_name, join_on: args.join_on, lookup_by: args.lookup_by, - condition: args.condition, + condition: args + .condition + .as_ref() + .map(parse_condition_literal) + .transpose()?, })) } @@ -1011,6 +1137,8 @@ pub fn parse_aggregate_attribute( #[derive(Debug, Clone)] pub struct ResolveAttribute { + pub attr_span: Span, + pub from_span: Option, pub from: Option, pub address: Option, pub url: Option, @@ -1020,24 +1148,27 @@ pub struct ResolveAttribute { pub target_field_name: String, pub resolver: Option, pub strategy: String, - pub condition: Option, - pub schedule_at: Option, + pub condition: Option, + pub schedule_at: Option, } #[derive(Debug, Clone)] pub struct ResolveSpec { + pub attr_span: Span, + pub from_span: Option, pub resolver: ResolverType, pub from: Option, pub address: Option, pub extract: Option, pub target_field_name: String, pub strategy: String, - pub condition: Option, - pub schedule_at: Option, + pub condition: Option, + pub schedule_at: Option, } struct ResolveAttributeArgs { from: Option, + from_span: Option, address: Option, url: Option, url_is_template: bool, @@ -1045,13 +1176,14 @@ struct ResolveAttributeArgs { extract: Option, resolver: Option, strategy: Option, - condition: Option, - schedule_at: Option, + condition: Option, + schedule_at: Option, } impl Parse for ResolveAttributeArgs { fn parse(input: ParseStream) -> syn::Result { let mut from = None; + let mut from_span = None; let mut address = None; let mut url = None; let mut url_is_template = false; @@ -1070,6 +1202,7 @@ impl Parse for ResolveAttributeArgs { if ident_str == "from" { let lit: syn::LitStr = input.parse()?; + from_span = Some(lit.span()); from = Some(lit.value()); } else if ident_str == "address" { let lit: syn::LitStr = input.parse()?; @@ -1119,19 +1252,9 @@ impl Parse for ResolveAttributeArgs { strategy = Some(ident.to_string()); } else if ident_str == "condition" { let lit: syn::LitStr = input.parse()?; - condition = Some(lit.value()); + condition = Some(lit); } else if ident_str == "schedule_at" { - let mut parts = Vec::new(); - let first: syn::Ident = input.parse()?; - parts.push(first.to_string()); - - while input.peek(Token![.]) { - input.parse::()?; - let next: syn::Ident = input.parse()?; - parts.push(next.to_string()); - } - - schedule_at = Some(parts.join(".")); + schedule_at = Some(parse_validated_field_path(input)?); } else { return Err(syn::Error::new( ident.span(), @@ -1146,6 +1269,7 @@ impl Parse for ResolveAttributeArgs { Ok(ResolveAttributeArgs { from, + from_span, address, url, url_is_template, @@ -1203,9 +1327,16 @@ pub fn parse_resolve_attribute( )); } - let strategy = args.strategy.unwrap_or_else(|| "SetOnce".to_string()); + let strategy = validate_strategy( + "#[resolve]", + args.strategy.unwrap_or_else(|| "SetOnce".to_string()), + attr, + &["SetOnce", "LastWrite"], + )?; Ok(Some(ResolveAttribute { + attr_span: attr.span(), + from_span: args.from_span, from: args.from, address: args.address, url: args.url, @@ -1215,13 +1346,18 @@ pub fn parse_resolve_attribute( target_field_name: target_field_name.to_string(), resolver: args.resolver, strategy, - condition: args.condition, + condition: args + .condition + .as_ref() + .map(parse_resolver_condition_literal) + .transpose()?, schedule_at: args.schedule_at, })) } #[derive(Debug, Clone)] pub struct ComputedAttribute { + pub attr_span: Span, /// The expression to evaluate (stored as TokenStream for code generation) pub expression: proc_macro2::TokenStream, /// Target field name (defaults to struct field name) @@ -1242,10 +1378,63 @@ pub fn parse_computed_attribute( let expression: proc_macro2::TokenStream = attr.parse_args()?; Ok(Some(ComputedAttribute { + attr_span: attr.span(), expression, target_field_name: target_field_name.to_string(), })) } + +#[derive(Debug, Clone)] +pub enum RecognizedFieldAttribute { + Map(Vec), + FromInstruction(Vec), + Event(EventAttribute), + Snapshot(CaptureAttribute), + Aggregate(AggregateAttribute), + DeriveFrom(DeriveFromAttribute), + Resolve(ResolveAttribute), + Computed(ComputedAttribute), +} + +pub fn parse_recognized_field_attribute( + attr: &Attribute, + target_field_name: &str, +) -> syn::Result> { + if let Some(map_attrs) = parse_map_attribute(attr, target_field_name)? { + return Ok(Some(RecognizedFieldAttribute::Map(map_attrs))); + } + + if let Some(map_attrs) = parse_from_instruction_attribute(attr, target_field_name)? { + return Ok(Some(RecognizedFieldAttribute::FromInstruction(map_attrs))); + } + + if let Some(event_attr) = parse_event_attribute(attr, target_field_name)? { + return Ok(Some(RecognizedFieldAttribute::Event(event_attr))); + } + + if let Some(snapshot_attr) = parse_snapshot_attribute(attr, target_field_name)? { + return Ok(Some(RecognizedFieldAttribute::Snapshot(snapshot_attr))); + } + + if let Some(aggregate_attr) = parse_aggregate_attribute(attr, target_field_name)? { + return Ok(Some(RecognizedFieldAttribute::Aggregate(aggregate_attr))); + } + + if let Some(derive_attr) = parse_derive_from_attribute(attr, target_field_name)? { + return Ok(Some(RecognizedFieldAttribute::DeriveFrom(derive_attr))); + } + + if let Some(resolve_attr) = parse_resolve_attribute(attr, target_field_name)? { + return Ok(Some(RecognizedFieldAttribute::Resolve(resolve_attr))); + } + + if let Some(computed_attr) = parse_computed_attribute(attr, target_field_name)? { + return Ok(Some(RecognizedFieldAttribute::Computed(computed_attr))); + } + + Ok(None) +} + pub fn has_entity_attribute(attrs: &[Attribute]) -> bool { attrs.iter().any(|attr| attr.path().is_ident("entity")) } @@ -1373,6 +1562,24 @@ pub fn parse_stream_spec_attribute( let args: StreamSpecAttributeArgs = syn::parse(attr)?; + for file in &args.proto_files { + if file.trim().is_empty() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "proto file references cannot be empty", + )); + } + } + + for file in &args.idl_files { + if file.trim().is_empty() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "idl file references cannot be empty", + )); + } + } + Ok(StreamSpecAttribute { proto_files: args.proto_files, idl_files: args.idl_files, @@ -1440,61 +1647,81 @@ pub struct AfterInstructionAttr { } /// Extract resolver hooks from an impl block -pub fn extract_resolver_hooks(item_impl: &syn::ItemImpl) -> Vec { +pub fn extract_resolver_hooks(item_impl: &syn::ItemImpl) -> syn::Result> { let mut hooks = Vec::new(); + let mut errors = ErrorCollector::default(); for item in &item_impl.items { if let syn::ImplItem::Fn(method) = item { for attr in &method.attrs { - if let Ok(Some(resolver_attr)) = parse_resolve_key_for_attribute(attr) { - hooks.push(ResolverHookSpec { - kind: ResolverHookKind::KeyResolver, - account_type_path: resolver_attr.account_type_path, - fn_name: method.sig.ident.clone(), - fn_sig: method.sig.clone(), - }); + match parse_resolve_key_for_attribute(attr) { + Ok(Some(resolver_attr)) => { + hooks.push(ResolverHookSpec { + kind: ResolverHookKind::KeyResolver, + account_type_path: resolver_attr.account_type_path, + fn_name: method.sig.ident.clone(), + fn_sig: method.sig.clone(), + }); + } + Ok(None) => {} + Err(error) => errors.push(error), } - if let Ok(Some(instruction_attr)) = parse_after_instruction_attribute(attr) { - hooks.push(ResolverHookSpec { - kind: ResolverHookKind::AfterInstruction, - account_type_path: instruction_attr.instruction_type_path, - fn_name: method.sig.ident.clone(), - fn_sig: method.sig.clone(), - }); + match parse_after_instruction_attribute(attr) { + Ok(Some(instruction_attr)) => { + hooks.push(ResolverHookSpec { + kind: ResolverHookKind::AfterInstruction, + account_type_path: instruction_attr.instruction_type_path, + fn_name: method.sig.ident.clone(), + fn_sig: method.sig.clone(), + }); + } + Ok(None) => {} + Err(error) => errors.push(error), } } } } - hooks + errors.finish()?; + Ok(hooks) } /// Extract resolver hooks from a standalone function -pub fn extract_resolver_hooks_from_fn(item_fn: &syn::ItemFn) -> Vec { +pub fn extract_resolver_hooks_from_fn(item_fn: &syn::ItemFn) -> syn::Result> { let mut hooks = Vec::new(); + let mut errors = ErrorCollector::default(); for attr in &item_fn.attrs { - if let Ok(Some(resolver_attr)) = parse_resolve_key_for_attribute(attr) { - hooks.push(ResolverHookSpec { - kind: ResolverHookKind::KeyResolver, - account_type_path: resolver_attr.account_type_path, - fn_name: item_fn.sig.ident.clone(), - fn_sig: item_fn.sig.clone(), - }); + match parse_resolve_key_for_attribute(attr) { + Ok(Some(resolver_attr)) => { + hooks.push(ResolverHookSpec { + kind: ResolverHookKind::KeyResolver, + account_type_path: resolver_attr.account_type_path, + fn_name: item_fn.sig.ident.clone(), + fn_sig: item_fn.sig.clone(), + }); + } + Ok(None) => {} + Err(error) => errors.push(error), } - if let Ok(Some(instruction_attr)) = parse_after_instruction_attribute(attr) { - hooks.push(ResolverHookSpec { - kind: ResolverHookKind::AfterInstruction, - account_type_path: instruction_attr.instruction_type_path, - fn_name: item_fn.sig.ident.clone(), - fn_sig: item_fn.sig.clone(), - }); + match parse_after_instruction_attribute(attr) { + Ok(Some(instruction_attr)) => { + hooks.push(ResolverHookSpec { + kind: ResolverHookKind::AfterInstruction, + account_type_path: instruction_attr.instruction_type_path, + fn_name: item_fn.sig.ident.clone(), + fn_sig: item_fn.sig.clone(), + }); + } + Ok(None) => {} + Err(error) => errors.push(error), } } - hooks + errors.finish()?; + Ok(hooks) } #[derive(Debug, Clone)] @@ -1518,11 +1745,12 @@ pub enum ResolverHookKind { // #[derive_from] Attribute Parser #[derive(Debug, Clone)] pub struct DeriveFromAttribute { + pub attr_span: Span, pub from_instructions: Vec, pub field: FieldSpec, // Can be special: __timestamp, __slot, __signature pub strategy: String, // LastWrite, SetOnce pub lookup_by: Option, - pub condition: Option, + pub condition: Option, pub transform: Option, pub target_field_name: String, } @@ -1532,7 +1760,7 @@ struct DeriveFromAttributeArgs { field: Option, strategy: Option, lookup_by: Option, - condition: Option, + condition: Option, transform: Option, } @@ -1582,7 +1810,7 @@ impl Parse for DeriveFromAttributeArgs { } } else if ident_str == "condition" { let condition_lit: syn::LitStr = input.parse()?; - condition = Some(condition_lit.value()); + condition = Some(condition_lit); } else if ident_str == "transform" { transform = Some(input.parse()?); } else { @@ -1629,17 +1857,26 @@ pub fn parse_derive_from_attribute( syn::Error::new_spanned(attr, "#[derive_from] requires 'field' parameter") })?; - let strategy = args - .strategy - .map(|s| s.to_string()) - .unwrap_or_else(|| "LastWrite".to_string()); + let strategy = validate_strategy( + "#[derive_from]", + args.strategy + .map(|s| s.to_string()) + .unwrap_or_else(|| "LastWrite".to_string()), + attr, + &["SetOnce", "LastWrite"], + )?; Ok(Some(DeriveFromAttribute { + attr_span: attr.span(), from_instructions: args.from, field, strategy, lookup_by: args.lookup_by, - condition: args.condition, + condition: args + .condition + .as_ref() + .map(parse_condition_literal) + .transpose()?, transform: args.transform, target_field_name: target_field_name.to_string(), })) @@ -1686,6 +1923,7 @@ fn parse_register_from_list(input: ParseStream) -> syn::Result, @@ -1765,12 +2003,17 @@ pub fn parse_resolve_key_attribute(attr: &Attribute) -> syn::Result syn::Result syn::Result syn::Result, +} + /// Parse #[view(name = "latest", sort_by = "id.round_id", order = "desc")] attributes -pub fn parse_view_attributes(attrs: &[Attribute]) -> Vec { +pub fn parse_view_attribute_specs(attrs: &[Attribute]) -> syn::Result> { use crate::ast::{FieldPath, SortOrder, ViewDef, ViewOutput, ViewSource, ViewTransform}; let mut views = Vec::new(); @@ -1881,18 +2133,20 @@ pub fn parse_view_attributes(attrs: &[Attribute]) -> Vec { let mut name: Option = None; let mut sort_by: Option = None; + let mut sort_key_span = None; let mut order = SortOrder::Desc; let mut take: Option = None; let output = ViewOutput::Collection; if let syn::Meta::List(meta_list) = &attr.meta { - let _ = meta_list.parse_nested_meta(|meta| { + meta_list.parse_nested_meta(|meta| { if meta.path.is_ident("name") { let value: syn::LitStr = meta.value()?.parse()?; name = Some(value.value()); } else if meta.path.is_ident("sort_by") { let value: syn::LitStr = meta.value()?.parse()?; sort_by = Some(value.value()); + sort_key_span = Some(value.span()); } else if meta.path.is_ident("order") { let value: syn::LitStr = meta.value()?.parse()?; order = match value.value().to_lowercase().as_str() { @@ -1904,7 +2158,7 @@ pub fn parse_view_attributes(attrs: &[Attribute]) -> Vec { take = Some(value.base10_parse::()?); } Ok(()) - }); + })?; } if let (Some(view_name), Some(sort_field)) = (name, sort_by) { @@ -1925,16 +2179,27 @@ pub fn parse_view_attributes(attrs: &[Attribute]) -> Vec { pipeline.push(ViewTransform::Take { count: n }); } - views.push(ViewDef { - id: view_name, - source: ViewSource::Entity { - name: String::new(), + views.push(ViewAttributeSpec { + view: ViewDef { + id: view_name, + source: ViewSource::Entity { + name: String::new(), + }, + pipeline, + output, }, - pipeline, - output, + attr_span: attr.span(), + sort_key_span, }); } } - views + Ok(views) +} + +pub fn parse_view_attributes(attrs: &[Attribute]) -> syn::Result> { + Ok(parse_view_attribute_specs(attrs)? + .into_iter() + .map(|spec| spec.view) + .collect()) } diff --git a/hyperstack-macros/src/parse/conditions.rs b/hyperstack-macros/src/parse/conditions.rs index bb896074..13684490 100644 --- a/hyperstack-macros/src/parse/conditions.rs +++ b/hyperstack-macros/src/parse/conditions.rs @@ -1,4 +1,4 @@ -use crate::ast::{ComparisonOp, FieldPath, LogicalOp, ParsedCondition}; +use crate::ast::{ComparisonOp, FieldPath, LogicalOp, ParsedCondition, ResolverCondition}; /// Parse a condition expression string into a ParsedCondition AST /// @@ -7,20 +7,28 @@ use crate::ast::{ComparisonOp, FieldPath, LogicalOp, ParsedCondition}; /// - Logical ops: "amount > 100 && user != \"excluded\"", "a < 10 || a > 1000" /// - Field refs: "amount", "data.field", "accounts.user" /// -/// Returns None if parsing fails (will emit compile error) +#[allow(dead_code)] pub fn parse_condition_expression(expr: &str) -> Option { + parse_condition_expression_strict(expr).ok() +} + +pub fn parse_condition_expression_strict(expr: &str) -> Result { let expr = expr.trim(); + if expr.is_empty() { + return Err("Condition expression cannot be empty".to_string()); + } + // Try to parse as logical expression first (contains && or ||) - if let Some(parsed) = try_parse_logical(expr) { - return Some(parsed); + if let Some(parsed) = try_parse_logical(expr)? { + return Ok(parsed); } // Parse as comparison try_parse_comparison(expr) } -fn try_parse_logical(expr: &str) -> Option { +fn try_parse_logical(expr: &str) -> Result, String> { // Split on && or || (respecting precedence: && before ||) // For simplicity, we can use a basic tokenizer @@ -29,13 +37,13 @@ fn try_parse_logical(expr: &str) -> Option { let left = expr[..pos].trim(); let right = expr[pos + 2..].trim(); - return Some(ParsedCondition::Logical { + return Ok(Some(ParsedCondition::Logical { op: LogicalOp::Or, conditions: vec![ - parse_condition_expression(left)?, - parse_condition_expression(right)?, + parse_condition_expression_strict(left)?, + parse_condition_expression_strict(right)?, ], - }); + })); } // Find top-level && (higher precedence) @@ -43,19 +51,19 @@ fn try_parse_logical(expr: &str) -> Option { let left = expr[..pos].trim(); let right = expr[pos + 2..].trim(); - return Some(ParsedCondition::Logical { + return Ok(Some(ParsedCondition::Logical { op: LogicalOp::And, conditions: vec![ - parse_condition_expression(left)?, - parse_condition_expression(right)?, + parse_condition_expression_strict(left)?, + parse_condition_expression_strict(right)?, ], - }); + })); } - None + Ok(None) } -fn try_parse_comparison(expr: &str) -> Option { +fn try_parse_comparison(expr: &str) -> Result { // Try each comparison operator in order (longer ones first to avoid conflicts) let operators = [ (">=", ComparisonOp::GreaterThanOrEqual), @@ -71,6 +79,30 @@ fn try_parse_comparison(expr: &str) -> Option { let field = expr[..pos].trim(); let value = expr[pos + op_str.len()..].trim(); + if field.is_empty() { + return Err(format!( + "Invalid condition expression '{}': missing field before operator", + expr + )); + } + + if value.is_empty() { + return Err(format!( + "Invalid condition expression '{}': missing value after operator", + expr + )); + } + + if operators + .iter() + .any(|(other_op, _)| value.starts_with(other_op)) + { + return Err(format!( + "Invalid condition expression '{}': unexpected operator sequence near '{}'", + expr, value + )); + } + // Parse field path let field_segments: Vec<&str> = field.split('.').collect(); let field_path = FieldPath::new(&field_segments); @@ -78,7 +110,7 @@ fn try_parse_comparison(expr: &str) -> Option { // Parse value (number, string, or bool) let value_json = parse_value(value)?; - return Some(ParsedCondition::Comparison { + return Ok(ParsedCondition::Comparison { field: field_path, op: op.clone(), value: value_json, @@ -86,7 +118,10 @@ fn try_parse_comparison(expr: &str) -> Option { } } - None + Err(format!( + "Invalid condition expression '{}'. Expected a comparison like 'field > 100' or a logical expression using && / ||", + expr + )) } fn find_top_level_operator(expr: &str, op: &str) -> Option { @@ -94,11 +129,9 @@ fn find_top_level_operator(expr: &str, op: &str) -> Option { let mut depth = 0; let mut in_quotes = false; let mut quote_char = '\0'; + let mut previous_char = None; - let chars: Vec = expr.chars().collect(); - for i in 0..chars.len() { - let c = chars[i]; - + for (byte_idx, c) in expr.char_indices() { if !in_quotes { if c == '"' || c == '\'' { in_quotes = true; @@ -107,26 +140,28 @@ fn find_top_level_operator(expr: &str, op: &str) -> Option { depth += 1; } else if c == ')' { depth -= 1; - } else if depth == 0 && expr[i..].starts_with(op) { - return Some(i); + } else if depth == 0 && expr[byte_idx..].starts_with(op) { + return Some(byte_idx); } - } else if c == quote_char && (i == 0 || chars[i - 1] != '\\') { + } else if c == quote_char && previous_char != Some('\\') { in_quotes = false; } + + previous_char = Some(c); } None } -fn parse_value(value: &str) -> Option { +fn parse_value(value: &str) -> Result { use serde_json::Value; let value_trimmed = value.trim(); if value_trimmed == "ZERO_32" { - return Some(Value::Array(vec![Value::Number(0.into()); 32])); + return Ok(Value::Array(vec![Value::Number(0.into()); 32])); } if value_trimmed == "ZERO_64" { - return Some(Value::Array(vec![Value::Number(0.into()); 64])); + return Ok(Value::Array(vec![Value::Number(0.into()); 64])); } // Remove underscores from numeric literals (Rust style: 1_000_000) @@ -134,23 +169,88 @@ fn parse_value(value: &str) -> Option { // Try parsing as different types if value_clean == "true" { - Some(Value::Bool(true)) + Ok(Value::Bool(true)) } else if value_clean == "false" { - Some(Value::Bool(false)) + Ok(Value::Bool(false)) } else if let Ok(num) = value_clean.parse::() { - Some(Value::Number(num.into())) + Ok(Value::Number(num.into())) } else if let Ok(num) = value_clean.parse::() { - serde_json::Number::from_f64(num).map(Value::Number) + serde_json::Number::from_f64(num) + .map(Value::Number) + .ok_or_else(|| format!("Invalid numeric value '{}'.", value)) } else if (value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\'')) { - Some(Value::String(value[1..value.len() - 1].to_string())) + Ok(Value::String(value[1..value.len() - 1].to_string())) } else { // Could be a field reference - for now, treat as string - Some(Value::String(value.to_string())) + Ok(Value::String(value.to_string())) } } +pub fn parse_resolver_condition_expression(expr: &str) -> Result { + let operators = ["==", "!=", ">=", "<=", ">", "<"]; + for op_str in &operators { + if let Some(pos) = expr.find(op_str) { + let field_path = expr[..pos].trim().to_string(); + let raw_value = expr[pos + op_str.len()..].trim(); + + if field_path.is_empty() { + return Err(format!( + "Invalid condition expression: '{}'. Missing field before operator.", + expr + )); + } + + if raw_value.is_empty() { + return Err(format!( + "Invalid condition expression: '{}'. Missing value after operator.", + expr + )); + } + + if operators + .iter() + .any(|other_op| raw_value.starts_with(other_op)) + { + return Err(format!( + "Invalid condition expression: '{}'. Unexpected operator sequence near '{}'.", + expr, raw_value + )); + } + + let op = match *op_str { + "==" => ComparisonOp::Equal, + "!=" => ComparisonOp::NotEqual, + ">=" => ComparisonOp::GreaterThanOrEqual, + "<=" => ComparisonOp::LessThanOrEqual, + ">" => ComparisonOp::GreaterThan, + "<" => ComparisonOp::LessThan, + _ => unreachable!(), + }; + + let value = match raw_value { + "null" => serde_json::Value::Null, + "true" => serde_json::Value::Bool(true), + "false" => serde_json::Value::Bool(false), + s if s.parse::().is_ok() => serde_json::json!(s.parse::().unwrap()), + s => serde_json::Value::String(s.trim_matches('"').to_string()), + }; + + return Ok(ResolverCondition { + field_path, + op, + value, + }); + } + } + + Err(format!( + "Invalid condition expression: '{}'. Expected format: 'field.path value' (supported operators: ==, !=, >, >=, <, <=)", + expr + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/hyperstack-macros/src/parse/pda_validation.rs b/hyperstack-macros/src/parse/pda_validation.rs index 3907128f..917efbf2 100644 --- a/hyperstack-macros/src/parse/pda_validation.rs +++ b/hyperstack-macros/src/parse/pda_validation.rs @@ -2,6 +2,8 @@ use std::collections::{HashMap, HashSet}; use syn::Error; +use crate::diagnostic::suggestion_or_available_suffix; + use super::idl::IdlSpec; use super::pdas::{ParsedSeedKind, PdasBlock, ProgramPdas}; @@ -23,12 +25,17 @@ impl<'a> PdaValidationContext<'a> { fn validate_program(&self, program: &ProgramPdas) -> Result<(), Error> { let idl = self.idls.get(&program.program_name).ok_or_else(|| { - let available: Vec<_> = self.idls.keys().collect(); + let available: Vec = self.idls.keys().cloned().collect(); Error::new( program.program_name_span, format!( - "unknown program '{}' in pdas! block. Available programs: {:?}", - program.program_name, available + "unknown program '{}' in pdas! block{}", + program.program_name, + suggestion_or_available_suffix( + &program.program_name, + &available, + "Available programs", + ) ), ) })?; @@ -74,8 +81,14 @@ impl<'a> PdaValidationContext<'a> { return Err(Error::new( seed.span, format!( - "account '{}' not found in program '{}'. Available accounts: {:?}", - account_name, program_name, available + "unknown account '{}' in program '{}'{}", + account_name, + program_name, + suggestion_or_available_suffix( + account_name, + &available, + "Available accounts", + ) ), )); } @@ -94,8 +107,14 @@ impl<'a> PdaValidationContext<'a> { Err(Error::new( seed.span, format!( - "arg '{}' not found in any instruction of program '{}'. Available args: {:?}", - name, program_name, available + "unknown instruction argument '{}' in program '{}'{}", + name, + program_name, + suggestion_or_available_suffix( + name, + &available, + "Available instruction arguments", + ) ), )) } @@ -105,8 +124,8 @@ impl<'a> PdaValidationContext<'a> { Err(Error::new( seed.span, format!( - "arg '{}' type mismatch: declared {}, but IDL has {}", - name, arg_type, actual_type + "invalid instruction argument type for '{}'. Expected '{}', found '{}'.", + name, actual_type, arg_type ), )) } else { @@ -245,7 +264,7 @@ mod tests { let result = ctx.validate(&block); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not found")); + assert!(result.unwrap_err().to_string().contains("unknown account")); } #[test] @@ -265,7 +284,10 @@ mod tests { let result = ctx.validate(&block); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("type mismatch")); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid instruction argument type")); } #[test] diff --git a/hyperstack-macros/src/stream_spec/ast_writer.rs b/hyperstack-macros/src/stream_spec/ast_writer.rs index 44a94366..8502ffa8 100644 --- a/hyperstack-macros/src/stream_spec/ast_writer.rs +++ b/hyperstack-macros/src/stream_spec/ast_writer.rs @@ -13,17 +13,20 @@ use crate::ast::writer::{ convert_idl_to_snapshot, parse_population_strategy, parse_transformation, }; use crate::ast::{ - ComparisonOp, ComputedFieldSpec, ConditionExpr, EntitySection, FieldPath, FieldTypeInfo, - HookAction, IdentitySpec, IdlSerializationSnapshot, InstructionHook, KeyResolutionStrategy, + ComputedFieldSpec, ConditionExpr, EntitySection, FieldPath, FieldTypeInfo, HookAction, + IdentitySpec, IdlSerializationSnapshot, InstructionHook, KeyResolutionStrategy, LookupIndexSpec, MappingSource, ResolveStrategy, ResolverCondition, ResolverExtractSpec, ResolverHook, ResolverSpec, ResolverStrategy, ResolverType, SerializableFieldMapping, SerializableHandlerSpec, SerializableStreamSpec, SourceSpec, }; +use crate::diagnostic::{idl_error_to_syn, internal_codegen_error}; use crate::event_type_helpers::{find_idl_for_type, program_name_for_type, IdlLookup}; use crate::parse; use crate::parse::conditions as condition_parser; use crate::parse::idl as idl_parser; use crate::utils::path_to_string; +use hyperstack_idl::error::IdlSearchError; +use hyperstack_idl::search::{lookup_account, lookup_instruction_field, InstructionFieldKind}; use super::computed::{ expr_contains_u64_from_bytes, extract_resolver_type_from_computed_expr, @@ -67,13 +70,13 @@ pub fn build_ast( resolver_hooks: &[parse::ResolveKeyAttribute], pda_registrations: &[parse::RegisterPdaAttribute], derive_from_mappings: &HashMap>, - aggregate_conditions: &HashMap, + aggregate_conditions: &HashMap, computed_fields: &[(String, proc_macro2::TokenStream, syn::Type)], resolve_specs: &[parse::ResolveSpec], section_specs: &[EntitySection], idls: IdlLookup, views: Vec, -) -> SerializableStreamSpec { +) -> syn::Result { let idl = idls.first().map(|(_, idl)| *idl); let handlers = build_handlers( sources_by_type, @@ -82,7 +85,7 @@ pub fn build_ast( lookup_indexes, aggregate_conditions, idls, - ); + )?; let mut resolver_hooks_ast = build_resolver_hooks_ast(resolver_hooks, idls); resolver_hooks_ast.extend(auto_generate_lookup_resolvers( @@ -136,7 +139,7 @@ pub fn build_ast( }) .collect(); - let resolver_specs = build_resolver_specs(resolve_specs); + let resolver_specs = build_resolver_specs(resolve_specs)?; // Build field_mappings from sections - this provides type information for ALL fields let mut field_mappings = BTreeMap::new(); @@ -227,11 +230,16 @@ pub fn build_ast( views, }; // Compute and set the content hash - spec.content_hash = Some(spec.compute_content_hash()); - spec + spec.content_hash = Some(spec.try_compute_content_hash().map_err(|error| { + internal_codegen_error( + proc_macro2::Span::call_site(), + format!("failed to serialize stream spec for hashing: {error}"), + ) + })?); + Ok(spec) } -fn build_resolver_specs(resolve_specs: &[parse::ResolveSpec]) -> Vec { +fn build_resolver_specs(resolve_specs: &[parse::ResolveSpec]) -> syn::Result> { let mut grouped: BTreeMap = BTreeMap::new(); for spec in resolve_specs { @@ -242,8 +250,16 @@ fn build_resolver_specs(resolve_specs: &[parse::ResolveSpec]) -> Vec Vec Vec Vec ResolveStrategy { @@ -297,49 +313,10 @@ fn parse_resolve_strategy(strategy: &str) -> ResolveStrategy { } } -pub fn parse_resolver_condition_from_str(s: &str) -> ResolverCondition { - parse_resolver_condition(s).unwrap_or_else(|e| panic!("{}", e)) -} - -fn parse_resolver_condition(s: &str) -> Result { - // Order matters: two-char operators (>=, <=) must precede single-char (>, <) - // to avoid partial matches. - let operators = ["==", "!=", ">=", "<=", ">", "<"]; - for op_str in &operators { - if let Some(pos) = s.find(op_str) { - let field_path = s[..pos].trim().to_string(); - let raw_value = s[pos + op_str.len()..].trim(); - let op = match *op_str { - "==" => ComparisonOp::Equal, - "!=" => ComparisonOp::NotEqual, - ">=" => ComparisonOp::GreaterThanOrEqual, - "<=" => ComparisonOp::LessThanOrEqual, - ">" => ComparisonOp::GreaterThan, - "<" => ComparisonOp::LessThan, - _ => unreachable!(), - }; - let value = match raw_value { - "null" => serde_json::Value::Null, - "true" => serde_json::Value::Bool(true), - "false" => serde_json::Value::Bool(false), - s if s.parse::().is_ok() => { - serde_json::json!(s.parse::().unwrap()) - } - s => serde_json::Value::String(s.trim_matches('"').to_string()), - }; - return Ok(ResolverCondition { - field_path, - op, - value, - }); - } - } - Err(format!( - "Invalid condition expression: '{}'. \ - Expected format: 'field.path value' \ - (supported operators: ==, !=, >, >=, <, <=)", - s - )) +#[allow(dead_code)] +pub fn parse_resolver_condition_from_str(s: &str) -> syn::Result { + condition_parser::parse_resolver_condition_expression(s) + .map_err(|error| syn::Error::new(proc_macro2::Span::call_site(), error)) } fn resolver_type_key(resolver: &ResolverType) -> String { @@ -376,13 +353,13 @@ pub fn build_and_write_ast( resolver_hooks: &[parse::ResolveKeyAttribute], pda_registrations: &[parse::RegisterPdaAttribute], derive_from_mappings: &HashMap>, - aggregate_conditions: &HashMap, + aggregate_conditions: &HashMap, computed_fields: &[(String, proc_macro2::TokenStream, syn::Type)], resolve_specs: &[parse::ResolveSpec], section_specs: &[EntitySection], idls: IdlLookup, views: Vec, -) -> SerializableStreamSpec { +) -> syn::Result { build_ast( entity_name, primary_keys, @@ -410,9 +387,9 @@ fn build_handlers( events_by_instruction: &HashMap>, primary_keys: &[String], lookup_indexes: &[(String, Option)], - aggregate_conditions: &HashMap, + aggregate_conditions: &HashMap, idls: IdlLookup, -) -> Vec { +) -> syn::Result> { let mut handlers = Vec::new(); // Group sources by type and join key @@ -437,7 +414,7 @@ fn build_handlers( primary_keys, lookup_indexes, idls, - ) { + )? { handlers.push(handler); } } @@ -467,23 +444,23 @@ fn build_handlers( primary_keys, lookup_indexes, idls, - ) { + )? { handlers.push(handler); } } - handlers + Ok(handlers) } fn build_source_handler( source_type: &str, join_key: &Option, mappings: &[parse::MapAttribute], - aggregate_conditions: &HashMap, + aggregate_conditions: &HashMap, primary_keys: &[String], lookup_indexes: &[(String, Option)], idls: IdlLookup, -) -> Option { +) -> syn::Result> { let account_type = source_type.split("::").last().unwrap_or(source_type); let idl = find_idl_for_type(source_type, idls); let program_name = program_name_for_type(source_type, idls); @@ -498,7 +475,14 @@ fn build_source_handler( .iter() .any(|m| m.target_field_name.starts_with("events.")) { - return None; + return Ok(None); + } + + if !is_instruction && !is_cpi_event { + if let Some(idl) = idl { + lookup_account(idl, account_type) + .map_err(|error| idl_error_to_syn(mappings[0].source_type_span, error))?; + } } let mut serializable_mappings = Vec::new(); @@ -548,14 +532,23 @@ fn build_source_handler( if mapping.source_field_name.is_empty() { FieldPath::new(&["data"]) } else { - let prefix = idl - .and_then(|idl| { - idl.get_instruction_field_prefix( - account_type, - &mapping.source_field_name, - ) - }) - .unwrap_or("data"); + let prefix = if let Some(idl) = idl { + match lookup_instruction_field( + idl, + account_type, + &mapping.source_field_name, + ) + .map_err(|error| { + idl_error_to_syn(span_for_map_lookup_error(mapping, &error), error) + })? + .kind + { + InstructionFieldKind::Account => "accounts", + InstructionFieldKind::Arg => "data", + } + } else { + "data" + }; FieldPath::new(&[prefix, &mapping.source_field_name]) } } else if mapping.source_field_name.is_empty() { @@ -576,10 +569,7 @@ fn build_source_handler( let population = parse_population_strategy(&mapping.strategy); - let condition = mapping.condition.as_ref().map(|cond| ConditionExpr { - expression: cond.clone(), - parsed: condition_parser::parse_condition_expression(cond), - }); + let condition = mapping.condition.clone(); let when = mapping.when.as_ref().map(|when_path| { let instr_type = path_to_string(when_path); @@ -620,11 +610,19 @@ fn build_source_handler( // CPI event fields are always in "data" primary_field = Some(format!("data.{}", mapping.source_field_name)); } else if is_instruction { - let prefix = idl - .and_then(|idl| { - idl.get_instruction_field_prefix(account_type, &mapping.source_field_name) - }) - .unwrap_or("data"); + let prefix = if let Some(idl) = idl { + match lookup_instruction_field(idl, account_type, &mapping.source_field_name) + .map_err(|error| { + idl_error_to_syn(span_for_map_lookup_error(mapping, &error), error) + })? + .kind + { + InstructionFieldKind::Account => "accounts", + InstructionFieldKind::Arg => "data", + } + } else { + "data" + }; primary_field = Some(format!("{}.{}", prefix, mapping.source_field_name)); } else { primary_field = Some(mapping.source_field_name.clone()); @@ -760,7 +758,7 @@ fn build_source_handler( format!("{}{}", account_type, type_suffix) }; - Some(SerializableHandlerSpec { + Ok(Some(SerializableHandlerSpec { source: SourceSpec::Source { program_id: None, discriminator: None, @@ -772,7 +770,44 @@ fn build_source_handler( mappings: serializable_mappings, conditions: Vec::new(), emit: true, - }) + })) +} + +fn span_for_map_lookup_error( + mapping: &parse::MapAttribute, + error: &IdlSearchError, +) -> proc_macro2::Span { + match error { + IdlSearchError::NotFound { section, .. } + if section == "instructions" || section == "accounts" || section == "types" => + { + mapping.source_type_span + } + IdlSearchError::NotFound { section, .. } if section.starts_with("instruction fields") => { + mapping.source_field_span + } + IdlSearchError::InvalidPath { .. } => mapping.attr_span, + _ => mapping.attr_span, + } +} + +fn span_for_event_lookup_error( + event_attr: &parse::EventAttribute, + field_spec: &parse::FieldSpec, + error: &IdlSearchError, +) -> proc_macro2::Span { + match error { + IdlSearchError::NotFound { section, .. } if section == "instructions" => { + event_attr.instruction_span.unwrap_or(event_attr.attr_span) + } + IdlSearchError::NotFound { section, .. } if section.starts_with("instruction fields") => { + field_spec.ident.span() + } + IdlSearchError::InvalidPath { .. } => { + event_attr.instruction_span.unwrap_or(event_attr.attr_span) + } + _ => field_spec.ident.span(), + } } fn build_event_handler( @@ -782,7 +817,7 @@ fn build_event_handler( primary_keys: &[String], lookup_indexes: &[(String, Option)], idls: IdlLookup, -) -> Option { +) -> syn::Result> { let instruction_path_str = event_mappings .first() .and_then(|(_, attr, _)| { @@ -803,7 +838,7 @@ fn build_event_handler( }; let parts: Vec<&str> = instruction.split("::").collect(); if parts.len() != 2 { - return None; + return Ok(None); } let program_id = parts[0]; @@ -822,7 +857,7 @@ fn build_event_handler( let captured_fields: Vec = event_attr .capture_fields .iter() - .map(|field_spec| { + .map(|field_spec| -> syn::Result { let field_name = field_spec.ident.to_string(); let transform = event_attr .field_transforms @@ -838,8 +873,14 @@ fn build_event_handler( .or(event_attr.inferred_instruction.as_ref()); if let Some(instr_path) = instruction_path { - find_field_in_instruction(instr_path, &field_name, idl) - .unwrap_or(parse::FieldLocation::InstructionArg) + find_field_in_instruction(instr_path, &field_name, idl).map_err( + |error| { + idl_error_to_syn( + span_for_event_lookup_error(event_attr, field_spec, &error), + error, + ) + }, + )? } else { parse::FieldLocation::InstructionArg } @@ -852,13 +893,13 @@ fn build_event_handler( } }; - MappingSource::FromSource { + Ok(MappingSource::FromSource { path: field_path, default: None, transform, - } + }) }) - .collect(); + .collect::>>()?; MappingSource::AsEvent { fields: captured_fields, @@ -919,8 +960,16 @@ fn build_event_handler( .or(first_event_attr.inferred_instruction.as_ref()); if let Some(instr_path) = instruction_path { - find_field_in_instruction(instr_path, &field_name, idl) - .unwrap_or(parse::FieldLocation::InstructionArg) + find_field_in_instruction(instr_path, &field_name, idl).map_err(|error| { + idl_error_to_syn( + span_for_event_lookup_error( + first_event_attr, + lookup_by_field_spec, + &error, + ), + error, + ) + })? } else { parse::FieldLocation::InstructionArg } @@ -985,7 +1034,7 @@ fn build_event_handler( format!("{}IxState", instruction_type_pascal) }; - Some(SerializableHandlerSpec { + Ok(Some(SerializableHandlerSpec { source: SourceSpec::Source { program_id: Some(program_id.to_string()), discriminator: None, @@ -997,7 +1046,7 @@ fn build_event_handler( mappings: serializable_mappings, conditions: Vec::new(), emit: true, - }) + })) } // ============================================================================ @@ -1140,7 +1189,7 @@ fn auto_generate_lookup_resolvers( fn build_instruction_hooks_ast( pda_registrations: &[parse::RegisterPdaAttribute], derive_from_mappings: &HashMap>, - aggregate_conditions: &HashMap, + aggregate_conditions: &HashMap, sources_by_type: &HashMap>, idls: IdlLookup, ) -> Vec { @@ -1225,10 +1274,7 @@ fn build_instruction_hooks_ast( } }; - let condition = derive_attr.condition.as_ref().map(|cond| ConditionExpr { - expression: cond.clone(), - parsed: condition_parser::parse_condition_expression(cond), - }); + let condition = derive_attr.condition.clone(); let action = HookAction::SetField { target_field: derive_attr.target_field_name.clone(), @@ -1330,7 +1376,7 @@ fn build_instruction_hooks_ast( let mut sorted_aggregate_conditions: Vec<_> = aggregate_conditions.iter().collect(); sorted_aggregate_conditions.sort_by_key(|(k, _)| *k); - for (field_path, condition_str) in sorted_aggregate_conditions { + for (field_path, condition_expr) in sorted_aggregate_conditions { for (source_type, mappings) in &sorted_sources { for mapping in *mappings { if &mapping.target_field_name == field_path @@ -1348,10 +1394,7 @@ fn build_instruction_hooks_ast( format!("{}IxState", instr_base) }; - let condition = ConditionExpr { - expression: condition_str.clone(), - parsed: condition_parser::parse_condition_expression(condition_str), - }; + let condition = condition_expr.clone(); if mapping.strategy == "Count" { let action = HookAction::IncrementField { diff --git a/hyperstack-macros/src/stream_spec/entity.rs b/hyperstack-macros/src/stream_spec/entity.rs index 470a7610..2a0483bd 100644 --- a/hyperstack-macros/src/stream_spec/entity.rs +++ b/hyperstack-macros/src/stream_spec/entity.rs @@ -16,6 +16,7 @@ use std::collections::{HashMap, HashSet}; use quote::{format_ident, quote}; +use syn::spanned::Spanned; use syn::{Fields, GenericArgument, ItemStruct, PathArguments, Type}; use super::resolve_snapshot_source; @@ -25,9 +26,11 @@ use crate::ast::{ UrlSource, UrlTemplatePart, }; use crate::codegen; +use crate::diagnostic::{internal_codegen_error, unknown_value_message}; use crate::event_type_helpers::IdlLookup; use crate::parse; use crate::utils::{path_to_string, to_pascal_case, to_snake_case}; +use crate::validation::{validate_semantics, ComputedFieldValidation, ValidationInput}; // ============================================================================ // Process Entity Result @@ -37,7 +40,7 @@ use crate::utils::{path_to_string, to_pascal_case, to_snake_case}; /// and any auto-generated resolver hooks that need to be threaded into the /// resolver registry. pub struct ProcessEntityResult { - pub token_stream: proc_macro::TokenStream, + pub token_stream: proc_macro2::TokenStream, /// Auto-generated resolver hooks (e.g., for lookup_index account types). /// These come from the AST's `auto_generate_lookup_resolvers()` and need /// to be converted to `ResolverHookSpec` entries in the IDL path. @@ -59,7 +62,7 @@ use super::sections::{ process_nested_struct, }; -pub fn parse_url_template(s: &str) -> Vec { +pub fn parse_url_template(s: &str, span: proc_macro2::Span) -> syn::Result> { let mut parts = Vec::new(); let mut rest = s; @@ -69,7 +72,7 @@ pub fn parse_url_template(s: &str) -> Vec { } let close = rest[open..] .find('}') - .expect("Unclosed '{' in URL template") + .ok_or_else(|| syn::Error::new(span, format!("Unclosed '{{' in URL template: {s}")))? + open; let field_ref = rest[open + 1..close].trim().to_string(); parts.push(UrlTemplatePart::FieldRef(field_ref)); @@ -80,7 +83,7 @@ pub fn parse_url_template(s: &str) -> Vec { parts.push(UrlTemplatePart::Literal(rest.to_string())); } - parts + Ok(parts) } // ============================================================================ @@ -93,7 +96,7 @@ pub fn process_entity_struct( section_structs: HashMap, skip_game_event: bool, stack_name: &str, -) -> ProcessEntityResult { +) -> syn::Result { process_entity_struct_with_idl( input, entity_name, @@ -119,11 +122,11 @@ pub fn process_entity_struct_with_idl( entity_name: String, section_structs: HashMap, skip_game_event: bool, - stack_name: &str, + _stack_name: &str, idls: IdlLookup, resolver_hooks: Vec, pda_registrations: Vec, -) -> ProcessEntityResult { +) -> syn::Result { let _name = syn::Ident::new(&entity_name, input.ident.span()); let state_name = syn::Ident::new(&format!("{}State", entity_name), input.ident.span()); let spec_fn_name = format_ident!("create_{}_spec", to_snake_case(&entity_name)); @@ -144,12 +147,13 @@ pub fn process_entity_struct_with_idl( let program_name = idl.map(|idl| idl.get_name()); let mut has_events = false; let mut computed_fields: Vec<(String, proc_macro2::TokenStream, syn::Type)> = Vec::new(); + let mut computed_field_validations: Vec = Vec::new(); let mut resolve_specs: Vec = Vec::new(); // Level 1: Declarative hook macros passed from caller // resolver_hooks and pda_registrations are now passed as parameters let mut derive_from_mappings: HashMap> = HashMap::new(); - let mut aggregate_conditions: HashMap = HashMap::new(); + let mut aggregate_conditions: HashMap = HashMap::new(); // Collect ALL section names from the entity struct FIRST // This is needed to properly detect cross-section references in #[computed] expressions @@ -189,7 +193,7 @@ pub fn process_entity_struct_with_idl( section_struct, None, idls, - ); + )?; section_specs.push(section); } else { let field_type_info = sections::analyze_field_type_with_idl( @@ -201,7 +205,7 @@ pub fn process_entity_struct_with_idl( field, field_name, field_type_info, - )); + )?); } } } @@ -214,7 +218,7 @@ pub fn process_entity_struct_with_idl( if field_type_info.resolved_type.is_some() || field_type_info.base_type == crate::ast::BaseType::Object { - root_fields.push(field_emit_override(field, field_name, field_type_info)); + root_fields.push(field_emit_override(field, field_name, field_type_info)?); } } } @@ -234,276 +238,261 @@ pub fn process_entity_struct_with_idl( for field in &fields.named { let field_name = field.ident.as_ref().unwrap(); let field_type = &field.ty; + let field_name_str = field_name.to_string(); let mut has_attrs = false; for attr in &field.attrs { - if let Ok(Some(map_attrs)) = - parse::parse_map_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - for map_attr in map_attrs { - process_map_attribute( - &map_attr, - field_name, - field_type, - &mut state_fields, - &mut accessor_defs, - &mut accessor_names, - &mut primary_keys, - &mut lookup_indexes, - &mut sources_by_type, - &mut field_mappings, - ); - } - } else if let Ok(Some(map_attrs)) = - parse::parse_from_instruction_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - for map_attr in map_attrs { - process_map_attribute( - &map_attr, - field_name, - field_type, - &mut state_fields, - &mut accessor_defs, - &mut accessor_names, - &mut primary_keys, - &mut lookup_indexes, - &mut sources_by_type, - &mut field_mappings, - ); + match parse::parse_recognized_field_attribute(attr, &field_name_str)? { + Some(parse::RecognizedFieldAttribute::Map(map_attrs)) + | Some(parse::RecognizedFieldAttribute::FromInstruction(map_attrs)) => { + has_attrs = true; + for map_attr in map_attrs { + process_map_attribute( + &map_attr, + field_name, + field_type, + &mut state_fields, + &mut accessor_defs, + &mut accessor_names, + &mut primary_keys, + &mut lookup_indexes, + &mut sources_by_type, + &mut field_mappings, + ); + } } - } else if let Ok(Some(mut event_attr)) = - parse::parse_event_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - has_events = true; - - state_fields.push(quote! { - pub #field_name: #field_type - }); - - // Determine instruction path (type-safe or legacy) - if let Some((_instruction_path, instruction_str)) = - determine_event_instruction(&mut event_attr, field_type, program_name) - { - events_by_instruction - .entry(instruction_str) - .or_default() - .push(( - event_attr.target_field_name.clone(), - event_attr, - field_type.clone(), - )); - } else { - // Fallback to legacy instruction string - events_by_instruction - .entry(event_attr.instruction.clone()) - .or_default() - .push(( - event_attr.target_field_name.clone(), - event_attr, - field_type.clone(), - )); + Some(parse::RecognizedFieldAttribute::Event(mut event_attr)) => { + has_attrs = true; + has_events = true; + + state_fields.push(quote! { + pub #field_name: #field_type + }); + + if let Some((_instruction_path, instruction_str)) = + determine_event_instruction(&mut event_attr, field_type, program_name) + { + events_by_instruction + .entry(instruction_str) + .or_default() + .push(( + event_attr.target_field_name.clone(), + event_attr, + field_type.clone(), + )); + } else { + events_by_instruction + .entry(event_attr.instruction.clone()) + .or_default() + .push(( + event_attr.target_field_name.clone(), + event_attr, + field_type.clone(), + )); + } } - } else if let Ok(Some(mut snapshot_attr)) = - parse::parse_snapshot_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - - state_fields.push(quote! { - pub #field_name: #field_type - }); - - // Infer account type from field type if not explicitly specified - let account_path = if let Some(ref path) = snapshot_attr.from_account { - Some(path.clone()) - } else if let Some(inferred_path) = extract_account_type_from_field(field_type) - { - snapshot_attr.inferred_account = Some(inferred_path.clone()); - Some(inferred_path) - } else { - None - }; - - if let Some(acct_path) = account_path { - let source_type_str = path_to_string(&acct_path); - - // Determine source field name and whether this is a whole-source capture - let (source_field_name, is_whole_source) = - resolve_snapshot_source(&snapshot_attr); - - let map_attr = parse::MapAttribute { - source_type_path: acct_path, - source_field_name, - target_field_name: snapshot_attr.target_field_name.clone(), - is_primary_key: false, - is_lookup_index: false, - register_from: Vec::new(), - temporal_field: None, - strategy: snapshot_attr.strategy.clone(), - join_on: snapshot_attr - .join_on - .as_ref() - .map(|fs| fs.ident.to_string()), - transform: None, - resolver_transform: None, - is_instruction: false, - is_whole_source, - lookup_by: snapshot_attr.lookup_by.clone(), - condition: None, - when: snapshot_attr.when.clone(), - stop: None, - stop_lookup_by: None, - emit: true, + Some(parse::RecognizedFieldAttribute::Snapshot(mut snapshot_attr)) => { + has_attrs = true; + + state_fields.push(quote! { + pub #field_name: #field_type + }); + + let account_path = if let Some(ref path) = snapshot_attr.from_account { + Some(path.clone()) + } else if let Some(inferred_path) = + extract_account_type_from_field(field_type) + { + snapshot_attr.inferred_account = Some(inferred_path.clone()); + Some(inferred_path) + } else { + None }; - sources_by_type - .entry(source_type_str) - .or_default() - .push(map_attr); + if let Some(acct_path) = account_path { + let source_type_str = path_to_string(&acct_path); + let (source_field_name, is_whole_source) = + resolve_snapshot_source(&snapshot_attr); + let source_field_span = snapshot_attr + .field + .as_ref() + .map(|field| field.span()) + .unwrap_or(snapshot_attr.attr_span); + + let map_attr = parse::MapAttribute { + attr_span: snapshot_attr.attr_span, + source_type_span: acct_path.span(), + source_field_span, + source_type_path: acct_path, + source_field_name, + target_field_name: snapshot_attr.target_field_name.clone(), + is_primary_key: false, + is_lookup_index: false, + register_from: Vec::new(), + temporal_field: None, + strategy: snapshot_attr.strategy.clone(), + join_on: snapshot_attr + .join_on + .as_ref() + .map(|fs| fs.ident.to_string()), + transform: None, + resolver_transform: None, + is_instruction: false, + is_whole_source, + lookup_by: snapshot_attr.lookup_by.clone(), + condition: None, + when: snapshot_attr.when.clone(), + stop: None, + stop_lookup_by: None, + emit: true, + }; + + sources_by_type + .entry(source_type_str) + .or_default() + .push(map_attr); + } } - } else if let Ok(Some(aggr_attr)) = - parse::parse_aggregate_attribute(attr, &field_name.to_string()) - { - has_attrs = true; + Some(parse::RecognizedFieldAttribute::Aggregate(aggr_attr)) => { + has_attrs = true; - state_fields.push(quote! { - pub #field_name: #field_type - }); + state_fields.push(quote! { + pub #field_name: #field_type + }); - // Level 1: Store condition for later AST generation - if let Some(condition) = &aggr_attr.condition { - let field_path = format!("{}.{}", entity_name, field_name); - aggregate_conditions.insert(field_path, condition.clone()); - } - - // Convert aggregate to map attributes for each instruction - for instr_path in &aggr_attr.from_instructions { - let source_field_name = aggr_attr - .field - .as_ref() - .map(|fs| fs.ident.to_string()) - .unwrap_or_default(); - - let map_attr = parse::MapAttribute { - source_type_path: instr_path.clone(), - source_field_name, - target_field_name: aggr_attr.target_field_name.clone(), - is_primary_key: false, - is_lookup_index: false, - register_from: Vec::new(), - temporal_field: None, - strategy: aggr_attr.strategy.clone(), - join_on: aggr_attr.join_on.as_ref().map(|fs| fs.ident.to_string()), - transform: aggr_attr.transform.as_ref().map(|t| t.to_string()), - resolver_transform: None, - is_instruction: true, - is_whole_source: false, - lookup_by: aggr_attr.lookup_by.clone(), - condition: None, - when: None, - stop: None, - stop_lookup_by: None, - emit: true, - }; + if let Some(condition) = &aggr_attr.condition { + let field_path = format!("{}.{}", entity_name, field_name); + aggregate_conditions.insert(field_path, condition.clone()); + } - // Add to sources_by_type for handler generation - let source_type_str = path_to_string(instr_path); - sources_by_type - .entry(source_type_str) - .or_default() - .push(map_attr); + for instr_path in &aggr_attr.from_instructions { + let source_field_name = aggr_attr + .field + .as_ref() + .map(|fs| fs.ident.to_string()) + .unwrap_or_default(); + let source_field_span = aggr_attr + .field + .as_ref() + .map(|field| field.ident.span()) + .unwrap_or(aggr_attr.attr_span); + + let map_attr = parse::MapAttribute { + attr_span: aggr_attr.attr_span, + source_type_span: instr_path.span(), + source_field_span, + source_type_path: instr_path.clone(), + source_field_name, + target_field_name: aggr_attr.target_field_name.clone(), + is_primary_key: false, + is_lookup_index: false, + register_from: Vec::new(), + temporal_field: None, + strategy: aggr_attr.strategy.clone(), + join_on: aggr_attr.join_on.as_ref().map(|fs| fs.ident.to_string()), + transform: aggr_attr.transform.as_ref().map(|t| t.to_string()), + resolver_transform: None, + is_instruction: true, + is_whole_source: false, + lookup_by: aggr_attr.lookup_by.clone(), + condition: None, + when: None, + stop: None, + stop_lookup_by: None, + emit: true, + }; + + let source_type_str = path_to_string(instr_path); + sources_by_type + .entry(source_type_str) + .or_default() + .push(map_attr); + } } - } else if let Ok(Some(derive_attr)) = - parse::parse_derive_from_attribute(attr, &field_name.to_string()) - { - // Level 1: Process #[derive_from] attribute - // This code successfully parses derive_from attributes and adds them to derive_from_mappings. - // The AST writer then processes these mappings to populate instruction_hooks in the AST. - has_attrs = true; - state_fields.push(quote! { pub #field_name: #field_type }); - - // Group by instruction for handler merging - for instr_path in &derive_attr.from_instructions { - let source_type_str = path_to_string(instr_path); - derive_from_mappings - .entry(source_type_str) - .or_default() - .push(derive_attr.clone()); + Some(parse::RecognizedFieldAttribute::DeriveFrom(derive_attr)) => { + has_attrs = true; + state_fields.push(quote! { pub #field_name: #field_type }); + + for instr_path in &derive_attr.from_instructions { + let source_type_str = path_to_string(instr_path); + derive_from_mappings + .entry(source_type_str) + .or_default() + .push(derive_attr.clone()); + } } - } else if let Ok(Some(resolve_attr)) = - parse::parse_resolve_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - - state_fields.push(quote! { - pub #field_name: #field_type - }); - - // Determine resolver type: URL resolver if url is present, otherwise Token resolver - let resolver = if let Some(url_path) = resolve_attr.url.clone() { - // URL resolver - let method = resolve_attr - .method - .as_deref() - .map(|m| match m.to_lowercase().as_str() { - "post" => HttpMethod::Post, - _ => HttpMethod::Get, + Some(parse::RecognizedFieldAttribute::Resolve(resolve_attr)) => { + has_attrs = true; + + state_fields.push(quote! { + pub #field_name: #field_type + }); + + let resolver = if let Some(url_path) = resolve_attr.url.clone() { + let method = resolve_attr + .method + .as_deref() + .map(|m| match m.to_lowercase().as_str() { + "post" => HttpMethod::Post, + _ => HttpMethod::Get, + }) + .unwrap_or(HttpMethod::Get); + + let url_source = if resolve_attr.url_is_template { + UrlSource::Template(parse_url_template(&url_path, attr.span())?) + } else { + UrlSource::FieldPath(url_path) + }; + + ResolverType::Url(UrlResolverConfig { + url_source, + method, + extract_path: resolve_attr.extract.clone(), }) - .unwrap_or(HttpMethod::Get); + } else if let Some(name) = resolve_attr.resolver.as_deref() { + parse_resolver_type_name(name, field_type)? + } else { + infer_resolver_type(field_type)? + }; - let url_source = if resolve_attr.url_is_template { - UrlSource::Template(parse_url_template(&url_path)) + let from = if resolve_attr.url_is_template { + None } else { - UrlSource::FieldPath(url_path) + resolve_attr.url.clone().or(resolve_attr.from) }; - ResolverType::Url(UrlResolverConfig { - url_source, - method, - extract_path: resolve_attr.extract.clone(), - }) - } else if let Some(name) = resolve_attr.resolver.as_deref() { - // Token resolver with explicit type - parse_resolver_type_name(name, field_type) - .unwrap_or_else(|err| panic!("{}", err)) - } else { - // Token resolver with inferred type - infer_resolver_type(field_type).unwrap_or_else(|err| panic!("{}", err)) - }; - - let from = if resolve_attr.url_is_template { - None - } else { - resolve_attr.url.clone().or(resolve_attr.from) - }; - - resolve_specs.push(parse::ResolveSpec { - resolver, - from, - address: resolve_attr.address, - extract: resolve_attr.extract, - target_field_name: resolve_attr.target_field_name, - strategy: resolve_attr.strategy, - condition: resolve_attr.condition, - schedule_at: resolve_attr.schedule_at, - }); - } else if let Ok(Some(computed_attr)) = - parse::parse_computed_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - - state_fields.push(quote! { - pub #field_name: #field_type - }); - - // Store computed field for later processing (after aggregations) - computed_fields.push(( - computed_attr.target_field_name.clone(), - computed_attr.expression.clone(), - field_type.clone(), - )); + resolve_specs.push(parse::ResolveSpec { + attr_span: resolve_attr.attr_span, + from_span: resolve_attr.from_span, + resolver, + from, + address: resolve_attr.address, + extract: resolve_attr.extract, + target_field_name: resolve_attr.target_field_name, + strategy: resolve_attr.strategy, + condition: resolve_attr.condition, + schedule_at: resolve_attr.schedule_at, + }); + } + Some(parse::RecognizedFieldAttribute::Computed(computed_attr)) => { + has_attrs = true; + + state_fields.push(quote! { + pub #field_name: #field_type + }); + + computed_fields.push(( + computed_attr.target_field_name.clone(), + computed_attr.expression.clone(), + field_type.clone(), + )); + computed_field_validations.push(ComputedFieldValidation { + target_path: computed_attr.target_field_name.clone(), + expression: computed_attr.expression.clone(), + span: computed_attr.attr_span, + }); + } + None => {} } } @@ -526,11 +515,12 @@ pub fn process_entity_struct_with_idl( &mut events_by_instruction, &mut has_events, &mut computed_fields, + &mut computed_field_validations, &mut resolve_specs, &mut derive_from_mappings, &mut aggregate_conditions, program_name, - ); + )?; } } } @@ -573,8 +563,9 @@ pub fn process_entity_struct_with_idl( } } - let mut views = parse::parse_view_attributes(&input.attrs); - for view in &mut views { + let mut view_specs = parse::parse_view_attribute_specs(&input.attrs)?; + for view_spec in &mut view_specs { + let view = &mut view_spec.view; if let crate::ast::ViewSource::Entity { name } = &mut view.source { *name = entity_name.clone(); } @@ -582,6 +573,19 @@ pub fn process_entity_struct_with_idl( view.id = format!("{}/{}", entity_name, view.id); } } + validate_semantics(ValidationInput { + entity_name: &entity_name, + sources_by_type: &sources_by_type, + events_by_instruction: &events_by_instruction, + derive_from_mappings: &derive_from_mappings, + computed_fields: &computed_field_validations, + resolve_specs: &resolve_specs, + section_specs: §ion_specs, + view_specs: &view_specs, + idls, + })?; + + let views = view_specs.into_iter().map(|spec| spec.view).collect(); let ast = build_and_write_ast( &entity_name, @@ -598,7 +602,14 @@ pub fn process_entity_struct_with_idl( §ion_specs, idls, views, - ); + )?; + + let spec_json = serde_json::to_string(&ast).map_err(|error| { + internal_codegen_error( + input.ident.span(), + format!("failed to serialize embedded stream spec: {error}"), + ) + })?; let explicit_account_types: HashSet = resolver_hooks .iter() @@ -695,7 +706,6 @@ pub fn process_entity_struct_with_idl( let field_accessors = codegen::generate_field_accessors(§ion_specs); let module_name = format_ident!("{}", to_snake_case(&entity_name)); - let stack_file_name = format!("{}.stack.json", stack_name); let output = quote! { #[derive(Debug, Clone, hyperstack::runtime::serde::Serialize, hyperstack::runtime::serde::Deserialize)] @@ -717,21 +727,10 @@ pub fn process_entity_struct_with_idl( } pub fn #spec_fn_name() -> hyperstack::runtime::hyperstack_interpreter::ast::TypedStreamSpec<#state_name> { - let stack_json = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/.hyperstack/", #stack_file_name)); - let stack_spec: hyperstack::runtime::hyperstack_interpreter::ast::SerializableStackSpec = - hyperstack::runtime::serde_json::from_str(stack_json) - .expect("Failed to parse stack AST file"); - - let entity_spec = stack_spec - .entities - .iter() - .find(|e| e.state_name == #entity_name) - .expect(&format!("Entity {} not found in stack AST", #entity_name)); - - let mut spec = entity_spec.clone(); - if spec.idl.is_none() { - spec.idl = stack_spec.idls.first().cloned(); - } + let spec_json = #spec_json; + let spec: hyperstack::runtime::hyperstack_interpreter::ast::SerializableStreamSpec = + hyperstack::runtime::serde_json::from_str(spec_json) + .unwrap_or_else(|error| panic!("embedded stream spec is invalid: {}", error)); hyperstack::runtime::hyperstack_interpreter::ast::TypedStreamSpec::from_serializable(spec) } @@ -739,34 +738,31 @@ pub fn process_entity_struct_with_idl( #(#handler_fns)* }; - ProcessEntityResult { - token_stream: output.into(), + Ok(ProcessEntityResult { + token_stream: output, auto_resolver_hooks, ast_spec: Some(ast), - } + }) } fn field_emit_override( field: &syn::Field, field_name: String, mut field_type_info: FieldTypeInfo, -) -> FieldTypeInfo { +) -> syn::Result { let mut found_mapping = false; let mut any_emit = false; for attr in &field.attrs { - if let Ok(Some(map_attrs)) = parse::parse_map_attribute(attr, &field_name) { - found_mapping = true; - if map_attrs.iter().any(|m| m.emit) { - any_emit = true; - } - } - - if let Ok(Some(map_attrs)) = parse::parse_from_instruction_attribute(attr, &field_name) { - found_mapping = true; - if map_attrs.iter().any(|m| m.emit) { - any_emit = true; + match parse::parse_recognized_field_attribute(attr, &field_name)? { + Some(parse::RecognizedFieldAttribute::Map(map_attrs)) + | Some(parse::RecognizedFieldAttribute::FromInstruction(map_attrs)) => { + found_mapping = true; + if map_attrs.iter().any(|m| m.emit) { + any_emit = true; + } } + _ => {} } } @@ -774,7 +770,7 @@ fn field_emit_override( field_type_info.emit = any_emit; } - field_type_info + Ok(field_type_info) } pub(super) fn parse_resolver_type_name(name: &str, field_type: &Type) -> syn::Result { @@ -782,7 +778,12 @@ pub(super) fn parse_resolver_type_name(name: &str, field_type: &Type) -> syn::Re "token" => Ok(ResolverType::Token), _ => Err(syn::Error::new_spanned( field_type, - format!("Unknown resolver type '{}'.", name), + unknown_value_message( + "resolver", + name, + "Available resolvers", + &["Token".to_string()], + ), )), } } @@ -796,7 +797,12 @@ pub(super) fn infer_resolver_type(field_type: &Type) -> syn::Result Ok(ResolverType::Token), _ => Err(syn::Error::new_spanned( field_type, - format!("No resolver registered for type '{}'.", type_ident), + unknown_value_message( + "resolver-backed type", + &type_ident, + "Available types", + &["TokenMetadata".to_string()], + ), )), } } @@ -1006,12 +1012,12 @@ fn generate_computed_fields_hook( let deps = section_dependencies.get(section).cloned().unwrap_or_default(); let section_str = section.as_str(); - + // Collect all computed field names in this section for cache tracking let computed_field_names: Vec = fields.iter().map(|(field_name, _expression, _field_type)| { field_name.clone() }).collect(); - + let field_evaluations: Vec<_> = fields.iter().map(|(field_name, expression, field_type)| { let field_str = field_name.as_str(); let field_ident = format_ident!("{}", field_name); diff --git a/hyperstack-macros/src/stream_spec/handlers.rs b/hyperstack-macros/src/stream_spec/handlers.rs index 233fcc89..c22b4dac 100644 --- a/hyperstack-macros/src/stream_spec/handlers.rs +++ b/hyperstack-macros/src/stream_spec/handlers.rs @@ -7,12 +7,16 @@ //! - Processing event fields for mapping use quote::{format_ident, quote}; +use syn::spanned::Spanned; use syn::{Path, Type}; use crate::ast::{ResolverHook, ResolverStrategy}; +use crate::diagnostic::idl_error_to_syn; use crate::parse; use crate::parse::idl as idl_parser; use crate::utils::{path_to_string, to_snake_case}; +use hyperstack_idl::error::IdlSearchError; +use hyperstack_idl::search::{lookup_instruction_field, InstructionFieldKind}; // ============================================================================ // Type Extraction Helpers @@ -143,7 +147,7 @@ pub fn find_field_in_instruction( instruction_path: &Path, field_name: &str, idl: Option<&idl_parser::IdlSpec>, -) -> Result { +) -> Result { let idl = match idl { Some(idl) => idl, None => return Ok(parse::FieldLocation::InstructionArg), // Default to arg if no IDL @@ -154,44 +158,13 @@ pub fn find_field_in_instruction( .segments .last() .map(|s| s.ident.to_string()) - .ok_or_else(|| "Invalid instruction path".to_string())?; - - // Check IDL - if let Some(prefix) = idl.get_instruction_field_prefix(&instruction_name, field_name) { - match prefix { - "accounts" => Ok(parse::FieldLocation::Account), - "data" => Ok(parse::FieldLocation::InstructionArg), - _ => Ok(parse::FieldLocation::InstructionArg), - } - } else { - // Field not found - collect available fields for error message - let mut available_fields = Vec::new(); - - for instruction in &idl.instructions { - if instruction.name.eq_ignore_ascii_case(&instruction_name) { - for account in &instruction.accounts { - available_fields.push(format!("accounts::{}", account.name)); - } - for arg in &instruction.args { - available_fields.push(format!("args::{}", arg.name)); - } - break; - } - } + .ok_or_else(|| IdlSearchError::InvalidPath { + path: path_to_string(instruction_path), + })?; - if available_fields.is_empty() { - Err(format!( - "Instruction '{}' not found in IDL", - instruction_name - )) - } else { - Err(format!( - "Field '{}' not found in instruction '{}'. Available fields: {}", - field_name, - instruction_name, - available_fields.join(", ") - )) - } + match lookup_instruction_field(idl, &instruction_name, field_name)?.kind { + InstructionFieldKind::Account => Ok(parse::FieldLocation::Account), + InstructionFieldKind::Arg => Ok(parse::FieldLocation::InstructionArg), } } @@ -294,6 +267,9 @@ pub fn convert_event_to_map_attributes( if !has_fields { // Whole instruction capture - create a single mapping for the whole source map_attrs.push(parse::MapAttribute { + attr_span: event_attr.attr_span, + source_type_span: instruction_path.span(), + source_field_span: event_attr.attr_span, source_type_path: instruction_path.clone(), source_field_name: String::new(), target_field_name: target_field.to_string(), @@ -326,6 +302,9 @@ pub fn convert_event_to_map_attributes( .map(|t| t.to_string()); map_attrs.push(parse::MapAttribute { + attr_span: event_attr.attr_span, + source_type_span: instruction_path.span(), + source_field_span: field_spec.ident.span(), source_type_path: instruction_path.clone(), source_field_name: field_name.clone(), target_field_name: format!("{}.{}", target_field, field_name), @@ -356,6 +335,9 @@ pub fn convert_event_to_map_attributes( .map(|t| t.to_string()); map_attrs.push(parse::MapAttribute { + attr_span: event_attr.attr_span, + source_type_span: instruction_path.span(), + source_field_span: event_attr.attr_span, source_type_path: instruction_path.clone(), source_field_name: field_name.clone(), target_field_name: format!("{}.{}", target_field, field_name), @@ -614,7 +596,7 @@ pub fn validate_event_fields( match find_field_in_instruction(instruction_path, &field_name, idl) { Ok(loc) => loc, Err(err_msg) => { - return Err(syn::Error::new(field_spec.ident.span(), err_msg)); + return Err(idl_error_to_syn(field_spec.ident.span(), err_msg)); } } }; diff --git a/hyperstack-macros/src/stream_spec/idl_spec.rs b/hyperstack-macros/src/stream_spec/idl_spec.rs index 81053091..849442a9 100644 --- a/hyperstack-macros/src/stream_spec/idl_spec.rs +++ b/hyperstack-macros/src/stream_spec/idl_spec.rs @@ -6,21 +6,22 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use proc_macro::TokenStream; use quote::quote; +use syn::spanned::Spanned; use syn::{Item, ItemMod}; use crate::ast::writer::{extract_instructions_from_idl, extract_pdas_from_idl}; use crate::ast::SerializableStackSpec; use crate::codegen::generate_multi_entity_builder; +use crate::diagnostic::{idl_error_to_syn, internal_codegen_error, parse_generated_items}; use crate::idl_codegen; use crate::idl_parser_gen; use crate::idl_vixen_gen; use crate::parse; use crate::parse::idl as idl_parser; -use crate::parse::pda_validation::PdaValidationContext; use crate::parse::pdas::PdasBlock; use crate::utils::{to_pascal_case, to_snake_case}; +use crate::validation::validate_pda_blocks; use super::entity::process_entity_struct_with_idl; use super::handlers::{ @@ -36,7 +37,10 @@ struct IdlInfo { parser_module_name: String, } -pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStream { +pub fn process_idl_spec( + mut module: ItemMod, + idl_paths: &[String], +) -> syn::Result { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); let mut idl_infos: Vec = Vec::new(); @@ -47,11 +51,13 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea let idl = match idl_parser::parse_idl_file(&full_path) { Ok(idl) => idl, Err(e) => { - let error_msg = format!("Failed to parse IDL file {}: {}", idl_path, e); - return quote! { - compile_error!(#error_msg); - } - .into(); + return Err(idl_error_to_syn( + module.ident.span(), + hyperstack_idl::error::IdlSearchError::ParseError { + path: idl_path.clone(), + source: e, + }, + )); } }; @@ -135,13 +141,7 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea if item_macro.mac.path.is_ident("pdas") { match syn::parse2::(item_macro.mac.tokens.clone()) { Ok(block) => manual_pdas_blocks.push(block), - Err(e) => { - let err_msg = e.to_string(); - return quote! { - compile_error!(#err_msg); - } - .into(); - } + Err(e) => return Err(e), } } } @@ -150,14 +150,14 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea let mut all_resolver_hooks = Vec::new(); for impl_block in &impl_blocks { - let hooks = parse::extract_resolver_hooks(impl_block); + let hooks = parse::extract_resolver_hooks(impl_block)?; all_resolver_hooks.extend(hooks); } if let Some((_, items)) = &module.content { for item in items { if let Item::Fn(item_fn) = item { - let hooks = parse::extract_resolver_hooks_from_fn(item_fn); + let hooks = parse::extract_resolver_hooks_from_fn(item_fn)?; all_resolver_hooks.extend(hooks); } } @@ -168,17 +168,17 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea // Collect per-entity PDA registrations to avoid cross-entity contamination let per_entity_pda_regs = - collect_pda_registrations_per_entity(&entity_structs, §ion_structs); + collect_pda_registrations_per_entity(&entity_structs, §ion_structs)?; if let Some((_, items)) = &module.content { for item in items { if let Item::Struct(item_struct) = item { for attr in &item_struct.attrs { - if let Ok(Some(resolve_attr)) = parse::parse_resolve_key_attribute(attr) { + if let Some(resolve_attr) = parse::parse_resolve_key_attribute(attr)? { resolver_hooks.push(resolve_attr); } - if let Ok(Some(register_attr)) = parse::parse_register_pda_attribute(attr) { + if let Some(register_attr) = parse::parse_register_pda_attribute(attr)? { pda_registrations.push(register_attr); } } @@ -192,7 +192,7 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea §ion_structs, &mut resolver_hooks, &mut pda_registrations, - ); + )?; let mut seen_resolver_fns: HashSet = HashSet::new(); resolver_hooks.retain(|hook| { @@ -215,7 +215,7 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea .unwrap_or_else(|| "unknown".to_string()); let fn_name = syn::Ident::new( &format!("resolve_{}_key", to_snake_case(&account_name)), - proc_macro2::Span::call_site(), + resolve_attr.account_path.span(), ); let fn_sig: syn::Signature = syn::parse_quote! { @@ -237,7 +237,7 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea for (i, pda_attr) in pda_registrations.iter().enumerate() { let fn_name = syn::Ident::new( &format!("register_pda_{}", i), - proc_macro2::Span::call_site(), + pda_attr.instruction_path.span(), ); let fn_sig: syn::Signature = syn::parse_quote! { @@ -278,7 +278,7 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea .get(&entity_name) .cloned() .unwrap_or_default(), - ); + )?; for hook in &result.auto_resolver_hooks { let account_name = @@ -341,26 +341,32 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea } for sdk_tokens in &all_sdk_tokens { - if let Ok(generated_items) = syn::parse::(sdk_tokens.clone().into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items( + sdk_tokens.clone(), + module.ident.span(), + "IDL SDK module", + )? { + items.push(gen_item); } } for parser_tokens in &all_parser_tokens { - if let Ok(generated_items) = syn::parse::(parser_tokens.clone().into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items( + parser_tokens.clone(), + module.ident.span(), + "IDL parser module", + )? { + items.push(gen_item); } } for result in &all_outputs { - if let Ok(generated_items) = syn::parse::(result.token_stream.clone()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items( + result.token_stream.clone(), + module.ident.span(), + "entity expansion", + )? { + items.push(gen_item); } } @@ -378,10 +384,10 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea } if !deduped_auto_hooks.is_empty() { let auto_fns = generate_auto_resolver_functions(&deduped_auto_hooks); - if let Ok(generated_items) = syn::parse::(auto_fns.into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in + parse_generated_items(auto_fns, module.ident.span(), "auto resolver functions")? + { + items.push(gen_item); } } @@ -392,10 +398,12 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea #resolver_fns #pda_registration_fns }; - if let Ok(generated_items) = syn::parse::(combined_hook_fns.into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items( + combined_hook_fns, + module.ident.span(), + "resolver hook functions", + )? { + items.push(gen_item); } let entity_asts: Vec = all_outputs @@ -433,17 +441,9 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea .iter() .map(|info| (info.program_name.clone(), info.idl.clone())) .collect(); - let validation_ctx = PdaValidationContext::new(&idl_map); + validate_pda_blocks(&idl_map, &manual_pdas_blocks)?; for manual_block in &manual_pdas_blocks { - if let Err(e) = validation_ctx.validate(manual_block) { - let err_msg = e.to_string(); - return quote! { - compile_error!(#err_msg); - } - .into(); - } - for program_pdas in &manual_block.programs { let program_entry = all_pdas .entry(program_pdas.program_name.clone()) @@ -471,28 +471,53 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea instructions: all_instructions, content_hash: None, } - .with_content_hash(); - - if let Err(e) = crate::ast::writer::write_stack_to_file(&stack_spec, &stack_name) { - eprintln!("Warning: Failed to write stack AST: {}", e); - } - - let multi_entity_builder = - generate_multi_entity_builder(&entity_names, &[], false, &stack_name); - if let Ok(generated_items) = syn::parse::(multi_entity_builder.into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + .try_with_content_hash() + .map_err(|error| { + internal_codegen_error( + module.ident.span(), + format!("failed to serialize stack spec for hashing: {error}"), + ) + })?; + + let stack_spec_json = serde_json::to_string(&stack_spec).map_err(|error| { + internal_codegen_error( + module.ident.span(), + format!("failed to serialize embedded stack spec: {error}"), + ) + })?; + + crate::ast::writer::write_stack_to_file(&stack_spec, &stack_name).map_err(|e| { + syn::Error::new( + module.ident.span(), + format!("Failed to write stack AST: {e}"), + ) + })?; + + let multi_entity_builder = generate_multi_entity_builder( + &entity_names, + &[], + false, + &stack_name, + &stack_spec_json, + ); + for gen_item in parse_generated_items( + multi_entity_builder, + module.ident.span(), + "multi-entity builder", + )? { + items.push(gen_item); } let resolver_registries = idl_vixen_gen::generate_resolver_registries( &all_resolver_hooks, &primary.program_name, ); - if let Ok(generated_items) = syn::parse::(resolver_registries.into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items( + resolver_registries, + module.ident.span(), + "resolver registries", + )? { + items.push(gen_item); } let spec_function = idl_vixen_gen::generate_multi_idl_spec_function( @@ -507,34 +532,35 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea }) .collect::>(), ); - if let Ok(generated_items) = syn::parse::(spec_function.into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in + parse_generated_items(spec_function, module.ident.span(), "IDL spec function")? + { + items.push(gen_item); } } } else if let Some((_brace, items)) = &mut module.content { for sdk_tokens in &all_sdk_tokens { - if let Ok(generated_items) = syn::parse::(sdk_tokens.clone().into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in + parse_generated_items(sdk_tokens.clone(), module.ident.span(), "IDL SDK module")? + { + items.push(gen_item); } } for parser_tokens in &all_parser_tokens { - if let Ok(generated_items) = syn::parse::(parser_tokens.clone().into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items( + parser_tokens.clone(), + module.ident.span(), + "IDL parser module", + )? { + items.push(gen_item); } } } - quote! { + Ok(quote! { #module - } - .into() + }) } fn collect_register_from_specs( @@ -542,7 +568,7 @@ fn collect_register_from_specs( section_structs: &HashMap, resolver_hooks: &mut Vec, pda_registrations: &mut Vec, -) { +) -> syn::Result<()> { let mut all_structs_to_scan: Vec<&syn::ItemStruct> = entity_structs.iter().collect(); all_structs_to_scan.extend(section_structs.values()); @@ -555,7 +581,9 @@ fn collect_register_from_specs( .map(|i| i.to_string()) .unwrap_or_default(); for attr in &field.attrs { - if let Ok(Some(map_attrs)) = parse::parse_map_attribute(attr, &field_name) { + if let Some(parse::RecognizedFieldAttribute::Map(map_attrs)) = + parse::parse_recognized_field_attribute(attr, &field_name)? + { for map_attr in &map_attrs { if !map_attr.register_from.is_empty() { let account_path = map_attr.source_type_path.clone(); @@ -566,6 +594,7 @@ fn collect_register_from_specs( .collect(); resolver_hooks.push(parse::ResolveKeyAttribute { + attr_span: map_attr.attr_span, account_path, strategy: "pda_reverse_lookup".to_string(), lookup_name: None, @@ -574,6 +603,7 @@ fn collect_register_from_specs( for rf in &map_attr.register_from { pda_registrations.push(parse::RegisterPdaAttribute { + attr_span: map_attr.attr_span, instruction_path: rf.instruction_path.clone(), pda_field: rf.pda_field.clone(), primary_key_field: rf.primary_key_field.clone(), @@ -587,6 +617,8 @@ fn collect_register_from_specs( } } } + + Ok(()) } /// Collect PDA registrations per-entity to prevent cross-entity contamination. @@ -599,7 +631,7 @@ fn collect_register_from_specs( fn collect_pda_registrations_per_entity( entity_structs: &[syn::ItemStruct], section_structs: &HashMap, -) -> HashMap> { +) -> syn::Result>> { let mut per_entity_regs: HashMap> = HashMap::new(); for entity_struct in entity_structs { @@ -631,11 +663,14 @@ fn collect_pda_registrations_per_entity( .map(|i| i.to_string()) .unwrap_or_default(); for attr in &field.attrs { - if let Ok(Some(map_attrs)) = parse::parse_map_attribute(attr, &field_name) { + if let Some(parse::RecognizedFieldAttribute::Map(map_attrs)) = + parse::parse_recognized_field_attribute(attr, &field_name)? + { for map_attr in &map_attrs { if !map_attr.register_from.is_empty() { for rf in &map_attr.register_from { entity_regs.push(parse::RegisterPdaAttribute { + attr_span: map_attr.attr_span, instruction_path: rf.instruction_path.clone(), pda_field: rf.pda_field.clone(), primary_key_field: rf.primary_key_field.clone(), @@ -655,5 +690,5 @@ fn collect_pda_registrations_per_entity( } } - per_entity_regs + Ok(per_entity_regs) } diff --git a/hyperstack-macros/src/stream_spec/mod.rs b/hyperstack-macros/src/stream_spec/mod.rs index 75118958..7decb1e7 100644 --- a/hyperstack-macros/src/stream_spec/mod.rs +++ b/hyperstack-macros/src/stream_spec/mod.rs @@ -37,7 +37,7 @@ //! ``` mod ast_writer; -mod computed; +pub(crate) mod computed; mod entity; mod handlers; mod idl_spec; diff --git a/hyperstack-macros/src/stream_spec/module.rs b/hyperstack-macros/src/stream_spec/module.rs index 68f3bc9b..cdefe838 100644 --- a/hyperstack-macros/src/stream_spec/module.rs +++ b/hyperstack-macros/src/stream_spec/module.rs @@ -11,6 +11,7 @@ use syn::{Item, ItemMod}; use crate::ast::SerializableStackSpec; use crate::codegen::generate_multi_entity_builder; +use crate::diagnostic::{internal_codegen_error, parse_generated_items}; use crate::parse; use crate::parse::proto as proto_parser; use crate::proto_codegen; @@ -19,6 +20,12 @@ use crate::utils::to_pascal_case; use super::entity::process_entity_struct; use super::proto_struct::process_struct_with_context; +type ParsedProtoAttrs = ( + Vec<(String, proto_parser::ProtoAnalysis)>, + bool, + Vec, +); + // ============================================================================ // Module Processing // ============================================================================ @@ -29,13 +36,17 @@ use super::proto_struct::process_struct_with_context; /// - Proto-based streams with `proto = ["file.proto"]` attribute /// - IDL-based streams with `idl = "file.json"` attribute /// - Multi-entity modules with multiple `#[entity]` structs -pub fn process_module(mut module: ItemMod, attr: TokenStream) -> TokenStream { +pub fn process_module( + mut module: ItemMod, + attr: TokenStream, +) -> syn::Result { let mut section_structs = HashMap::new(); let mut main_struct = None; let mut entity_structs = Vec::new(); let mut has_game_event = false; - let (proto_analyses, skip_decoders, idl_files) = parse_proto_files_from_attr(attr.clone()); + let (proto_analyses, skip_decoders, idl_files) = + parse_proto_files_from_attr(attr.clone(), module.ident.span())?; if !idl_files.is_empty() { return super::idl_spec::process_idl_spec(module, &idl_files); @@ -92,7 +103,7 @@ pub fn process_module(mut module: ItemMod, attr: TokenStream) -> TokenStream { section_structs.clone(), has_game_event, &stack_name, - ); + )?; all_outputs.push(output); } @@ -109,18 +120,23 @@ pub fn process_module(mut module: ItemMod, attr: TokenStream) -> TokenStream { if !proto_analyses.is_empty() { let proto_modules = proto_codegen::generate_proto_module_declarations(&proto_analyses); - if let Ok(generated_items) = syn::parse::(proto_modules.into()) { - for gen_item in generated_items.items.into_iter().rev() { - items.insert(0, gen_item); - } + let generated_items = parse_generated_items( + proto_modules, + module.ident.span(), + "proto module declarations", + )?; + for gen_item in generated_items.into_iter().rev() { + items.insert(0, gen_item); } } for output in &all_outputs { - if let Ok(generated_items) = syn::parse::(output.token_stream.clone()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items( + output.token_stream.clone(), + module.ident.span(), + "entity expansion", + )? { + items.push(gen_item); } } @@ -139,28 +155,48 @@ pub fn process_module(mut module: ItemMod, attr: TokenStream) -> TokenStream { instructions: vec![], content_hash: None, } - .with_content_hash(); - - if let Err(e) = crate::ast::writer::write_stack_to_file(&stack_spec, &stack_name) { - eprintln!("Warning: Failed to write stack AST: {}", e); - } + .try_with_content_hash() + .map_err(|error| { + internal_codegen_error( + module.ident.span(), + format!("failed to serialize stack spec for hashing: {error}"), + ) + })?; + + let stack_spec_json = serde_json::to_string(&stack_spec).map_err(|error| { + internal_codegen_error( + module.ident.span(), + format!("failed to serialize embedded stack spec: {error}"), + ) + })?; + + crate::ast::writer::write_stack_to_file(&stack_spec, &stack_name).map_err(|e| { + syn::Error::new( + module.ident.span(), + format!("Failed to write stack AST: {e}"), + ) + })?; let multi_entity_builder = generate_multi_entity_builder( &entity_names, &proto_analyses, skip_decoders, &stack_name, + &stack_spec_json, ); - if let Ok(generated_items) = syn::parse::(multi_entity_builder.into()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items( + multi_entity_builder, + module.ident.span(), + "multi-entity builder", + )? { + items.push(gen_item); } } - quote! { #module }.into() + Ok(quote! { #module }) } else if let Some(main) = main_struct { - let output = process_struct_with_context(main, section_structs, has_game_event); + let main_span = main.ident.span(); + let output = process_struct_with_context(main, section_structs, has_game_event)?; if let Some((_brace, items)) = &mut module.content { items.retain(|item| { @@ -173,16 +209,14 @@ pub fn process_module(mut module: ItemMod, attr: TokenStream) -> TokenStream { } }); - if let Ok(generated_items) = syn::parse::(output.clone()) { - for gen_item in generated_items.items { - items.push(gen_item); - } + for gen_item in parse_generated_items(output, main_span, "module struct expansion")? { + items.push(gen_item); } } - quote! { #module }.into() + Ok(quote! { #module }) } else { - quote! { #module }.into() + Ok(quote! { #module }) } } @@ -192,20 +226,14 @@ pub fn process_module(mut module: ItemMod, attr: TokenStream) -> TokenStream { pub fn parse_proto_files_from_attr( attr: TokenStream, -) -> ( - Vec<(String, proto_parser::ProtoAnalysis)>, - bool, - Vec, -) { - let hyperstack_attr = match parse::parse_stream_spec_attribute(attr) { - Ok(attr) => attr, - Err(_) => return (Vec::new(), false, Vec::new()), - }; + span: proc_macro2::Span, +) -> syn::Result { + let hyperstack_attr = parse::parse_stream_spec_attribute(attr)?; let idl_files = hyperstack_attr.idl_files.clone(); if hyperstack_attr.proto_files.is_empty() { - return (Vec::new(), hyperstack_attr.skip_decoders, idl_files); + return Ok((Vec::new(), hyperstack_attr.skip_decoders, idl_files)); } let mut analyses = Vec::new(); @@ -220,13 +248,16 @@ pub fn parse_proto_files_from_attr( analyses.push((proto_path.clone(), analysis)); } Err(e) => { - eprintln!( - "Warning: Failed to parse proto file {} (full path: {:?}): {}", - proto_path, full_path, e - ); + return Err(syn::Error::new( + span, + format!( + "Failed to parse proto file {} (full path: {:?}): {}", + proto_path, full_path, e + ), + )); } } } - (analyses, hyperstack_attr.skip_decoders, idl_files) + Ok((analyses, hyperstack_attr.skip_decoders, idl_files)) } diff --git a/hyperstack-macros/src/stream_spec/proto_struct.rs b/hyperstack-macros/src/stream_spec/proto_struct.rs index c1353282..7ae18d41 100644 --- a/hyperstack-macros/src/stream_spec/proto_struct.rs +++ b/hyperstack-macros/src/stream_spec/proto_struct.rs @@ -5,8 +5,8 @@ use std::collections::{HashMap, HashSet}; -use proc_macro::TokenStream; use quote::{format_ident, quote}; +use syn::spanned::Spanned; use syn::{Fields, ItemStruct, Type}; use crate::parse; @@ -28,7 +28,7 @@ pub fn process_struct_with_context( input: ItemStruct, section_structs: HashMap, skip_game_event: bool, -) -> TokenStream { +) -> syn::Result { let name = &input.ident; let state_name = syn::Ident::new(&format!("{}State", name), name.span()); @@ -45,112 +45,123 @@ pub fn process_struct_with_context( > = HashMap::new(); let mut has_events = false; let mut computed_fields: Vec<(String, proc_macro2::TokenStream, Type)> = Vec::new(); + let mut computed_field_validations = Vec::new(); let mut resolve_specs: Vec = Vec::new(); let mut derive_from_mappings: HashMap> = HashMap::new(); - let mut aggregate_conditions: HashMap = HashMap::new(); + let mut aggregate_conditions: HashMap = HashMap::new(); if let Fields::Named(fields) = &input.fields { for field in &fields.named { let field_name = field.ident.as_ref().unwrap(); let field_type = &field.ty; + let field_name_str = field_name.to_string(); let mut has_attrs = false; for attr in &field.attrs { - if let Ok(Some(map_attrs)) = - parse::parse_map_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - for map_attr in map_attrs { - process_map_attribute( - &map_attr, - field_name, - field_type, - &mut state_fields, - &mut accessor_defs, - &mut accessor_names, - &mut primary_keys, - &mut lookup_indexes, - &mut sources_by_type, - &mut field_mappings, - ); - } - } else if let Ok(Some(map_attrs)) = - parse::parse_from_instruction_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - for map_attr in map_attrs { - process_map_attribute( - &map_attr, - field_name, - field_type, - &mut state_fields, - &mut accessor_defs, - &mut accessor_names, - &mut primary_keys, - &mut lookup_indexes, - &mut sources_by_type, - &mut field_mappings, - ); + match parse::parse_recognized_field_attribute(attr, &field_name_str)? { + Some(parse::RecognizedFieldAttribute::Map(map_attrs)) + | Some(parse::RecognizedFieldAttribute::FromInstruction(map_attrs)) => { + has_attrs = true; + for map_attr in map_attrs { + process_map_attribute( + &map_attr, + field_name, + field_type, + &mut state_fields, + &mut accessor_defs, + &mut accessor_names, + &mut primary_keys, + &mut lookup_indexes, + &mut sources_by_type, + &mut field_mappings, + ); + } } - } else if let Ok(Some(mut event_attr)) = - parse::parse_event_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - has_events = true; - - state_fields.push(quote! { - pub #field_name: #field_type - }); - - // Determine instruction path (type-safe or legacy) - if let Some((_instruction_path, instruction_str)) = - determine_event_instruction(&mut event_attr, field_type, None) - { - events_by_instruction - .entry(instruction_str) - .or_default() - .push(( - event_attr.target_field_name.clone(), - event_attr, - field_type.clone(), - )); - } else { - // Fallback to legacy instruction string - events_by_instruction - .entry(event_attr.instruction.clone()) - .or_default() - .push(( - event_attr.target_field_name.clone(), - event_attr, - field_type.clone(), - )); + Some(parse::RecognizedFieldAttribute::Event(mut event_attr)) => { + has_attrs = true; + has_events = true; + + state_fields.push(quote! { + pub #field_name: #field_type + }); + + if let Some((_instruction_path, instruction_str)) = + determine_event_instruction(&mut event_attr, field_type, None) + { + events_by_instruction + .entry(instruction_str) + .or_default() + .push(( + event_attr.target_field_name.clone(), + event_attr, + field_type.clone(), + )); + } else { + events_by_instruction + .entry(event_attr.instruction.clone()) + .or_default() + .push(( + event_attr.target_field_name.clone(), + event_attr, + field_type.clone(), + )); + } } - } else if let Ok(Some(resolve_attr)) = - parse::parse_resolve_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - - state_fields.push(quote! { - pub #field_name: #field_type - }); - - let resolver = if let Some(name) = resolve_attr.resolver.as_deref() { - parse_resolver_type_name(name, field_type) - } else { - infer_resolver_type(field_type) + Some(parse::RecognizedFieldAttribute::Resolve(resolve_attr)) => { + has_attrs = true; + + state_fields.push(quote! { + pub #field_name: #field_type + }); + + let resolver = if let Some(url_path) = resolve_attr.url.clone() { + let method = resolve_attr + .method + .as_deref() + .map(|m| match m.to_lowercase().as_str() { + "post" => crate::ast::HttpMethod::Post, + _ => crate::ast::HttpMethod::Get, + }) + .unwrap_or(crate::ast::HttpMethod::Get); + + let url_source = if resolve_attr.url_is_template { + crate::ast::UrlSource::Template(super::entity::parse_url_template( + &url_path, + attr.span(), + )?) + } else { + crate::ast::UrlSource::FieldPath(url_path) + }; + + crate::ast::ResolverType::Url(crate::ast::UrlResolverConfig { + url_source, + method, + extract_path: resolve_attr.extract.clone(), + }) + } else if let Some(name) = resolve_attr.resolver.as_deref() { + parse_resolver_type_name(name, field_type)? + } else { + infer_resolver_type(field_type)? + }; + + resolve_specs.push(parse::ResolveSpec { + attr_span: resolve_attr.attr_span, + from_span: resolve_attr.from_span, + resolver, + from: if resolve_attr.url_is_template { + None + } else { + resolve_attr.url.clone().or(resolve_attr.from) + }, + address: resolve_attr.address, + extract: resolve_attr.extract, + target_field_name: resolve_attr.target_field_name, + strategy: resolve_attr.strategy, + condition: resolve_attr.condition, + schedule_at: resolve_attr.schedule_at, + }); } - .unwrap_or_else(|err| panic!("{}", err)); - - resolve_specs.push(parse::ResolveSpec { - resolver, - from: resolve_attr.from, - address: resolve_attr.address, - extract: resolve_attr.extract, - target_field_name: resolve_attr.target_field_name, - strategy: resolve_attr.strategy, - condition: resolve_attr.condition, - schedule_at: resolve_attr.schedule_at, - }); + Some(_) | None => {} } } @@ -173,11 +184,12 @@ pub fn process_struct_with_context( &mut events_by_instruction, &mut has_events, &mut computed_fields, + &mut computed_field_validations, &mut resolve_specs, &mut derive_from_mappings, &mut aggregate_conditions, None, - ); + )?; } } } @@ -447,7 +459,7 @@ pub fn process_struct_with_context( let resolver_specs_code: Vec<_> = resolver_specs_by_key .into_iter() - .map(|((resolver, _input_key, strategy), specs)| { + .map(|((resolver, _input_key, strategy), specs)| -> syn::Result { let resolver_code = match resolver { crate::ast::ResolverType::Token => quote! { hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Token @@ -539,9 +551,9 @@ pub fn process_struct_with_context( }) .collect(); - let condition_code = match specs.first().and_then(|s| s.condition.as_deref()) { + let condition_code = match specs.first().and_then(|s| s.condition.as_ref()) { Some(cond_str) => { - let parsed = super::ast_writer::parse_resolver_condition_from_str(cond_str); + let parsed = &cond_str.parsed; let field_path = &parsed.field_path; let op_code = match parsed.op { crate::ast::ComparisonOp::Equal => quote! { hyperstack::runtime::hyperstack_interpreter::ast::ComparisonOp::Equal }, @@ -573,11 +585,14 @@ pub fn process_struct_with_context( }; let schedule_at_code = match specs.first().and_then(|s| s.schedule_at.as_ref()) { - Some(path) => quote! { Some(#path.to_string()) }, + Some(path) => { + let raw = &path.raw; + quote! { Some(#raw.to_string()) } + } None => quote! { None }, }; - quote! { + Ok(quote! { hyperstack::runtime::hyperstack_interpreter::ast::ResolverSpec { resolver: #resolver_code, input_path: #input_path_code, @@ -589,9 +604,9 @@ pub fn process_struct_with_context( condition: #condition_code, schedule_at: #schedule_at_code, } - } + }) }) - .collect(); + .collect::>>()?; let output = quote! { #[derive(Debug, Clone, hyperstack::runtime::serde::Serialize, hyperstack::runtime::serde::Deserialize)] @@ -628,5 +643,5 @@ pub fn process_struct_with_context( #(#handler_fns)* }; - output.into() + Ok(output) } diff --git a/hyperstack-macros/src/stream_spec/sections.rs b/hyperstack-macros/src/stream_spec/sections.rs index 96cde9ca..855515ec 100644 --- a/hyperstack-macros/src/stream_spec/sections.rs +++ b/hyperstack-macros/src/stream_spec/sections.rs @@ -8,6 +8,7 @@ use std::collections::{HashMap, HashSet}; use quote::quote; +use syn::spanned::Spanned; use syn::{Fields, ItemStruct, Type}; use crate::ast::{BaseType, EntitySection, FieldTypeInfo, ResolvedField, ResolvedStructType}; @@ -29,7 +30,7 @@ pub fn extract_section_from_struct( section_name: &str, item_struct: &ItemStruct, parent_field: Option, -) -> EntitySection { +) -> syn::Result { extract_section_from_struct_with_idl(section_name, item_struct, parent_field, &[]) } @@ -38,7 +39,7 @@ pub fn extract_section_from_struct_with_idl( item_struct: &ItemStruct, parent_field: Option, idls: IdlLookup, -) -> EntitySection { +) -> syn::Result { let mut fields = Vec::new(); if let Fields::Named(struct_fields) = &item_struct.fields { @@ -49,45 +50,38 @@ pub fn extract_section_from_struct_with_idl( let rust_type_name = quote::quote!(#field_ty).to_string(); let mut field_type_info = analyze_field_type_with_idl(&field_name, &rust_type_name, idls); - field_type_info.emit = field_emit_from_attrs(field, &field_name); + field_type_info.emit = field_emit_from_attrs(field, &field_name)?; fields.push(field_type_info); } } } - EntitySection { + Ok(EntitySection { name: section_name.to_string(), fields, is_nested_struct: parent_field.is_some(), parent_field, - } + }) } -fn field_emit_from_attrs(field: &syn::Field, field_name: &str) -> bool { +fn field_emit_from_attrs(field: &syn::Field, field_name: &str) -> syn::Result { let mut found_mapping = false; let mut any_emit = false; for attr in &field.attrs { - if let Ok(Some(map_attrs)) = parse::parse_map_attribute(attr, field_name) { - found_mapping = true; - if map_attrs.iter().any(|m| m.emit) { - any_emit = true; - } - } - - if let Ok(Some(map_attrs)) = parse::parse_from_instruction_attribute(attr, field_name) { - found_mapping = true; - if map_attrs.iter().any(|m| m.emit) { - any_emit = true; + match parse::parse_recognized_field_attribute(attr, field_name)? { + Some(parse::RecognizedFieldAttribute::Map(map_attrs)) + | Some(parse::RecognizedFieldAttribute::FromInstruction(map_attrs)) => { + found_mapping = true; + if map_attrs.iter().any(|m| m.emit) { + any_emit = true; + } } + _ => {} } } - if found_mapping { - any_emit - } else { - true - } + Ok(if found_mapping { any_emit } else { true }) } // ============================================================================ @@ -306,11 +300,12 @@ pub fn process_nested_struct( events_by_instruction: &mut HashMap>, has_events: &mut bool, computed_fields: &mut Vec<(String, proc_macro2::TokenStream, Type)>, + computed_field_validations: &mut Vec, resolve_specs: &mut Vec, derive_from_mappings: &mut HashMap>, - aggregate_conditions: &mut HashMap, + aggregate_conditions: &mut HashMap, program_name: Option<&str>, -) { +) -> syn::Result<()> { let section_name = section_field_name.to_string(); let mut nested_fields = Vec::new(); @@ -319,371 +314,317 @@ pub fn process_nested_struct( for field in &fields.named { let field_name = field.ident.as_ref().unwrap(); let field_type = &field.ty; + let field_name_str = field_name.to_string(); nested_fields.push(quote! { pub #field_name: #field_type }); for attr in &field.attrs { - if let Ok(Some(map_attrs)) = - parse::parse_map_attribute(attr, &field_name.to_string()) - { - for mut map_attr in map_attrs { - if !map_attr.target_field_name.contains('.') { - map_attr.target_field_name = - format!("{}.{}", section_name, map_attr.target_field_name); + match parse::parse_recognized_field_attribute(attr, &field_name_str)? { + Some(parse::RecognizedFieldAttribute::Map(map_attrs)) + | Some(parse::RecognizedFieldAttribute::FromInstruction(map_attrs)) => { + for mut map_attr in map_attrs { + if !map_attr.target_field_name.contains('.') { + map_attr.target_field_name = + format!("{}.{}", section_name, map_attr.target_field_name); + } + + if let Some(rt) = map_attr.resolver_transform.take() { + let visible_target = map_attr.target_field_name.clone(); + let raw_field_name = format!("__{}_raw", field_name); + let raw_target = format!("{}.{}", section_name, raw_field_name); + + map_attr.target_field_name = raw_target.clone(); + map_attr.emit = false; + + super::entity::process_map_attribute( + &map_attr, + field_name, + field_type, + &mut Vec::new(), + accessor_defs, + accessor_names, + primary_keys, + lookup_indexes, + sources_by_type, + field_mappings, + ); + + let raw_ref = raw_target.clone(); + let computed_expr: proc_macro2::TokenStream = { + let raw_ident: proc_macro2::TokenStream = + raw_ref.replace('.', " . ").parse().unwrap_or_default(); + let method_ident: proc_macro2::TokenStream = + rt.method.parse().unwrap_or_default(); + let args = &rt.args; + quote::quote! { #raw_ident . #method_ident ( #args ) } + }; + + computed_fields.push(( + visible_target, + computed_expr, + field_type.clone(), + )); + } else { + super::entity::process_map_attribute( + &map_attr, + field_name, + field_type, + &mut Vec::new(), + accessor_defs, + accessor_names, + primary_keys, + lookup_indexes, + sources_by_type, + field_mappings, + ); + } } + } + Some(parse::RecognizedFieldAttribute::Event(mut event_attr)) => { + *has_events = true; - if let Some(rt) = map_attr.resolver_transform.take() { - let visible_target = map_attr.target_field_name.clone(); - let raw_field_name = format!("__{}_raw", field_name); - let raw_target = format!("{}.{}", section_name, raw_field_name); - - map_attr.target_field_name = raw_target.clone(); - map_attr.emit = false; - - super::entity::process_map_attribute( - &map_attr, - field_name, - field_type, - &mut Vec::new(), - accessor_defs, - accessor_names, - primary_keys, - lookup_indexes, - sources_by_type, - field_mappings, - ); - - let raw_ref = raw_target.clone(); - let computed_expr: proc_macro2::TokenStream = { - let raw_ident: proc_macro2::TokenStream = - raw_ref.replace('.', " . ").parse().unwrap_or_default(); - let method_ident: proc_macro2::TokenStream = - rt.method.parse().unwrap_or_default(); - let args = &rt.args; - quote::quote! { #raw_ident . #method_ident ( #args ) } - }; + if !event_attr.target_field_name.contains('.') { + event_attr.target_field_name = + format!("{}.{}", section_name, event_attr.target_field_name); + } - computed_fields.push(( - visible_target, - computed_expr, - field_type.clone(), - )); + if let Some((_instruction_path, instruction_str)) = + determine_event_instruction(&mut event_attr, field_type, program_name) + { + events_by_instruction + .entry(instruction_str) + .or_default() + .push(( + event_attr.target_field_name.clone(), + event_attr, + field_type.clone(), + )); } else { - super::entity::process_map_attribute( - &map_attr, - field_name, - field_type, - &mut Vec::new(), - accessor_defs, - accessor_names, - primary_keys, - lookup_indexes, - sources_by_type, - field_mappings, - ); + events_by_instruction + .entry(event_attr.instruction.clone()) + .or_default() + .push(( + event_attr.target_field_name.clone(), + event_attr, + field_type.clone(), + )); } } - } else if let Ok(Some(map_attrs)) = - parse::parse_from_instruction_attribute(attr, &field_name.to_string()) - { - for mut map_attr in map_attrs { - if !map_attr.target_field_name.contains('.') { - map_attr.target_field_name = - format!("{}.{}", section_name, map_attr.target_field_name); + Some(parse::RecognizedFieldAttribute::Snapshot(mut snapshot_attr)) => { + if !snapshot_attr.target_field_name.contains('.') { + snapshot_attr.target_field_name = + format!("{}.{}", section_name, snapshot_attr.target_field_name); } - if let Some(rt) = map_attr.resolver_transform.take() { - let visible_target = map_attr.target_field_name.clone(); - let raw_field_name = format!("__{}_raw", field_name); - let raw_target = format!("{}.{}", section_name, raw_field_name); - - map_attr.target_field_name = raw_target.clone(); - map_attr.emit = false; - - super::entity::process_map_attribute( - &map_attr, - field_name, - field_type, - &mut Vec::new(), - accessor_defs, - accessor_names, - primary_keys, - lookup_indexes, - sources_by_type, - field_mappings, - ); - - let raw_ref = raw_target.clone(); - let computed_expr: proc_macro2::TokenStream = { - let raw_ident: proc_macro2::TokenStream = - raw_ref.replace('.', " . ").parse().unwrap_or_default(); - let method_ident: proc_macro2::TokenStream = - rt.method.parse().unwrap_or_default(); - let args = &rt.args; - quote::quote! { #raw_ident . #method_ident ( #args ) } + let account_path = if let Some(ref path) = snapshot_attr.from_account { + Some(path.clone()) + } else if let Some(inferred_path) = + extract_account_type_from_field(field_type) + { + snapshot_attr.inferred_account = Some(inferred_path.clone()); + Some(inferred_path) + } else { + None + }; + + if let Some(acct_path) = account_path { + let source_type_str = path_to_string(&acct_path); + let (source_field_name, is_whole_source) = + resolve_snapshot_source(&snapshot_attr); + let source_field_span = snapshot_attr + .field + .as_ref() + .map(|field| field.span()) + .unwrap_or(snapshot_attr.attr_span); + + let map_attr = parse::MapAttribute { + attr_span: snapshot_attr.attr_span, + source_type_span: acct_path.span(), + source_field_span, + source_type_path: acct_path, + source_field_name, + target_field_name: snapshot_attr.target_field_name.clone(), + is_primary_key: false, + is_lookup_index: false, + register_from: Vec::new(), + temporal_field: None, + strategy: snapshot_attr.strategy.clone(), + join_on: snapshot_attr + .join_on + .as_ref() + .map(|fs| fs.ident.to_string()), + transform: None, + resolver_transform: None, + is_instruction: false, + is_whole_source, + lookup_by: snapshot_attr.lookup_by.clone(), + condition: None, + when: snapshot_attr.when.clone(), + stop: None, + stop_lookup_by: None, + emit: true, }; - computed_fields.push(( - visible_target, - computed_expr, - field_type.clone(), - )); - continue; + sources_by_type + .entry(source_type_str) + .or_default() + .push(map_attr); } - - super::entity::process_map_attribute( - &map_attr, - field_name, - field_type, - &mut Vec::new(), - accessor_defs, - accessor_names, - primary_keys, - lookup_indexes, - sources_by_type, - field_mappings, - ); - } - } else if let Ok(Some(mut event_attr)) = - parse::parse_event_attribute(attr, &field_name.to_string()) - { - *has_events = true; - - if !event_attr.target_field_name.contains('.') { - event_attr.target_field_name = - format!("{}.{}", section_name, event_attr.target_field_name); } + Some(parse::RecognizedFieldAttribute::Aggregate(mut aggr_attr)) => { + if !aggr_attr.target_field_name.contains('.') { + aggr_attr.target_field_name = + format!("{}.{}", section_name, aggr_attr.target_field_name); + } - // Determine instruction path (type-safe or legacy) - if let Some((_instruction_path, instruction_str)) = - determine_event_instruction(&mut event_attr, field_type, program_name) - { - events_by_instruction - .entry(instruction_str) - .or_default() - .push(( - event_attr.target_field_name.clone(), - event_attr, - field_type.clone(), - )); - } else { - // Fallback to legacy instruction string - events_by_instruction - .entry(event_attr.instruction.clone()) - .or_default() - .push(( - event_attr.target_field_name.clone(), - event_attr, - field_type.clone(), - )); - } - } else if let Ok(Some(mut snapshot_attr)) = - parse::parse_snapshot_attribute(attr, &field_name.to_string()) - { - // Add section prefix if needed - if !snapshot_attr.target_field_name.contains('.') { - snapshot_attr.target_field_name = - format!("{}.{}", section_name, snapshot_attr.target_field_name); - } + if let Some(condition) = &aggr_attr.condition { + aggregate_conditions + .insert(aggr_attr.target_field_name.clone(), condition.clone()); + } - // Infer account type from field type if not explicitly specified - let account_path = if let Some(ref path) = snapshot_attr.from_account { - Some(path.clone()) - } else if let Some(inferred_path) = extract_account_type_from_field(field_type) - { - snapshot_attr.inferred_account = Some(inferred_path.clone()); - Some(inferred_path) - } else { - None - }; - - if let Some(acct_path) = account_path { - let source_type_str = path_to_string(&acct_path); - - // Determine source field name and whether this is a whole-source capture - let (source_field_name, is_whole_source) = - resolve_snapshot_source(&snapshot_attr); - - let map_attr = parse::MapAttribute { - source_type_path: acct_path, - source_field_name, - target_field_name: snapshot_attr.target_field_name.clone(), - is_primary_key: false, - is_lookup_index: false, - register_from: Vec::new(), - temporal_field: None, - strategy: snapshot_attr.strategy.clone(), - join_on: snapshot_attr - .join_on + for instr_path in &aggr_attr.from_instructions { + let source_field_name = aggr_attr + .field .as_ref() - .map(|fs| fs.ident.to_string()), - transform: None, - resolver_transform: None, - is_instruction: false, - is_whole_source, - lookup_by: snapshot_attr.lookup_by.clone(), - condition: None, - when: snapshot_attr.when.clone(), - stop: None, - stop_lookup_by: None, - emit: true, - }; - - sources_by_type - .entry(source_type_str) - .or_default() - .push(map_attr); - } - } else if let Ok(Some(mut aggr_attr)) = - parse::parse_aggregate_attribute(attr, &field_name.to_string()) - { - // Add section prefix if needed - if !aggr_attr.target_field_name.contains('.') { - aggr_attr.target_field_name = - format!("{}.{}", section_name, aggr_attr.target_field_name); - } + .map(|fs| fs.ident.to_string()) + .unwrap_or_default(); + let source_field_span = aggr_attr + .field + .as_ref() + .map(|field| field.ident.span()) + .unwrap_or(aggr_attr.attr_span); + + let map_attr = parse::MapAttribute { + attr_span: aggr_attr.attr_span, + source_type_span: instr_path.span(), + source_field_span, + source_type_path: instr_path.clone(), + source_field_name, + target_field_name: aggr_attr.target_field_name.clone(), + is_primary_key: false, + is_lookup_index: false, + register_from: Vec::new(), + temporal_field: None, + strategy: aggr_attr.strategy.clone(), + join_on: aggr_attr.join_on.as_ref().map(|fs| fs.ident.to_string()), + transform: aggr_attr.transform.as_ref().map(|t| t.to_string()), + resolver_transform: None, + is_instruction: true, + is_whole_source: false, + lookup_by: aggr_attr.lookup_by.clone(), + condition: None, + when: None, + stop: None, + stop_lookup_by: None, + emit: true, + }; - // Store condition for later AST generation (with section prefix) - if let Some(condition) = &aggr_attr.condition { - aggregate_conditions - .insert(aggr_attr.target_field_name.clone(), condition.clone()); + let source_type_str = path_to_string(instr_path); + sources_by_type + .entry(source_type_str) + .or_default() + .push(map_attr); + } } + Some(parse::RecognizedFieldAttribute::DeriveFrom(mut derive_attr)) => { + if !derive_attr.target_field_name.contains('.') { + derive_attr.target_field_name = + format!("{}.{}", section_name, derive_attr.target_field_name); + } - // Convert aggregate to map attributes for each instruction - for instr_path in &aggr_attr.from_instructions { - let source_field_name = aggr_attr - .field - .as_ref() - .map(|fs| fs.ident.to_string()) - .unwrap_or_default(); - - let map_attr = parse::MapAttribute { - source_type_path: instr_path.clone(), - source_field_name, - target_field_name: aggr_attr.target_field_name.clone(), - is_primary_key: false, - is_lookup_index: false, - register_from: Vec::new(), - temporal_field: None, - strategy: aggr_attr.strategy.clone(), - join_on: aggr_attr.join_on.as_ref().map(|fs| fs.ident.to_string()), - transform: aggr_attr.transform.as_ref().map(|t| t.to_string()), - resolver_transform: None, - is_instruction: true, - is_whole_source: false, - lookup_by: aggr_attr.lookup_by.clone(), - condition: None, - when: None, - stop: None, - stop_lookup_by: None, - emit: true, - }; - - // Add to sources_by_type for handler generation - let source_type_str = path_to_string(instr_path); - sources_by_type - .entry(source_type_str) - .or_default() - .push(map_attr); - } - } else if let Ok(Some(mut derive_attr)) = - parse::parse_derive_from_attribute(attr, &field_name.to_string()) - { - // Add section prefix if needed - if !derive_attr.target_field_name.contains('.') { - derive_attr.target_field_name = - format!("{}.{}", section_name, derive_attr.target_field_name); + for instr_path in &derive_attr.from_instructions { + let source_type_str = path_to_string(instr_path); + derive_from_mappings + .entry(source_type_str) + .or_default() + .push(derive_attr.clone()); + } } + Some(parse::RecognizedFieldAttribute::Computed(mut computed_attr)) => { + if !computed_attr.target_field_name.contains('.') { + computed_attr.target_field_name = + format!("{}.{}", section_name, computed_attr.target_field_name); + } - // Group by instruction for handler merging - for instr_path in &derive_attr.from_instructions { - let source_type_str = path_to_string(instr_path); - derive_from_mappings - .entry(source_type_str) - .or_default() - .push(derive_attr.clone()); - } - } else if let Ok(Some(mut computed_attr)) = - parse::parse_computed_attribute(attr, &field_name.to_string()) - { - // Add section prefix if needed - if !computed_attr.target_field_name.contains('.') { - computed_attr.target_field_name = - format!("{}.{}", section_name, computed_attr.target_field_name); + computed_fields.push(( + computed_attr.target_field_name.clone(), + computed_attr.expression.clone(), + field_type.clone(), + )); + computed_field_validations.push( + crate::validation::ComputedFieldValidation { + target_path: computed_attr.target_field_name.clone(), + expression: computed_attr.expression.clone(), + span: computed_attr.attr_span, + }, + ); } + Some(parse::RecognizedFieldAttribute::Resolve(resolve_attr)) => { + let qualified_url = resolve_attr.url.as_deref().map(|url_path_raw| { + if url_path_raw.contains('.') { + url_path_raw.to_string() + } else { + format!("{}.{}", section_name, url_path_raw) + } + }); + + let resolver = if let Some(ref url_path) = qualified_url { + let method = resolve_attr + .method + .as_deref() + .map(|m| match m.to_lowercase().as_str() { + "post" => crate::ast::HttpMethod::Post, + _ => crate::ast::HttpMethod::Get, + }) + .unwrap_or(crate::ast::HttpMethod::Get); + + let url_source = if resolve_attr.url_is_template { + crate::ast::UrlSource::Template(super::entity::parse_url_template( + url_path, + attr.span(), + )?) + } else { + crate::ast::UrlSource::FieldPath(url_path.clone()) + }; - // Store computed field for later processing (after aggregations) - computed_fields.push(( - computed_attr.target_field_name.clone(), - computed_attr.expression.clone(), - field_type.clone(), - )); - } else if let Ok(Some(resolve_attr)) = - parse::parse_resolve_attribute(attr, &field_name.to_string()) - { - // Determine resolver type: URL resolver if url is present, otherwise Token resolver - let qualified_url = resolve_attr.url.as_deref().map(|url_path_raw| { - if url_path_raw.contains('.') { - url_path_raw.to_string() + crate::ast::ResolverType::Url(crate::ast::UrlResolverConfig { + url_source, + method, + extract_path: resolve_attr.extract.clone(), + }) + } else if let Some(name) = resolve_attr.resolver.as_deref() { + super::entity::parse_resolver_type_name(name, field_type)? } else { - format!("{}.{}", section_name, url_path_raw) - } - }); + super::entity::infer_resolver_type(field_type)? + }; - let resolver = if let Some(ref url_path) = qualified_url { - let method = resolve_attr - .method - .as_deref() - .map(|m| match m.to_lowercase().as_str() { - "post" => crate::ast::HttpMethod::Post, - _ => crate::ast::HttpMethod::Get, - }) - .unwrap_or(crate::ast::HttpMethod::Get); + let mut target_field_name = resolve_attr.target_field_name; + if !target_field_name.contains('.') { + target_field_name = format!("{}.{}", section_name, target_field_name); + } - let url_source = if resolve_attr.url_is_template { - crate::ast::UrlSource::Template(super::entity::parse_url_template( - url_path, - )) + let from = if resolve_attr.url_is_template { + None } else { - crate::ast::UrlSource::FieldPath(url_path.clone()) + qualified_url.or(resolve_attr.from.clone()) }; - crate::ast::ResolverType::Url(crate::ast::UrlResolverConfig { - url_source, - method, - extract_path: resolve_attr.extract.clone(), - }) - } else if let Some(name) = resolve_attr.resolver.as_deref() { - super::entity::parse_resolver_type_name(name, field_type) - .unwrap_or_else(|err| panic!("{}", err)) - } else { - super::entity::infer_resolver_type(field_type) - .unwrap_or_else(|err| panic!("{}", err)) - }; - - let mut target_field_name = resolve_attr.target_field_name; - if !target_field_name.contains('.') { - target_field_name = format!("{}.{}", section_name, target_field_name); + resolve_specs.push(parse::ResolveSpec { + attr_span: resolve_attr.attr_span, + from_span: resolve_attr.from_span, + resolver, + from, + address: resolve_attr.address, + extract: resolve_attr.extract, + target_field_name, + strategy: resolve_attr.strategy, + condition: resolve_attr.condition, + schedule_at: resolve_attr.schedule_at, + }); } - - let from = if resolve_attr.url_is_template { - None - } else { - qualified_url.or(resolve_attr.from.clone()) - }; - - resolve_specs.push(parse::ResolveSpec { - resolver, - from, - address: resolve_attr.address, - extract: resolve_attr.extract, - target_field_name, - strategy: resolve_attr.strategy, - condition: resolve_attr.condition, - schedule_at: resolve_attr.schedule_at, - }); + None => {} } } } @@ -692,6 +633,8 @@ pub fn process_nested_struct( state_fields.push(quote! { pub #section_field_name: #section_field_type }); + + Ok(()) } // ============================================================================ diff --git a/hyperstack-macros/src/validation/idl_refs.rs b/hyperstack-macros/src/validation/idl_refs.rs new file mode 100644 index 00000000..0f7aba91 --- /dev/null +++ b/hyperstack-macros/src/validation/idl_refs.rs @@ -0,0 +1,211 @@ +use crate::event_type_helpers::{find_idl_for_type, IdlLookup}; +use crate::parse; +use crate::utils::path_to_string; +use hyperstack_idl::error::IdlSearchError; +use hyperstack_idl::search::{ + lookup_account, lookup_instruction, lookup_instruction_field, lookup_type, suggest_similar, + InstructionFieldKind, +}; +use hyperstack_idl::types::{IdlSpec, IdlTypeDefKind}; + +fn not_found_with_suggestions( + input: &str, + section: String, + available: Vec, +) -> IdlSearchError { + let candidates: Vec<&str> = available.iter().map(String::as_str).collect(); + let suggestions = suggest_similar(input, &candidates, 3); + IdlSearchError::NotFound { + input: input.to_string(), + section, + suggestions, + available, + } +} + +fn find_idl_for_program_name<'a>(program_name: &str, idls: IdlLookup<'a>) -> Option<&'a IdlSpec> { + idls.iter() + .find(|(_, idl)| idl.get_name() == program_name) + .map(|(_, idl)| *idl) + .or_else(|| { + let sdk_name = format!("{}_sdk", program_name); + idls.iter() + .find(|(name, _)| name == &sdk_name) + .map(|(_, idl)| *idl) + }) +} + +pub fn resolve_instruction_lookup<'a>( + event_attr: &parse::EventAttribute, + fallback_instruction_key: &str, + idls: IdlLookup<'a>, +) -> Result<(&'a IdlSpec, String), IdlSearchError> { + if let Some(path) = event_attr + .from_instruction + .as_ref() + .or(event_attr.inferred_instruction.as_ref()) + { + return resolve_instruction_lookup_from_path(path, idls); + } + + if !event_attr.instruction.is_empty() { + return resolve_instruction_lookup_from_string(&event_attr.instruction, idls); + } + + resolve_instruction_lookup_from_string(fallback_instruction_key, idls) +} + +pub fn resolve_instruction_lookup_from_path<'a>( + instruction_path: &syn::Path, + idls: IdlLookup<'a>, +) -> Result<(&'a IdlSpec, String), IdlSearchError> { + let type_str = path_to_string(instruction_path); + let idl = find_idl_for_type(&type_str, idls).ok_or_else(|| IdlSearchError::InvalidPath { + path: type_str.clone(), + })?; + let instruction_name = instruction_path + .segments + .last() + .map(|segment| segment.ident.to_string()) + .ok_or_else(|| IdlSearchError::InvalidPath { + path: type_str.clone(), + })?; + lookup_instruction(idl, &instruction_name)?; + Ok((idl, instruction_name)) +} + +pub fn resolve_instruction_lookup_from_string<'a>( + instruction: &str, + idls: IdlLookup<'a>, +) -> Result<(&'a IdlSpec, String), IdlSearchError> { + let (program_name, instruction_name) = + instruction + .rsplit_once("::") + .ok_or_else(|| IdlSearchError::InvalidPath { + path: instruction.to_string(), + })?; + + let idl = find_idl_for_program_name(program_name, idls) + .or_else(|| idls.first().map(|(_, idl)| *idl)) + .ok_or_else(|| IdlSearchError::InvalidPath { + path: instruction.to_string(), + })?; + + lookup_instruction(idl, instruction_name)?; + Ok((idl, instruction_name.to_string())) +} + +pub fn validate_instruction_field_spec( + idl: &IdlSpec, + instruction_name: &str, + field_spec: &parse::FieldSpec, +) -> Result<(), IdlSearchError> { + let lookup = lookup_instruction_field(idl, instruction_name, &field_spec.ident.to_string())?; + if let Some(location) = &field_spec.explicit_location { + match (location, lookup.kind) { + (parse::FieldLocation::Account, InstructionFieldKind::Account) + | (parse::FieldLocation::InstructionArg, InstructionFieldKind::Arg) => {} + (parse::FieldLocation::Account, InstructionFieldKind::Arg) => { + return Err(IdlSearchError::InvalidPath { + path: format!( + "accounts::{} is not valid for instruction '{}'", + field_spec.ident, instruction_name + ), + }); + } + (parse::FieldLocation::InstructionArg, InstructionFieldKind::Account) => { + return Err(IdlSearchError::InvalidPath { + path: format!( + "args::{} is not valid for instruction '{}'", + field_spec.ident, instruction_name + ), + }); + } + } + } + Ok(()) +} + +fn fields_from_type_def(type_def: &IdlTypeDefKind) -> Vec { + match type_def { + IdlTypeDefKind::Struct { fields, .. } => { + fields.iter().map(|field| field.name.clone()).collect() + } + _ => Vec::new(), + } +} + +fn account_fields(idl: &IdlSpec, account_name: &str) -> Result, IdlSearchError> { + let account = lookup_account(idl, account_name)?; + if let Some(type_def) = &account.type_def { + return Ok(fields_from_type_def(type_def)); + } + + match lookup_type(idl, account_name) { + Ok(type_def) => Ok(fields_from_type_def(&type_def.type_def)), + Err(_) => Ok(Vec::new()), + } +} + +pub fn validate_account_field( + idl: &IdlSpec, + account_name: &str, + field_name: &str, +) -> Result<(), IdlSearchError> { + let fields = account_fields(idl, account_name)?; + if fields.is_empty() + || fields + .iter() + .any(|field| field.eq_ignore_ascii_case(field_name)) + { + return Ok(()); + } + + Err(not_found_with_suggestions( + field_name, + format!("account fields for '{}'", account_name), + fields, + )) +} + +pub fn validate_mapping_source( + source_type: &str, + mapping: &parse::MapAttribute, + idls: IdlLookup, +) -> Result<(), IdlSearchError> { + if mapping.source_field_name.is_empty() || mapping.source_field_name.starts_with("__") { + return Ok(()); + } + + if source_type.contains("::instructions::") || mapping.is_instruction { + let path = + syn::parse_str::(source_type).map_err(|_| IdlSearchError::InvalidPath { + path: source_type.to_string(), + })?; + let (idl, instruction_name) = resolve_instruction_lookup_from_path(&path, idls)?; + let temp_field = parse::FieldSpec { + ident: syn::Ident::new(&mapping.source_field_name, mapping.source_field_span), + explicit_location: None, + }; + validate_instruction_field_spec(idl, &instruction_name, &temp_field) + } else if source_type.contains("::accounts::") { + let path = + syn::parse_str::(source_type).map_err(|_| IdlSearchError::InvalidPath { + path: source_type.to_string(), + })?; + let idl = + find_idl_for_type(source_type, idls).ok_or_else(|| IdlSearchError::InvalidPath { + path: source_type.to_string(), + })?; + let account_name = path + .segments + .last() + .map(|segment| segment.ident.to_string()) + .ok_or_else(|| IdlSearchError::InvalidPath { + path: source_type.to_string(), + })?; + validate_account_field(idl, &account_name, &mapping.source_field_name) + } else { + Ok(()) + } +} diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs new file mode 100644 index 00000000..16f3f931 --- /dev/null +++ b/hyperstack-macros/src/validation/mod.rs @@ -0,0 +1,605 @@ +use std::collections::{HashMap, HashSet}; + +use crate::ast::{ComputedExpr, EntitySection, FieldPath, ViewTransform}; +use crate::diagnostic::{preview_values, suggestion_or_available_suffix, ErrorCollector}; +use crate::event_type_helpers::IdlLookup; +use crate::parse; +use crate::parse::idl as idl_parser; +use crate::parse::pda_validation::PdaValidationContext; +use crate::parse::pdas::PdasBlock; +use crate::validation::idl_refs::{ + resolve_instruction_lookup, resolve_instruction_lookup_from_path, + validate_instruction_field_spec, validate_mapping_source, +}; + +use crate::diagnostic::idl_error_to_syn; +use crate::stream_spec::computed::{parse_computed_expression, qualify_field_refs}; + +pub mod idl_refs; + +pub struct ComputedFieldValidation { + pub target_path: String, + pub expression: proc_macro2::TokenStream, + pub span: proc_macro2::Span, +} + +pub struct ValidationInput<'a> { + pub entity_name: &'a str, + pub sources_by_type: &'a HashMap>, + pub events_by_instruction: &'a HashMap>, + pub derive_from_mappings: &'a HashMap>, + pub computed_fields: &'a [ComputedFieldValidation], + pub resolve_specs: &'a [parse::ResolveSpec], + pub section_specs: &'a [EntitySection], + pub view_specs: &'a [parse::ViewAttributeSpec], + pub idls: IdlLookup<'a>, +} + +pub fn validate_semantics(input: ValidationInput<'_>) -> syn::Result<()> { + let known_fields = collect_known_field_paths(input.section_specs, input.computed_fields); + let available_fields = sorted_field_paths(&known_fields); + + let mut errors = ErrorCollector::default(); + + validate_mapping_references( + input.entity_name, + input.sources_by_type, + &known_fields, + &available_fields, + input.idls, + &mut errors, + ); + validate_event_references( + input.entity_name, + input.events_by_instruction, + &known_fields, + &available_fields, + input.idls, + &mut errors, + ); + validate_derive_from_references(input.derive_from_mappings, input.idls, &mut errors); + validate_resolve_specs( + input.entity_name, + input.resolve_specs, + &known_fields, + &available_fields, + &mut errors, + ); + validate_views( + input.entity_name, + input.view_specs, + &known_fields, + &available_fields, + &mut errors, + ); + validate_computed_fields( + input.entity_name, + input.computed_fields, + &known_fields, + &available_fields, + &mut errors, + ); + + errors.finish() +} + +pub fn validate_pda_blocks( + idls: &HashMap, + blocks: &[PdasBlock], +) -> syn::Result<()> { + let ctx = PdaValidationContext::new(idls); + let mut errors = ErrorCollector::default(); + + for block in blocks { + if let Err(error) = ctx.validate(block) { + errors.push(error); + } + } + + errors.finish() +} + +fn collect_known_field_paths( + section_specs: &[EntitySection], + computed_fields: &[ComputedFieldValidation], +) -> HashSet { + let mut known = HashSet::new(); + + for section in section_specs { + for field in §ion.fields { + if section.name == "root" { + known.insert(field.field_name.clone()); + } else { + known.insert(format!("{}.{}", section.name, field.field_name)); + } + } + } + + for computed in computed_fields { + known.insert(computed.target_path.clone()); + } + + known +} + +fn sorted_field_paths(known_fields: &HashSet) -> Vec { + let mut values: Vec = known_fields.iter().cloned().collect(); + values.sort(); + values +} + +fn entity_field_error( + entity_name: &str, + reference: &str, + context: &str, + span: proc_macro2::Span, + available_fields: &[String], +) -> syn::Error { + let mut message = format!( + "unknown {} '{}' on entity '{}'", + context, reference, entity_name + ); + let suffix = suggestion_or_available_suffix(reference, available_fields, "Available fields"); + if suffix.is_empty() && !available_fields.is_empty() { + message.push_str(&format!( + ". Available fields: {}", + preview_values(available_fields, 6) + )); + } else { + message.push_str(&suffix); + } + + syn::Error::new(span, message) +} + +fn validate_mapping_references( + entity_name: &str, + sources_by_type: &HashMap>, + known_fields: &HashSet, + available_fields: &[String], + idls: IdlLookup, + errors: &mut ErrorCollector, +) { + for (source_type, mappings) in sources_by_type { + for mapping in mappings { + if let Err(error) = validate_mapping_source(source_type, mapping, idls) { + let span = match &error { + hyperstack_idl::error::IdlSearchError::NotFound { section, .. } + if section == "instructions" + || section == "accounts" + || section == "types" => + { + mapping.source_type_span + } + hyperstack_idl::error::IdlSearchError::NotFound { section, .. } + if section.starts_with("instruction fields") + || section.starts_with("account fields") => + { + mapping.source_field_span + } + _ => mapping.attr_span, + }; + errors.push(idl_error_to_syn(span, error)); + } + + if let Some(join_on) = &mapping.join_on { + if !known_fields.contains(join_on) { + errors.push(entity_field_error( + entity_name, + join_on, + "join_on field", + mapping.attr_span, + available_fields, + )); + } + } + + if let Some(lookup_by) = &mapping.lookup_by { + if source_type.contains("::instructions::") || mapping.is_instruction { + match syn::parse_str::(source_type) + .map_err(|_| hyperstack_idl::error::IdlSearchError::InvalidPath { + path: source_type.clone(), + }) + .and_then(|path| resolve_instruction_lookup_from_path(&path, idls)) + { + Ok((idl, instruction_name)) => { + if let Err(error) = + validate_instruction_field_spec(idl, &instruction_name, lookup_by) + { + errors.push(idl_error_to_syn(lookup_by.ident.span(), error)); + } + } + Err(error) => errors.push(idl_error_to_syn(mapping.attr_span, error)), + } + } + } + + if let Some(stop_lookup_by) = &mapping.stop_lookup_by { + if source_type.contains("::instructions::") || mapping.is_instruction { + match syn::parse_str::(source_type) + .map_err(|_| hyperstack_idl::error::IdlSearchError::InvalidPath { + path: source_type.clone(), + }) + .and_then(|path| resolve_instruction_lookup_from_path(&path, idls)) + { + Ok((idl, instruction_name)) => { + if let Err(error) = validate_instruction_field_spec( + idl, + &instruction_name, + stop_lookup_by, + ) { + errors.push(idl_error_to_syn(stop_lookup_by.ident.span(), error)); + } + } + Err(error) => errors.push(idl_error_to_syn(mapping.attr_span, error)), + } + } + } + } + } +} + +fn validate_event_references( + entity_name: &str, + events_by_instruction: &HashMap>, + known_fields: &HashSet, + available_fields: &[String], + idls: IdlLookup, + errors: &mut ErrorCollector, +) { + for (instruction_key, event_mappings) in events_by_instruction { + for (_target_field, event_attr, _field_type) in event_mappings { + let instruction_lookup = resolve_instruction_lookup(event_attr, instruction_key, idls); + + let (idl, instruction_name) = match instruction_lookup { + Ok(value) => value, + Err(error) => { + errors.push(idl_error_to_syn( + event_attr.instruction_span.unwrap_or(event_attr.attr_span), + error, + )); + continue; + } + }; + + for field_spec in &event_attr.capture_fields { + if let Err(error) = + validate_instruction_field_spec(idl, &instruction_name, field_spec) + { + errors.push(idl_error_to_syn(field_spec.ident.span(), error)); + } + } + + if let Some(field_spec) = &event_attr.lookup_by { + if let Err(error) = + validate_instruction_field_spec(idl, &instruction_name, field_spec) + { + errors.push(idl_error_to_syn(field_spec.ident.span(), error)); + } + } + + if let Some(join_on) = &event_attr.join_on { + let reference = join_on.ident.to_string(); + if !known_fields.contains(&reference) { + errors.push(entity_field_error( + entity_name, + &reference, + "join_on field", + join_on.ident.span(), + available_fields, + )); + } + } + } + } +} + +fn validate_derive_from_references( + derive_from_mappings: &HashMap>, + idls: IdlLookup, + errors: &mut ErrorCollector, +) { + for (instruction_type, derive_attrs) in derive_from_mappings { + let path = match syn::parse_str::(instruction_type) { + Ok(path) => path, + Err(_) => continue, + }; + + let instruction_lookup = idl_refs::resolve_instruction_lookup_from_path(&path, idls); + let (idl, instruction_name) = match instruction_lookup { + Ok(value) => value, + Err(error) => { + for derive_attr in derive_attrs { + errors.push(idl_error_to_syn(derive_attr.attr_span, error.clone())); + } + continue; + } + }; + + for derive_attr in derive_attrs { + if !derive_attr.field.ident.to_string().starts_with("__") { + if let Err(error) = + validate_instruction_field_spec(idl, &instruction_name, &derive_attr.field) + { + errors.push(idl_error_to_syn(derive_attr.field.ident.span(), error)); + } + } + + if let Some(lookup_by) = &derive_attr.lookup_by { + if let Err(error) = + validate_instruction_field_spec(idl, &instruction_name, lookup_by) + { + errors.push(idl_error_to_syn(lookup_by.ident.span(), error)); + } + } + } + } +} + +fn validate_resolve_specs( + entity_name: &str, + resolve_specs: &[parse::ResolveSpec], + known_fields: &HashSet, + available_fields: &[String], + errors: &mut ErrorCollector, +) { + for spec in resolve_specs { + if let Some(from) = &spec.from { + if !known_fields.contains(from) { + errors.push(entity_field_error( + entity_name, + from, + "resolver input field", + spec.from_span.unwrap_or(spec.attr_span), + available_fields, + )); + } + } + + if let Some(schedule_at) = &spec.schedule_at { + if !known_fields.contains(&schedule_at.raw) { + errors.push(entity_field_error( + entity_name, + &schedule_at.raw, + "resolver schedule_at field", + schedule_at.span, + available_fields, + )); + } + } + } +} + +fn validate_views( + entity_name: &str, + view_specs: &[parse::ViewAttributeSpec], + known_fields: &HashSet, + available_fields: &[String], + errors: &mut ErrorCollector, +) { + let mut seen_ids = HashSet::new(); + + for view_spec in view_specs { + if !seen_ids.insert(view_spec.view.id.clone()) { + errors.push(syn::Error::new( + view_spec.attr_span, + format!( + "duplicate view id '{}' on entity '{}'", + view_spec.view.id, entity_name + ), + )); + } + + for transform in &view_spec.view.pipeline { + let maybe_field = match transform { + ViewTransform::Sort { key, .. } + | ViewTransform::MaxBy { key } + | ViewTransform::MinBy { key } => Some(key), + _ => None, + }; + + if let Some(field) = maybe_field { + let raw = field_path_to_string(field); + if !known_fields.contains(&raw) { + errors.push(entity_field_error( + entity_name, + &raw, + "view field", + view_spec.sort_key_span.unwrap_or(view_spec.attr_span), + available_fields, + )); + } + } + } + } +} + +fn validate_computed_fields( + entity_name: &str, + computed_fields: &[ComputedFieldValidation], + known_fields: &HashSet, + available_fields: &[String], + errors: &mut ErrorCollector, +) { + let computed_targets: HashSet = computed_fields + .iter() + .map(|field| field.target_path.clone()) + .collect(); + let mut dependencies: HashMap> = HashMap::new(); + let mut spans = HashMap::new(); + + for computed in computed_fields { + spans.insert(computed.target_path.clone(), computed.span); + + let parsed = parse_computed_expression(&computed.expression); + let section = computed.target_path.split('.').next().unwrap_or(""); + let parsed = if computed.target_path.contains('.') { + qualify_field_refs(parsed, section) + } else { + parsed + }; + + let refs = collect_field_refs(&parsed); + for reference in &refs { + if !known_fields.contains(reference) { + errors.push(entity_field_error( + entity_name, + reference, + "computed field reference", + computed.span, + available_fields, + )); + } + } + + dependencies.insert( + computed.target_path.clone(), + refs.into_iter() + .filter(|reference| computed_targets.contains(reference)) + .collect(), + ); + } + + for cycle in detect_cycles(&dependencies) { + if let Some(first) = cycle.first() { + errors.push(syn::Error::new( + spans + .get(first) + .copied() + .unwrap_or(proc_macro2::Span::call_site()), + format!( + "computed fields contain a dependency cycle: {}", + cycle.join(" -> ") + ), + )); + } + } +} + +fn field_path_to_string(path: &FieldPath) -> String { + path.segments.join(".") +} + +fn collect_field_refs(expr: &ComputedExpr) -> HashSet { + let mut refs = HashSet::new(); + collect_field_refs_recursive(expr, &mut refs); + refs +} + +fn collect_field_refs_recursive(expr: &ComputedExpr, refs: &mut HashSet) { + match expr { + ComputedExpr::FieldRef { path } => { + refs.insert(path.clone()); + } + ComputedExpr::Binary { left, right, .. } => { + collect_field_refs_recursive(left, refs); + collect_field_refs_recursive(right, refs); + } + ComputedExpr::Unary { expr, .. } + | ComputedExpr::Paren { expr } + | ComputedExpr::Cast { expr, .. } + | ComputedExpr::UnwrapOr { expr, .. } + | ComputedExpr::Slice { expr, .. } + | ComputedExpr::Index { expr, .. } + | ComputedExpr::Keccak256 { expr } + | ComputedExpr::JsonToBytes { expr } + | ComputedExpr::U64FromLeBytes { bytes: expr } + | ComputedExpr::U64FromBeBytes { bytes: expr } => { + collect_field_refs_recursive(expr, refs); + } + ComputedExpr::MethodCall { expr, args, .. } => { + collect_field_refs_recursive(expr, refs); + for arg in args { + collect_field_refs_recursive(arg, refs); + } + } + ComputedExpr::ResolverComputed { args, .. } => { + for arg in args { + collect_field_refs_recursive(arg, refs); + } + } + ComputedExpr::Let { value, body, .. } => { + collect_field_refs_recursive(value, refs); + collect_field_refs_recursive(body, refs); + } + ComputedExpr::If { + condition, + then_branch, + else_branch, + } => { + collect_field_refs_recursive(condition, refs); + collect_field_refs_recursive(then_branch, refs); + collect_field_refs_recursive(else_branch, refs); + } + ComputedExpr::Some { value } => collect_field_refs_recursive(value, refs), + ComputedExpr::Closure { body, .. } => collect_field_refs_recursive(body, refs), + ComputedExpr::Var { .. } + | ComputedExpr::Literal { .. } + | ComputedExpr::ByteArray { .. } + | ComputedExpr::None + | ComputedExpr::ContextSlot + | ComputedExpr::ContextTimestamp => {} + } +} + +fn detect_cycles(graph: &HashMap>) -> Vec> { + let mut visited = HashSet::new(); + let mut stack = Vec::new(); + let mut active = HashSet::new(); + let mut cycles = Vec::new(); + + let mut nodes: Vec<&String> = graph.keys().collect(); + nodes.sort(); + + for node in nodes { + detect_cycles_from( + node, + graph, + &mut visited, + &mut active, + &mut stack, + &mut cycles, + ); + } + + cycles +} + +fn detect_cycles_from( + node: &str, + graph: &HashMap>, + visited: &mut HashSet, + active: &mut HashSet, + stack: &mut Vec, + cycles: &mut Vec>, +) { + if active.contains(node) { + if let Some(index) = stack.iter().position(|entry| entry == node) { + let mut cycle = stack[index..].to_vec(); + cycle.push(node.to_string()); + if !cycles.iter().any(|existing| existing == &cycle) { + cycles.push(cycle); + } + } + return; + } + + if !visited.insert(node.to_string()) { + return; + } + + active.insert(node.to_string()); + stack.push(node.to_string()); + + if let Some(edges) = graph.get(node) { + let mut sorted_edges: Vec<&String> = edges.iter().collect(); + sorted_edges.sort(); + + for edge in sorted_edges { + detect_cycles_from(edge, graph, visited, active, stack, cycles); + } + } + + stack.pop(); + active.remove(node); +} diff --git a/hyperstack-macros/tests/phase0_dynamic.rs b/hyperstack-macros/tests/phase0_dynamic.rs new file mode 100644 index 00000000..7abf5d04 --- /dev/null +++ b/hyperstack-macros/tests/phase0_dynamic.rs @@ -0,0 +1,140 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn run_compile_failure(name: &str, source: &str, extra_files: &[(&str, &str)], expected: &[&str]) { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .expect("hyperstack-macros should live in workspace root"); + let temp_root = workspace_root.join("target/tests/phase0-dynamic"); + fs::create_dir_all(&temp_root).expect("create dynamic test root"); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let crate_dir = temp_root.join(format!("{name}-{unique}")); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +hyperstack-macros = {{ path = "{}" }} +"#, + escape_path(&manifest_dir) + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + for (relative_path, contents) in extra_files { + let file_path = crate_dir.join(relative_path); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("create extra file parent dir"); + } + fs::write(file_path, contents).expect("write extra test file"); + } + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(&crate_dir) + .env("CARGO_TARGET_DIR", workspace_root.join("target")) + .output() + .expect("run cargo check"); + + assert!( + !output.status.success(), + "expected cargo check to fail for {name}" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + for needle in expected { + assert!( + stderr.contains(needle), + "expected stderr for {name} to contain {needle:?}, got:\n{stderr}" + ); + } +} + +fn run_idl_compile_failure(name: &str, module_body: &str, expected: &[&str]) { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "fixture/minimal.json")] +mod broken { +__MODULE_BODY__ +} + +fn main() {} +"# + .replace("__MODULE_BODY__", module_body); + + let minimal_idl = r#"{ + "name": "ore", + "instructions": [], + "accounts": [], + "types": [], + "events": [], + "errors": [], + "constants": [] +} +"#; + + run_compile_failure( + name, + &source, + &[("fixture/minimal.json", minimal_idl)], + expected, + ); +} + +fn escape_path(path: &Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + +#[test] +fn after_instruction_attribute_is_hard_error() { + run_idl_compile_failure( + "after_instruction_attribute_is_hard_error", + " #[after_instruction(ore_sdk::instructions::Initialize)]\n fn bad() {}", + &[ + "Direct use of #[after_instruction] is not allowed.", + "Use declarative macros instead:", + ], + ); +} + +#[test] +fn resolve_key_attribute_is_hard_error() { + run_idl_compile_failure( + "resolve_key_attribute_is_hard_error", + " #[resolve_key(strategy = \"direct_field\")]\n struct Marker;", + &["#[resolve_key] requires 'account' parameter"], + ); +} + +#[test] +fn register_pda_attribute_is_hard_error() { + run_idl_compile_failure( + "register_pda_attribute_is_hard_error", + " #[register_pda(instruction = ore_sdk::instructions::Initialize)]\n struct Marker;", + &["#[register_pda] requires 'pda_field' parameter"], + ); +} + +#[test] +fn manual_pdas_block_is_hard_error() { + run_idl_compile_failure( + "manual_pdas_block_is_hard_error", + " #[entity(name = \"Thing\")]\n struct Marker {}\n\n pdas! {\n missing_program {\n broken = [literal(\"broken\")];\n }\n }", + &["unknown program 'missing_program' in pdas! block"], + ); +} diff --git a/hyperstack-macros/tests/phase1_runtime.rs b/hyperstack-macros/tests/phase1_runtime.rs new file mode 100644 index 00000000..5172391c --- /dev/null +++ b/hyperstack-macros/tests/phase1_runtime.rs @@ -0,0 +1,90 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn escape_path(path: &Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + +fn run_binary_success(name: &str, source: &str) { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .expect("hyperstack-macros should live in workspace root"); + let hyperstack_dir = workspace_root.join("hyperstack"); + let temp_root = workspace_root.join("target/tests/phase1-runtime"); + fs::create_dir_all(&temp_root).expect("create phase1 runtime root"); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let crate_dir = temp_root.join(format!("{name}-{unique}")); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +hyperstack = {{ path = "{}" }} +hyperstack-macros = {{ path = "{}" }} +serde = {{ version = "1", features = ["derive"] }} +"#, + escape_path(&hyperstack_dir), + escape_path(&manifest_dir) + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + + let output = Command::new("cargo") + .arg("run") + .arg("--quiet") + .current_dir(&crate_dir) + .env("CARGO_TARGET_DIR", workspace_root.join("target")) + .output() + .expect("run cargo run"); + + assert!( + output.status.success(), + "expected cargo run to succeed for {name}, stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn generated_loaders_do_not_require_stack_artifact_file() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod stream { + #[entity(name = "Thing")] + struct Thing {} +} + +fn main() { + let stack_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join(".hyperstack/Stream.stack.json"); + + if stack_path.exists() { + std::fs::remove_file(&stack_path).unwrap(); + } + + let _spec = stream::create_thing_spec(); + let _views = stream::get_view_definitions(); + let _bytecode = stream::create_multi_entity_bytecode(); +} +"#; + + run_binary_success( + "generated_loaders_do_not_require_stack_artifact_file", + source, + ); +} diff --git a/hyperstack-macros/tests/phase2_dynamic.rs b/hyperstack-macros/tests/phase2_dynamic.rs new file mode 100644 index 00000000..33f75790 --- /dev/null +++ b/hyperstack-macros/tests/phase2_dynamic.rs @@ -0,0 +1,205 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn escape_path(path: &Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + +fn run_compile_failure(name: &str, source: &str, expected: &[&str]) { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .expect("hyperstack-macros should live in workspace root"); + let temp_root = workspace_root.join("target/tests/phase2-dynamic"); + fs::create_dir_all(&temp_root).expect("create dynamic test root"); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let crate_dir = temp_root.join(format!("{name}-{unique}")); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +hyperstack-macros = {{ path = "{}" }} +"#, + escape_path(&manifest_dir) + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(&crate_dir) + .env("CARGO_TARGET_DIR", workspace_root.join("target")) + .output() + .expect("run cargo check"); + + assert!( + !output.status.success(), + "expected cargo check to fail for {name}" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + for needle in expected { + assert!( + stderr.contains(needle), + "expected stderr for {name} to contain {needle:?}, got:\n{stderr}" + ); + } +} + +fn pump_idl_path() -> String { + escape_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("hyperstack-idl/tests/fixtures/pump.json"), + ) +} + +#[test] +fn invalid_map_strategy_is_rejected_early() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[map(fake_sdk::accounts::Thing::value, strategy = Invalid)] + value: u64, + } +} + +fn main() {} +"#; + + run_compile_failure( + "invalid_map_strategy_is_rejected_early", + source, + &["invalid strategy 'Invalid' for #[map]"], + ); +} + +#[test] +fn invalid_condition_is_rejected_early() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[aggregate(from = fake_sdk::instructions::Trade, field = amount, condition = "amount >> 1")] + total: u64, + } +} + +fn main() {} +"#; + + run_compile_failure( + "invalid_condition_is_rejected_early", + source, + &["Invalid condition expression 'amount >> 1'"], + ); +} + +#[test] +fn missing_instruction_gets_suggestion() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[event(from = pump_sdk::instructions::Initialise, fields = [user])] + trades: Vec, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + run_compile_failure( + "missing_instruction_gets_suggestion", + &source, + &[ + "Not found: 'Initialise' in instructions", + "Did you mean: initialize?", + ], + ); +} + +#[test] +fn missing_instruction_field_gets_suggestion() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[event(from = pump_sdk::instructions::Buy, fields = [usr])] + trades: Vec, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + run_compile_failure( + "missing_instruction_field_gets_suggestion", + &source, + &[ + "Not found: 'usr' in instruction fields for 'buy'", + "Did you mean: user?", + ], + ); +} + +#[test] +fn missing_account_type_gets_suggestion() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[map(pump_sdk::accounts::BondingCurv::complete, strategy = LastWrite)] + complete: bool, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + run_compile_failure( + "missing_account_type_gets_suggestion", + &source, + &[ + "Not found: 'BondingCurv' in accounts", + "Did you mean: BondingCurve?", + ], + ); +} diff --git a/hyperstack-macros/tests/phase3_dynamic.rs b/hyperstack-macros/tests/phase3_dynamic.rs new file mode 100644 index 00000000..29dedb9d --- /dev/null +++ b/hyperstack-macros/tests/phase3_dynamic.rs @@ -0,0 +1,150 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn escape_path(path: &Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + +fn compile_failure_stderr(name: &str, source: &str) -> String { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .expect("hyperstack-macros should live in workspace root"); + let temp_root = workspace_root.join("target/tests/phase3-dynamic"); + fs::create_dir_all(&temp_root).expect("create dynamic test root"); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let crate_dir = temp_root.join(format!("{name}-{unique}")); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +hyperstack-macros = {{ path = "{}" }} +"#, + escape_path(&manifest_dir) + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(&crate_dir) + .env("CARGO_TARGET_DIR", workspace_root.join("target")) + .output() + .expect("run cargo check"); + + assert!( + !output.status.success(), + "expected cargo check to fail for {name}" + ); + + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn pump_idl_path() -> String { + escape_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("hyperstack-idl/tests/fixtures/pump.json"), + ) +} + +#[test] +fn missing_instruction_error_points_to_from_path() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[event( + from = pump_sdk::instructions::Initialise, + fields = [user] + )] + trades: Vec, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + let stderr = compile_failure_stderr("missing_instruction_error_points_to_from_path", &source); + assert!(stderr.contains("Not found: 'Initialise' in instructions")); + assert!(stderr.contains("src/main.rs:8:"), "stderr was:\n{stderr}"); +} + +#[test] +fn missing_instruction_field_error_points_to_field_token() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[event( + from = pump_sdk::instructions::Buy, + fields = [usr] + )] + trades: Vec, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + let stderr = compile_failure_stderr( + "missing_instruction_field_error_points_to_field_token", + &source, + ); + assert!(stderr.contains("Not found: 'usr' in instruction fields for 'buy'")); + assert!(stderr.contains("src/main.rs:9:"), "stderr was:\n{stderr}"); +} + +#[test] +fn missing_account_error_points_to_map_path() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[map( + pump_sdk::accounts::BondingCurv::complete, + strategy = LastWrite + )] + complete: bool, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + let stderr = compile_failure_stderr("missing_account_error_points_to_map_path", &source); + assert!(stderr.contains("Not found: 'BondingCurv' in accounts")); + assert!(stderr.contains("src/main.rs:8:"), "stderr was:\n{stderr}"); +} diff --git a/hyperstack-macros/tests/phase4_dynamic.rs b/hyperstack-macros/tests/phase4_dynamic.rs new file mode 100644 index 00000000..b8640062 --- /dev/null +++ b/hyperstack-macros/tests/phase4_dynamic.rs @@ -0,0 +1,196 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn escape_path(path: &Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + +fn compile_failure_stderr(name: &str, source: &str) -> String { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .expect("hyperstack-macros should live in workspace root"); + let temp_root = workspace_root.join("target/tests/phase4-dynamic"); + fs::create_dir_all(&temp_root).expect("create dynamic test root"); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let crate_dir = temp_root.join(format!("{name}-{unique}")); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +hyperstack-macros = {{ path = "{}" }} +"#, + escape_path(&manifest_dir) + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(&crate_dir) + .env("CARGO_TARGET_DIR", workspace_root.join("target")) + .output() + .expect("run cargo check"); + + assert!( + !output.status.success(), + "expected cargo check to fail for {name}" + ); + + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn pump_idl_path() -> String { + escape_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("hyperstack-idl/tests/fixtures/pump.json"), + ) +} + +#[test] +fn unknown_account_field_is_rejected() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[map(pump_sdk::accounts::BondingCurve::bogus, strategy = LastWrite)] + value: u64, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + let stderr = compile_failure_stderr("unknown_account_field_is_rejected", &source); + assert!(stderr.contains("Not found: 'bogus' in account fields for 'BondingCurve'")); +} + +#[test] +fn missing_computed_section_reference_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + base: u64, + #[computed(ghost.value + 1)] + total: u64, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("missing_computed_section_reference_is_rejected", source); + assert!(stderr.contains("unknown computed field reference 'ghost.value' on entity 'Thing'")); +} + +#[test] +fn invalid_resolver_input_field_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + existing: String, + #[resolve(from = "ghost.value", resolver = Token)] + metadata: String, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("invalid_resolver_input_field_is_rejected", source); + assert!(stderr.contains("unknown resolver input field 'ghost.value' on entity 'Thing'")); +} + +#[test] +fn invalid_view_sort_by_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + #[view(name = "latest", sort_by = "ghost.value")] + struct Thing { + base: u64, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("invalid_view_sort_by_is_rejected", source); + assert!(stderr.contains("unknown view field 'ghost.value' on entity 'Thing'")); +} + +#[test] +fn computed_cycle_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[computed(b)] + a: u64, + #[computed(a)] + b: u64, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("computed_cycle_is_rejected", source); + assert!(stderr.contains("computed fields contain a dependency cycle")); +} + +#[test] +fn validation_reports_multiple_errors() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + #[view(name = "latest", sort_by = "ghost.value")] + struct Thing { + existing: String, + #[resolve(from = "missing.field", resolver = Token)] + metadata: String, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("validation_reports_multiple_errors", source); + assert!(stderr.contains("unknown view field 'ghost.value' on entity 'Thing'")); + assert!(stderr.contains("unknown resolver input field 'missing.field' on entity 'Thing'")); +} diff --git a/hyperstack-macros/tests/phase5_dynamic.rs b/hyperstack-macros/tests/phase5_dynamic.rs new file mode 100644 index 00000000..5f4bd439 --- /dev/null +++ b/hyperstack-macros/tests/phase5_dynamic.rs @@ -0,0 +1,137 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn escape_path(path: &Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + +fn compile_failure_stderr(name: &str, source: &str) -> String { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .expect("hyperstack-macros should live in workspace root"); + let temp_root = workspace_root.join("target/tests/phase5-dynamic"); + fs::create_dir_all(&temp_root).expect("create dynamic test root"); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let crate_dir = temp_root.join(format!("{name}-{unique}")); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +hyperstack-macros = {{ path = "{}" }} +"#, + escape_path(&manifest_dir) + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(&crate_dir) + .env("CARGO_TARGET_DIR", workspace_root.join("target")) + .output() + .expect("run cargo check"); + + assert!( + !output.status.success(), + "expected cargo check to fail for {name}" + ); + + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn pump_idl_path() -> String { + escape_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("hyperstack-idl/tests/fixtures/pump.json"), + ) +} + +#[test] +fn invalid_strategy_suggests_valid_value() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[map(fake_sdk::accounts::Thing::value, strategy = LastWrit)] + value: u64, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("invalid_strategy_suggests_valid_value", source); + assert!(stderr.contains("invalid strategy 'LastWrit' for #[map]")); + assert!(stderr.contains("Expected one of: SetOnce, LastWrite")); + assert!(stderr.contains("Did you mean: LastWrite?")); +} + +#[test] +fn unknown_resolver_suggests_valid_name() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + existing: String, + #[resolve(from = "existing", resolver = Toke)] + metadata: String, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("unknown_resolver_suggests_valid_name", source); + assert!(stderr.contains("unknown resolver 'Toke'")); + assert!(stderr.contains("Did you mean: Token?")); +} + +#[test] +fn unknown_pda_program_suggests_available_name() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{}} + + pdas! {{ + pum {{ + broken = [literal("broken")]; + }} + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + let stderr = compile_failure_stderr("unknown_pda_program_suggests_available_name", &source); + assert!(stderr.contains("unknown program 'pum' in pdas! block")); + assert!(stderr.contains("Did you mean: pump?")); +} diff --git a/hyperstack-macros/tests/ui.rs b/hyperstack-macros/tests/ui.rs new file mode 100644 index 00000000..db3129bb --- /dev/null +++ b/hyperstack-macros/tests/ui.rs @@ -0,0 +1,9 @@ +#[test] +fn ui() { + let tests = trybuild::TestCases::new(); + tests.pass("tests/ui/pass/*.rs"); + tests.compile_fail("tests/ui/map_errors/*.rs"); + tests.compile_fail("tests/ui/resolve_errors/*.rs"); + tests.compile_fail("tests/ui/validation_errors/*.rs"); + tests.compile_fail("tests/ui/view_errors/*.rs"); +} diff --git a/hyperstack-macros/tests/ui/map_errors/malformed_map_attribute.rs b/hyperstack-macros/tests/ui/map_errors/malformed_map_attribute.rs new file mode 100644 index 00000000..a2f8bedb --- /dev/null +++ b/hyperstack-macros/tests/ui/map_errors/malformed_map_attribute.rs @@ -0,0 +1,9 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +struct Broken { + #[map(source)] + value: u64, +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/map_errors/malformed_map_attribute.stderr b/hyperstack-macros/tests/ui/map_errors/malformed_map_attribute.stderr new file mode 100644 index 00000000..0e2bcec9 --- /dev/null +++ b/hyperstack-macros/tests/ui/map_errors/malformed_map_attribute.stderr @@ -0,0 +1,5 @@ +error: Source path must be in format ModulePath::TypeName::field_name + --> tests/ui/map_errors/malformed_map_attribute.rs:5:11 + | +5 | #[map(source)] + | ^^^^^^ diff --git a/hyperstack-macros/tests/ui/map_errors/nested_malformed_map_attribute.rs b/hyperstack-macros/tests/ui/map_errors/nested_malformed_map_attribute.rs new file mode 100644 index 00000000..ef562b8b --- /dev/null +++ b/hyperstack-macros/tests/ui/map_errors/nested_malformed_map_attribute.rs @@ -0,0 +1,16 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[derive(hyperstack_macros::Stream)] + struct Nested { + #[map(source)] + value: u64, + } + + struct Root { + nested: Nested, + } +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/map_errors/nested_malformed_map_attribute.stderr b/hyperstack-macros/tests/ui/map_errors/nested_malformed_map_attribute.stderr new file mode 100644 index 00000000..b0e9bb3d --- /dev/null +++ b/hyperstack-macros/tests/ui/map_errors/nested_malformed_map_attribute.stderr @@ -0,0 +1,5 @@ +error: Source path must be in format ModulePath::TypeName::field_name + --> tests/ui/map_errors/nested_malformed_map_attribute.rs:7:15 + | +7 | #[map(source)] + | ^^^^^^ diff --git a/hyperstack-macros/tests/ui/pass/empty_module.rs b/hyperstack-macros/tests/ui/pass/empty_module.rs new file mode 100644 index 00000000..bcd96ccf --- /dev/null +++ b/hyperstack-macros/tests/ui/pass/empty_module.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +mod empty {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/pass/proto_flag.rs b/hyperstack-macros/tests/ui/pass/proto_flag.rs new file mode 100644 index 00000000..ee6ab33b --- /dev/null +++ b/hyperstack-macros/tests/ui/pass/proto_flag.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(skip_decoders)] +mod empty {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/pass/proto_list.rs b/hyperstack-macros/tests/ui/pass/proto_list.rs new file mode 100644 index 00000000..830b827f --- /dev/null +++ b/hyperstack-macros/tests/ui/pass/proto_list.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(proto = [])] +mod empty {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/resolve_errors/malformed_resolve_attribute.rs b/hyperstack-macros/tests/ui/resolve_errors/malformed_resolve_attribute.rs new file mode 100644 index 00000000..32e4ce72 --- /dev/null +++ b/hyperstack-macros/tests/ui/resolve_errors/malformed_resolve_attribute.rs @@ -0,0 +1,9 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +struct Broken { + #[resolve()] + value: u64, +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/resolve_errors/malformed_resolve_attribute.stderr b/hyperstack-macros/tests/ui/resolve_errors/malformed_resolve_attribute.stderr new file mode 100644 index 00000000..68894414 --- /dev/null +++ b/hyperstack-macros/tests/ui/resolve_errors/malformed_resolve_attribute.stderr @@ -0,0 +1,5 @@ +error: #[resolve] requires either 'url' or 'from'/'address' parameter + --> tests/ui/resolve_errors/malformed_resolve_attribute.rs:5:5 + | +5 | #[resolve()] + | ^^^^^^^^^^^^ diff --git a/hyperstack-macros/tests/ui/resolve_errors/malformed_url_template.rs b/hyperstack-macros/tests/ui/resolve_errors/malformed_url_template.rs new file mode 100644 index 00000000..4a1a5574 --- /dev/null +++ b/hyperstack-macros/tests/ui/resolve_errors/malformed_url_template.rs @@ -0,0 +1,12 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[resolve(url = "https://example.com/{mint", extract = "name")] + metadata: String, + } +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/resolve_errors/malformed_url_template.stderr b/hyperstack-macros/tests/ui/resolve_errors/malformed_url_template.stderr new file mode 100644 index 00000000..6edbca1e --- /dev/null +++ b/hyperstack-macros/tests/ui/resolve_errors/malformed_url_template.stderr @@ -0,0 +1,5 @@ +error: Unclosed '{' in URL template: https://example.com/{mint + --> tests/ui/resolve_errors/malformed_url_template.rs:7:9 + | +7 | #[resolve(url = "https://example.com/{mint", extract = "name")] + | ^ diff --git a/hyperstack-macros/tests/ui/resolve_errors/unknown_resolver.rs b/hyperstack-macros/tests/ui/resolve_errors/unknown_resolver.rs new file mode 100644 index 00000000..c03668c9 --- /dev/null +++ b/hyperstack-macros/tests/ui/resolve_errors/unknown_resolver.rs @@ -0,0 +1,13 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + existing: String, + #[resolve(from = "existing", resolver = Toke)] + metadata: String, + } +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/resolve_errors/unknown_resolver.stderr b/hyperstack-macros/tests/ui/resolve_errors/unknown_resolver.stderr new file mode 100644 index 00000000..7426eb0e --- /dev/null +++ b/hyperstack-macros/tests/ui/resolve_errors/unknown_resolver.stderr @@ -0,0 +1,5 @@ +error: unknown resolver 'Toke'. Did you mean: Token? + --> tests/ui/resolve_errors/unknown_resolver.rs:9:19 + | +9 | metadata: String, + | ^^^^^^ diff --git a/hyperstack-macros/tests/ui/resolve_errors/unsupported_resolver_type.rs b/hyperstack-macros/tests/ui/resolve_errors/unsupported_resolver_type.rs new file mode 100644 index 00000000..48220c7e --- /dev/null +++ b/hyperstack-macros/tests/ui/resolve_errors/unsupported_resolver_type.rs @@ -0,0 +1,12 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[resolve(from = "mint")] + metadata: u64, + } +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/resolve_errors/unsupported_resolver_type.stderr b/hyperstack-macros/tests/ui/resolve_errors/unsupported_resolver_type.stderr new file mode 100644 index 00000000..badb6050 --- /dev/null +++ b/hyperstack-macros/tests/ui/resolve_errors/unsupported_resolver_type.stderr @@ -0,0 +1,5 @@ +error: unknown resolver-backed type 'u64'. Available types: TokenMetadata + --> tests/ui/resolve_errors/unsupported_resolver_type.rs:8:19 + | +8 | metadata: u64, + | ^^^ diff --git a/hyperstack-macros/tests/ui/validation_errors/computed_cycle.rs b/hyperstack-macros/tests/ui/validation_errors/computed_cycle.rs new file mode 100644 index 00000000..1c1d8953 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/computed_cycle.rs @@ -0,0 +1,14 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[computed(b)] + a: u64, + #[computed(a)] + b: u64, + } +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/validation_errors/computed_cycle.stderr b/hyperstack-macros/tests/ui/validation_errors/computed_cycle.stderr new file mode 100644 index 00000000..56a14a73 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/computed_cycle.stderr @@ -0,0 +1,5 @@ +error: computed fields contain a dependency cycle: a -> b -> a + --> tests/ui/validation_errors/computed_cycle.rs:7:9 + | +7 | #[computed(b)] + | ^ diff --git a/hyperstack-macros/tests/ui/validation_errors/invalid_idl_literal.rs b/hyperstack-macros/tests/ui/validation_errors/invalid_idl_literal.rs new file mode 100644 index 00000000..2eba8097 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/invalid_idl_literal.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(idl = true)] +mod broken {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/validation_errors/invalid_idl_literal.stderr b/hyperstack-macros/tests/ui/validation_errors/invalid_idl_literal.stderr new file mode 100644 index 00000000..7a35c936 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/invalid_idl_literal.stderr @@ -0,0 +1,5 @@ +error: Expected string literal or array of string literals for idl + --> tests/ui/validation_errors/invalid_idl_literal.rs:3:20 + | +3 | #[hyperstack(idl = true)] + | ^^^^ diff --git a/hyperstack-macros/tests/ui/validation_errors/invalid_proto_literal.rs b/hyperstack-macros/tests/ui/validation_errors/invalid_proto_literal.rs new file mode 100644 index 00000000..a10153f1 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/invalid_proto_literal.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(proto = true)] +mod broken {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/validation_errors/invalid_proto_literal.stderr b/hyperstack-macros/tests/ui/validation_errors/invalid_proto_literal.stderr new file mode 100644 index 00000000..c3343c9d --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/invalid_proto_literal.stderr @@ -0,0 +1,5 @@ +error: Expected string literal or array of string literals + --> tests/ui/validation_errors/invalid_proto_literal.rs:3:22 + | +3 | #[hyperstack(proto = true)] + | ^^^^ diff --git a/hyperstack-macros/tests/ui/validation_errors/invalid_skip_decoders_assignment.rs b/hyperstack-macros/tests/ui/validation_errors/invalid_skip_decoders_assignment.rs new file mode 100644 index 00000000..b16b8827 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/invalid_skip_decoders_assignment.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(skip_decoders = true)] +mod broken {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/validation_errors/invalid_skip_decoders_assignment.stderr b/hyperstack-macros/tests/ui/validation_errors/invalid_skip_decoders_assignment.stderr new file mode 100644 index 00000000..aa05f6d2 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/invalid_skip_decoders_assignment.stderr @@ -0,0 +1,5 @@ +error: expected `,` + --> tests/ui/validation_errors/invalid_skip_decoders_assignment.rs:3:28 + | +3 | #[hyperstack(skip_decoders = true)] + | ^ diff --git a/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.rs b/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.rs new file mode 100644 index 00000000..43e78ab1 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(proto = "missing.proto")] +mod broken {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.stderr b/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.stderr new file mode 100644 index 00000000..8eea1a83 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.stderr @@ -0,0 +1,5 @@ +error: Failed to parse proto file missing.proto (full path: "$WORKSPACE/target/tests/trybuild/hyperstack-macros/missing.proto"): Failed to read proto file "$WORKSPACE/target/tests/trybuild/hyperstack-macros/missing.proto": No such file or directory (os error 2) + --> tests/ui/validation_errors/missing_proto_file.rs:4:5 + | +4 | mod broken {} + | ^^^^^^ diff --git a/hyperstack-macros/tests/ui/validation_errors/missing_proto_value.rs b/hyperstack-macros/tests/ui/validation_errors/missing_proto_value.rs new file mode 100644 index 00000000..2fdfaffd --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/missing_proto_value.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(proto)] +mod broken {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/validation_errors/missing_proto_value.stderr b/hyperstack-macros/tests/ui/validation_errors/missing_proto_value.stderr new file mode 100644 index 00000000..aeafddf3 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/missing_proto_value.stderr @@ -0,0 +1,7 @@ +error: expected `=` + --> tests/ui/validation_errors/missing_proto_value.rs:3:1 + | +3 | #[hyperstack(proto)] + | ^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `hyperstack` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/hyperstack-macros/tests/ui/validation_errors/struct_mode_args_not_supported.rs b/hyperstack-macros/tests/ui/validation_errors/struct_mode_args_not_supported.rs new file mode 100644 index 00000000..ae56bd11 --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/struct_mode_args_not_supported.rs @@ -0,0 +1,8 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "tests/ui/fixtures/unused.json")] +struct Broken { + value: u64, +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/validation_errors/struct_mode_args_not_supported.stderr b/hyperstack-macros/tests/ui/validation_errors/struct_mode_args_not_supported.stderr new file mode 100644 index 00000000..7774ae1c --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/struct_mode_args_not_supported.stderr @@ -0,0 +1,5 @@ +error: #[hyperstack(...)] arguments are only supported on modules + --> tests/ui/validation_errors/struct_mode_args_not_supported.rs:4:8 + | +4 | struct Broken { + | ^^^^^^ diff --git a/hyperstack-macros/tests/ui/validation_errors/unknown_top_level_arg.rs b/hyperstack-macros/tests/ui/validation_errors/unknown_top_level_arg.rs new file mode 100644 index 00000000..6861fcbf --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/unknown_top_level_arg.rs @@ -0,0 +1,6 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack(unknown = "value")] +mod broken {} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/validation_errors/unknown_top_level_arg.stderr b/hyperstack-macros/tests/ui/validation_errors/unknown_top_level_arg.stderr new file mode 100644 index 00000000..701152bd --- /dev/null +++ b/hyperstack-macros/tests/ui/validation_errors/unknown_top_level_arg.stderr @@ -0,0 +1,5 @@ +error: Unknown stream_spec attribute argument: unknown + --> tests/ui/validation_errors/unknown_top_level_arg.rs:3:14 + | +3 | #[hyperstack(unknown = "value")] + | ^^^^^^^ diff --git a/hyperstack-macros/tests/ui/view_errors/invalid_view_sort_field.rs b/hyperstack-macros/tests/ui/view_errors/invalid_view_sort_field.rs new file mode 100644 index 00000000..11977374 --- /dev/null +++ b/hyperstack-macros/tests/ui/view_errors/invalid_view_sort_field.rs @@ -0,0 +1,12 @@ +use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + #[view(name = "latest", sort_by = "ghost.value")] + struct Thing { + base: u64, + } +} + +fn main() {} diff --git a/hyperstack-macros/tests/ui/view_errors/invalid_view_sort_field.stderr b/hyperstack-macros/tests/ui/view_errors/invalid_view_sort_field.stderr new file mode 100644 index 00000000..a8abd6da --- /dev/null +++ b/hyperstack-macros/tests/ui/view_errors/invalid_view_sort_field.stderr @@ -0,0 +1,5 @@ +error: unknown view field 'ghost.value' on entity 'Thing' + --> tests/ui/view_errors/invalid_view_sort_field.rs:6:39 + | +6 | #[view(name = "latest", sort_by = "ghost.value")] + | ^^^^^^^^^^^^^ From a645a7579f55c744bc00a02fbf386d69a0d73b87 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 01:20:00 +0000 Subject: [PATCH 02/18] chore: refresh ore generated stack artifacts --- stacks/ore/.hyperstack/OreStream.stack.json | 12 ++++++++---- stacks/sdk/rust/src/ore/entity.rs | 12 ++++++++---- stacks/sdk/rust/src/ore/mod.rs | 5 ++++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/stacks/ore/.hyperstack/OreStream.stack.json b/stacks/ore/.hyperstack/OreStream.stack.json index 7f3c8ca5..9d3d98c9 100644 --- a/stacks/ore/.hyperstack/OreStream.stack.json +++ b/stacks/ore/.hyperstack/OreStream.stack.json @@ -1,4 +1,5 @@ { + "ast_version": "0.0.1", "stack_name": "OreStream", "program_ids": [ "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", @@ -2760,6 +2761,7 @@ ], "entities": [ { + "ast_version": "0.0.1", "state_name": "OreRound", "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "idl": null, @@ -5219,7 +5221,7 @@ "result_type": "Option < f64 >" } ], - "content_hash": "f4f0834dae1b5a90d32250f96663d719c0697d22d3ef7c95b6189ff7a167079e", + "content_hash": "dca9e9640a9b26f306aca74ac490d9af1e4ef417a896dc35abe40c1d0f7a7a45", "views": [ { "id": "OreRound/latest", @@ -5247,6 +5249,7 @@ ] }, { + "ast_version": "0.0.1", "state_name": "OreTreasury", "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "idl": null, @@ -5829,10 +5832,11 @@ "result_type": "Option < f64 >" } ], - "content_hash": "2209440d59e788f8381792812368fc7e1b9bc9f188c8ab8352cc1bf6e05001d5", + "content_hash": "6c2f198fd874e0ee222ba766ff3f222f0a539a5bd2e4e612e5caf87987a67cd7", "views": [] }, { + "ast_version": "0.0.1", "state_name": "OreMiner", "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "idl": null, @@ -7182,7 +7186,7 @@ "resolver_specs": [], "computed_fields": [], "computed_field_specs": [], - "content_hash": "22aea1c2e9647ccbed3ff8311ce336354a0b79e8d9ef11933d262faed8b1adb9", + "content_hash": "92656d0e56356698ad917cf006cbc2ca7e4b25529b4fd36a9052a400d350a6c5", "views": [] } ], @@ -9122,5 +9126,5 @@ ] } ], - "content_hash": "3cb0cdf6716a7a3c096d4b92cdb60adcb8d982b4d6f22d2f96a7381b2583c84f" + "content_hash": "d1e5d83f1f0d5d451a96289704f6858a4071e42d58a6341d75cc477b4cbe8f3d" } \ No newline at end of file diff --git a/stacks/sdk/rust/src/ore/entity.rs b/stacks/sdk/rust/src/ore/entity.rs index 96b2e521..408cc4d8 100644 --- a/stacks/sdk/rust/src/ore/entity.rs +++ b/stacks/sdk/rust/src/ore/entity.rs @@ -1,4 +1,4 @@ -use super::types::{OreRound, OreTreasury, OreMiner}; +use super::types::{OreMiner, OreRound, OreTreasury}; use hyperstack_sdk::{Stack, StateView, ViewBuilder, ViewHandle, Views}; pub struct OreStreamStack; @@ -24,8 +24,12 @@ pub struct OreStreamStackViews { impl Views for OreStreamStackViews { fn from_builder(builder: ViewBuilder) -> Self { Self { - ore_round: OreRoundEntityViews { builder: builder.clone() }, - ore_treasury: OreTreasuryEntityViews { builder: builder.clone() }, + ore_round: OreRoundEntityViews { + builder: builder.clone(), + }, + ore_treasury: OreTreasuryEntityViews { + builder: builder.clone(), + }, ore_miner: OreMinerEntityViews { builder }, } } @@ -90,4 +94,4 @@ impl OreMinerEntityViews { pub fn list(&self) -> ViewHandle { self.builder.view("OreMiner/list") } -} \ No newline at end of file +} diff --git a/stacks/sdk/rust/src/ore/mod.rs b/stacks/sdk/rust/src/ore/mod.rs index d47c6b65..bf159f9e 100644 --- a/stacks/sdk/rust/src/ore/mod.rs +++ b/stacks/sdk/rust/src/ore/mod.rs @@ -1,7 +1,10 @@ mod entity; mod types; -pub use entity::{OreStreamStack, OreStreamStackViews, OreRoundEntityViews, OreTreasuryEntityViews, OreMinerEntityViews}; +pub use entity::{ + OreMinerEntityViews, OreRoundEntityViews, OreStreamStack, OreStreamStackViews, + OreTreasuryEntityViews, +}; pub use types::*; pub use hyperstack_sdk::{ConnectionState, HyperStack, Stack, Update, Views}; From 7e7014c9c2a1bca5d4db743e96c6d2220bdeb012 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 01:20:06 +0000 Subject: [PATCH 03/18] chore: format touched runtime crates --- hyperstack-idl/src/analysis/mod.rs | 4 +- hyperstack-idl/src/analysis/pda_graph.rs | 6 +- hyperstack-idl/src/discriminator.rs | 6 +- interpreter/src/resolvers.rs | 62 ++++++++++++------- rust/hyperstack-sdk/src/lib.rs | 4 +- rust/hyperstack-sdk/src/store.rs | 2 - rust/hyperstack-server/src/cache.rs | 28 ++++----- rust/hyperstack-server/src/lib.rs | 5 +- rust/hyperstack-server/src/runtime.rs | 10 +-- .../hyperstack-server/src/websocket/server.rs | 28 +++++++-- 10 files changed, 92 insertions(+), 63 deletions(-) diff --git a/hyperstack-idl/src/analysis/mod.rs b/hyperstack-idl/src/analysis/mod.rs index 2bbfaef6..9249c1d5 100644 --- a/hyperstack-idl/src/analysis/mod.rs +++ b/hyperstack-idl/src/analysis/mod.rs @@ -1,11 +1,11 @@ //! Analysis utilities pub mod connect; -pub mod relations; pub mod pda_graph; +pub mod relations; pub mod type_graph; pub use connect::*; -pub use relations::*; pub use pda_graph::*; +pub use relations::*; pub use type_graph::*; diff --git a/hyperstack-idl/src/analysis/pda_graph.rs b/hyperstack-idl/src/analysis/pda_graph.rs index 96206875..636bf071 100644 --- a/hyperstack-idl/src/analysis/pda_graph.rs +++ b/hyperstack-idl/src/analysis/pda_graph.rs @@ -40,11 +40,7 @@ pub fn extract_pda_graph(idl: &IdlSpec) -> Vec { for ix in &idl.instructions { for acc in &ix.accounts { if let Some(pda) = &acc.pda { - let seeds = pda - .seeds - .iter() - .map(extract_seed_info) - .collect(); + let seeds = pda.seeds.iter().map(extract_seed_info).collect(); nodes.push(PdaNode { account_name: acc.name.clone(), diff --git a/hyperstack-idl/src/discriminator.rs b/hyperstack-idl/src/discriminator.rs index 1fba9bb3..cc9f87c7 100644 --- a/hyperstack-idl/src/discriminator.rs +++ b/hyperstack-idl/src/discriminator.rs @@ -29,7 +29,7 @@ pub fn compute_discriminator(namespace: &str, name: &str) -> [u8; 8] { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_discriminator_global_initialize() { // Known Anchor discriminator for "global:initialize" @@ -38,7 +38,7 @@ mod tests { assert_eq!(disc.len(), 8); assert!(disc.iter().any(|&b| b != 0)); } - + #[test] fn test_discriminator_consistency() { // Same inputs always produce same output @@ -46,7 +46,7 @@ mod tests { let disc2 = compute_discriminator("global", "deposit"); assert_eq!(disc1, disc2); } - + #[test] fn test_discriminator_different_names() { // Different names produce different discriminators diff --git a/interpreter/src/resolvers.rs b/interpreter/src/resolvers.rs index adf3d828..00c0a5d0 100644 --- a/interpreter/src/resolvers.rs +++ b/interpreter/src/resolvers.rs @@ -256,7 +256,7 @@ impl ResolverRegistry { | crate::ast::ComputedExpr::Some { value: expr } | crate::ast::ComputedExpr::Slice { expr, .. } | crate::ast::ComputedExpr::Index { expr, .. } - | crate::ast::ComputedExpr::U64FromLeBytes { bytes: expr } + | crate::ast::ComputedExpr::U64FromLeBytes { bytes: expr } | crate::ast::ComputedExpr::U64FromBeBytes { bytes: expr } | crate::ast::ComputedExpr::JsonToBytes { expr } | crate::ast::ComputedExpr::Keccak256 { expr } @@ -364,8 +364,7 @@ impl TokenMetadataResolverClient { }) } - pub fn from_env( - ) -> Result, Box> { + pub fn from_env() -> Result, Box> { let Some(endpoint) = std::env::var(DAS_API_ENDPOINT_ENV).ok() else { return Ok(None); }; @@ -427,7 +426,12 @@ impl TokenMetadataResolverClient { }, }); - let response = self.client.post(&self.endpoint).json(&payload).send().await?; + let response = self + .client + .post(&self.endpoint) + .json(&payload) + .send() + .await?; let response = response.error_for_status()?; let value = response.json::().await?; @@ -439,10 +443,7 @@ impl TokenMetadataResolverClient { .get("result") .and_then(|result| match result { Value::Array(items) => Some(items.clone()), - Value::Object(obj) => obj - .get("items") - .and_then(|items| items.as_array()) - .cloned(), + Value::Object(obj) => obj.get("items").and_then(|items| items.as_array()).cloned(), _ => None, }) .ok_or_else(|| "Resolver response missing result".to_string())?; @@ -452,7 +453,10 @@ impl TokenMetadataResolverClient { } fn build_token_metadata(asset: &Value) -> Option<(String, Value)> { - let mint = asset.get("id").and_then(|value| value.as_str())?.to_string(); + let mint = asset + .get("id") + .and_then(|value| value.as_str())? + .to_string(); let name = asset .pointer("/content/metadata/name") @@ -462,7 +466,9 @@ impl TokenMetadataResolverClient { .pointer("/content/metadata/symbol") .and_then(|value| value.as_str()); - let token_info = asset.get("token_info").or_else(|| asset.pointer("/content/token_info")); + let token_info = asset + .get("token_info") + .or_else(|| asset.pointer("/content/token_info")); let decimals = token_info .and_then(|info| info.get("decimals")) @@ -471,7 +477,11 @@ impl TokenMetadataResolverClient { let logo_uri = asset .pointer("/content/links/image") .and_then(|value| value.as_str()) - .or_else(|| asset.pointer("/content/links/image_uri").and_then(|value| value.as_str())); + .or_else(|| { + asset + .pointer("/content/links/image_uri") + .and_then(|value| value.as_str()) + }); let mut obj = serde_json::Map::new(); obj.insert("mint".to_string(), serde_json::json!(mint)); @@ -482,7 +492,8 @@ impl TokenMetadataResolverClient { ); obj.insert( "symbol".to_string(), - symbol.map(|value| serde_json::json!(value)) + symbol + .map(|value| serde_json::json!(value)) .unwrap_or(Value::Null), ); obj.insert( @@ -594,7 +605,9 @@ impl UrlResolverClient { if let Some(next) = current.get(index) { current = next; } else { - return Err(format!("Index '{}' out of bounds in path '{}'", index, path).into()); + return Err( + format!("Index '{}' out of bounds in path '{}'", index, path).into(), + ); } } else { return Err(format!("Key '{}' not found in path '{}'", segment, path).into()); @@ -617,12 +630,10 @@ impl UrlResolverClient { } } - let futures = unique - .into_iter() - .map(|(url, method)| async move { - let result = self.resolve(&url, &method).await; - (url, result) - }); + let futures = unique.into_iter().map(|(url, method)| async move { + let result = self.resolve(&url, &method).await; + (url, result) + }); join_all(futures) .await @@ -711,10 +722,12 @@ pub async fn resolve_url_batch( for entry in valid { match results.get(&entry.url) { Some(resolved_value) => { - match vm.apply_resolver_result(bytecode, &entry.request.cache_key, resolved_value.clone()) { - Ok(mut new_mutations) => { - mutations.append(&mut new_mutations) - } + match vm.apply_resolver_result( + bytecode, + &entry.request.cache_key, + resolved_value.clone(), + ) { + Ok(mut new_mutations) => mutations.append(&mut new_mutations), Err(err) => { tracing::warn!(url = %entry.url, "Failed to apply URL resolver result: {}", err); } @@ -839,7 +852,8 @@ impl SlotHashResolver { match bs58::decode(&hash).into_vec() { Ok(bytes) if bytes.len() == 32 => { // Return as { bytes: [...] } to match the SlotHashBytes TypeScript interface - let json_bytes: Vec = bytes.into_iter().map(|b| Value::Number(b.into())).collect(); + let json_bytes: Vec = + bytes.into_iter().map(|b| Value::Number(b.into())).collect(); let mut obj = serde_json::Map::new(); obj.insert("bytes".to_string(), Value::Array(json_bytes)); Ok(Value::Object(obj)) diff --git a/rust/hyperstack-sdk/src/lib.rs b/rust/hyperstack-sdk/src/lib.rs index c44ecafd..a5cafdca 100644 --- a/rust/hyperstack-sdk/src/lib.rs +++ b/rust/hyperstack-sdk/src/lib.rs @@ -40,4 +40,6 @@ pub use stream::{ RichUpdate, Update, UseStream, }; pub use subscription::Subscription; -pub use view::{RichWatchBuilder, StateView, UseBuilder, ViewBuilder, ViewHandle, Views, WatchBuilder}; +pub use view::{ + RichWatchBuilder, StateView, UseBuilder, ViewBuilder, ViewHandle, Views, WatchBuilder, +}; diff --git a/rust/hyperstack-sdk/src/store.rs b/rust/hyperstack-sdk/src/store.rs index 91afe189..a2b4fcb0 100644 --- a/rust/hyperstack-sdk/src/store.rs +++ b/rust/hyperstack-sdk/src/store.rs @@ -107,8 +107,6 @@ fn extract_sort_value(entity: &Value, field_path: &[String]) -> SortValue { } } - - struct ViewData { entities: HashMap, access_order: VecDeque, diff --git a/rust/hyperstack-server/src/cache.rs b/rust/hyperstack-server/src/cache.rs index 266de235..de8fe2c2 100644 --- a/rust/hyperstack-server/src/cache.rs +++ b/rust/hyperstack-server/src/cache.rs @@ -647,8 +647,10 @@ mod tests { .await; // Get all entities after "100:000000000002" - let after = cache.get_after("tokens/list", "100:000000000002", None).await; - + let after = cache + .get_after("tokens/list", "100:000000000002", None) + .await; + // Should return key3 and key4 (sorted by _seq) assert_eq!(after.len(), 2); assert_eq!(after[0].0, "key3"); @@ -683,8 +685,10 @@ mod tests { .await; // Get entities after "100:000000000000" with limit 2 - let after = cache.get_after("tokens/list", "100:000000000000", Some(2)).await; - + let after = cache + .get_after("tokens/list", "100:000000000000", Some(2)) + .await; + // Should return only first 2 (key1 and key2) assert_eq!(after.len(), 2); assert_eq!(after[0].0, "key1"); @@ -704,8 +708,10 @@ mod tests { .await; // Get entities after a future cursor - let after = cache.get_after("tokens/list", "999:000000000000", None).await; - + let after = cache + .get_after("tokens/list", "999:000000000000", None) + .await; + assert!(after.is_empty()); } @@ -714,17 +720,11 @@ mod tests { let cache = EntityCache::new(); // Insert entity without _seq - cache - .upsert( - "tokens/list", - "key1", - json!({"id": 1}), - ) - .await; + cache.upsert("tokens/list", "key1", json!({"id": 1})).await; // Get entities after any cursor - entity without _seq should not be included let after = cache.get_after("tokens/list", "0:000000000000", None).await; - + assert!(after.is_empty()); } } diff --git a/rust/hyperstack-server/src/lib.rs b/rust/hyperstack-server/src/lib.rs index a71b0861..db7db1d5 100644 --- a/rust/hyperstack-server/src/lib.rs +++ b/rust/hyperstack-server/src/lib.rs @@ -372,6 +372,9 @@ mod tests { fn test_spec_creation() { let bytecode = hyperstack_interpreter::compiler::MultiEntityBytecode::new().build(); let spec = Spec::new(bytecode, "test_program"); - assert_eq!(spec.program_ids.first().map(String::as_str), Some("test_program")); + assert_eq!( + spec.program_ids.first().map(String::as_str), + Some("test_program") + ); } } diff --git a/rust/hyperstack-server/src/runtime.rs b/rust/hyperstack-server/src/runtime.rs index 9829a54a..50931a8a 100644 --- a/rust/hyperstack-server/src/runtime.rs +++ b/rust/hyperstack-server/src/runtime.rs @@ -165,10 +165,7 @@ impl Runtime { .first() .cloned() .unwrap_or_else(|| "unknown".to_string()); - info!( - "Starting Vixen parser runtime for program: {}", - program_id - ); + info!("Starting Vixen parser runtime for program: {}", program_id); let tx = mutations_tx.clone(); let health = health_monitor.clone(); let reconnection_config = self.config.reconnection.clone().unwrap_or_default(); @@ -215,7 +212,10 @@ impl Runtime { }); }) .expect("Failed to spawn health server thread"); - info!("HTTP health server running on dedicated thread at {}", bind_addr); + info!( + "HTTP health server running on dedicated thread at {}", + bind_addr + ); Some(join_handle) } else { None diff --git a/rust/hyperstack-server/src/websocket/server.rs b/rust/hyperstack-server/src/websocket/server.rs index d2b7d290..6d0c0ca7 100644 --- a/rust/hyperstack-server/src/websocket/server.rs +++ b/rust/hyperstack-server/src/websocket/server.rs @@ -609,7 +609,10 @@ async fn attach_client_to_bus( let _ = ctx.client_manager.send_to_client(ctx.client_id, data); } } else { - info!("Client {} subscribed to {} without snapshot", ctx.client_id, view_id); + info!( + "Client {} subscribed to {} without snapshot", + ctx.client_id, view_id + ); rx.borrow_and_update(); } @@ -653,7 +656,9 @@ async fn attach_client_to_bus( if should_send_snapshot { // Determine which entities to send based on cursor let mut snapshots = if let Some(ref cursor) = subscription.after { - ctx.entity_cache.get_after(view_id, cursor, subscription.snapshot_limit).await + ctx.entity_cache + .get_after(view_id, cursor, subscription.snapshot_limit) + .await } else { ctx.entity_cache.get_all(view_id).await }; @@ -698,7 +703,10 @@ async fn attach_client_to_bus( } } } else { - info!("Client {} subscribed to {} without snapshot", ctx.client_id, view_id); + info!( + "Client {} subscribed to {} without snapshot", + ctx.client_id, view_id + ); } let client_id = ctx.client_id; @@ -1022,7 +1030,10 @@ async fn attach_client_to_bus( let _ = ctx.client_manager.send_to_client(ctx.client_id, data); } } else { - info!("Client {} subscribed to {} without snapshot", ctx.client_id, view_id); + info!( + "Client {} subscribed to {} without snapshot", + ctx.client_id, view_id + ); rx.borrow_and_update(); } @@ -1062,7 +1073,9 @@ async fn attach_client_to_bus( if should_send_snapshot { // Determine which entities to send based on cursor let mut snapshots = if let Some(ref cursor) = subscription.after { - ctx.entity_cache.get_after(view_id, cursor, subscription.snapshot_limit).await + ctx.entity_cache + .get_after(view_id, cursor, subscription.snapshot_limit) + .await } else { ctx.entity_cache.get_all(view_id).await }; @@ -1105,7 +1118,10 @@ async fn attach_client_to_bus( } } } else { - info!("Client {} subscribed to {} without snapshot", ctx.client_id, view_id); + info!( + "Client {} subscribed to {} without snapshot", + ctx.client_id, view_id + ); } let client_id = ctx.client_id; From 8f23f4c2e51609e0f704c9084ab8f4afb28ae15a Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 02:27:39 +0000 Subject: [PATCH 04/18] fix: validate handler key resolution paths during macro expansion --- hyperstack-macros/src/parse/attributes.rs | 3 + hyperstack-macros/src/stream_spec/entity.rs | 5 + hyperstack-macros/src/stream_spec/handlers.rs | 3 + .../src/stream_spec/proto_struct.rs | 16 + hyperstack-macros/src/stream_spec/sections.rs | 2 + hyperstack-macros/src/validation/mod.rs | 363 ++++++++++++++++++ .../tests/key_resolution_dynamic.rs | 189 +++++++++ interpreter/src/compiler.rs | 8 +- 8 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 hyperstack-macros/tests/key_resolution_dynamic.rs diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index 4ac5530e..cd7ea25c 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -26,6 +26,7 @@ pub struct MapAttribute { pub attr_span: Span, pub source_type_span: Span, pub source_field_span: Span, + pub is_event_source: bool, pub source_type_path: Path, pub source_field_name: String, pub target_field_name: String, @@ -383,6 +384,7 @@ pub fn parse_map_attribute( attr_span: attr.span(), source_type_span: split.source_type_span, source_field_span: split.source_field_span, + is_event_source: false, source_type_path: split.source_type_path, source_field_name: split.source_field_name, target_field_name: target_name.clone(), @@ -446,6 +448,7 @@ pub fn parse_from_instruction_attribute( attr_span: attr.span(), source_type_span: split.source_type_span, source_field_span: split.source_field_span, + is_event_source: false, source_type_path: split.source_type_path, source_field_name: split.source_field_name, target_field_name: target_name.clone(), diff --git a/hyperstack-macros/src/stream_spec/entity.rs b/hyperstack-macros/src/stream_spec/entity.rs index 2a0483bd..3dc84817 100644 --- a/hyperstack-macros/src/stream_spec/entity.rs +++ b/hyperstack-macros/src/stream_spec/entity.rs @@ -323,6 +323,7 @@ pub fn process_entity_struct_with_idl( attr_span: snapshot_attr.attr_span, source_type_span: acct_path.span(), source_field_span, + is_event_source: false, source_type_path: acct_path, source_field_name, target_field_name: snapshot_attr.target_field_name.clone(), @@ -381,6 +382,7 @@ pub fn process_entity_struct_with_idl( attr_span: aggr_attr.attr_span, source_type_span: instr_path.span(), source_field_span, + is_event_source: false, source_type_path: instr_path.clone(), source_field_name, target_field_name: aggr_attr.target_field_name.clone(), @@ -575,9 +577,12 @@ pub fn process_entity_struct_with_idl( } validate_semantics(ValidationInput { entity_name: &entity_name, + primary_keys: &primary_keys, + lookup_indexes: &lookup_indexes, sources_by_type: &sources_by_type, events_by_instruction: &events_by_instruction, derive_from_mappings: &derive_from_mappings, + resolver_hooks: &resolver_hooks, computed_fields: &computed_field_validations, resolve_specs: &resolve_specs, section_specs: §ion_specs, diff --git a/hyperstack-macros/src/stream_spec/handlers.rs b/hyperstack-macros/src/stream_spec/handlers.rs index c22b4dac..4994ea4d 100644 --- a/hyperstack-macros/src/stream_spec/handlers.rs +++ b/hyperstack-macros/src/stream_spec/handlers.rs @@ -270,6 +270,7 @@ pub fn convert_event_to_map_attributes( attr_span: event_attr.attr_span, source_type_span: instruction_path.span(), source_field_span: event_attr.attr_span, + is_event_source: true, source_type_path: instruction_path.clone(), source_field_name: String::new(), target_field_name: target_field.to_string(), @@ -305,6 +306,7 @@ pub fn convert_event_to_map_attributes( attr_span: event_attr.attr_span, source_type_span: instruction_path.span(), source_field_span: field_spec.ident.span(), + is_event_source: true, source_type_path: instruction_path.clone(), source_field_name: field_name.clone(), target_field_name: format!("{}.{}", target_field, field_name), @@ -338,6 +340,7 @@ pub fn convert_event_to_map_attributes( attr_span: event_attr.attr_span, source_type_span: instruction_path.span(), source_field_span: event_attr.attr_span, + is_event_source: true, source_type_path: instruction_path.clone(), source_field_name: field_name.clone(), target_field_name: format!("{}.{}", target_field, field_name), diff --git a/hyperstack-macros/src/stream_spec/proto_struct.rs b/hyperstack-macros/src/stream_spec/proto_struct.rs index 7ae18d41..8d748be3 100644 --- a/hyperstack-macros/src/stream_spec/proto_struct.rs +++ b/hyperstack-macros/src/stream_spec/proto_struct.rs @@ -11,6 +11,7 @@ use syn::{Fields, ItemStruct, Type}; use crate::parse; use crate::utils::{path_to_string, to_snake_case}; +use crate::validation::{validate_key_resolution_paths, KeyResolutionValidationInput}; use super::entity::{infer_resolver_type, parse_resolver_type_name, process_map_attribute}; use super::handlers::{convert_event_to_map_attributes, determine_event_instruction}; @@ -231,6 +232,21 @@ pub fn process_struct_with_context( } } + let mut key_resolution_errors = crate::diagnostic::ErrorCollector::default(); + validate_key_resolution_paths( + KeyResolutionValidationInput { + entity_name: &name.to_string(), + primary_keys: &primary_keys, + lookup_indexes: &lookup_indexes, + sources_by_type: &sources_by_type, + events_by_instruction: &events_by_instruction, + derive_from_mappings: &derive_from_mappings, + resolver_hooks: &[], + }, + &mut key_resolution_errors, + ); + key_resolution_errors.finish()?; + let mut sources_by_type_and_join: HashMap<(String, Option), Vec> = HashMap::new(); for (source_type, mappings) in &sources_by_type { diff --git a/hyperstack-macros/src/stream_spec/sections.rs b/hyperstack-macros/src/stream_spec/sections.rs index 855515ec..29ed3a5d 100644 --- a/hyperstack-macros/src/stream_spec/sections.rs +++ b/hyperstack-macros/src/stream_spec/sections.rs @@ -443,6 +443,7 @@ pub fn process_nested_struct( attr_span: snapshot_attr.attr_span, source_type_span: acct_path.span(), source_field_span, + is_event_source: false, source_type_path: acct_path, source_field_name, target_field_name: snapshot_attr.target_field_name.clone(), @@ -500,6 +501,7 @@ pub fn process_nested_struct( attr_span: aggr_attr.attr_span, source_type_span: instr_path.span(), source_field_span, + is_event_source: false, source_type_path: instr_path.clone(), source_field_name, target_field_name: aggr_attr.target_field_name.clone(), diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index 16f3f931..24cfc752 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -25,9 +25,12 @@ pub struct ComputedFieldValidation { pub struct ValidationInput<'a> { pub entity_name: &'a str, + pub primary_keys: &'a [String], + pub lookup_indexes: &'a [(String, Option)], pub sources_by_type: &'a HashMap>, pub events_by_instruction: &'a HashMap>, pub derive_from_mappings: &'a HashMap>, + pub resolver_hooks: &'a [parse::ResolveKeyAttribute], pub computed_fields: &'a [ComputedFieldValidation], pub resolve_specs: &'a [parse::ResolveSpec], pub section_specs: &'a [EntitySection], @@ -35,12 +38,38 @@ pub struct ValidationInput<'a> { pub idls: IdlLookup<'a>, } +pub struct KeyResolutionValidationInput<'a> { + pub entity_name: &'a str, + pub primary_keys: &'a [String], + pub lookup_indexes: &'a [(String, Option)], + pub sources_by_type: &'a HashMap>, + pub events_by_instruction: &'a HashMap>, + pub derive_from_mappings: &'a HashMap>, + pub resolver_hooks: &'a [parse::ResolveKeyAttribute], +} + +type GroupedEventMappings = + HashMap<(String, Option), Vec<(String, parse::EventAttribute, syn::Type)>>; + pub fn validate_semantics(input: ValidationInput<'_>) -> syn::Result<()> { let known_fields = collect_known_field_paths(input.section_specs, input.computed_fields); let available_fields = sorted_field_paths(&known_fields); let mut errors = ErrorCollector::default(); + validate_key_resolution_paths( + KeyResolutionValidationInput { + entity_name: input.entity_name, + primary_keys: input.primary_keys, + lookup_indexes: input.lookup_indexes, + sources_by_type: input.sources_by_type, + events_by_instruction: input.events_by_instruction, + derive_from_mappings: input.derive_from_mappings, + resolver_hooks: input.resolver_hooks, + }, + &mut errors, + ); + validate_mapping_references( input.entity_name, input.sources_by_type, @@ -83,6 +112,38 @@ pub fn validate_semantics(input: ValidationInput<'_>) -> syn::Result<()> { errors.finish() } +pub fn validate_key_resolution_paths( + input: KeyResolutionValidationInput<'_>, + errors: &mut ErrorCollector, +) { + let primary_key_leafs = primary_key_leafs(input.primary_keys); + let lookup_index_leafs = lookup_index_leafs(input.lookup_indexes); + + validate_source_handler_keys( + input.entity_name, + &primary_key_leafs, + &lookup_index_leafs, + input.lookup_indexes, + input.sources_by_type, + input.resolver_hooks, + errors, + ); + validate_event_handler_keys( + input.entity_name, + &primary_key_leafs, + &lookup_index_leafs, + input.events_by_instruction, + errors, + ); + validate_instruction_hook_keys( + input.entity_name, + &primary_key_leafs, + &lookup_index_leafs, + input.derive_from_mappings, + errors, + ); +} + pub fn validate_pda_blocks( idls: &HashMap, blocks: &[PdasBlock], @@ -152,6 +213,308 @@ fn entity_field_error( syn::Error::new(span, message) } +fn primary_key_leafs(primary_keys: &[String]) -> HashSet { + primary_keys + .iter() + .map(|key| key.split('.').next_back().unwrap_or(key).to_string()) + .collect() +} + +fn lookup_index_leafs(lookup_indexes: &[(String, Option)]) -> HashSet { + let mut values = HashSet::new(); + + for (field, _) in lookup_indexes { + let leaf = field.split('.').next_back().unwrap_or(field).to_string(); + values.insert(leaf.clone()); + if let Some(stripped) = leaf.strip_suffix("_address") { + values.insert(stripped.to_string()); + } + } + + values +} + +fn has_explicit_key_resolver( + source_type: &str, + resolver_hooks: &[parse::ResolveKeyAttribute], +) -> bool { + resolver_hooks + .iter() + .any(|hook| crate::utils::path_to_string(&hook.account_path) == source_type) +} + +fn source_field_can_resolve_key( + field_name: &str, + primary_key_leafs: &HashSet, + lookup_index_leafs: &HashSet, +) -> bool { + primary_key_leafs.contains(field_name) || lookup_index_leafs.contains(field_name) +} + +fn source_exposes_field(mappings: &[parse::MapAttribute], field_name: &str) -> bool { + mappings + .iter() + .any(|mapping| mapping.source_field_name == field_name) +} + +fn has_account_address_lookup_path( + mappings: &[parse::MapAttribute], + lookup_indexes: &[(String, Option)], +) -> bool { + mappings.iter().any(|mapping| { + mapping.source_field_name == "__account_address" + && lookup_indexes + .iter() + .any(|(field_name, _)| field_name == &mapping.target_field_name) + }) +} + +fn key_resolution_error( + span: proc_macro2::Span, + source_kind: &str, + source_name: &str, + entity_name: &str, + detail: &str, +) -> syn::Error { + syn::Error::new( + span, + format!( + "{} '{}' cannot resolve the primary key for entity '{}'. {}", + source_kind, source_name, entity_name, detail + ), + ) +} + +fn validate_source_handler_keys( + entity_name: &str, + primary_key_leafs: &HashSet, + lookup_index_leafs: &HashSet, + lookup_indexes: &[(String, Option)], + sources_by_type: &HashMap>, + resolver_hooks: &[parse::ResolveKeyAttribute], + errors: &mut ErrorCollector, +) { + let mut grouped: HashMap<(String, Option), Vec> = HashMap::new(); + for (source_type, mappings) in sources_by_type { + for mapping in mappings { + grouped + .entry((source_type.clone(), mapping.join_on.clone())) + .or_default() + .push(mapping.clone()); + } + } + + for ((source_type, join_key), mappings) in grouped { + let Some(first_mapping) = mappings.first() else { + continue; + }; + + if mappings.iter().any(|mapping| mapping.is_primary_key) { + continue; + } + + let is_instruction = mappings.iter().any(|mapping| mapping.is_instruction); + let is_cpi_event = + source_type.contains("::events::") || source_type.contains("::cpi_events::"); + + if mappings.iter().all(|mapping| mapping.is_event_source) { + continue; + } + + if !is_instruction + && !is_cpi_event + && has_explicit_key_resolver(&source_type, resolver_hooks) + { + continue; + } + + if mappings.iter().any(|mapping| { + source_field_can_resolve_key( + &mapping.source_field_name, + primary_key_leafs, + lookup_index_leafs, + ) + }) { + continue; + } + + if !is_instruction + && !is_cpi_event + && has_account_address_lookup_path(&mappings, lookup_indexes) + { + continue; + } + + if let Some(lookup_by) = mappings + .iter() + .find_map(|mapping| mapping.lookup_by.as_ref()) + { + let field_name = lookup_by.ident.to_string(); + if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) { + continue; + } + + errors.push(key_resolution_error( + lookup_by.ident.span(), + if is_instruction { "instruction source" } else { "account source" }, + &source_type, + entity_name, + &format!( + "The `lookup_by` field '{}' is neither a primary-key field nor a lookup-index-backed field.", + field_name + ), + )); + continue; + } + + if let Some(join_field) = join_key { + if source_exposes_field(&mappings, &join_field) + && source_field_can_resolve_key(&join_field, primary_key_leafs, lookup_index_leafs) + { + continue; + } + + errors.push(key_resolution_error( + first_mapping.attr_span, + if is_instruction { "instruction source" } else { "account source" }, + &source_type, + entity_name, + &format!( + "The `join_on` field '{}' does not provide a provable path back to the entity primary key. Use `primary_key`, `lookup_by`, a lookup-index-backed source field, or an explicit `#[resolve_key(...)]` hook.", + join_field + ), + )); + continue; + } + + errors.push(key_resolution_error( + first_mapping.attr_span, + if is_instruction { "instruction source" } else { "account source" }, + &source_type, + entity_name, + if is_instruction { + "Add a `primary_key` mapping or `lookup_by = ...` that points to the primary key or to a lookup index field." + } else { + "Add a `primary_key` mapping, a lookup-index-backed field (commonly via `__account_address`), or an explicit `#[resolve_key(...)]` hook." + }, + )); + } +} + +fn validate_event_handler_keys( + entity_name: &str, + primary_key_leafs: &HashSet, + lookup_index_leafs: &HashSet, + events_by_instruction: &HashMap>, + errors: &mut ErrorCollector, +) { + let mut grouped: GroupedEventMappings = HashMap::new(); + for (instruction, event_mappings) in events_by_instruction { + for event_mapping in event_mappings { + let join_key = event_mapping + .1 + .join_on + .as_ref() + .map(|field_spec| field_spec.ident.to_string()); + grouped + .entry((instruction.clone(), join_key)) + .or_default() + .push(event_mapping.clone()); + } + } + + for ((instruction, _join_key), mappings) in grouped { + let Some((_, first_attr, _)) = mappings.first() else { + continue; + }; + + if let Some(lookup_by) = &first_attr.lookup_by { + let field_name = lookup_by.ident.to_string(); + if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) { + continue; + } + + errors.push(key_resolution_error( + lookup_by.ident.span(), + "event source", + &instruction, + entity_name, + &format!( + "The `lookup_by` field '{}' is neither a primary-key field nor a lookup-index-backed field.", + field_name + ), + )); + continue; + } + + if let Some(join_on) = &first_attr.join_on { + let field_name = join_on.ident.to_string(); + if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) { + continue; + } + + errors.push(key_resolution_error( + join_on.ident.span(), + "event source", + &instruction, + entity_name, + &format!( + "The `join_on` field '{}' is neither a primary-key field nor a lookup-index-backed field.", + field_name + ), + )); + continue; + } + + errors.push(key_resolution_error( + first_attr.attr_span, + "event source", + &instruction, + entity_name, + "Add `lookup_by = ...` or `join_on = ...` that points to the primary key or to a lookup index field.", + )); + } +} + +fn validate_instruction_hook_keys( + entity_name: &str, + primary_key_leafs: &HashSet, + lookup_index_leafs: &HashSet, + derive_from_mappings: &HashMap>, + errors: &mut ErrorCollector, +) { + for (instruction_type, derive_attrs) in derive_from_mappings { + for derive_attr in derive_attrs { + if let Some(lookup_by) = &derive_attr.lookup_by { + let field_name = lookup_by.ident.to_string(); + if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) + { + continue; + } + + errors.push(key_resolution_error( + lookup_by.ident.span(), + "instruction hook", + instruction_type, + entity_name, + &format!( + "The `lookup_by` field '{}' is neither a primary-key field nor a lookup-index-backed field.", + field_name + ), + )); + } else { + errors.push(key_resolution_error( + derive_attr.attr_span, + "instruction hook", + instruction_type, + entity_name, + "Add `lookup_by = ...` that points to the primary key or to a lookup index field.", + )); + } + } + } +} + fn validate_mapping_references( entity_name: &str, sources_by_type: &HashMap>, diff --git a/hyperstack-macros/tests/key_resolution_dynamic.rs b/hyperstack-macros/tests/key_resolution_dynamic.rs new file mode 100644 index 00000000..7e09342d --- /dev/null +++ b/hyperstack-macros/tests/key_resolution_dynamic.rs @@ -0,0 +1,189 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn escape_path(path: &Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + +fn compile_failure_stderr_with_files( + name: &str, + source: &str, + extra_files: &[(&str, &str)], +) -> String { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .expect("hyperstack-macros should live in workspace root"); + let temp_root = workspace_root.join("target/tests/key-resolution-dynamic"); + fs::create_dir_all(&temp_root).expect("create dynamic test root"); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let crate_dir = temp_root.join(format!("{name}-{unique}")); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +hyperstack-macros = {{ path = "{}" }} +"#, + escape_path(&manifest_dir) + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + for (relative_path, contents) in extra_files { + let file_path = crate_dir.join(relative_path); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("create extra file parent dir"); + } + fs::write(file_path, contents).expect("write extra test file"); + } + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(&crate_dir) + .env("CARGO_TARGET_DIR", workspace_root.join("target")) + .output() + .expect("run cargo check"); + + assert!( + !output.status.success(), + "expected cargo check to fail for {name}" + ); + + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn minimal_idl() -> &'static str { + r#"{ + "name": "fake", + "instructions": [ + { + "name": "Trade", + "accounts": [{ "name": "thing" }], + "args": [{ "name": "user", "type": "string" }] + } + ], + "accounts": [ + { + "name": "Thing", + "type": { + "kind": "struct", + "fields": [{ "name": "id", "type": "string" }] + } + }, + { + "name": "Position", + "type": { + "kind": "struct", + "fields": [{ "name": "amount", "type": "u64" }] + } + } + ], + "types": [], + "events": [], + "errors": [], + "constants": [] +}"# +} + +#[test] +fn instruction_source_without_lookup_path_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "fixture/minimal.json")] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[map(fake_sdk::accounts::Thing::id, primary_key, strategy = SetOnce)] + id: String, + + #[aggregate(from = fake_sdk::instructions::Trade, strategy = Count)] + trades: u64, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr_with_files( + "instruction_source_without_lookup_path_is_rejected", + source, + &[("fixture/minimal.json", minimal_idl())], + ); + assert!(stderr.contains( + "instruction source 'fake_sdk::instructions::Trade' cannot resolve the primary key" + )); + assert!(stderr.contains("Add a `primary_key` mapping or `lookup_by = ...`")); +} + +#[test] +fn account_source_without_pk_lookup_or_resolver_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "fixture/minimal.json")] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[map(fake_sdk::accounts::Thing::id, primary_key, strategy = SetOnce)] + id: String, + + #[map(fake_sdk::accounts::Position::amount, strategy = LastWrite)] + amount: u64, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr_with_files( + "account_source_without_pk_lookup_or_resolver_is_rejected", + source, + &[("fixture/minimal.json", minimal_idl())], + ); + assert!(stderr + .contains("account source 'fake_sdk::accounts::Position' cannot resolve the primary key")); + assert!(stderr.contains("lookup-index-backed field")); +} + +#[test] +fn event_source_without_lookup_or_join_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "fixture/minimal.json")] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[map(fake_sdk::accounts::Thing::id, primary_key, strategy = SetOnce)] + id: String, + + #[event(from = fake_sdk::instructions::Trade, fields = [user])] + trades: Vec, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr_with_files( + "event_source_without_lookup_or_join_is_rejected", + source, + &[("fixture/minimal.json", minimal_idl())], + ); + assert!(stderr.contains("event source '")); + assert!(stderr.contains("cannot resolve the primary key")); + assert!(stderr.contains("Add `lookup_by = ...` or `join_on = ...`")); +} diff --git a/interpreter/src/compiler.rs b/interpreter/src/compiler.rs index 7816d91a..38eff9bb 100644 --- a/interpreter/src/compiler.rs +++ b/interpreter/src/compiler.rs @@ -1524,7 +1524,13 @@ impl TypedCompiler { .split('.') .next_back() .unwrap_or(&lookup_index.field_name); - if index_field_name == lookup_field_name { + let matches_directly = index_field_name == lookup_field_name; + let matches_address_alias = index_field_name + .strip_suffix("_address") + .map(|base| base == lookup_field_name) + .unwrap_or(false); + + if matches_directly || matches_address_alias { return Some(format!("{}_lookup_index", index_field_name)); } } From 1c0d2a86a68bd42512c139404ba3069a454c26ff Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 03:08:54 +0000 Subject: [PATCH 05/18] fix: address review feedback on macro diagnostics --- hyperstack-macros/src/diagnostic.rs | 19 ++++++++++++++++++- hyperstack-macros/src/parse/attributes.rs | 1 + hyperstack-macros/src/parse/conditions.rs | 12 +++++++++++- hyperstack-macros/src/stream_spec/module.rs | 9 +++------ hyperstack-macros/src/validation/mod.rs | 2 ++ 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/hyperstack-macros/src/diagnostic.rs b/hyperstack-macros/src/diagnostic.rs index 03d63a32..106fd1fb 100644 --- a/hyperstack-macros/src/diagnostic.rs +++ b/hyperstack-macros/src/diagnostic.rs @@ -84,13 +84,19 @@ pub fn invalid_choice_message( .iter() .map(|value| value.to_string()) .collect::>(); + let candidate_refs: Vec<&str> = available.iter().map(String::as_str).collect(); + let suggestion = suggest_similar(actual, &candidate_refs, 3) + .first() + .map(|suggestion| format!(". Did you mean: {}?", suggestion.candidate)) + .unwrap_or_default(); + format!( "invalid {} '{}' for {}. Expected one of: {}{}", choice_kind, actual, context, expected.join(", "), - suggestion_or_available_suffix(actual, &available, "Available values") + suggestion ) } @@ -152,4 +158,15 @@ mod tests { "unknown resolver-backed type 'u64'. Available types: TokenMetadata" ); } + + #[test] + fn invalid_choice_message_does_not_repeat_available_values() { + let message = + invalid_choice_message("strategy", "foo", "#[map]", &["SetOnce", "LastWrite"]); + + assert_eq!( + message, + "invalid strategy 'foo' for #[map]. Expected one of: SetOnce, LastWrite" + ); + } } diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index cd7ea25c..a0395bb2 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -26,6 +26,7 @@ pub struct MapAttribute { pub attr_span: Span, pub source_type_span: Span, pub source_field_span: Span, + // Set when #[event(...)] handlers are normalized into MapAttribute values. pub is_event_source: bool, pub source_type_path: Path, pub source_field_name: String, diff --git a/hyperstack-macros/src/parse/conditions.rs b/hyperstack-macros/src/parse/conditions.rs index 13684490..f89382b2 100644 --- a/hyperstack-macros/src/parse/conditions.rs +++ b/hyperstack-macros/src/parse/conditions.rs @@ -191,7 +191,7 @@ fn parse_value(value: &str) -> Result { pub fn parse_resolver_condition_expression(expr: &str) -> Result { let operators = ["==", "!=", ">=", "<=", ">", "<"]; for op_str in &operators { - if let Some(pos) = expr.find(op_str) { + if let Some(pos) = find_top_level_operator(expr, op_str) { let field_path = expr[..pos].trim().to_string(); let raw_value = expr[pos + op_str.len()..].trim(); @@ -332,4 +332,14 @@ mod tests { _ => panic!("Expected comparison"), } } + + #[test] + fn test_resolver_condition_ignores_operators_inside_quotes() { + let parsed = + parse_resolver_condition_expression("status == \"pending >= review\"").unwrap(); + + assert_eq!(parsed.field_path, "status"); + assert!(matches!(parsed.op, ComparisonOp::Equal)); + assert_eq!(parsed.value, serde_json::json!("pending >= review")); + } } diff --git a/hyperstack-macros/src/stream_spec/module.rs b/hyperstack-macros/src/stream_spec/module.rs index cdefe838..2315ccaf 100644 --- a/hyperstack-macros/src/stream_spec/module.rs +++ b/hyperstack-macros/src/stream_spec/module.rs @@ -170,12 +170,9 @@ pub fn process_module( ) })?; - crate::ast::writer::write_stack_to_file(&stack_spec, &stack_name).map_err(|e| { - syn::Error::new( - module.ident.span(), - format!("Failed to write stack AST: {e}"), - ) - })?; + if let Err(error) = crate::ast::writer::write_stack_to_file(&stack_spec, &stack_name) { + eprintln!("Warning: Failed to write stack AST: {error}"); + } let multi_entity_builder = generate_multi_entity_builder( &entity_names, diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index 24cfc752..37e086c4 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -317,6 +317,8 @@ fn validate_source_handler_keys( let is_cpi_event = source_type.contains("::events::") || source_type.contains("::cpi_events::"); + // Event-only mappings are validated in validate_event_handler_keys before + // #[event(...)] handlers are merged into sources_by_type for codegen. if mappings.iter().all(|mapping| mapping.is_event_source) { continue; } From 487e9cbc0c85b820a0073597d08788aaf8844110 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 13:01:53 +0000 Subject: [PATCH 06/18] fix: handle derive_from key fields and non-finite conditions --- hyperstack-macros/src/parse/conditions.rs | 24 ++++- hyperstack-macros/src/validation/mod.rs | 12 ++- .../tests/key_resolution_dynamic.rs | 91 ++++++++++++++++++- 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/hyperstack-macros/src/parse/conditions.rs b/hyperstack-macros/src/parse/conditions.rs index f89382b2..8565854f 100644 --- a/hyperstack-macros/src/parse/conditions.rs +++ b/hyperstack-macros/src/parse/conditions.rs @@ -233,8 +233,21 @@ pub fn parse_resolver_condition_expression(expr: &str) -> Result serde_json::Value::Null, "true" => serde_json::Value::Bool(true), "false" => serde_json::Value::Bool(false), - s if s.parse::().is_ok() => serde_json::json!(s.parse::().unwrap()), - s => serde_json::Value::String(s.trim_matches('"').to_string()), + s => { + if let Ok(number) = s.parse::() { + match serde_json::Number::from_f64(number) { + Some(number) => serde_json::Value::Number(number), + None => { + return Err(format!( + "Invalid numeric value '{}' in condition expression '{}': non-finite floats are not supported.", + s, expr + )) + } + } + } else { + serde_json::Value::String(s.trim_matches('"').to_string()) + } + } }; return Ok(ResolverCondition { @@ -342,4 +355,11 @@ mod tests { assert!(matches!(parsed.op, ComparisonOp::Equal)); assert_eq!(parsed.value, serde_json::json!("pending >= review")); } + + #[test] + fn test_resolver_condition_rejects_non_finite_float_values() { + let error = parse_resolver_condition_expression("score == NaN").unwrap_err(); + + assert!(error.contains("non-finite floats are not supported")); + } } diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index 37e086c4..afd603aa 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -201,13 +201,13 @@ fn entity_field_error( context, reference, entity_name ); let suffix = suggestion_or_available_suffix(reference, available_fields, "Available fields"); - if suffix.is_empty() && !available_fields.is_empty() { + if !suffix.is_empty() { + message.push_str(&suffix); + } else if !available_fields.is_empty() { message.push_str(&format!( ". Available fields: {}", preview_values(available_fields, 6) )); - } else { - message.push_str(&suffix); } syn::Error::new(span, message) @@ -505,6 +505,12 @@ fn validate_instruction_hook_keys( ), )); } else { + let field_name = derive_attr.field.ident.to_string(); + if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) + { + continue; + } + errors.push(key_resolution_error( derive_attr.attr_span, "instruction hook", diff --git a/hyperstack-macros/tests/key_resolution_dynamic.rs b/hyperstack-macros/tests/key_resolution_dynamic.rs index 7e09342d..be26bac5 100644 --- a/hyperstack-macros/tests/key_resolution_dynamic.rs +++ b/hyperstack-macros/tests/key_resolution_dynamic.rs @@ -67,6 +67,66 @@ hyperstack-macros = {{ path = "{}" }} String::from_utf8_lossy(&output.stderr).into_owned() } +fn compile_success_with_files(name: &str, source: &str, extra_files: &[(&str, &str)]) { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .expect("hyperstack-macros should live in workspace root"); + let hyperstack_dir = workspace_root.join("hyperstack"); + let temp_root = workspace_root.join("target/tests/key-resolution-dynamic"); + fs::create_dir_all(&temp_root).expect("create dynamic test root"); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let crate_dir = temp_root.join(format!("{name}-{unique}")); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +hyperstack = {{ path = "{}" }} +hyperstack-macros = {{ path = "{}" }} +borsh = {{ version = "1.5", features = ["derive"] }} +serde = {{ version = "1", features = ["derive"] }} +"#, + escape_path(&hyperstack_dir), + escape_path(&manifest_dir) + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + for (relative_path, contents) in extra_files { + let file_path = crate_dir.join(relative_path); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("create extra file parent dir"); + } + fs::write(file_path, contents).expect("write extra test file"); + } + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(&crate_dir) + .env("CARGO_TARGET_DIR", workspace_root.join("target")) + .output() + .expect("run cargo check"); + + assert!( + output.status.success(), + "expected cargo check to succeed for {name}, stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + fn minimal_idl() -> &'static str { r#"{ "name": "fake", @@ -74,7 +134,10 @@ fn minimal_idl() -> &'static str { { "name": "Trade", "accounts": [{ "name": "thing" }], - "args": [{ "name": "user", "type": "string" }] + "args": [ + { "name": "user", "type": "string" }, + { "name": "id", "type": "string" } + ] } ], "accounts": [ @@ -187,3 +250,29 @@ fn main() {} assert!(stderr.contains("cannot resolve the primary key")); assert!(stderr.contains("Add `lookup_by = ...` or `join_on = ...`")); } + +#[test] +fn derive_from_primary_key_field_does_not_require_lookup_by() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "fixture/minimal.json")] +mod valid { + #[entity(name = "Thing")] + struct Thing { + #[map(fake_sdk::accounts::Thing::id, primary_key, strategy = SetOnce)] + id: String, + + #[derive_from(from = fake_sdk::instructions::Trade, field = id, strategy = LastWrite)] + latest_id: String, + } +} + +fn main() {} +"#; + + compile_success_with_files( + "derive_from_primary_key_field_does_not_require_lookup_by", + source, + &[("fixture/minimal.json", minimal_idl())], + ); +} From 2af6b83e5b7adf50ba22500c2b31f48e6a68d4ae Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 13:22:00 +0000 Subject: [PATCH 07/18] fix: stabilize event key validation and resolver parsing --- hyperstack-macros/src/parse/conditions.rs | 13 ++++- hyperstack-macros/src/validation/mod.rs | 50 +++++++++++++------ .../tests/key_resolution_dynamic.rs | 31 ++++++++++++ 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/hyperstack-macros/src/parse/conditions.rs b/hyperstack-macros/src/parse/conditions.rs index 8565854f..1ba2a69a 100644 --- a/hyperstack-macros/src/parse/conditions.rs +++ b/hyperstack-macros/src/parse/conditions.rs @@ -234,7 +234,9 @@ pub fn parse_resolver_condition_expression(expr: &str) -> Result serde_json::Value::Bool(true), "false" => serde_json::Value::Bool(false), s => { - if let Ok(number) = s.parse::() { + if let Ok(number) = s.parse::() { + serde_json::Value::Number(number.into()) + } else if let Ok(number) = s.parse::() { match serde_json::Number::from_f64(number) { Some(number) => serde_json::Value::Number(number), None => { @@ -362,4 +364,13 @@ mod tests { assert!(error.contains("non-finite floats are not supported")); } + + #[test] + fn test_resolver_condition_preserves_large_integer_values() { + let parsed = + parse_resolver_condition_expression("lamport_balance == 9999999999999999").unwrap(); + + assert_eq!(parsed.field_path, "lamport_balance"); + assert_eq!(parsed.value, serde_json::json!(9999999999999999i64)); + } } diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index afd603aa..1b2fe6cd 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use crate::ast::{ComputedExpr, EntitySection, FieldPath, ViewTransform}; -use crate::diagnostic::{preview_values, suggestion_or_available_suffix, ErrorCollector}; +use crate::diagnostic::{suggestion_or_available_suffix, ErrorCollector}; use crate::event_type_helpers::IdlLookup; use crate::parse; use crate::parse::idl as idl_parser; @@ -203,11 +203,6 @@ fn entity_field_error( let suffix = suggestion_or_available_suffix(reference, available_fields, "Available fields"); if !suffix.is_empty() { message.push_str(&suffix); - } else if !available_fields.is_empty() { - message.push_str(&format!( - ". Available fields: {}", - preview_values(available_fields, 6) - )); } syn::Error::new(span, message) @@ -425,17 +420,31 @@ fn validate_event_handler_keys( } } - for ((instruction, _join_key), mappings) in grouped { + for ((instruction, join_key), mappings) in grouped { let Some((_, first_attr, _)) = mappings.first() else { continue; }; - if let Some(lookup_by) = &first_attr.lookup_by { - let field_name = lookup_by.ident.to_string(); - if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) { - continue; - } + let lookup_by = mappings + .iter() + .filter_map(|(_, attr, _)| attr.lookup_by.as_ref()) + .find(|lookup_by| { + source_field_can_resolve_key( + &lookup_by.ident.to_string(), + primary_key_leafs, + lookup_index_leafs, + ) + }); + if lookup_by.is_some() { + continue; + } + if let Some(lookup_by) = mappings + .iter() + .filter_map(|(_, attr, _)| attr.lookup_by.as_ref()) + .next() + { + let field_name = lookup_by.ident.to_string(); errors.push(key_resolution_error( lookup_by.ident.span(), "event source", @@ -449,8 +458,14 @@ fn validate_event_handler_keys( continue; } - if let Some(join_on) = &first_attr.join_on { - let field_name = join_on.ident.to_string(); + if let Some(join_field) = join_key { + let Some(join_on) = mappings + .iter() + .find_map(|(_, attr, _)| attr.join_on.as_ref()) + else { + continue; + }; + let field_name = join_field; if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) { continue; } @@ -945,7 +960,12 @@ fn detect_cycles_from( cycles: &mut Vec>, ) { if active.contains(node) { - if let Some(index) = stack.iter().position(|entry| entry == node) { + let index = stack.iter().position(|entry| entry == node); + debug_assert!( + index.is_some(), + "node in active set but missing from stack: {node}" + ); + if let Some(index) = index { let mut cycle = stack[index..].to_vec(); cycle.push(node.to_string()); if !cycles.iter().any(|existing| existing == &cycle) { diff --git a/hyperstack-macros/tests/key_resolution_dynamic.rs b/hyperstack-macros/tests/key_resolution_dynamic.rs index be26bac5..5c60fe81 100644 --- a/hyperstack-macros/tests/key_resolution_dynamic.rs +++ b/hyperstack-macros/tests/key_resolution_dynamic.rs @@ -276,3 +276,34 @@ fn main() {} &[("fixture/minimal.json", minimal_idl())], ); } + +#[test] +fn event_group_accepts_any_valid_lookup_by_regardless_of_field_order() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "fixture/minimal.json")] +mod valid { + #[entity(name = "Thing")] + struct Thing { + #[map(fake_sdk::accounts::Thing::id, primary_key, strategy = SetOnce)] + id: String, + + #[event(from = fake_sdk::instructions::Trade, fields = [user])] + raw_trades: Vec, + + #[event(from = fake_sdk::instructions::Trade, fields = [user], lookup_by = id)] + keyed_trades: Vec, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr_with_files( + "event_group_accepts_any_valid_lookup_by_regardless_of_field_order", + source, + &[("fixture/minimal.json", minimal_idl())], + ); + + assert!(!stderr.contains("cannot resolve the primary key")); +} From 6d88bd6af3681b90de7353bb0da082af9d43c7a8 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 14:41:28 +0000 Subject: [PATCH 08/18] fix: stabilize event lookup handling across validation and codegen --- .../src/stream_spec/ast_writer.rs | 55 +++++++++++++++++- hyperstack-macros/src/validation/mod.rs | 56 +++++++++++++++---- .../tests/key_resolution_dynamic.rs | 15 +++-- 3 files changed, 108 insertions(+), 18 deletions(-) diff --git a/hyperstack-macros/src/stream_spec/ast_writer.rs b/hyperstack-macros/src/stream_spec/ast_writer.rs index 8502ffa8..93659c9d 100644 --- a/hyperstack-macros/src/stream_spec/ast_writer.rs +++ b/hyperstack-macros/src/stream_spec/ast_writer.rs @@ -845,6 +845,16 @@ fn build_event_handler( let instruction_type = parts[1]; let instruction_type_pascal = idl_parser::to_pascal_case(instruction_type); + let field_resolves_key = |field_name: &str| { + primary_keys + .iter() + .any(|pk| pk.split('.').next_back().unwrap_or(pk) == field_name) + || lookup_indexes.iter().any(|(field, _)| { + let leaf = field.split('.').next_back().unwrap_or(field); + leaf == field_name || leaf.strip_suffix("_address") == Some(field_name) + }) + }; + let mut serializable_mappings = Vec::new(); for (target_field, event_attr, _field_type) in event_mappings { @@ -946,7 +956,50 @@ fn build_event_handler( join_field_name.clone(), parse::FieldLocation::InstructionArg, ) - } else if let Some((_, first_event_attr, _)) = event_mappings.first() { + } else if let Some((_, first_event_attr, _)) = + event_mappings.iter().find(|(_, event_attr, _)| { + event_attr + .lookup_by + .as_ref() + .is_some_and(|field_spec| field_resolves_key(&field_spec.ident.to_string())) + }) + { + if let Some(ref lookup_by_field_spec) = first_event_attr.lookup_by { + let field_name = lookup_by_field_spec.ident.to_string(); + + let field_location = if let Some(explicit_loc) = &lookup_by_field_spec.explicit_location + { + explicit_loc.clone() + } else { + let instruction_path = first_event_attr + .from_instruction + .as_ref() + .or(first_event_attr.inferred_instruction.as_ref()); + + if let Some(instr_path) = instruction_path { + find_field_in_instruction(instr_path, &field_name, idl).map_err(|error| { + idl_error_to_syn( + span_for_event_lookup_error( + first_event_attr, + lookup_by_field_spec, + &error, + ), + error, + ) + })? + } else { + parse::FieldLocation::InstructionArg + } + }; + + (field_name, field_location) + } else { + (String::new(), parse::FieldLocation::InstructionArg) + } + } else if let Some((_, first_event_attr, _)) = event_mappings + .iter() + .find(|(_, event_attr, _)| event_attr.lookup_by.is_some()) + { if let Some(ref lookup_by_field_spec) = first_event_attr.lookup_by { let field_name = lookup_by_field_spec.ident.to_string(); diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index 1b2fe6cd..4d220127 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use crate::ast::{ComputedExpr, EntitySection, FieldPath, ViewTransform}; @@ -208,6 +209,43 @@ fn entity_field_error( syn::Error::new(span, message) } +fn field_spec_sort_key(field_spec: Option<&parse::FieldSpec>) -> Option<(u8, String)> { + field_spec.map(|field_spec| { + let location = match field_spec.explicit_location { + Some(parse::FieldLocation::Account) => 0, + Some(parse::FieldLocation::InstructionArg) => 1, + None => 2, + }; + (location, field_spec.ident.to_string()) + }) +} + +fn stable_map_attribute_cmp(a: &parse::MapAttribute, b: &parse::MapAttribute) -> Ordering { + a.target_field_name + .cmp(&b.target_field_name) + .then_with(|| a.source_field_name.cmp(&b.source_field_name)) + .then_with(|| a.join_on.cmp(&b.join_on)) + .then_with(|| { + field_spec_sort_key(a.lookup_by.as_ref()) + .cmp(&field_spec_sort_key(b.lookup_by.as_ref())) + }) +} + +fn stable_event_mapping_cmp( + a: &(String, parse::EventAttribute, syn::Type), + b: &(String, parse::EventAttribute, syn::Type), +) -> Ordering { + a.0.cmp(&b.0) + .then_with(|| { + field_spec_sort_key(a.1.lookup_by.as_ref()) + .cmp(&field_spec_sort_key(b.1.lookup_by.as_ref())) + }) + .then_with(|| { + field_spec_sort_key(a.1.join_on.as_ref()) + .cmp(&field_spec_sort_key(b.1.join_on.as_ref())) + }) +} + fn primary_key_leafs(primary_keys: &[String]) -> HashSet { primary_keys .iter() @@ -299,7 +337,8 @@ fn validate_source_handler_keys( } } - for ((source_type, join_key), mappings) in grouped { + for ((source_type, join_key), mut mappings) in grouped { + mappings.sort_by(stable_map_attribute_cmp); let Some(first_mapping) = mappings.first() else { continue; }; @@ -309,8 +348,6 @@ fn validate_source_handler_keys( } let is_instruction = mappings.iter().any(|mapping| mapping.is_instruction); - let is_cpi_event = - source_type.contains("::events::") || source_type.contains("::cpi_events::"); // Event-only mappings are validated in validate_event_handler_keys before // #[event(...)] handlers are merged into sources_by_type for codegen. @@ -318,10 +355,7 @@ fn validate_source_handler_keys( continue; } - if !is_instruction - && !is_cpi_event - && has_explicit_key_resolver(&source_type, resolver_hooks) - { + if !is_instruction && has_explicit_key_resolver(&source_type, resolver_hooks) { continue; } @@ -335,10 +369,7 @@ fn validate_source_handler_keys( continue; } - if !is_instruction - && !is_cpi_event - && has_account_address_lookup_path(&mappings, lookup_indexes) - { + if !is_instruction && has_account_address_lookup_path(&mappings, lookup_indexes) { continue; } @@ -420,7 +451,8 @@ fn validate_event_handler_keys( } } - for ((instruction, join_key), mappings) in grouped { + for ((instruction, join_key), mut mappings) in grouped { + mappings.sort_by(stable_event_mapping_cmp); let Some((_, first_attr, _)) = mappings.first() else { continue; }; diff --git a/hyperstack-macros/tests/key_resolution_dynamic.rs b/hyperstack-macros/tests/key_resolution_dynamic.rs index 5c60fe81..e5bc12ab 100644 --- a/hyperstack-macros/tests/key_resolution_dynamic.rs +++ b/hyperstack-macros/tests/key_resolution_dynamic.rs @@ -281,29 +281,34 @@ fn main() {} fn event_group_accepts_any_valid_lookup_by_regardless_of_field_order() { let source = r#"use hyperstack_macros::hyperstack; +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +struct TradeCapture { + user: String, +} + #[hyperstack(idl = "fixture/minimal.json")] mod valid { + use super::TradeCapture; + #[entity(name = "Thing")] struct Thing { #[map(fake_sdk::accounts::Thing::id, primary_key, strategy = SetOnce)] id: String, #[event(from = fake_sdk::instructions::Trade, fields = [user])] - raw_trades: Vec, + raw_trades: TradeCapture, #[event(from = fake_sdk::instructions::Trade, fields = [user], lookup_by = id)] - keyed_trades: Vec, + keyed_trades: TradeCapture, } } fn main() {} "#; - let stderr = compile_failure_stderr_with_files( + compile_success_with_files( "event_group_accepts_any_valid_lookup_by_regardless_of_field_order", source, &[("fixture/minimal.json", minimal_idl())], ); - - assert!(!stderr.contains("cannot resolve the primary key")); } From c49105380610a0e1c093d01a70c56258e3f3dc8a Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 15:28:33 +0000 Subject: [PATCH 09/18] fix: validate event join_on and URL template references --- hyperstack-macros/src/parse/attributes.rs | 5 +++- hyperstack-macros/src/stream_spec/entity.rs | 6 ++++ hyperstack-macros/src/validation/mod.rs | 26 ++++++++--------- hyperstack-macros/tests/phase2_dynamic.rs | 31 +++++++++++++++++++++ hyperstack-macros/tests/phase5_dynamic.rs | 20 +++++++++++++ 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index a0395bb2..141e9cdd 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -172,15 +172,18 @@ fn field_path_to_string(path: &FieldPath) -> String { fn parse_validated_field_path(input: ParseStream) -> syn::Result { let mut segments = Vec::new(); let first: syn::Ident = input.parse()?; - let span = first.span(); + let mut last_span = first.span(); segments.push(first.to_string()); while input.peek(Token![.]) { input.parse::()?; let next: syn::Ident = input.parse()?; + last_span = next.span(); segments.push(next.to_string()); } + let span = first.span().join(last_span).unwrap_or(first.span()); + let refs: Vec<&str> = segments.iter().map(String::as_str).collect(); let parsed = FieldPath::new(&refs); Ok(ValidatedFieldPath { diff --git a/hyperstack-macros/src/stream_spec/entity.rs b/hyperstack-macros/src/stream_spec/entity.rs index 3dc84817..91e9a783 100644 --- a/hyperstack-macros/src/stream_spec/entity.rs +++ b/hyperstack-macros/src/stream_spec/entity.rs @@ -75,6 +75,12 @@ pub fn parse_url_template(s: &str, span: proc_macro2::Span) -> syn::Result, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + run_compile_failure( + "event_join_on_field_is_validated_even_when_instruction_lookup_fails", + &source, + &[ + "Not found: 'Initialise' in instructions", + "unknown join_on field 'ghost' on entity 'Thing'", + ], + ); +} + #[test] fn missing_account_type_gets_suggestion() { let source = format!( diff --git a/hyperstack-macros/tests/phase5_dynamic.rs b/hyperstack-macros/tests/phase5_dynamic.rs index 5f4bd439..8e993575 100644 --- a/hyperstack-macros/tests/phase5_dynamic.rs +++ b/hyperstack-macros/tests/phase5_dynamic.rs @@ -135,3 +135,23 @@ fn main() {{}} assert!(stderr.contains("unknown program 'pum' in pdas! block")); assert!(stderr.contains("Did you mean: pump?")); } + +#[test] +fn empty_url_template_field_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + #[resolve(url = "https://example.com/{ }/metadata", extract = "name")] + metadata: String, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("empty_url_template_field_is_rejected", source); + assert!(stderr.contains("Empty field reference '{}' in URL template")); +} From dc6daa53ae8df25993b94e8d1cadfba9d4d9e764 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 15:48:32 +0000 Subject: [PATCH 10/18] fix: reject invalid legacy event IDL lookups --- hyperstack-macros/src/stream_spec/module.rs | 41 ++++++-- hyperstack-macros/src/validation/idl_refs.rs | 8 +- hyperstack-macros/src/validation/mod.rs | 100 +++++++++++++++++++ hyperstack-macros/tests/phase2_dynamic.rs | 29 ++++++ 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/hyperstack-macros/src/stream_spec/module.rs b/hyperstack-macros/src/stream_spec/module.rs index 2315ccaf..8e7e3b14 100644 --- a/hyperstack-macros/src/stream_spec/module.rs +++ b/hyperstack-macros/src/stream_spec/module.rs @@ -227,6 +227,13 @@ pub fn parse_proto_files_from_attr( ) -> syn::Result { let hyperstack_attr = parse::parse_stream_spec_attribute(attr)?; + parse_proto_files_from_parsed_attr(hyperstack_attr, span) +} + +fn parse_proto_files_from_parsed_attr( + hyperstack_attr: parse::StreamSpecAttribute, + span: proc_macro2::Span, +) -> syn::Result { let idl_files = hyperstack_attr.idl_files.clone(); if hyperstack_attr.proto_files.is_empty() { @@ -245,16 +252,36 @@ pub fn parse_proto_files_from_attr( analyses.push((proto_path.clone(), analysis)); } Err(e) => { - return Err(syn::Error::new( - span, - format!( - "Failed to parse proto file {} (full path: {:?}): {}", - proto_path, full_path, e - ), - )); + let _ = span; + eprintln!( + "Warning: Failed to parse proto file {} (full path: {:?}): {}", + proto_path, full_path, e + ); } } } Ok((analyses, hyperstack_attr.skip_decoders, idl_files)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_proto_file_is_non_fatal() { + let (analyses, skip_decoders, idl_files) = parse_proto_files_from_parsed_attr( + parse::StreamSpecAttribute { + proto_files: vec!["missing.proto".to_string()], + idl_files: Vec::new(), + skip_decoders: false, + }, + proc_macro2::Span::call_site(), + ) + .expect("missing proto files should remain non-fatal"); + + assert!(analyses.is_empty()); + assert!(!skip_decoders); + assert!(idl_files.is_empty()); + } +} diff --git a/hyperstack-macros/src/validation/idl_refs.rs b/hyperstack-macros/src/validation/idl_refs.rs index 0f7aba91..5c2ccd2d 100644 --- a/hyperstack-macros/src/validation/idl_refs.rs +++ b/hyperstack-macros/src/validation/idl_refs.rs @@ -85,11 +85,11 @@ pub fn resolve_instruction_lookup_from_string<'a>( path: instruction.to_string(), })?; - let idl = find_idl_for_program_name(program_name, idls) - .or_else(|| idls.first().map(|(_, idl)| *idl)) - .ok_or_else(|| IdlSearchError::InvalidPath { + let idl = find_idl_for_program_name(program_name, idls).ok_or_else(|| { + IdlSearchError::InvalidPath { path: instruction.to_string(), - })?; + } + })?; lookup_instruction(idl, instruction_name)?; Ok((idl, instruction_name.to_string())) diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index e4acf39d..9adc931a 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -8,6 +8,7 @@ use crate::parse; use crate::parse::idl as idl_parser; use crate::parse::pda_validation::PdaValidationContext; use crate::parse::pdas::PdasBlock; +use crate::utils::path_to_string; use crate::validation::idl_refs::{ resolve_instruction_lookup, resolve_instruction_lookup_from_path, validate_instruction_field_spec, validate_mapping_source, @@ -220,15 +221,90 @@ fn field_spec_sort_key(field_spec: Option<&parse::FieldSpec>) -> Option<(u8, Str }) } +fn field_specs_sort_key(field_specs: &[parse::FieldSpec]) -> Vec<(u8, String)> { + field_specs + .iter() + .map(|field_spec| field_spec_sort_key(Some(field_spec)).expect("field spec key")) + .collect() +} + +fn path_sort_key(path: Option<&syn::Path>) -> Option { + path.map(path_to_string) +} + +fn register_from_sort_key( + register_from: &[parse::RegisterFromSpec], +) -> Vec<(String, (u8, String), (u8, String))> { + register_from + .iter() + .map(|spec| { + ( + path_to_string(&spec.instruction_path), + field_spec_sort_key(Some(&spec.pda_field)).expect("pda field key"), + field_spec_sort_key(Some(&spec.primary_key_field)).expect("primary key field key"), + ) + }) + .collect() +} + +fn condition_sort_key(condition: &Option) -> Option { + condition.as_ref().map(|condition| format!("{condition:?}")) +} + +fn resolver_transform_sort_key( + transform: &Option, +) -> Option<(String, String)> { + transform + .as_ref() + .map(|transform| (transform.method.clone(), transform.args.to_string())) +} + +fn event_transforms_sort_key(transforms: &HashMap) -> Vec<(String, String)> +where + K: ToString, + V: ToString, +{ + let mut entries = transforms + .iter() + .map(|(field, transform)| (field.to_string(), transform.to_string())) + .collect::>(); + entries.sort(); + entries +} + fn stable_map_attribute_cmp(a: &parse::MapAttribute, b: &parse::MapAttribute) -> Ordering { a.target_field_name .cmp(&b.target_field_name) .then_with(|| a.source_field_name.cmp(&b.source_field_name)) + .then_with(|| path_to_string(&a.source_type_path).cmp(&path_to_string(&b.source_type_path))) + .then_with(|| a.strategy.cmp(&b.strategy)) .then_with(|| a.join_on.cmp(&b.join_on)) .then_with(|| { field_spec_sort_key(a.lookup_by.as_ref()) .cmp(&field_spec_sort_key(b.lookup_by.as_ref())) }) + .then_with(|| a.transform.cmp(&b.transform)) + .then_with(|| { + resolver_transform_sort_key(&a.resolver_transform) + .cmp(&resolver_transform_sort_key(&b.resolver_transform)) + }) + .then_with(|| { + register_from_sort_key(&a.register_from).cmp(®ister_from_sort_key(&b.register_from)) + }) + .then_with(|| a.temporal_field.cmp(&b.temporal_field)) + .then_with(|| condition_sort_key(&a.condition).cmp(&condition_sort_key(&b.condition))) + .then_with(|| path_sort_key(a.when.as_ref()).cmp(&path_sort_key(b.when.as_ref()))) + .then_with(|| path_sort_key(a.stop.as_ref()).cmp(&path_sort_key(b.stop.as_ref()))) + .then_with(|| { + field_spec_sort_key(a.stop_lookup_by.as_ref()) + .cmp(&field_spec_sort_key(b.stop_lookup_by.as_ref())) + }) + .then_with(|| a.is_primary_key.cmp(&b.is_primary_key)) + .then_with(|| a.is_lookup_index.cmp(&b.is_lookup_index)) + .then_with(|| a.is_instruction.cmp(&b.is_instruction)) + .then_with(|| a.is_event_source.cmp(&b.is_event_source)) + .then_with(|| a.is_whole_source.cmp(&b.is_whole_source)) + .then_with(|| a.emit.cmp(&b.emit)) } fn stable_event_mapping_cmp( @@ -236,6 +312,30 @@ fn stable_event_mapping_cmp( b: &(String, parse::EventAttribute, syn::Type), ) -> Ordering { a.0.cmp(&b.0) + .then_with(|| a.1.target_field_name.cmp(&b.1.target_field_name)) + .then_with(|| a.1.strategy.cmp(&b.1.strategy)) + .then_with(|| { + path_sort_key(a.1.from_instruction.as_ref()) + .cmp(&path_sort_key(b.1.from_instruction.as_ref())) + }) + .then_with(|| { + path_sort_key(a.1.inferred_instruction.as_ref()) + .cmp(&path_sort_key(b.1.inferred_instruction.as_ref())) + }) + .then_with(|| a.1.instruction.cmp(&b.1.instruction)) + .then_with(|| { + field_specs_sort_key(&a.1.capture_fields) + .cmp(&field_specs_sort_key(&b.1.capture_fields)) + }) + .then_with(|| a.1.capture_fields_legacy.cmp(&b.1.capture_fields_legacy)) + .then_with(|| { + event_transforms_sort_key(&a.1.field_transforms) + .cmp(&event_transforms_sort_key(&b.1.field_transforms)) + }) + .then_with(|| { + event_transforms_sort_key(&a.1.field_transforms_legacy) + .cmp(&event_transforms_sort_key(&b.1.field_transforms_legacy)) + }) .then_with(|| { field_spec_sort_key(a.1.lookup_by.as_ref()) .cmp(&field_spec_sort_key(b.1.lookup_by.as_ref())) diff --git a/hyperstack-macros/tests/phase2_dynamic.rs b/hyperstack-macros/tests/phase2_dynamic.rs index 43120dfd..06306513 100644 --- a/hyperstack-macros/tests/phase2_dynamic.rs +++ b/hyperstack-macros/tests/phase2_dynamic.rs @@ -206,6 +206,35 @@ fn main() {{}} ); } +#[test] +fn unknown_legacy_event_program_reports_invalid_path() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[map(pump_sdk::accounts::BondingCurve::complete, primary_key, strategy = SetOnce)] + id: String, + + #[event(instruction = "unknown_program::Transfer", capture = [user], lookup_by = id)] + trades: String, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + run_compile_failure( + "unknown_legacy_event_program_reports_invalid_path", + &source, + &["Invalid path 'unknown_program::Transfer'"], + ); +} + #[test] fn missing_account_type_gets_suggestion() { let source = format!( From 548824bef3a59559a1d85d547f1a6c02d383cb0a Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 16:38:07 +0000 Subject: [PATCH 11/18] fix: tighten macro validation follow-up checks --- hyperstack-macros/src/stream_spec/module.rs | 22 ++++------ hyperstack-macros/src/validation/idl_refs.rs | 6 +-- hyperstack-macros/src/validation/mod.rs | 23 ++++++++-- hyperstack-macros/tests/phase4_dynamic.rs | 44 ++++++++++++++++++++ 4 files changed, 72 insertions(+), 23 deletions(-) diff --git a/hyperstack-macros/src/stream_spec/module.rs b/hyperstack-macros/src/stream_spec/module.rs index 8e7e3b14..4ec8deeb 100644 --- a/hyperstack-macros/src/stream_spec/module.rs +++ b/hyperstack-macros/src/stream_spec/module.rs @@ -45,8 +45,7 @@ pub fn process_module( let mut entity_structs = Vec::new(); let mut has_game_event = false; - let (proto_analyses, skip_decoders, idl_files) = - parse_proto_files_from_attr(attr.clone(), module.ident.span())?; + let (proto_analyses, skip_decoders, idl_files) = parse_proto_files_from_attr(attr.clone())?; if !idl_files.is_empty() { return super::idl_spec::process_idl_spec(module, &idl_files); @@ -221,18 +220,14 @@ pub fn process_module( // Attribute Parsing // ============================================================================ -pub fn parse_proto_files_from_attr( - attr: TokenStream, - span: proc_macro2::Span, -) -> syn::Result { +pub fn parse_proto_files_from_attr(attr: TokenStream) -> syn::Result { let hyperstack_attr = parse::parse_stream_spec_attribute(attr)?; - parse_proto_files_from_parsed_attr(hyperstack_attr, span) + parse_proto_files_from_parsed_attr(hyperstack_attr) } fn parse_proto_files_from_parsed_attr( hyperstack_attr: parse::StreamSpecAttribute, - span: proc_macro2::Span, ) -> syn::Result { let idl_files = hyperstack_attr.idl_files.clone(); @@ -252,7 +247,6 @@ fn parse_proto_files_from_parsed_attr( analyses.push((proto_path.clone(), analysis)); } Err(e) => { - let _ = span; eprintln!( "Warning: Failed to parse proto file {} (full path: {:?}): {}", proto_path, full_path, e @@ -270,15 +264,13 @@ mod tests { #[test] fn missing_proto_file_is_non_fatal() { - let (analyses, skip_decoders, idl_files) = parse_proto_files_from_parsed_attr( - parse::StreamSpecAttribute { + let (analyses, skip_decoders, idl_files) = + parse_proto_files_from_parsed_attr(parse::StreamSpecAttribute { proto_files: vec!["missing.proto".to_string()], idl_files: Vec::new(), skip_decoders: false, - }, - proc_macro2::Span::call_site(), - ) - .expect("missing proto files should remain non-fatal"); + }) + .expect("missing proto files should remain non-fatal"); assert!(analyses.is_empty()); assert!(!skip_decoders); diff --git a/hyperstack-macros/src/validation/idl_refs.rs b/hyperstack-macros/src/validation/idl_refs.rs index 5c2ccd2d..16e6c4f2 100644 --- a/hyperstack-macros/src/validation/idl_refs.rs +++ b/hyperstack-macros/src/validation/idl_refs.rs @@ -153,11 +153,7 @@ pub fn validate_account_field( field_name: &str, ) -> Result<(), IdlSearchError> { let fields = account_fields(idl, account_name)?; - if fields.is_empty() - || fields - .iter() - .any(|field| field.eq_ignore_ascii_case(field_name)) - { + if fields.is_empty() || fields.iter().any(|field| field == field_name) { return Ok(()); } diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index 9adc931a..b14e52c1 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -591,10 +591,14 @@ fn validate_event_handler_keys( } if let Some(join_field) = join_key { - let Some(join_on) = mappings + let join_on = mappings .iter() - .find_map(|(_, attr, _)| attr.join_on.as_ref()) - else { + .find_map(|(_, attr, _)| attr.join_on.as_ref()); + debug_assert!( + join_on.is_some(), + "group key has a join field but no mapping carries join_on" + ); + let Some(join_on) = join_on else { continue; }; let field_name = join_field; @@ -885,6 +889,19 @@ fn validate_resolve_specs( )); } } + + if let Some(condition) = &spec.condition { + let field_path = &condition.parsed.field_path; + if !field_path.is_empty() && !known_fields.contains(field_path) { + errors.push(entity_field_error( + entity_name, + field_path, + "resolver condition field", + spec.attr_span, + available_fields, + )); + } + } } } diff --git a/hyperstack-macros/tests/phase4_dynamic.rs b/hyperstack-macros/tests/phase4_dynamic.rs index b8640062..feac6ac0 100644 --- a/hyperstack-macros/tests/phase4_dynamic.rs +++ b/hyperstack-macros/tests/phase4_dynamic.rs @@ -88,6 +88,29 @@ fn main() {{}} assert!(stderr.contains("Not found: 'bogus' in account fields for 'BondingCurve'")); } +#[test] +fn account_field_validation_is_case_sensitive() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[map(pump_sdk::accounts::BondingCurve::Complete, strategy = LastWrite)] + value: bool, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + let stderr = compile_failure_stderr("account_field_validation_is_case_sensitive", &source); + assert!(stderr.contains("Not found: 'Complete' in account fields for 'BondingCurve'")); +} + #[test] fn missing_computed_section_reference_is_rejected() { let source = r#"use hyperstack_macros::hyperstack; @@ -130,6 +153,27 @@ fn main() {} assert!(stderr.contains("unknown resolver input field 'ghost.value' on entity 'Thing'")); } +#[test] +fn invalid_resolver_condition_field_is_rejected() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack] +mod broken { + #[entity(name = "Thing")] + struct Thing { + existing: String, + #[resolve(from = "existing", resolver = Token, condition = "ghost.value == pending")] + metadata: String, + } +} + +fn main() {} +"#; + + let stderr = compile_failure_stderr("invalid_resolver_condition_field_is_rejected", source); + assert!(stderr.contains("unknown resolver condition field 'ghost.value' on entity 'Thing'")); +} + #[test] fn invalid_view_sort_by_is_rejected() { let source = r#"use hyperstack_macros::hyperstack; From c747ffd4558ccbe98b2dfbea4582ad91716f75c0 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 17:06:37 +0000 Subject: [PATCH 12/18] fix: align proto UI tests with warning-only behavior --- .../ui/{validation_errors => pass}/missing_proto_file.rs | 0 .../tests/ui/validation_errors/missing_proto_file.stderr | 5 ----- 2 files changed, 5 deletions(-) rename hyperstack-macros/tests/ui/{validation_errors => pass}/missing_proto_file.rs (100%) delete mode 100644 hyperstack-macros/tests/ui/validation_errors/missing_proto_file.stderr diff --git a/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.rs b/hyperstack-macros/tests/ui/pass/missing_proto_file.rs similarity index 100% rename from hyperstack-macros/tests/ui/validation_errors/missing_proto_file.rs rename to hyperstack-macros/tests/ui/pass/missing_proto_file.rs diff --git a/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.stderr b/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.stderr deleted file mode 100644 index 8eea1a83..00000000 --- a/hyperstack-macros/tests/ui/validation_errors/missing_proto_file.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: Failed to parse proto file missing.proto (full path: "$WORKSPACE/target/tests/trybuild/hyperstack-macros/missing.proto"): Failed to read proto file "$WORKSPACE/target/tests/trybuild/hyperstack-macros/missing.proto": No such file or directory (os error 2) - --> tests/ui/validation_errors/missing_proto_file.rs:4:5 - | -4 | mod broken {} - | ^^^^^^ From 21c176b70cb13229e82738ce188a680dca185156 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 17:08:44 +0000 Subject: [PATCH 13/18] fix: align resolver condition parsing with strict conditions --- hyperstack-macros/src/parse/conditions.rs | 20 ++++++++++++++++++-- hyperstack-macros/src/validation/idl_refs.rs | 2 ++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/hyperstack-macros/src/parse/conditions.rs b/hyperstack-macros/src/parse/conditions.rs index 1ba2a69a..a7a4a2e7 100644 --- a/hyperstack-macros/src/parse/conditions.rs +++ b/hyperstack-macros/src/parse/conditions.rs @@ -189,7 +189,7 @@ fn parse_value(value: &str) -> Result { } pub fn parse_resolver_condition_expression(expr: &str) -> Result { - let operators = ["==", "!=", ">=", "<=", ">", "<"]; + let operators = [">=", "<=", "==", "!=", ">", "<"]; for op_str in &operators { if let Some(pos) = find_top_level_operator(expr, op_str) { let field_path = expr[..pos].trim().to_string(); @@ -247,7 +247,15 @@ pub fn parse_resolver_condition_expression(expr: &str) -> Result Result<(), IdlSearchError> { let fields = account_fields(idl, account_name)?; + // Some IDLs omit struct field metadata for accounts; keep that case non-fatal + // so validation does not reject otherwise valid mappings on incomplete schemas. if fields.is_empty() || fields.iter().any(|field| field == field_name) { return Ok(()); } From 6c1265b1e6dae9b3e68e2207f2d2c23a11299760 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 17:16:43 +0000 Subject: [PATCH 14/18] refactor: simplify validation sort key aliases --- hyperstack-macros/src/validation/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index b14e52c1..632fff81 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -210,7 +210,10 @@ fn entity_field_error( syn::Error::new(span, message) } -fn field_spec_sort_key(field_spec: Option<&parse::FieldSpec>) -> Option<(u8, String)> { +type FieldSpecSortKey = (u8, String); +type RegisterFromSortKey = (String, FieldSpecSortKey, FieldSpecSortKey); + +fn field_spec_sort_key(field_spec: Option<&parse::FieldSpec>) -> Option { field_spec.map(|field_spec| { let location = match field_spec.explicit_location { Some(parse::FieldLocation::Account) => 0, @@ -221,7 +224,7 @@ fn field_spec_sort_key(field_spec: Option<&parse::FieldSpec>) -> Option<(u8, Str }) } -fn field_specs_sort_key(field_specs: &[parse::FieldSpec]) -> Vec<(u8, String)> { +fn field_specs_sort_key(field_specs: &[parse::FieldSpec]) -> Vec { field_specs .iter() .map(|field_spec| field_spec_sort_key(Some(field_spec)).expect("field spec key")) @@ -232,9 +235,7 @@ fn path_sort_key(path: Option<&syn::Path>) -> Option { path.map(path_to_string) } -fn register_from_sort_key( - register_from: &[parse::RegisterFromSpec], -) -> Vec<(String, (u8, String), (u8, String))> { +fn register_from_sort_key(register_from: &[parse::RegisterFromSpec]) -> Vec { register_from .iter() .map(|spec| { From efeeb64b1c13606ccc644fe46716de68c66d4173 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 19:33:06 +0000 Subject: [PATCH 15/18] fix: stabilize dynamic macro test harnesses --- Cargo.lock | 1 + hyperstack-macros/Cargo.toml | 1 + hyperstack-macros/src/validation/mod.rs | 20 ++- .../tests/key_resolution_dynamic.rs | 139 +++++------------- hyperstack-macros/tests/phase0_dynamic.rs | 69 +++------ hyperstack-macros/tests/phase1_runtime.rs | 72 +++------ hyperstack-macros/tests/phase2_dynamic.rs | 62 +++----- hyperstack-macros/tests/phase3_dynamic.rs | 62 +++----- hyperstack-macros/tests/phase4_dynamic.rs | 62 +++----- hyperstack-macros/tests/phase5_dynamic.rs | 62 +++----- hyperstack-macros/tests/support.rs | 110 ++++++++++++++ 11 files changed, 280 insertions(+), 380 deletions(-) create mode 100644 hyperstack-macros/tests/support.rs diff --git a/Cargo.lock b/Cargo.lock index d3993b57..eec95384 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1731,6 +1731,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "syn 2.0.113", + "tempfile", "trybuild", ] diff --git a/hyperstack-macros/Cargo.toml b/hyperstack-macros/Cargo.toml index bc1ae5bb..db746f2d 100644 --- a/hyperstack-macros/Cargo.toml +++ b/hyperstack-macros/Cargo.toml @@ -26,4 +26,5 @@ bs58 = "0.5" hyperstack-idl = { path = "../hyperstack-idl", version = "0.1.5" } [dev-dependencies] +tempfile = "3" trybuild = "1.0" diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index 632fff81..69d1c331 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -683,8 +683,14 @@ fn validate_mapping_references( idls: IdlLookup, errors: &mut ErrorCollector, ) { - for (source_type, mappings) in sources_by_type { - for mapping in mappings { + let mut source_types: Vec<&String> = sources_by_type.keys().collect(); + source_types.sort(); + + for source_type in source_types { + let mut mappings = sources_by_type[source_type].clone(); + mappings.sort_by(stable_map_attribute_cmp); + + for mapping in &mappings { if let Err(error) = validate_mapping_source(source_type, mapping, idls) { let span = match &error { hyperstack_idl::error::IdlSearchError::NotFound { section, .. } @@ -770,8 +776,14 @@ fn validate_event_references( idls: IdlLookup, errors: &mut ErrorCollector, ) { - for (instruction_key, event_mappings) in events_by_instruction { - for (_target_field, event_attr, _field_type) in event_mappings { + let mut instruction_keys: Vec<&String> = events_by_instruction.keys().collect(); + instruction_keys.sort(); + + for instruction_key in instruction_keys { + let mut event_mappings = events_by_instruction[instruction_key].clone(); + event_mappings.sort_by(stable_event_mapping_cmp); + + for (_target_field, event_attr, _field_type) in &event_mappings { if let Some(join_on) = &event_attr.join_on { let reference = join_on.ident.to_string(); if !known_fields.contains(&reference) { diff --git a/hyperstack-macros/tests/key_resolution_dynamic.rs b/hyperstack-macros/tests/key_resolution_dynamic.rs index e5bc12ab..c8e55b05 100644 --- a/hyperstack-macros/tests/key_resolution_dynamic.rs +++ b/hyperstack-macros/tests/key_resolution_dynamic.rs @@ -1,63 +1,28 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +mod support; -fn escape_path(path: &Path) -> String { - path.display().to_string().replace('\\', "\\\\") -} +use support::{cargo_toml, escape_path, hyperstack_dir, macro_manifest_dir, TempCrate}; fn compile_failure_stderr_with_files( name: &str, source: &str, extra_files: &[(&str, &str)], ) -> String { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .expect("hyperstack-macros should live in workspace root"); - let temp_root = workspace_root.join("target/tests/key-resolution-dynamic"); - fs::create_dir_all(&temp_root).expect("create dynamic test root"); - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); - let crate_dir = temp_root.join(format!("{name}-{unique}")); - let src_dir = crate_dir.join("src"); - fs::create_dir_all(&src_dir).expect("create temp crate src dir"); - - let cargo_toml = format!( - r#"[package] -name = "{name}" -version = "0.0.0" -edition = "2021" - -[workspace] - -[dependencies] -hyperstack-macros = {{ path = "{}" }} -"#, - escape_path(&manifest_dir) + let manifest_dir = macro_manifest_dir(); + let temp_crate = TempCrate::new( + "key-resolution-dynamic", + name, + cargo_toml( + name, + &[format!( + "hyperstack-macros = {{ path = \"{}\" }}", + escape_path(&manifest_dir) + )], + ), + source, + extra_files, ); - fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); - fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); - for (relative_path, contents) in extra_files { - let file_path = crate_dir.join(relative_path); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("create extra file parent dir"); - } - fs::write(file_path, contents).expect("write extra test file"); - } - - let output = Command::new("cargo") - .arg("check") - .arg("--quiet") - .current_dir(&crate_dir) - .env("CARGO_TARGET_DIR", workspace_root.join("target")) - .output() - .expect("run cargo check"); + let output = temp_crate.cargo_check(); assert!( !output.status.success(), @@ -68,57 +33,31 @@ hyperstack-macros = {{ path = "{}" }} } fn compile_success_with_files(name: &str, source: &str, extra_files: &[(&str, &str)]) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .expect("hyperstack-macros should live in workspace root"); - let hyperstack_dir = workspace_root.join("hyperstack"); - let temp_root = workspace_root.join("target/tests/key-resolution-dynamic"); - fs::create_dir_all(&temp_root).expect("create dynamic test root"); - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); - let crate_dir = temp_root.join(format!("{name}-{unique}")); - let src_dir = crate_dir.join("src"); - fs::create_dir_all(&src_dir).expect("create temp crate src dir"); - - let cargo_toml = format!( - r#"[package] -name = "{name}" -version = "0.0.0" -edition = "2021" - -[workspace] - -[dependencies] -hyperstack = {{ path = "{}" }} -hyperstack-macros = {{ path = "{}" }} -borsh = {{ version = "1.5", features = ["derive"] }} -serde = {{ version = "1", features = ["derive"] }} -"#, - escape_path(&hyperstack_dir), - escape_path(&manifest_dir) + let hyperstack_dir = hyperstack_dir(); + let manifest_dir = macro_manifest_dir(); + let temp_crate = TempCrate::new( + "key-resolution-dynamic", + name, + cargo_toml( + name, + &[ + format!( + "hyperstack = {{ path = \"{}\" }}", + escape_path(&hyperstack_dir) + ), + format!( + "hyperstack-macros = {{ path = \"{}\" }}", + escape_path(&manifest_dir) + ), + "borsh = { version = \"1.5\", features = [\"derive\"] }".to_string(), + "serde = { version = \"1\", features = [\"derive\"] }".to_string(), + ], + ), + source, + extra_files, ); - fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); - fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); - for (relative_path, contents) in extra_files { - let file_path = crate_dir.join(relative_path); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("create extra file parent dir"); - } - fs::write(file_path, contents).expect("write extra test file"); - } - - let output = Command::new("cargo") - .arg("check") - .arg("--quiet") - .current_dir(&crate_dir) - .env("CARGO_TARGET_DIR", workspace_root.join("target")) - .output() - .expect("run cargo check"); + let output = temp_crate.cargo_check(); assert!( output.status.success(), diff --git a/hyperstack-macros/tests/phase0_dynamic.rs b/hyperstack-macros/tests/phase0_dynamic.rs index 7abf5d04..7e9e12c8 100644 --- a/hyperstack-macros/tests/phase0_dynamic.rs +++ b/hyperstack-macros/tests/phase0_dynamic.rs @@ -1,55 +1,24 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +mod support; -fn run_compile_failure(name: &str, source: &str, extra_files: &[(&str, &str)], expected: &[&str]) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .expect("hyperstack-macros should live in workspace root"); - let temp_root = workspace_root.join("target/tests/phase0-dynamic"); - fs::create_dir_all(&temp_root).expect("create dynamic test root"); - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); - let crate_dir = temp_root.join(format!("{name}-{unique}")); - let src_dir = crate_dir.join("src"); - fs::create_dir_all(&src_dir).expect("create temp crate src dir"); - - let cargo_toml = format!( - r#"[package] -name = "{name}" -version = "0.0.0" -edition = "2021" +use support::{cargo_toml, escape_path, macro_manifest_dir, TempCrate}; -[workspace] - -[dependencies] -hyperstack-macros = {{ path = "{}" }} -"#, - escape_path(&manifest_dir) +fn run_compile_failure(name: &str, source: &str, extra_files: &[(&str, &str)], expected: &[&str]) { + let manifest_dir = macro_manifest_dir(); + let temp_crate = TempCrate::new( + "phase0-dynamic", + name, + cargo_toml( + name, + &[format!( + "hyperstack-macros = {{ path = \"{}\" }}", + escape_path(&manifest_dir) + )], + ), + source, + extra_files, ); - fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); - fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); - for (relative_path, contents) in extra_files { - let file_path = crate_dir.join(relative_path); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("create extra file parent dir"); - } - fs::write(file_path, contents).expect("write extra test file"); - } - - let output = Command::new("cargo") - .arg("check") - .arg("--quiet") - .current_dir(&crate_dir) - .env("CARGO_TARGET_DIR", workspace_root.join("target")) - .output() - .expect("run cargo check"); + let output = temp_crate.cargo_check(); assert!( !output.status.success(), @@ -96,10 +65,6 @@ fn main() {} ); } -fn escape_path(path: &Path) -> String { - path.display().to_string().replace('\\', "\\\\") -} - #[test] fn after_instruction_attribute_is_hard_error() { run_idl_compile_failure( diff --git a/hyperstack-macros/tests/phase1_runtime.rs b/hyperstack-macros/tests/phase1_runtime.rs index 5172391c..f66010c2 100644 --- a/hyperstack-macros/tests/phase1_runtime.rs +++ b/hyperstack-macros/tests/phase1_runtime.rs @@ -1,56 +1,32 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +mod support; -fn escape_path(path: &Path) -> String { - path.display().to_string().replace('\\', "\\\\") -} +use support::{cargo_toml, escape_path, hyperstack_dir, macro_manifest_dir, TempCrate}; fn run_binary_success(name: &str, source: &str) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .expect("hyperstack-macros should live in workspace root"); - let hyperstack_dir = workspace_root.join("hyperstack"); - let temp_root = workspace_root.join("target/tests/phase1-runtime"); - fs::create_dir_all(&temp_root).expect("create phase1 runtime root"); - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); - let crate_dir = temp_root.join(format!("{name}-{unique}")); - let src_dir = crate_dir.join("src"); - fs::create_dir_all(&src_dir).expect("create temp crate src dir"); - - let cargo_toml = format!( - r#"[package] -name = "{name}" -version = "0.0.0" -edition = "2021" - -[workspace] - -[dependencies] -hyperstack = {{ path = "{}" }} -hyperstack-macros = {{ path = "{}" }} -serde = {{ version = "1", features = ["derive"] }} -"#, - escape_path(&hyperstack_dir), - escape_path(&manifest_dir) + let hyperstack_dir = hyperstack_dir(); + let manifest_dir = macro_manifest_dir(); + let temp_crate = TempCrate::new( + "phase1-runtime", + name, + cargo_toml( + name, + &[ + format!( + "hyperstack = {{ path = \"{}\" }}", + escape_path(&hyperstack_dir) + ), + format!( + "hyperstack-macros = {{ path = \"{}\" }}", + escape_path(&manifest_dir) + ), + "serde = { version = \"1\", features = [\"derive\"] }".to_string(), + ], + ), + source, + &[], ); - fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); - fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); - - let output = Command::new("cargo") - .arg("run") - .arg("--quiet") - .current_dir(&crate_dir) - .env("CARGO_TARGET_DIR", workspace_root.join("target")) - .output() - .expect("run cargo run"); + let output = temp_crate.cargo_run(); assert!( output.status.success(), diff --git a/hyperstack-macros/tests/phase2_dynamic.rs b/hyperstack-macros/tests/phase2_dynamic.rs index 06306513..7c91f259 100644 --- a/hyperstack-macros/tests/phase2_dynamic.rs +++ b/hyperstack-macros/tests/phase2_dynamic.rs @@ -1,52 +1,26 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +mod support; -fn escape_path(path: &Path) -> String { - path.display().to_string().replace('\\', "\\\\") -} +use std::path::PathBuf; + +use support::{cargo_toml, escape_path, macro_manifest_dir, TempCrate}; fn run_compile_failure(name: &str, source: &str, expected: &[&str]) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .expect("hyperstack-macros should live in workspace root"); - let temp_root = workspace_root.join("target/tests/phase2-dynamic"); - fs::create_dir_all(&temp_root).expect("create dynamic test root"); - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); - let crate_dir = temp_root.join(format!("{name}-{unique}")); - let src_dir = crate_dir.join("src"); - fs::create_dir_all(&src_dir).expect("create temp crate src dir"); - - let cargo_toml = format!( - r#"[package] -name = "{name}" -version = "0.0.0" -edition = "2021" - -[workspace] - -[dependencies] -hyperstack-macros = {{ path = "{}" }} -"#, - escape_path(&manifest_dir) + let manifest_dir = macro_manifest_dir(); + let temp_crate = TempCrate::new( + "phase2-dynamic", + name, + cargo_toml( + name, + &[format!( + "hyperstack-macros = {{ path = \"{}\" }}", + escape_path(&manifest_dir) + )], + ), + source, + &[], ); - fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); - fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); - - let output = Command::new("cargo") - .arg("check") - .arg("--quiet") - .current_dir(&crate_dir) - .env("CARGO_TARGET_DIR", workspace_root.join("target")) - .output() - .expect("run cargo check"); + let output = temp_crate.cargo_check(); assert!( !output.status.success(), diff --git a/hyperstack-macros/tests/phase3_dynamic.rs b/hyperstack-macros/tests/phase3_dynamic.rs index 29dedb9d..1d64b9e9 100644 --- a/hyperstack-macros/tests/phase3_dynamic.rs +++ b/hyperstack-macros/tests/phase3_dynamic.rs @@ -1,52 +1,26 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +mod support; -fn escape_path(path: &Path) -> String { - path.display().to_string().replace('\\', "\\\\") -} +use std::path::PathBuf; + +use support::{cargo_toml, escape_path, macro_manifest_dir, TempCrate}; fn compile_failure_stderr(name: &str, source: &str) -> String { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .expect("hyperstack-macros should live in workspace root"); - let temp_root = workspace_root.join("target/tests/phase3-dynamic"); - fs::create_dir_all(&temp_root).expect("create dynamic test root"); - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); - let crate_dir = temp_root.join(format!("{name}-{unique}")); - let src_dir = crate_dir.join("src"); - fs::create_dir_all(&src_dir).expect("create temp crate src dir"); - - let cargo_toml = format!( - r#"[package] -name = "{name}" -version = "0.0.0" -edition = "2021" - -[workspace] - -[dependencies] -hyperstack-macros = {{ path = "{}" }} -"#, - escape_path(&manifest_dir) + let manifest_dir = macro_manifest_dir(); + let temp_crate = TempCrate::new( + "phase3-dynamic", + name, + cargo_toml( + name, + &[format!( + "hyperstack-macros = {{ path = \"{}\" }}", + escape_path(&manifest_dir) + )], + ), + source, + &[], ); - fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); - fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); - - let output = Command::new("cargo") - .arg("check") - .arg("--quiet") - .current_dir(&crate_dir) - .env("CARGO_TARGET_DIR", workspace_root.join("target")) - .output() - .expect("run cargo check"); + let output = temp_crate.cargo_check(); assert!( !output.status.success(), diff --git a/hyperstack-macros/tests/phase4_dynamic.rs b/hyperstack-macros/tests/phase4_dynamic.rs index feac6ac0..3ffcdda4 100644 --- a/hyperstack-macros/tests/phase4_dynamic.rs +++ b/hyperstack-macros/tests/phase4_dynamic.rs @@ -1,52 +1,26 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +mod support; -fn escape_path(path: &Path) -> String { - path.display().to_string().replace('\\', "\\\\") -} +use std::path::PathBuf; + +use support::{cargo_toml, escape_path, macro_manifest_dir, TempCrate}; fn compile_failure_stderr(name: &str, source: &str) -> String { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .expect("hyperstack-macros should live in workspace root"); - let temp_root = workspace_root.join("target/tests/phase4-dynamic"); - fs::create_dir_all(&temp_root).expect("create dynamic test root"); - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); - let crate_dir = temp_root.join(format!("{name}-{unique}")); - let src_dir = crate_dir.join("src"); - fs::create_dir_all(&src_dir).expect("create temp crate src dir"); - - let cargo_toml = format!( - r#"[package] -name = "{name}" -version = "0.0.0" -edition = "2021" - -[workspace] - -[dependencies] -hyperstack-macros = {{ path = "{}" }} -"#, - escape_path(&manifest_dir) + let manifest_dir = macro_manifest_dir(); + let temp_crate = TempCrate::new( + "phase4-dynamic", + name, + cargo_toml( + name, + &[format!( + "hyperstack-macros = {{ path = \"{}\" }}", + escape_path(&manifest_dir) + )], + ), + source, + &[], ); - fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); - fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); - - let output = Command::new("cargo") - .arg("check") - .arg("--quiet") - .current_dir(&crate_dir) - .env("CARGO_TARGET_DIR", workspace_root.join("target")) - .output() - .expect("run cargo check"); + let output = temp_crate.cargo_check(); assert!( !output.status.success(), diff --git a/hyperstack-macros/tests/phase5_dynamic.rs b/hyperstack-macros/tests/phase5_dynamic.rs index 8e993575..4c44f5aa 100644 --- a/hyperstack-macros/tests/phase5_dynamic.rs +++ b/hyperstack-macros/tests/phase5_dynamic.rs @@ -1,52 +1,26 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +mod support; -fn escape_path(path: &Path) -> String { - path.display().to_string().replace('\\', "\\\\") -} +use std::path::PathBuf; + +use support::{cargo_toml, escape_path, macro_manifest_dir, TempCrate}; fn compile_failure_stderr(name: &str, source: &str) -> String { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .expect("hyperstack-macros should live in workspace root"); - let temp_root = workspace_root.join("target/tests/phase5-dynamic"); - fs::create_dir_all(&temp_root).expect("create dynamic test root"); - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); - let crate_dir = temp_root.join(format!("{name}-{unique}")); - let src_dir = crate_dir.join("src"); - fs::create_dir_all(&src_dir).expect("create temp crate src dir"); - - let cargo_toml = format!( - r#"[package] -name = "{name}" -version = "0.0.0" -edition = "2021" - -[workspace] - -[dependencies] -hyperstack-macros = {{ path = "{}" }} -"#, - escape_path(&manifest_dir) + let manifest_dir = macro_manifest_dir(); + let temp_crate = TempCrate::new( + "phase5-dynamic", + name, + cargo_toml( + name, + &[format!( + "hyperstack-macros = {{ path = \"{}\" }}", + escape_path(&manifest_dir) + )], + ), + source, + &[], ); - fs::write(crate_dir.join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); - fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); - - let output = Command::new("cargo") - .arg("check") - .arg("--quiet") - .current_dir(&crate_dir) - .env("CARGO_TARGET_DIR", workspace_root.join("target")) - .output() - .expect("run cargo check"); + let output = temp_crate.cargo_check(); assert!( !output.status.success(), diff --git a/hyperstack-macros/tests/support.rs b/hyperstack-macros/tests/support.rs new file mode 100644 index 00000000..c30ae7da --- /dev/null +++ b/hyperstack-macros/tests/support.rs @@ -0,0 +1,110 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use tempfile::TempDir; + +pub fn escape_path(path: &Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + +pub fn macro_manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +pub fn workspace_root() -> PathBuf { + macro_manifest_dir() + .parent() + .expect("hyperstack-macros should live in workspace root") + .to_path_buf() +} + +#[allow(dead_code)] +pub fn hyperstack_dir() -> PathBuf { + workspace_root().join("hyperstack") +} + +pub fn cargo_toml(name: &str, dependencies: &[String]) -> String { + let dependencies = dependencies.join("\n"); + + format!( + r#"[package] +name = "{name}" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +{dependencies} +"# + ) +} + +pub struct TempCrate { + workspace_root: PathBuf, + temp_dir: TempDir, +} + +impl TempCrate { + pub fn new( + test_subdir: &str, + name: &str, + cargo_toml: String, + source: &str, + extra_files: &[(&str, &str)], + ) -> Self { + let workspace_root = workspace_root(); + let temp_root = workspace_root.join("target/tests").join(test_subdir); + fs::create_dir_all(&temp_root).expect("create dynamic test root"); + + let temp_dir = tempfile::Builder::new() + .prefix(&format!("{name}-")) + .tempdir_in(&temp_root) + .expect("create temp crate dir"); + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir).expect("create temp crate src dir"); + + fs::write(temp_dir.path().join("Cargo.toml"), cargo_toml).expect("write temp Cargo.toml"); + fs::write(src_dir.join("main.rs"), source).expect("write temp main.rs"); + + for (relative_path, contents) in extra_files { + let file_path = temp_dir.path().join(relative_path); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("create extra file parent dir"); + } + fs::write(file_path, contents).expect("write extra test file"); + } + + Self { + workspace_root, + temp_dir, + } + } + + pub fn path(&self) -> &Path { + self.temp_dir.path() + } + + #[allow(dead_code)] + pub fn cargo_check(&self) -> Output { + Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(self.path()) + .env("CARGO_TARGET_DIR", self.workspace_root.join("target")) + .output() + .expect("run cargo check") + } + + #[allow(dead_code)] + pub fn cargo_run(&self) -> Output { + Command::new("cargo") + .arg("run") + .arg("--quiet") + .current_dir(self.path()) + .env("CARGO_TARGET_DIR", self.workspace_root.join("target")) + .output() + .expect("run cargo run") + } +} From ffc95871fbe15c19e5ac8c5e03d49ed249916862 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 20:14:26 +0000 Subject: [PATCH 16/18] fix: stabilize derive validation diagnostics --- hyperstack-macros/src/parse/attributes.rs | 7 ++++++- hyperstack-macros/src/validation/mod.rs | 24 ++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index 141e9cdd..22af5a7a 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -114,6 +114,7 @@ pub struct ValidatedFieldPath { pub struct ValidatedResolverCondition { pub expression: String, pub parsed: ResolverCondition, + pub span: Span, } #[derive(Debug, Clone)] @@ -162,7 +163,11 @@ fn parse_resolver_condition_literal( let parsed = condition_parser::parse_resolver_condition_expression(&expression) .map_err(|error| syn::Error::new_spanned(literal, error))?; - Ok(ValidatedResolverCondition { expression, parsed }) + Ok(ValidatedResolverCondition { + expression, + parsed, + span: literal.span(), + }) } fn field_path_to_string(path: &FieldPath) -> String { diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index 69d1c331..b9f1b207 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -438,7 +438,10 @@ fn validate_source_handler_keys( } } - for ((source_type, join_key), mut mappings) in grouped { + let mut grouped_entries: Vec<_> = grouped.into_iter().collect(); + grouped_entries.sort_by(|(a_key, _), (b_key, _)| a_key.cmp(b_key)); + + for ((source_type, join_key), mut mappings) in grouped_entries { mappings.sort_by(stable_map_attribute_cmp); let Some(first_mapping) = mappings.first() else { continue; @@ -552,7 +555,10 @@ fn validate_event_handler_keys( } } - for ((instruction, join_key), mut mappings) in grouped { + let mut grouped_entries: Vec<_> = grouped.into_iter().collect(); + grouped_entries.sort_by(|(a_key, _), (b_key, _)| a_key.cmp(b_key)); + + for ((instruction, join_key), mut mappings) in grouped_entries { mappings.sort_by(stable_event_mapping_cmp); let Some((_, first_attr, _)) = mappings.first() else { continue; @@ -637,7 +643,11 @@ fn validate_instruction_hook_keys( derive_from_mappings: &HashMap>, errors: &mut ErrorCollector, ) { - for (instruction_type, derive_attrs) in derive_from_mappings { + let mut instruction_types: Vec<&String> = derive_from_mappings.keys().collect(); + instruction_types.sort(); + + for instruction_type in instruction_types { + let derive_attrs = &derive_from_mappings[instruction_type]; for derive_attr in derive_attrs { if let Some(lookup_by) = &derive_attr.lookup_by { let field_name = lookup_by.ident.to_string(); @@ -834,7 +844,11 @@ fn validate_derive_from_references( idls: IdlLookup, errors: &mut ErrorCollector, ) { - for (instruction_type, derive_attrs) in derive_from_mappings { + let mut instruction_types: Vec<&String> = derive_from_mappings.keys().collect(); + instruction_types.sort(); + + for instruction_type in instruction_types { + let derive_attrs = &derive_from_mappings[instruction_type]; let path = match syn::parse_str::(instruction_type) { Ok(path) => path, Err(_) => continue, @@ -910,7 +924,7 @@ fn validate_resolve_specs( entity_name, field_path, "resolver condition field", - spec.attr_span, + condition.span, available_fields, )); } From f98a21ad5ae52d08b5190d47493964dec0cde554 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 20:55:16 +0000 Subject: [PATCH 17/18] fix: deduplicate invalid source diagnostics --- hyperstack-macros/src/validation/idl_refs.rs | 42 ---- hyperstack-macros/src/validation/mod.rs | 211 +++++++++++++------ hyperstack-macros/tests/phase2_dynamic.rs | 2 +- hyperstack-macros/tests/phase3_dynamic.rs | 72 +++++++ 4 files changed, 218 insertions(+), 109 deletions(-) diff --git a/hyperstack-macros/src/validation/idl_refs.rs b/hyperstack-macros/src/validation/idl_refs.rs index 003034a4..0c716f34 100644 --- a/hyperstack-macros/src/validation/idl_refs.rs +++ b/hyperstack-macros/src/validation/idl_refs.rs @@ -165,45 +165,3 @@ pub fn validate_account_field( fields, )) } - -pub fn validate_mapping_source( - source_type: &str, - mapping: &parse::MapAttribute, - idls: IdlLookup, -) -> Result<(), IdlSearchError> { - if mapping.source_field_name.is_empty() || mapping.source_field_name.starts_with("__") { - return Ok(()); - } - - if source_type.contains("::instructions::") || mapping.is_instruction { - let path = - syn::parse_str::(source_type).map_err(|_| IdlSearchError::InvalidPath { - path: source_type.to_string(), - })?; - let (idl, instruction_name) = resolve_instruction_lookup_from_path(&path, idls)?; - let temp_field = parse::FieldSpec { - ident: syn::Ident::new(&mapping.source_field_name, mapping.source_field_span), - explicit_location: None, - }; - validate_instruction_field_spec(idl, &instruction_name, &temp_field) - } else if source_type.contains("::accounts::") { - let path = - syn::parse_str::(source_type).map_err(|_| IdlSearchError::InvalidPath { - path: source_type.to_string(), - })?; - let idl = - find_idl_for_type(source_type, idls).ok_or_else(|| IdlSearchError::InvalidPath { - path: source_type.to_string(), - })?; - let account_name = path - .segments - .last() - .map(|segment| segment.ident.to_string()) - .ok_or_else(|| IdlSearchError::InvalidPath { - path: source_type.to_string(), - })?; - validate_account_field(idl, &account_name, &mapping.source_field_name) - } else { - Ok(()) - } -} diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index b9f1b207..4ac20636 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -3,19 +3,21 @@ use std::collections::{HashMap, HashSet}; use crate::ast::{ComputedExpr, EntitySection, FieldPath, ViewTransform}; use crate::diagnostic::{suggestion_or_available_suffix, ErrorCollector}; -use crate::event_type_helpers::IdlLookup; +use crate::event_type_helpers::{find_idl_for_type, IdlLookup}; use crate::parse; use crate::parse::idl as idl_parser; use crate::parse::pda_validation::PdaValidationContext; use crate::parse::pdas::PdasBlock; use crate::utils::path_to_string; use crate::validation::idl_refs::{ - resolve_instruction_lookup, resolve_instruction_lookup_from_path, - validate_instruction_field_spec, validate_mapping_source, + resolve_instruction_lookup, resolve_instruction_lookup_from_path, validate_account_field, + validate_instruction_field_spec, }; use crate::diagnostic::idl_error_to_syn; use crate::stream_spec::computed::{parse_computed_expression, qualify_field_refs}; +use hyperstack_idl::error::IdlSearchError; +use hyperstack_idl::types::IdlSpec; pub mod idl_refs; @@ -53,6 +55,18 @@ pub struct KeyResolutionValidationInput<'a> { type GroupedEventMappings = HashMap<(String, Option), Vec<(String, parse::EventAttribute, syn::Type)>>; +enum ResolvedMappingSource<'a> { + Instruction { + idl: &'a IdlSpec, + instruction_name: String, + }, + Account { + idl: &'a IdlSpec, + account_name: String, + }, + Other, +} + pub fn validate_semantics(input: ValidationInput<'_>) -> syn::Result<()> { let known_fields = collect_known_field_paths(input.section_specs, input.computed_fields); let available_fields = sorted_field_paths(&known_fields); @@ -419,6 +433,45 @@ fn key_resolution_error( ) } +fn resolve_mapping_source_once<'a>( + source_type: &str, + mappings: &[parse::MapAttribute], + idls: IdlLookup<'a>, +) -> Result, IdlSearchError> { + let is_instruction = mappings.iter().any(|mapping| mapping.is_instruction); + + if source_type.contains("::instructions::") || is_instruction { + let path = + syn::parse_str::(source_type).map_err(|_| IdlSearchError::InvalidPath { + path: source_type.to_string(), + })?; + let (idl, instruction_name) = resolve_instruction_lookup_from_path(&path, idls)?; + Ok(ResolvedMappingSource::Instruction { + idl, + instruction_name, + }) + } else if source_type.contains("::accounts::") { + let path = + syn::parse_str::(source_type).map_err(|_| IdlSearchError::InvalidPath { + path: source_type.to_string(), + })?; + let idl = + find_idl_for_type(source_type, idls).ok_or_else(|| IdlSearchError::InvalidPath { + path: source_type.to_string(), + })?; + let account_name = path + .segments + .last() + .map(|segment| segment.ident.to_string()) + .ok_or_else(|| IdlSearchError::InvalidPath { + path: source_type.to_string(), + })?; + Ok(ResolvedMappingSource::Account { idl, account_name }) + } else { + Ok(ResolvedMappingSource::Other) + } +} + fn validate_source_handler_keys( entity_name: &str, primary_key_leafs: &HashSet, @@ -477,6 +530,14 @@ fn validate_source_handler_keys( continue; } + if let Some(join_field) = join_key.as_ref() { + if source_exposes_field(&mappings, join_field) + && source_field_can_resolve_key(join_field, primary_key_leafs, lookup_index_leafs) + { + continue; + } + } + if let Some(lookup_by) = mappings .iter() .find_map(|mapping| mapping.lookup_by.as_ref()) @@ -500,12 +561,6 @@ fn validate_source_handler_keys( } if let Some(join_field) = join_key { - if source_exposes_field(&mappings, &join_field) - && source_field_can_resolve_key(&join_field, primary_key_leafs, lookup_index_leafs) - { - continue; - } - errors.push(key_resolution_error( first_mapping.attr_span, if is_instruction { "instruction source" } else { "account source" }, @@ -578,6 +633,12 @@ fn validate_event_handler_keys( continue; } + if let Some(join_field) = join_key.as_ref() { + if source_field_can_resolve_key(join_field, primary_key_leafs, lookup_index_leafs) { + continue; + } + } + if let Some(lookup_by) = mappings .iter() .filter_map(|(_, attr, _)| attr.lookup_by.as_ref()) @@ -700,25 +761,54 @@ fn validate_mapping_references( let mut mappings = sources_by_type[source_type].clone(); mappings.sort_by(stable_map_attribute_cmp); + let Some(first_mapping) = mappings.first() else { + continue; + }; + + let resolved_source = match resolve_mapping_source_once(source_type, &mappings, idls) { + Ok(resolved_source) => resolved_source, + Err(error) => { + errors.push(idl_error_to_syn(first_mapping.source_type_span, error)); + continue; + } + }; + for mapping in &mappings { - if let Err(error) = validate_mapping_source(source_type, mapping, idls) { - let span = match &error { - hyperstack_idl::error::IdlSearchError::NotFound { section, .. } - if section == "instructions" - || section == "accounts" - || section == "types" => + match &resolved_source { + ResolvedMappingSource::Instruction { + idl, + instruction_name, + } => { + if !mapping.source_field_name.is_empty() + && !mapping.source_field_name.starts_with("__") { - mapping.source_type_span + let temp_field = parse::FieldSpec { + ident: syn::Ident::new( + &mapping.source_field_name, + mapping.source_field_span, + ), + explicit_location: None, + }; + + if let Err(error) = + validate_instruction_field_spec(idl, instruction_name, &temp_field) + { + errors.push(idl_error_to_syn(mapping.source_field_span, error)); + } } - hyperstack_idl::error::IdlSearchError::NotFound { section, .. } - if section.starts_with("instruction fields") - || section.starts_with("account fields") => + } + ResolvedMappingSource::Account { idl, account_name } => { + if !mapping.source_field_name.is_empty() + && !mapping.source_field_name.starts_with("__") { - mapping.source_field_span + if let Err(error) = + validate_account_field(idl, account_name, &mapping.source_field_name) + { + errors.push(idl_error_to_syn(mapping.source_field_span, error)); + } } - _ => mapping.attr_span, - }; - errors.push(idl_error_to_syn(span, error)); + } + ResolvedMappingSource::Other => {} } if let Some(join_on) = &mapping.join_on { @@ -734,43 +824,29 @@ fn validate_mapping_references( } if let Some(lookup_by) = &mapping.lookup_by { - if source_type.contains("::instructions::") || mapping.is_instruction { - match syn::parse_str::(source_type) - .map_err(|_| hyperstack_idl::error::IdlSearchError::InvalidPath { - path: source_type.clone(), - }) - .and_then(|path| resolve_instruction_lookup_from_path(&path, idls)) + if let ResolvedMappingSource::Instruction { + idl, + instruction_name, + } = &resolved_source + { + if let Err(error) = + validate_instruction_field_spec(idl, instruction_name, lookup_by) { - Ok((idl, instruction_name)) => { - if let Err(error) = - validate_instruction_field_spec(idl, &instruction_name, lookup_by) - { - errors.push(idl_error_to_syn(lookup_by.ident.span(), error)); - } - } - Err(error) => errors.push(idl_error_to_syn(mapping.attr_span, error)), + errors.push(idl_error_to_syn(lookup_by.ident.span(), error)); } } } if let Some(stop_lookup_by) = &mapping.stop_lookup_by { - if source_type.contains("::instructions::") || mapping.is_instruction { - match syn::parse_str::(source_type) - .map_err(|_| hyperstack_idl::error::IdlSearchError::InvalidPath { - path: source_type.clone(), - }) - .and_then(|path| resolve_instruction_lookup_from_path(&path, idls)) + if let ResolvedMappingSource::Instruction { + idl, + instruction_name, + } = &resolved_source + { + if let Err(error) = + validate_instruction_field_spec(idl, instruction_name, stop_lookup_by) { - Ok((idl, instruction_name)) => { - if let Err(error) = validate_instruction_field_spec( - idl, - &instruction_name, - stop_lookup_by, - ) { - errors.push(idl_error_to_syn(stop_lookup_by.ident.span(), error)); - } - } - Err(error) => errors.push(idl_error_to_syn(mapping.attr_span, error)), + errors.push(idl_error_to_syn(stop_lookup_by.ident.span(), error)); } } } @@ -793,6 +869,22 @@ fn validate_event_references( let mut event_mappings = events_by_instruction[instruction_key].clone(); event_mappings.sort_by(stable_event_mapping_cmp); + let Some((_, first_attr, _)) = event_mappings.first() else { + continue; + }; + + let (idl, instruction_name) = + match resolve_instruction_lookup(first_attr, instruction_key, idls) { + Ok(value) => value, + Err(error) => { + errors.push(idl_error_to_syn( + first_attr.instruction_span.unwrap_or(first_attr.attr_span), + error, + )); + continue; + } + }; + for (_target_field, event_attr, _field_type) in &event_mappings { if let Some(join_on) = &event_attr.join_on { let reference = join_on.ident.to_string(); @@ -807,19 +899,6 @@ fn validate_event_references( } } - let instruction_lookup = resolve_instruction_lookup(event_attr, instruction_key, idls); - - let (idl, instruction_name) = match instruction_lookup { - Ok(value) => value, - Err(error) => { - errors.push(idl_error_to_syn( - event_attr.instruction_span.unwrap_or(event_attr.attr_span), - error, - )); - continue; - } - }; - for field_spec in &event_attr.capture_fields { if let Err(error) = validate_instruction_field_spec(idl, &instruction_name, field_spec) diff --git a/hyperstack-macros/tests/phase2_dynamic.rs b/hyperstack-macros/tests/phase2_dynamic.rs index 7c91f259..787f5765 100644 --- a/hyperstack-macros/tests/phase2_dynamic.rs +++ b/hyperstack-macros/tests/phase2_dynamic.rs @@ -175,7 +175,7 @@ fn main() {{}} &source, &[ "Not found: 'Initialise' in instructions", - "unknown join_on field 'ghost' on entity 'Thing'", + "The `join_on` field 'ghost' is neither a primary-key field nor a lookup-index-backed field.", ], ); } diff --git a/hyperstack-macros/tests/phase3_dynamic.rs b/hyperstack-macros/tests/phase3_dynamic.rs index 1d64b9e9..39f8d213 100644 --- a/hyperstack-macros/tests/phase3_dynamic.rs +++ b/hyperstack-macros/tests/phase3_dynamic.rs @@ -122,3 +122,75 @@ fn main() {{}} assert!(stderr.contains("Not found: 'BondingCurv' in accounts")); assert!(stderr.contains("src/main.rs:8:"), "stderr was:\n{stderr}"); } + +#[test] +fn invalid_mapping_source_type_is_reported_once_per_source() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[map(pump_sdk::accounts::BondingCurve::complete, primary_key, strategy = SetOnce)] + id: bool, + + #[map(pump_sdk::instructions::Buuy::user, strategy = LastWrite)] + first_user: String, + + #[map(pump_sdk::instructions::Buuy::user, strategy = LastWrite)] + second_user: String, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + let stderr = compile_failure_stderr( + "invalid_mapping_source_type_is_reported_once_per_source", + &source, + ); + assert_eq!( + stderr.matches("Not found: 'Buuy' in instructions").count(), + 1, + "stderr was:\n{stderr}" + ); +} + +#[test] +fn invalid_event_instruction_is_reported_once_per_group() { + let source = format!( + r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "{}")] +mod broken {{ + #[entity(name = "Thing")] + struct Thing {{ + #[map(pump_sdk::accounts::BondingCurve::complete, primary_key, strategy = SetOnce)] + id: bool, + + #[event(from = pump_sdk::instructions::Buuy, fields = [user])] + first_trade: Vec, + + #[event(from = pump_sdk::instructions::Buuy, fields = [user])] + second_trade: Vec, + }} +}} + +fn main() {{}} +"#, + pump_idl_path() + ); + + let stderr = compile_failure_stderr( + "invalid_event_instruction_is_reported_once_per_group", + &source, + ); + assert_eq!( + stderr.matches("Not found: 'Buuy' in instructions").count(), + 1, + "stderr was:\n{stderr}" + ); +} From 1d97659af8d60ab7f23b4ad0699bdf32710a28af Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sun, 22 Mar 2026 21:16:05 +0000 Subject: [PATCH 18/18] fix: validate derive_from hooks as instruction groups --- hyperstack-macros/src/validation/mod.rs | 72 +++++++++++-------- .../tests/key_resolution_dynamic.rs | 29 ++++++++ 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/hyperstack-macros/src/validation/mod.rs b/hyperstack-macros/src/validation/mod.rs index 4ac20636..7e93f519 100644 --- a/hyperstack-macros/src/validation/mod.rs +++ b/hyperstack-macros/src/validation/mod.rs @@ -709,40 +709,50 @@ fn validate_instruction_hook_keys( for instruction_type in instruction_types { let derive_attrs = &derive_from_mappings[instruction_type]; - for derive_attr in derive_attrs { - if let Some(lookup_by) = &derive_attr.lookup_by { - let field_name = lookup_by.ident.to_string(); - if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) - { - continue; - } - errors.push(key_resolution_error( - lookup_by.ident.span(), - "instruction hook", - instruction_type, - entity_name, - &format!( - "The `lookup_by` field '{}' is neither a primary-key field nor a lookup-index-backed field.", - field_name - ), - )); - } else { - let field_name = derive_attr.field.ident.to_string(); - if source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) - { - continue; - } + let group_resolved = derive_attrs.iter().any(|derive_attr| { + let field_name = derive_attr + .lookup_by + .as_ref() + .map(|lookup_by| lookup_by.ident.to_string()) + .unwrap_or_else(|| derive_attr.field.ident.to_string()); - errors.push(key_resolution_error( - derive_attr.attr_span, - "instruction hook", - instruction_type, - entity_name, - "Add `lookup_by = ...` that points to the primary key or to a lookup index field.", - )); - } + source_field_can_resolve_key(&field_name, primary_key_leafs, lookup_index_leafs) + }); + + if group_resolved { + continue; } + + if let Some(lookup_by) = derive_attrs + .iter() + .find_map(|derive_attr| derive_attr.lookup_by.as_ref()) + { + let field_name = lookup_by.ident.to_string(); + errors.push(key_resolution_error( + lookup_by.ident.span(), + "instruction hook", + instruction_type, + entity_name, + &format!( + "The `lookup_by` field '{}' is neither a primary-key field nor a lookup-index-backed field.", + field_name + ), + )); + continue; + } + + let Some(first_attr) = derive_attrs.first() else { + continue; + }; + + errors.push(key_resolution_error( + first_attr.attr_span, + "instruction hook", + instruction_type, + entity_name, + "Add `lookup_by = ...` that points to the primary key or to a lookup index field.", + )); } } diff --git a/hyperstack-macros/tests/key_resolution_dynamic.rs b/hyperstack-macros/tests/key_resolution_dynamic.rs index c8e55b05..5d3502dd 100644 --- a/hyperstack-macros/tests/key_resolution_dynamic.rs +++ b/hyperstack-macros/tests/key_resolution_dynamic.rs @@ -216,6 +216,35 @@ fn main() {} ); } +#[test] +fn derive_from_group_passes_when_any_field_resolves_key() { + let source = r#"use hyperstack_macros::hyperstack; + +#[hyperstack(idl = "fixture/minimal.json")] +mod valid { + #[entity(name = "Thing")] + struct Thing { + #[map(fake_sdk::accounts::Thing::id, primary_key, strategy = SetOnce)] + id: String, + + #[derive_from(from = fake_sdk::instructions::Trade, field = id, strategy = LastWrite)] + latest_id: String, + + #[derive_from(from = fake_sdk::instructions::Trade, field = user, strategy = LastWrite)] + latest_user: String, + } +} + +fn main() {} +"#; + + compile_success_with_files( + "derive_from_group_passes_when_any_field_resolves_key", + source, + &[("fixture/minimal.json", minimal_idl())], + ); +} + #[test] fn event_group_accepts_any_valid_lookup_by_regardless_of_field_order() { let source = r#"use hyperstack_macros::hyperstack;