From 304c68663999717b6945c45f65f1362da89113eb Mon Sep 17 00:00:00 2001 From: Kinflou Date: Tue, 1 Sep 2026 23:36:47 +0800 Subject: [PATCH] feat(ir): throws is Vec - schema-global error ordinals, cross-schema The 4th core-target-contract IR change (docs Design 4.4). `Function.throws` goes from `Vec` (bare names) to `Vec` (schema-global error ordinals), resolved at freeze. ## The error space `plan_error_space` runs before the main freeze loop: - Locally-declared `error`s take ordinals 0..N in declaration order. - Each distinct `! Name` a function throws that isn't local is appended as a re-exported import: resolved through the schema's in-scope `use`s (when there's a ProjectContext) to the foreign `error` decl, which is frozen into this schema's stream with `imported_from: Some()`. Unresolvable names still get a stable slot, marked `imported_from: Some("")`. So every `! Name` ends up a `u16`, and cross-schema throws keep a single-u16 wire id -- the design's "re-export slot in the importing schema's ordinal space". ## FrozenUnit::Error Two new fields: `ordinal: u16` (the slot -- what travels in the envelope's `err` id) and `imported_from: Option` (`None` local, `Some(ns)` a re-export). The ordinal is explicit rather than stream position, so a local `error` declared after a `protocol` still has a stable id. Append-only discipline (retire in place, never reorder/reuse) is the author's to keep; version-diff enforcement of it is a separate follow-up, same as Error fields/message not being diffed yet. ## Not in scope - Multiple throws per function (`! A, B`) -- grammar is still single `! Name`; `Vec` is 0-or-1 today, forward-compatible. - Errors as general importable symbols (glob-expansion, validation) -- this resolves them only for `throws`, via a local walk of the `use`s, without touching `schema_declares_symbol` / the validator. ## Tests `incremental` + `imports`: local ordinal assignment, an unresolved throw gets a marker slot, a foreign `use`d error gets a re-export slot after the locals with its fields/message carried over, and the function points at the right ordinal. Full core suite green (247 passed). ## Downstream `FrozenUnit` shape change -> coordinated `comline-core` rev-bump across generation / cli / comline-{rust,typescript} when this + core#46 land (they only match `Function` / `Error` with `..`, so rev-bump not code). --- .../ir/compiler/interpreter/incremental.rs | 220 +++++++++++++++--- core/src/schema/ir/frozen/unit.rs | 21 +- core/tests/schema/ir/imports.rs | 100 ++++++++ core/tests/schema/ir/validation.rs | 75 ++++-- 4 files changed, 364 insertions(+), 52 deletions(-) diff --git a/core/src/schema/ir/compiler/interpreter/incremental.rs b/core/src/schema/ir/compiler/interpreter/incremental.rs index 2b374ae..713d09c 100644 --- a/core/src/schema/ir/compiler/interpreter/incremental.rs +++ b/core/src/schema/ir/compiler/interpreter/incremental.rs @@ -1,10 +1,11 @@ // Standard Uses +use std::collections::HashMap; // Crate Uses // use crate::schema::idl::ast::unit; // use crate::schema::idl::ast::unit::ASTUnit; use crate::package::config::ir::context::ProjectContext; -use crate::schema::idl::grammar::{Annotation, AnnotationValue, Declaration, UsePath}; +use crate::schema::idl::grammar::{self, Annotation, AnnotationValue, Declaration, UsePath}; use crate::schema::ir::compiler::import_resolver::{ declared_symbol_names, resolve_use_to_schema, schema_declares_symbol, ImportResolver, }; @@ -48,6 +49,16 @@ impl IncrementalInterpreter { ) -> Vec { tracing::debug!("Processing {} declarations...", declarations.len()); + // Resolve the schema's error space up front: every locally-declared + // `error` gets an ordinal in declaration order, then every distinct + // `! Name` a function throws that *isn't* local gets the next slot as + // a re-exported import (resolved through the in-scope `use`s, or a + // `` marker slot). `throws` then freezes as `u16`s. + let ErrorPlan { + ordinals: error_ordinals, + reexports, + } = plan_error_space(&declarations, use_context); + let mut frozen_units: Vec = vec![]; for spanned_decl in declarations { @@ -193,7 +204,16 @@ impl IncrementalInterpreter { arguments, _return: return_type, docstring: func.docstring().unwrap_or_default(), - throws: func.throws().into_iter().collect(), + throws: func + .throws() + .into_iter() + .map(|name| { + // Every throw name was given a slot by + // `plan_error_space`; the default is a + // defensive fallback only. + error_ordinals.get(&name).copied().unwrap_or(0) + }) + .collect(), span: func.span, } }) @@ -208,36 +228,11 @@ impl IncrementalInterpreter { }); } Declaration::Error(error_decl) => { - let error_name = error_decl.name(); - let message = error_decl.message(); - let fields = error_decl.fields(); - - let field_units: Vec = fields - .iter() - .map(|field| { - let fname = field.name(); - let field_type = field.field_type(); - - let kind_value = build_kind_value(field_type, field.default_value()); - - FrozenUnit::Field { - docstring: field.docstring(), - parameters: annotation_units(&field.annotations()), - optional: field.optional(), - name: fname, - kind_value, - span: field.span, - } - }) - .collect(); - - frozen_units.push(FrozenUnit::Error { - docstring: error_decl.docstring(), - parameters: vec![], - name: error_name, - message, - fields: field_units, - }); + // Local errors keep the ordinal `plan_error_space` gave + // them; `None` only if a Protocol arm somehow references a + // name before it's planned, which can't happen. + let ordinal = error_ordinals.get(&error_decl.name()).copied().unwrap_or(0); + frozen_units.push(frozen_error(&error_decl, ordinal, None)); } Declaration::Settings(settings_def) => { let parameters: Vec = settings_def @@ -292,6 +287,10 @@ impl IncrementalInterpreter { } } + // Re-exported foreign errors named by a `throws`, in ordinal order, + // after the local declarations. + frozen_units.extend(reexports); + tracing::debug!("Generated {} IR units", frozen_units.len()); for unit in &frozen_units { tracing::trace!(" {:?}", unit); @@ -313,6 +312,163 @@ impl IncrementalInterpreter { */ } +/// The resolved error space of one schema: every error name this schema can +/// throw, mapped to its schema-global ordinal, plus the `FrozenUnit::Error` +/// re-export slots for the foreign ones (in ordinal order). +struct ErrorPlan { + ordinals: HashMap, + reexports: Vec, +} + +/// Assign schema-global error ordinals: locally-declared `error`s take +/// `0..N` in declaration order; each distinct `! Name` a function throws that +/// isn't local is appended as a re-exported import - resolved through the +/// in-scope `use`s when there's a project context, or a `` +/// marker slot otherwise. Either way every throw name ends up with a stable +/// `u16`. +fn plan_error_space( + declarations: &[rust_sitter::Spanned], + use_context: Option<(&[String], &ProjectContext)>, +) -> ErrorPlan { + let mut ordinals: HashMap = HashMap::new(); + let mut next: u16 = 0; + + // Locals, in declaration order. A duplicate `error` name (a validation + // error, reported elsewhere) keeps the first slot. + for decl in declarations { + if let Declaration::Error(error_decl) = &decl.value { + ordinals.entry(error_decl.name()).or_insert_with(|| { + let ord = next; + next += 1; + ord + }); + } + } + + // Foreign errors named by a `throws`, first-reference order. + let mut reexports: Vec = Vec::new(); + for decl in declarations { + let Declaration::Protocol(protocol) = &decl.value else { + continue; + }; + for func in protocol.functions() { + let Some(name) = func.throws() else { continue }; + if ordinals.contains_key(&name) { + continue; + } + let ord = next; + next += 1; + ordinals.insert(name.clone(), ord); + reexports.push( + resolve_foreign_error(&name, declarations, use_context, ord).unwrap_or_else(|| { + FrozenUnit::Error { + docstring: None, + parameters: vec![], + ordinal: ord, + imported_from: Some(format!("")), + name: name.clone(), + message: String::new(), + fields: vec![], + } + }), + ); + } + } + + ErrorPlan { ordinals, reexports } +} + +/// Locate a foreign `error` named by a bare `! Name` throw: walk the schema's +/// `use` declarations, and for each that brings `name` into scope, look for a +/// matching `error` declaration in the target schema. `None` if there's no +/// project context, or nothing matches. +fn resolve_foreign_error( + name: &str, + declarations: &[rust_sitter::Spanned], + use_context: Option<(&[String], &ProjectContext)>, + ordinal: u16, +) -> Option { + let (current_namespace, project_context) = use_context?; + let resolver = ImportResolver::new(vec![], Default::default(), None); + + for decl in declarations { + let Declaration::Use(use_stmt) = &decl.value else { + continue; + }; + let Ok(target) = + resolve_use_to_schema(project_context, &resolver, current_namespace, &use_stmt.path) + else { + continue; + }; + let Some(schema) = &target.schema else { + continue; + }; + + let alias = use_stmt.alias.as_ref().map(|a| a.name.text.as_str()); + let brings_into_scope = if target.resolved.symbols == ["*".to_string()] { + true + } else if !target.resolved.symbols.is_empty() { + target.resolved.symbols.iter().any(|s| s == name) + } else if target.remaining.is_empty() { + // `use ns;` - the whole namespace; a bare `! Name` resolves if + // `ns` declares it. + true + } else { + let symbol = target.remaining.join("::"); + symbol == name || alias == Some(name) + }; + if !brings_into_scope { + continue; + } + + let schema_ref = schema.borrow(); + for target_decl in &schema_ref.declarations { + if let Declaration::Error(error_decl) = &target_decl.value { + if error_decl.name() == name { + return Some(frozen_error( + error_decl, + ordinal, + Some(schema_ref.namespace_joined()), + )); + } + } + } + } + + None +} + +/// Freeze one `error` declaration - shared by a local declaration and a +/// re-exported import (which only differ in `ordinal` / `imported_from`). +fn frozen_error( + error_decl: &grammar::Error, + ordinal: u16, + imported_from: Option, +) -> FrozenUnit { + let field_units: Vec = error_decl + .fields() + .iter() + .map(|field| FrozenUnit::Field { + docstring: field.docstring(), + parameters: annotation_units(&field.annotations()), + optional: field.optional(), + name: field.name(), + kind_value: build_kind_value(field.field_type(), field.default_value()), + span: field.span, + }) + .collect(); + + FrozenUnit::Error { + docstring: error_decl.docstring(), + parameters: vec![], + ordinal, + imported_from, + name: error_decl.name(), + message: error_decl.message(), + fields: field_units, + } +} + /// Turn a declaration's annotations into frozen units, in source order. /// /// A scalar `@key = value` becomes one `FrozenUnit::Property { name, expression }`. diff --git a/core/src/schema/ir/frozen/unit.rs b/core/src/schema/ir/frozen/unit.rs index 37d034f..5a9d152 100644 --- a/core/src/schema/ir/frozen/unit.rs +++ b/core/src/schema/ir/frozen/unit.rs @@ -94,15 +94,28 @@ pub enum FrozenUnit { // direction: Box, arguments: Vec, _return: Option, - // Names of the error(s) this function can throw - a reference by - // name only, not resolved/validated against a declared `error` - // (that's validator.rs-style work, out of scope here). - throws: Vec, + // The schema-global ordinal of each `error` this function can throw - + // resolved at freeze from the `! Name` reference to the matching + // `FrozenUnit::Error`'s `ordinal` (local or a re-exported import; see + // `Error::ordinal`). An unresolvable name still gets a stable slot. + throws: Vec, span: (usize, usize), }, Error { docstring: Option, parameters: Vec, + /// Schema-global error ordinal - this error's slot in the schema's + /// error space. Locally-declared errors take `0..N` in declaration + /// order; an imported error named by a `throws` gets the next slot as + /// a re-export. This is the `u16` that travels in the response + /// envelope. Append-only: an `error` is retired in place, its ordinal + /// never reordered or reused. + ordinal: u16, + /// `Some(ns)` when this unit is a re-export slot for an `error` + /// declared in another schema (`ns` = that schema's namespace, or + /// `` if it could not be located); `None` for a + /// local `error` declaration. + imported_from: Option, name: String, message: String, fields: Vec diff --git a/core/tests/schema/ir/imports.rs b/core/tests/schema/ir/imports.rs index f52349e..eb62306 100644 --- a/core/tests/schema/ir/imports.rs +++ b/core/tests/schema/ir/imports.rs @@ -317,3 +317,103 @@ fn test_use_as_does_not_bind_the_original_bare_name() { "a bare `User` after `use ... as Account` should not resolve" ); } + +/// Pull the `(ordinal, imported_from)` of every frozen `Error` unit. +fn error_slots(frozen: &[FrozenUnit]) -> Vec<(u16, String, Option)> { + frozen + .iter() + .filter_map(|u| match u { + FrozenUnit::Error { name, ordinal, imported_from, .. } => { + Some((*ordinal, name.clone(), imported_from.clone())) + } + _ => None, + }) + .collect() +} + +fn only_function_throws(frozen: &[FrozenUnit]) -> Vec { + frozen + .iter() + .find_map(|u| match u { + FrozenUnit::Protocol { functions, .. } => Some(functions), + _ => None, + }) + .and_then(|fns| { + fns.iter().find_map(|f| match f { + FrozenUnit::Function { throws, .. } => Some(throws.clone()), + _ => None, + }) + }) + .expect("a protocol with one function") +} + +#[test] +fn test_thrown_foreign_error_gets_a_reexport_slot() { + let mut project = build_project(); + add_schema( + &mut project, + &["errs"], + "error NotFound {\n message = \"{self.id} is gone\"\n id: u64\n}\n", + ); + add_schema( + &mut project, + &["api"], + "use errs::NotFound\n\nprotocol Store {\n function get(u64) -> str ! NotFound;\n}\n", + ); + + interpret_context(&project).expect("compilation should succeed"); + let frozen = frozen_units_for(&project, "api"); + + // The foreign error is re-exported into `api`'s ordinal space at 0 (no + // local errors), with its fields/message carried over from `errs`. + let slots = error_slots(&frozen); + assert_eq!( + slots, + vec![(0u16, "NotFound".to_string(), Some("errs".to_string()))], + "got {frozen:?}" + ); + let reexport = frozen + .iter() + .find_map(|u| match u { + FrozenUnit::Error { name, fields, message, .. } if name == "NotFound" => { + Some((fields.len(), message.clone())) + } + _ => None, + }) + .unwrap(); + assert_eq!(reexport, (1, "{self.id} is gone".to_string())); + + // ...and the function points at that ordinal. + assert_eq!(only_function_throws(&frozen), vec![0u16]); +} + +#[test] +fn test_local_errors_keep_low_ordinals_foreign_reexports_come_after() { + let mut project = build_project(); + add_schema( + &mut project, + &["errs"], + "error Denied {\n message = \"no\"\n}\n", + ); + add_schema( + &mut project, + &["api"], + "use errs::Denied\n\n\ + error Local {\n message = \"local\"\n}\n\ + protocol Store {\n function act(u64) -> str ! Denied;\n}\n", + ); + + interpret_context(&project).expect("compilation should succeed"); + let frozen = frozen_units_for(&project, "api"); + + let slots = error_slots(&frozen); + assert_eq!( + slots, + vec![ + (0u16, "Local".to_string(), None), + (1u16, "Denied".to_string(), Some("errs".to_string())), + ], + "local error takes ordinal 0, the re-exported foreign one comes after" + ); + assert_eq!(only_function_throws(&frozen), vec![1u16]); +} diff --git a/core/tests/schema/ir/validation.rs b/core/tests/schema/ir/validation.rs index bbfa146..d7a583e 100644 --- a/core/tests/schema/ir/validation.rs +++ b/core/tests/schema/ir/validation.rs @@ -207,29 +207,39 @@ error NotFoundError { #[test] fn test_function_throws_populates_ir() { + use comline_core::schema::ir::frozen::unit::FrozenUnit; + + // `NotFoundError` is neither declared locally nor imported: it still + // gets a stable ordinal (0), and an `` re-export slot + // is appended so the ordinal has a home in the IR. let code = "protocol P {\n function get(u64) -> str ! NotFoundError;\n}"; let ir_units = IncrementalInterpreter::from_source(code); - match &ir_units[0] { - comline_core::schema::ir::frozen::unit::FrozenUnit::Protocol { functions, .. } => { - match &functions[0] { - comline_core::schema::ir::frozen::unit::FrozenUnit::Function { - throws, - .. - } => { - assert_eq!(throws, &vec!["NotFoundError".to_string()]); - } - _ => panic!("Expected Function"), + + let FrozenUnit::Protocol { functions, .. } = &ir_units[0] else { + panic!("Expected Protocol"); + }; + let FrozenUnit::Function { throws, .. } = &functions[0] else { + panic!("Expected Function"); + }; + assert_eq!(throws, &vec![0u16]); + + let reexport = ir_units + .iter() + .find_map(|u| match u { + FrozenUnit::Error { name, ordinal, imported_from, .. } if name == "NotFoundError" => { + Some((*ordinal, imported_from.clone())) } - } - _ => panic!("Expected Protocol"), - } + _ => None, + }) + .expect("an Error unit for the thrown name"); + assert_eq!(reexport.0, 0); + assert_eq!(reexport.1.as_deref(), Some("")); } #[test] fn test_function_without_throws_still_empty_vec() { - // Regression guard for the Vec -> Vec type - // change: a function with no throws clause still gets an empty - // Vec, same as before. + // A function with no throws clause still gets an empty Vec (now + // Vec, resolved from the `! Name` references). let code = "protocol P {\n function get(u64) -> str;\n}"; let ir_units = IncrementalInterpreter::from_source(code); match &ir_units[0] { @@ -248,6 +258,39 @@ error NotFoundError { } } + #[test] + fn test_local_error_gets_ordinal_and_throws_resolves_to_it() { + use comline_core::schema::ir::frozen::unit::FrozenUnit; + + let code = "\ +error Missing {\n message = \"gone\"\n}\n\ +error Denied {\n message = \"no\"\n}\n\ +protocol P {\n function get(u64) -> str ! Denied;\n}"; + let ir_units = IncrementalInterpreter::from_source(code); + + // Declaration order: Missing = 0, Denied = 1. + let ordinal_of = |want: &str| { + ir_units.iter().find_map(|u| match u { + FrozenUnit::Error { name, ordinal, imported_from: None, .. } if name == want => { + Some(*ordinal) + } + _ => None, + }) + }; + assert_eq!(ordinal_of("Missing"), Some(0)); + assert_eq!(ordinal_of("Denied"), Some(1)); + + let FrozenUnit::Protocol { functions, .. } = + ir_units.iter().find(|u| matches!(u, FrozenUnit::Protocol { .. })).unwrap() + else { + unreachable!() + }; + let FrozenUnit::Function { throws, .. } = &functions[0] else { + panic!("Expected Function"); + }; + assert_eq!(throws, &vec![1u16]); + } + #[test] fn test_struct_field_default_values() { use comline_core::schema::ir::compiler::interpreted::kind_search::{KindValue, Primitive};