diff --git a/examples/cli_registered_frontend.rs b/examples/cli_registered_frontend.rs new file mode 100644 index 00000000..85b9dcc6 --- /dev/null +++ b/examples/cli_registered_frontend.rs @@ -0,0 +1,56 @@ +//! Public three-field CLI consumer: file registration does not select a REPL producer. +#[cfg(feature = "cli")] +mod consumer { + use std::sync::atomic::{AtomicUsize, Ordering}; + use vm::{ + CompileSourceFileOptions, FrontendIr, ParseError, SourceFrontendContext, SourcePlugin, + }; + + struct FileFrontend; + static FILE_FRONTEND: FileFrontend = FileFrontend; + static HOOKS: AtomicUsize = AtomicUsize::new(0); + impl SourcePlugin for FileFrontend { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("external-file-probe"), + extensions: &["external"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + _: &str, + _: &SourceFrontendContext<'_>, + ) -> Result { + HOOKS.fetch_add(1, Ordering::SeqCst); + Err(ParseError::new( + "this fixture only exercises native REPL selection", + )) + } + fn validate_typed_ir(&self, _: &vm::FrontendTypedIr<'_>) -> Result<(), vm::CompileError> { + HOOKS.fetch_add(1, Ordering::SeqCst); + panic!("unselected file hook must not run") + } + } + + pub fn run() -> Result<(), Box> { + vm::cli::main(vm::cli::CliRuntime { + binary_name: "cli-registered-frontend", + default_source: "unused.rss", + compile_options: || { + CompileSourceFileOptions::default().with_source_plugin(&FILE_FRONTEND) + }, + })?; + assert_eq!(HOOKS.load(Ordering::SeqCst), 0); + println!("external-file-hooks=0"); + Ok(()) + } +} + +#[cfg(feature = "cli")] +fn main() -> Result<(), Box> { + consumer::run() +} +#[cfg(not(feature = "cli"))] +fn main() {} diff --git a/pd-vm-wasm/src/lib.rs b/pd-vm-wasm/src/lib.rs index 48b31cb6..23d5f979 100644 --- a/pd-vm-wasm/src/lib.rs +++ b/pd-vm-wasm/src/lib.rs @@ -6,6 +6,10 @@ mod stdlib; use std::path::Path; +#[cfg(test)] +#[path = "../tests/fixtures/coherent_typehint.rs"] +mod coherent_typehint; + use serde::{Deserialize, Serialize}; use vm::{ CompileSourceFileOptions, InferredLocalTypeHint, SourceFlavor, @@ -32,6 +36,8 @@ struct LintResponse { #[derive(Serialize)] struct LocalTypeHintsResponse { hints: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, } #[derive(Serialize)] @@ -60,12 +66,59 @@ struct LintSpanJson { #[derive(Serialize)] struct LocalTypeHintJson { + source_name: Option, + schema: Option, + span: Option, name: String, inferred_type: String, declared_line: Option, last_line: Option, } +#[derive(Serialize)] +struct SourceByteSpanJson { + source_id: u32, + lo: usize, + hi: usize, +} + +fn type_schema_to_json(schema: &vm::TypeSchema) -> serde_json::Value { + use serde_json::json; + use vm::TypeSchema; + match schema { + TypeSchema::Unknown => json!({"kind": "unknown"}), + TypeSchema::Null => json!({"kind": "null"}), + TypeSchema::Int => json!({"kind": "int"}), + TypeSchema::Float => json!({"kind": "float"}), + TypeSchema::Number => json!({"kind": "number"}), + TypeSchema::Bool => json!({"kind": "bool"}), + TypeSchema::String => json!({"kind": "string"}), + TypeSchema::Bytes => json!({"kind": "bytes"}), + TypeSchema::GenericParam(name) => json!({"kind": "generic_param", "name": name}), + TypeSchema::Resource(key) => json!({"kind": "resource", "key": key.as_str()}), + TypeSchema::Optional(inner) => { + json!({"kind": "optional", "inner": type_schema_to_json(inner)}) + } + TypeSchema::Array(item) => json!({"kind": "array", "item": type_schema_to_json(item)}), + TypeSchema::Map(item) => json!({"kind": "map", "item": type_schema_to_json(item)}), + TypeSchema::Named(name, args) => { + json!({"kind": "named", "name": name, "args": args.iter().map(type_schema_to_json).collect::>()}) + } + TypeSchema::ArrayTuple(items) => { + json!({"kind": "tuple", "items": items.iter().map(type_schema_to_json).collect::>()}) + } + TypeSchema::ArrayTupleRest { prefix, rest } => { + json!({"kind": "tuple_rest", "prefix": prefix.iter().map(type_schema_to_json).collect::>(), "rest": type_schema_to_json(rest)}) + } + TypeSchema::Object(fields) => { + json!({"kind": "object", "fields": fields.iter().map(|(name, schema)| (name.clone(), type_schema_to_json(schema))).collect::>()}) + } + TypeSchema::Callable { params, result } => { + json!({"kind": "callable", "params": params.iter().map(type_schema_to_json).collect::>(), "result": type_schema_to_json(result)}) + } + } +} + #[cfg(feature = "runtime")] #[derive(Serialize)] struct RunResponse { @@ -112,11 +165,10 @@ struct ModuleOverrideInput { source: String, } -fn parse_flavor(raw: &str) -> SourceFlavor { +fn parse_flavor(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { - "javascript" | "js" => SourceFlavor::JavaScript, - "lua" => SourceFlavor::Lua, - _ => SourceFlavor::RustScript, + "" | "default" | "rss" | "rustscript" => Ok(SourceFlavor::RustScript), + _ => Err(format!("unsupported frontend selector: {}", raw.trim())), } } @@ -171,6 +223,7 @@ fn lint_response_to_json(report: LintReport) -> Vec { fn local_type_hints_to_json(hints: Vec) -> Vec { let response = LocalTypeHintsResponse { hints: hints.into_iter().map(local_type_hint_to_json).collect(), + error: None, }; serde_json::to_vec(&response).unwrap_or_else(|_| b"{\"hints\":[]}".to_vec()) } @@ -193,13 +246,16 @@ fn format_response_to_json(result: Result) -> Vec { }) } -fn local_type_hints_with_flavor(source: &str, flavor: SourceFlavor) -> Vec { +fn local_type_hints_with_flavor( + source: &str, + flavor: SourceFlavor, +) -> Result, String> { collect_inferred_local_type_hints_with_options( source, flavor, stdlib::embedded_stdlib_compile_options(), ) - .unwrap_or_default() + .map_err(|err| err.to_string()) } fn local_type_hints_with_flavor_at_path( @@ -207,9 +263,108 @@ fn local_type_hints_with_flavor_at_path( path: &Path, flavor: SourceFlavor, options: CompileSourceFileOptions, -) -> Vec { +) -> Result, String> { collect_inferred_local_type_hints_at_path_with_options(path, source, flavor, options) - .unwrap_or_default() + .map_err(|err| err.to_string()) +} + +fn lint_error_response(message: String) -> Vec { + let response = LintResponse { + diagnostics: vec![LintDiagnosticJson { + line: 1, + severity: "error", + rendered: message.clone(), + message, + span: None, + }], + }; + serde_json::to_vec(&response).unwrap_or_else(|_| b"{\"diagnostics\":[]}".to_vec()) +} + +fn format_error_response(message: String) -> Vec { + serde_json::to_vec(&FormatResponse { + ok: false, + formatted: None, + error: Some(message), + }) + .unwrap_or_else(|_| b"{\"ok\":false,\"formatted\":null,\"error\":\"format failed\"}".to_vec()) +} + +fn local_type_hints_error_response(message: String) -> Vec { + serde_json::to_vec(&LocalTypeHintsResponse { + hints: Vec::new(), + error: Some(message), + }) + .unwrap_or_else(|_| b"{\"hints\":[],\"error\":\"type hints failed\"}".to_vec()) +} + +fn lint_source_json_response(source: &str, flavor_raw: &str) -> Vec { + let flavor = match parse_flavor(flavor_raw) { + Ok(flavor) => flavor, + Err(error) => return lint_error_response(error), + }; + lint_response_to_json(lint_source_with_flavor(source, flavor)) +} + +fn lint_source_json_with_context_response( + source: &str, + flavor_raw: &str, + path_raw: &str, + overrides_raw: &str, +) -> Vec { + let flavor = match parse_flavor(flavor_raw) { + Ok(flavor) => flavor, + Err(error) => return lint_error_response(error), + }; + let options = parse_module_overrides(overrides_raw); + let report = if path_raw.trim().is_empty() { + lint_source_with_flavor(source, flavor) + } else { + lint_source_with_flavor_at_path(source, Path::new(path_raw), flavor, options) + }; + lint_response_to_json(report) +} + +fn format_source_json_response(source: &str, flavor_raw: &str) -> Vec { + let flavor = match parse_flavor(flavor_raw) { + Ok(flavor) => flavor, + Err(error) => return format_error_response(error), + }; + format_response_to_json(format_source_with_flavor(source, flavor)) +} + +fn local_type_hints_json_response(source: &str, flavor_raw: &str) -> Vec { + let flavor = match parse_flavor(flavor_raw) { + Ok(flavor) => flavor, + Err(error) => return local_type_hints_error_response(error), + }; + match local_type_hints_with_flavor(source, flavor) { + Ok(hints) => local_type_hints_to_json(hints), + Err(error) => local_type_hints_error_response(error), + } +} + +fn local_type_hints_json_with_context_response( + source: &str, + flavor_raw: &str, + path_raw: &str, + overrides_raw: &str, +) -> Vec { + let flavor = match parse_flavor(flavor_raw) { + Ok(flavor) => flavor, + Err(error) => return local_type_hints_error_response(error), + }; + let options = parse_module_overrides(overrides_raw); + let result = if path_raw.trim().is_empty() { + collect_inferred_local_type_hints_with_options(source, flavor, options) + .map_err(|err| err.to_string()) + } else { + local_type_hints_with_flavor_at_path(source, Path::new(path_raw), flavor, options) + }; + match result { + Ok(hints) => local_type_hints_to_json(hints), + Err(error) => local_type_hints_error_response(error), + } } #[cfg(feature = "runtime")] @@ -257,6 +412,70 @@ fn debug_response_to_json(report: DebugReport) -> Vec { }) } +#[cfg(feature = "runtime")] +fn frontend_run_error_response(message: String) -> Vec { + let response = RunResponse { + ok: false, + diagnostics: vec![LintDiagnosticJson { + line: 1, + severity: "error", + message: message.clone(), + span: None, + rendered: message.clone(), + }], + output: Vec::new(), + stack: Vec::new(), + error: Some(message), + halted: true, + yielded: false, + command_output: String::new(), + fuel: fuel_state_to_json(FuelState { + enabled: false, + mode: InterruptModeState::None, + remaining: None, + check_interval: 1, + epoch_current: 0, + epoch_deadline: None, + epoch_slice: None, + }), + }; + serde_json::to_vec(&response).unwrap_or_else(|_| { + b"{\"ok\":false,\"diagnostics\":[],\"output\":[],\"stack\":[],\"halted\":true,\"yielded\":false,\"command_output\":\"\",\"fuel\":{\"enabled\":false,\"remaining\":null,\"check_interval\":1}}".to_vec() + }) +} + +#[cfg(feature = "runtime")] +fn frontend_debug_error_response(message: String) -> Vec { + let response = DebugResponse { + diagnostics: vec![LintDiagnosticJson { + line: 1, + severity: "error", + message: message.clone(), + span: None, + rendered: message.clone(), + }], + output: Vec::new(), + stack: Vec::new(), + error: Some(message), + current_line: None, + breakpoints: Vec::new(), + halted: true, + command_output: String::new(), + fuel: fuel_state_to_json(FuelState { + enabled: false, + mode: InterruptModeState::None, + remaining: None, + check_interval: 1, + epoch_current: 0, + epoch_deadline: None, + epoch_slice: None, + }), + }; + serde_json::to_vec(&response).unwrap_or_else(|_| { + b"{\"diagnostics\":[],\"output\":[],\"stack\":[],\"breakpoints\":[],\"halted\":true,\"command_output\":\"\",\"fuel\":{\"enabled\":false,\"remaining\":null,\"check_interval\":1}}".to_vec() + }) +} + #[cfg(feature = "runtime")] fn fuel_state_to_json(fuel: FuelState) -> FuelStateJson { FuelStateJson { @@ -281,6 +500,13 @@ fn completion_catalog_to_json(catalog: CompletionCatalog) -> Vec { fn local_type_hint_to_json(hint: InferredLocalTypeHint) -> LocalTypeHintJson { LocalTypeHintJson { + source_name: hint.source_name, + schema: hint.schema.as_ref().map(type_schema_to_json), + span: hint.span.map(|span| SourceByteSpanJson { + source_id: span.source_id, + lo: span.lo, + hi: span.hi, + }), name: hint.name, inferred_type: hint.inferred_type, declared_line: hint.declared_line, @@ -300,6 +526,46 @@ fn parse_module_overrides(raw: &str) -> CompileSourceFileOptions { options } +#[cfg(feature = "runtime")] +fn run_source_json_response(source: &str, flavor_raw: &str, options_json: Option<&str>) -> Vec { + let flavor = match parse_flavor(flavor_raw) { + Ok(flavor) => flavor, + Err(error) => return frontend_run_error_response(error), + }; + let fuel_config = match options_json { + None => FuelConfig::default(), + Some(options_json) => match serde_json::from_str::(options_json) { + Ok(value) => value, + Err(err) => { + return invalid_run_options_response(options_json, &err.to_string()); + } + }, + }; + run_response_to_json(start_run_source_with_flavor(source, flavor, fuel_config)) +} + +#[cfg(feature = "runtime")] +fn debug_start_json_response( + source: &str, + flavor_raw: &str, + options_json: Option<&str>, +) -> Vec { + let flavor = match parse_flavor(flavor_raw) { + Ok(flavor) => flavor, + Err(error) => return frontend_debug_error_response(error), + }; + let fuel_config = match options_json { + None => FuelConfig::default(), + Some(options_json) => match serde_json::from_str::(options_json) { + Ok(value) => value, + Err(err) => { + return invalid_debug_options_response(options_json, &err.to_string()); + } + }, + }; + debug_response_to_json(start_debug_source_with_flavor(source, flavor, fuel_config)) +} + fn invalid_utf8_lint_response(label: &str, err: &std::str::Utf8Error) -> Vec { let response = LintResponse { diagnostics: vec![LintDiagnosticJson { @@ -314,9 +580,7 @@ fn invalid_utf8_lint_response(label: &str, err: &std::str::Utf8Error) -> Vec } fn invalid_utf8_local_type_response(label: &str, err: &std::str::Utf8Error) -> Vec { - let response = LocalTypeHintsResponse { hints: Vec::new() }; - let _ = (label, err); - serde_json::to_vec(&response).unwrap_or_else(|_| b"{\"hints\":[]}".to_vec()) + local_type_hints_error_response(format!("invalid utf-8 {label}: {err}")) } fn invalid_utf8_format_response(label: &str, err: &std::str::Utf8Error) -> Vec { @@ -539,8 +803,7 @@ pub extern "C" fn lint_source_json( Ok(value) => value, Err(err) => return leak_bytes(invalid_utf8_lint_response("flavor", &err)), }; - let report = lint_source_with_flavor(source, parse_flavor(flavor_raw)); - leak_bytes(lint_response_to_json(report)) + leak_bytes(lint_source_json_response(source, flavor_raw)) } #[unsafe(no_mangle)] @@ -558,10 +821,7 @@ pub extern "C" fn format_source_json( Ok(value) => value, Err(err) => return leak_bytes(invalid_utf8_format_response("flavor", &err)), }; - leak_bytes(format_response_to_json(format_source_with_flavor( - source, - parse_flavor(flavor_raw), - ))) + leak_bytes(format_source_json_response(source, flavor_raw)) } #[unsafe(no_mangle)] @@ -579,23 +839,19 @@ pub extern "C" fn lint_source_json_with_context( Ok(value) => value, Err(err) => return leak_bytes(invalid_utf8_lint_response("source", &err)), }; - let flavor_raw = std::str::from_utf8(unpack_input(flavor_ptr, flavor_len)).unwrap_or("rss"); + let flavor_raw = match std::str::from_utf8(unpack_input(flavor_ptr, flavor_len)) { + Ok(value) => value, + Err(err) => return leak_bytes(invalid_utf8_lint_response("flavor", &err)), + }; let path_raw = std::str::from_utf8(unpack_input(path_ptr, path_len)).unwrap_or(""); let overrides_raw = std::str::from_utf8(unpack_input(overrides_ptr, overrides_len)).unwrap_or("[]"); - let options = parse_module_overrides(overrides_raw); - - let report = if path_raw.trim().is_empty() { - lint_source_with_flavor(source, parse_flavor(flavor_raw)) - } else { - lint_source_with_flavor_at_path( - source, - Path::new(path_raw), - parse_flavor(flavor_raw), - options, - ) - }; - leak_bytes(lint_response_to_json(report)) + leak_bytes(lint_source_json_with_context_response( + source, + flavor_raw, + path_raw, + overrides_raw, + )) } #[unsafe(no_mangle)] @@ -613,8 +869,7 @@ pub extern "C" fn local_type_hints_json( Ok(value) => value, Err(err) => return leak_bytes(invalid_utf8_local_type_response("flavor", &err)), }; - let hints = local_type_hints_with_flavor(source, parse_flavor(flavor_raw)); - leak_bytes(local_type_hints_to_json(hints)) + leak_bytes(local_type_hints_json_response(source, flavor_raw)) } #[unsafe(no_mangle)] @@ -632,25 +887,19 @@ pub extern "C" fn local_type_hints_json_with_context( Ok(value) => value, Err(err) => return leak_bytes(invalid_utf8_local_type_response("source", &err)), }; - let flavor_raw = std::str::from_utf8(unpack_input(flavor_ptr, flavor_len)).unwrap_or("rss"); + let flavor_raw = match std::str::from_utf8(unpack_input(flavor_ptr, flavor_len)) { + Ok(value) => value, + Err(err) => return leak_bytes(invalid_utf8_local_type_response("flavor", &err)), + }; let path_raw = std::str::from_utf8(unpack_input(path_ptr, path_len)).unwrap_or(""); let overrides_raw = std::str::from_utf8(unpack_input(overrides_ptr, overrides_len)).unwrap_or("[]"); - let options = parse_module_overrides(overrides_raw); - - let hints = if path_raw.trim().is_empty() { - collect_inferred_local_type_hints_with_options(source, parse_flavor(flavor_raw), options) - .unwrap_or_default() - } else { - local_type_hints_with_flavor_at_path( - source, - Path::new(path_raw), - parse_flavor(flavor_raw), - options, - ) - }; - - leak_bytes(local_type_hints_to_json(hints)) + leak_bytes(local_type_hints_json_with_context_response( + source, + flavor_raw, + path_raw, + overrides_raw, + )) } #[cfg(feature = "runtime")] @@ -671,22 +920,17 @@ pub extern "C" fn run_source_json( Ok(value) => value, Err(err) => return leak_bytes(invalid_utf8_run_response("flavor", &err)), }; - let fuel_config = if options_len == 0 { - FuelConfig::default() + let options_json = if options_len == 0 { + None } else { - let options_json = match std::str::from_utf8(unpack_input(options_ptr, options_len)) { - Ok(value) => value, - Err(err) => return leak_bytes(invalid_utf8_run_response("options", &err)), - }; - match serde_json::from_str::(options_json) { - Ok(value) => value, - Err(err) => { - return leak_bytes(invalid_run_options_response(options_json, &err.to_string())); - } - } + Some( + match std::str::from_utf8(unpack_input(options_ptr, options_len)) { + Ok(value) => value, + Err(err) => return leak_bytes(invalid_utf8_run_response("options", &err)), + }, + ) }; - let report = start_run_source_with_flavor(source, parse_flavor(flavor_raw), fuel_config); - leak_bytes(run_response_to_json(report)) + leak_bytes(run_source_json_response(source, flavor_raw, options_json)) } #[cfg(feature = "runtime")] @@ -707,25 +951,17 @@ pub extern "C" fn debug_start_json( Ok(value) => value, Err(err) => return leak_bytes(invalid_utf8_debug_response("flavor", &err)), }; - let fuel_config = if options_len == 0 { - FuelConfig::default() + let options_json = if options_len == 0 { + None } else { - let options_json = match std::str::from_utf8(unpack_input(options_ptr, options_len)) { - Ok(value) => value, - Err(err) => return leak_bytes(invalid_utf8_debug_response("options", &err)), - }; - match serde_json::from_str::(options_json) { - Ok(value) => value, - Err(err) => { - return leak_bytes(invalid_debug_options_response( - options_json, - &err.to_string(), - )); - } - } + Some( + match std::str::from_utf8(unpack_input(options_ptr, options_len)) { + Ok(value) => value, + Err(err) => return leak_bytes(invalid_utf8_debug_response("options", &err)), + }, + ) }; - let report = start_debug_source_with_flavor(source, parse_flavor(flavor_raw), fuel_config); - leak_bytes(debug_response_to_json(report)) + leak_bytes(debug_start_json_response(source, flavor_raw, options_json)) } #[cfg(feature = "runtime")] @@ -785,6 +1021,209 @@ mod lint_tests { use serde_json::Value; use vm::{SourceFlavor, collect_inferred_local_type_hints}; + #[test] + fn frontend_request_helpers_preserve_rss_results_and_reject_unknown_selectors() { + let source = "let value = 1;"; + + let lint: Value = serde_json::from_slice(&super::lint_source_json_response(source, "rss")) + .expect("RSS lint response should be JSON"); + assert!(lint["diagnostics"].is_array()); + assert!( + lint["diagnostics"] + .as_array() + .unwrap() + .iter() + .all(|item| item["severity"] != "error") + ); + + let formatted: Value = + serde_json::from_slice(&super::format_source_json_response(source, "rustscript")) + .expect("RSS format response should be JSON"); + assert_eq!(formatted["ok"], true); + assert!(formatted["formatted"].as_str().is_some()); + let default_formatted: Value = + serde_json::from_slice(&super::format_source_json_response(source, "default")) + .expect("default frontend format response should be JSON"); + assert_eq!(default_formatted["ok"], true); + + let hints: Value = + serde_json::from_slice(&super::local_type_hints_json_response(source, "")) + .expect("RSS hints response should be JSON"); + assert!( + hints["hints"] + .as_array() + .unwrap() + .iter() + .any(|hint| hint["name"] == "value") + ); + + let contextual_lint: Value = serde_json::from_slice( + &super::lint_source_json_with_context_response(source, "rss", "", "[]"), + ) + .expect("contextual RSS lint response should be JSON"); + assert!(contextual_lint["diagnostics"].is_array()); + let lint_at_path: Value = + serde_json::from_slice(&super::lint_source_json_with_context_response( + source, + "rss", + "/virtual/main.rss", + "[]", + )) + .expect("path-aware RSS lint response should be JSON"); + assert!(lint_at_path["diagnostics"].is_array()); + + let contextual_hints: Value = serde_json::from_slice( + &super::local_type_hints_json_with_context_response(source, "rss", "", "[]"), + ) + .expect("contextual RSS hints response should be JSON"); + assert!( + contextual_hints["hints"] + .as_array() + .unwrap() + .iter() + .any(|hint| hint["name"] == "value") + ); + let hints_at_path: Value = + serde_json::from_slice(&super::local_type_hints_json_with_context_response( + source, + "rss", + "/virtual/main.rss", + "[]", + )) + .expect("path-aware RSS hints response should be JSON"); + assert!( + hints_at_path["hints"] + .as_array() + .unwrap() + .iter() + .any(|hint| hint["name"] == "value") + ); + + let rejected_lint: Value = + serde_json::from_slice(&super::lint_source_json_response(source, "opaque-frontend")) + .expect("rejected lint response should be JSON"); + assert!( + rejected_lint["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|item| { + item["severity"] == "error" + && item["message"] + .as_str() + .unwrap() + .contains("unsupported frontend") + }) + ); + + let rejected_contextual_lint: Value = serde_json::from_slice( + &super::lint_source_json_with_context_response(source, "opaque-frontend", "", "[]"), + ) + .expect("rejected contextual lint response should be JSON"); + assert!( + rejected_contextual_lint["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|item| item["severity"] == "error") + ); + + let rejected_format: Value = serde_json::from_slice(&super::format_source_json_response( + source, + "opaque-frontend", + )) + .expect("rejected format response should be JSON"); + assert_eq!(rejected_format["ok"], false); + assert!( + rejected_format["error"] + .as_str() + .unwrap() + .contains("unsupported frontend") + ); + + let rejected_hints: Value = serde_json::from_slice(&super::local_type_hints_json_response( + source, + "opaque-frontend", + )) + .expect("rejected hints response should be JSON"); + assert!(rejected_hints["hints"].as_array().unwrap().is_empty()); + assert!( + rejected_hints["error"] + .as_str() + .unwrap() + .contains("unsupported frontend") + ); + + let rejected_contextual_hints: Value = + serde_json::from_slice(&super::local_type_hints_json_with_context_response( + source, + "opaque-frontend", + "/virtual/main.rss", + "[]", + )) + .expect("rejected contextual hints response should be JSON"); + assert!( + rejected_contextual_hints["hints"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + rejected_contextual_hints["error"] + .as_str() + .unwrap() + .contains("unsupported frontend") + ); + } + + #[cfg(feature = "runtime")] + #[test] + fn runtime_request_helpers_execute_rss_and_reject_unknown_frontends_before_execution() { + let run: Value = serde_json::from_slice(&super::run_source_json_response( + "print(\"wasm-runtime-output\");", + "rss", + None, + )) + .expect("RSS run response should be JSON"); + assert_eq!(run["ok"], true); + assert!( + run["output"] + .as_array() + .unwrap() + .iter() + .any(|line| line == "wasm-runtime-output") + ); + + let rejected_run: Value = serde_json::from_slice(&super::run_source_json_response( + "print(\"must-not-run\");", + "opaque-frontend", + None, + )) + .expect("rejected run response should be JSON"); + assert_eq!(rejected_run["ok"], false); + assert!( + rejected_run["error"] + .as_str() + .unwrap() + .contains("unsupported frontend") + ); + assert!(rejected_run["output"].as_array().unwrap().is_empty()); + + let rejected_debug: Value = serde_json::from_slice(&super::debug_start_json_response( + "print(\"must-not-debug\");", + "opaque-frontend", + None, + )) + .expect("rejected debug response should be JSON"); + assert!( + rejected_debug["error"] + .as_str() + .unwrap() + .contains("unsupported frontend") + ); + assert!(rejected_debug["output"].as_array().unwrap().is_empty()); + } + #[test] fn lint_accepts_bytes_literals_and_native_bytes_helpers() { let source = r#" @@ -849,7 +1288,8 @@ mod lint_tests { label; "#; - let hints = super::local_type_hints_with_flavor(source, SourceFlavor::RustScript); + let hints = super::local_type_hints_with_flavor(source, SourceFlavor::RustScript) + .expect("type hints should succeed"); let label = hints .iter() .find(|hint| hint.name == "label") diff --git a/pd-vm-wasm/tests/fixtures/coherent_typehint.rs b/pd-vm-wasm/tests/fixtures/coherent_typehint.rs new file mode 100644 index 00000000..0fe995ac --- /dev/null +++ b/pd-vm-wasm/tests/fixtures/coherent_typehint.rs @@ -0,0 +1,156 @@ +use super::*; +#[test] +fn coherent_schema_json_preserves_optional_callable_named_and_module_byte_identity() { + let source = "// 中文\r\nuse self::lib as lib;\r\nlet optional: int? = null;\r\nlet value = lib::make();\r\nfn factory() { lib::make() }\r\nlet function = factory;\r\n"; + let library = "struct Box { value: int }\npub fn make() -> Box { { value: 42 } }\nlet local: int? = null;\n"; + let overrides = serde_json::json!([{"path": "./lib.rss", "source": library}]).to_string(); + let response: serde_json::Value = + serde_json::from_slice(&local_type_hints_json_with_context_response( + source, + "rustscript", + "/neutral/main.rss", + &overrides, + )) + .unwrap(); + assert!(response.get("error").is_none(), "{response}"); + let hints = response["hints"].as_array().unwrap(); + let optional = hints + .iter() + .find(|hint| hint["name"] == "optional") + .unwrap(); + assert_eq!( + optional["schema"], + serde_json::json!({"kind": "optional", "inner": {"kind": "int"}}) + ); + assert_eq!(optional["span"]["source_id"], 0); + let lo = optional["span"]["lo"].as_u64().unwrap() as usize; + let hi = optional["span"]["hi"].as_u64().unwrap() as usize; + assert_eq!(&source[lo..hi], "optional"); + assert_eq!(optional["source_name"], "/neutral/main.rss"); + let named = hints.iter().find(|hint| hint["name"] == "value").unwrap(); + assert_eq!(named["schema"]["kind"], "named"); + let function = hints + .iter() + .find(|hint| hint["name"] == "function") + .unwrap(); + assert_eq!(function["schema"]["kind"], "callable"); + assert_eq!(function["schema"]["result"], named["schema"]); + let dependency = hints.iter().find(|hint| hint["name"] == "local").unwrap(); + assert_eq!(dependency["span"]["source_id"], 1); + let lo = dependency["span"]["lo"].as_u64().unwrap() as usize; + let hi = dependency["span"]["hi"].as_u64().unwrap() as usize; + assert_eq!(&library[lo..hi], "local"); + assert_eq!(dependency["schema"], optional["schema"]); +} +#[test] +fn coherent_recursive_json_keeps_distinct_module_nominals_and_instantiated_callable_contracts() { + let source = "// 中文\r\nuse self::left::lib as left;\r\nuse self::right::lib as right;\r\nlet left_value = left::make();\r\nlet right_value = right::make();\r\nfn identity(value: T) -> T { value }\r\nlet specialized = identity::;\r\nlet nested: map<[int?]> = { \"one\": [null, 42] };\r\nlet tuple: [int?, fn(int) -> int] = [null, specialized];\r\n"; + let left = "struct Box { value: T }\npub fn make() -> Box { { value: 42 } }\n"; + let right = + "struct Box { value: T }\npub fn make() -> Box { { value: \"label\" } }\n"; + let overrides = serde_json::json!([ + {"path": "./left/lib.rss", "source": left}, + {"path": "./right/lib.rss", "source": right}, + ]) + .to_string(); + let response: serde_json::Value = + serde_json::from_slice(&local_type_hints_json_with_context_response( + source, + "rustscript", + "/neutral/main.rss", + &overrides, + )) + .unwrap(); + assert!(response.get("error").is_none(), "{response}"); + let hints = response["hints"].as_array().unwrap(); + let hint = |name| hints.iter().find(|hint| hint["name"] == name).unwrap(); + let left_schema = &hint("left_value")["schema"]; + let right_schema = &hint("right_value")["schema"]; + assert_eq!(left_schema["kind"], "named"); + assert_eq!(right_schema["kind"], "named"); + assert_ne!(left_schema["name"], right_schema["name"]); + assert_eq!(left_schema["args"], serde_json::json!([{"kind": "int"}])); + assert_eq!( + right_schema["args"], + serde_json::json!([{"kind": "string"}]) + ); + let callable = serde_json::json!({"kind": "callable", "params": [{"kind": "int"}], "result": {"kind": "int"}}); + assert_eq!(hint("specialized")["schema"], callable); + assert_eq!( + hint("nested")["schema"], + serde_json::json!({"kind": "map", "item": {"kind": "array", "item": {"kind": "optional", "inner": {"kind": "int"}}}}) + ); + assert_eq!( + hint("tuple")["schema"], + serde_json::json!({"kind": "tuple", "items": [{"kind": "optional", "inner": {"kind": "int"}}, callable]}) + ); + for name in [ + "left_value", + "right_value", + "specialized", + "nested", + "tuple", + ] { + let span = &hint(name)["span"]; + assert_eq!(span["source_id"], 0); + assert_eq!( + &source[span["lo"].as_u64().unwrap() as usize..span["hi"].as_u64().unwrap() as usize], + name + ); + } +} + +#[test] +fn coherent_inferred_callable_json_keeps_optional_results_and_per_call_generics() { + let source = "// 中文\r\nfn maybe(value: int?) { value }\r\nlet g = maybe;\r\nlet closure: fn(int?) -> int? = |value| value;\r\nfn answer() { 42 }\r\nlet f = answer;\r\nfn identity(value: T) -> T { value }\r\nlet output = identity::(41);\r\ng(null);\r\n"; + let response: serde_json::Value = + serde_json::from_slice(&local_type_hints_json_with_context_response( + source, + "rustscript", + "/neutral/main.rss", + "[]", + )) + .unwrap(); + assert!(response.get("error").is_none(), "{response}"); + let hints = response["hints"].as_array().unwrap(); + let hint = |name| hints.iter().find(|hint| hint["name"] == name).unwrap(); + let optional = serde_json::json!({"kind": "optional", "inner": {"kind": "int"}}); + let callable = + serde_json::json!({"kind": "callable", "params": [optional.clone()], "result": optional}); + assert_eq!(hint("g")["schema"], callable); + assert_eq!(hint("closure")["schema"], callable); + assert_eq!( + hint("f")["schema"], + serde_json::json!({"kind":"callable", "params":[], "result":{"kind":"int"}}) + ); + assert_eq!(hint("output")["schema"], serde_json::json!({"kind":"int"})); + for name in ["g", "closure", "f", "output"] { + let span = &hint(name)["span"]; + assert_eq!(span["source_id"], 0); + assert_eq!( + &source[span["lo"].as_u64().unwrap() as usize..span["hi"].as_u64().unwrap() as usize], + name + ); + assert_eq!(hint(name)["source_name"], "/neutral/main.rss"); + } +} + +#[test] +fn coherent_typehint_json_preserves_errors_and_existing_scalar_fields() { + let response: serde_json::Value = serde_json::from_slice(&local_type_hints_json_response( + "let value = 42;", + "rustscript", + )) + .unwrap(); + let hint = &response["hints"][0]; + assert_eq!(hint["name"], "value"); + assert_eq!(hint["inferred_type"], "int"); + assert_eq!(hint["declared_line"], 1); + let response: serde_json::Value = serde_json::from_slice(&local_type_hints_json_response( + "let value: unknown = 42;", + "rustscript", + )) + .unwrap(); + assert_eq!(response["hints"], serde_json::json!([])); + assert!(response["error"].is_string()); +} diff --git a/src/builtins/runtime/http/resources.rs b/src/builtins/runtime/http/resources.rs index 0a3e3c88..39ce643d 100644 --- a/src/builtins/runtime/http/resources.rs +++ b/src/builtins/runtime/http/resources.rs @@ -470,13 +470,16 @@ mod tests { .push_resource(HttpHeaders(Vec::new())) .unwrap(); let raw = Value::Int(summary.handle().raw() as i64); - assert_eq!(sse_outcome(&mut vm, &[raw.clone()]).unwrap(), "eof"); + assert_eq!( + sse_outcome(&mut vm, std::slice::from_ref(&raw)).unwrap(), + "eof" + ); assert!(sse_outcome(&mut vm, &[Value::Int(wrong.handle().raw() as i64)]).is_err()); assert!(sse_headers(&mut vm, &[Value::Int(wrong.handle().raw() as i64)]).is_err()); vm.host_context() .close_resource::(summary.handle(), ResourceCloseReason::Requested) .unwrap(); - assert!(sse_outcome(&mut vm, &[raw.clone()]).is_err()); + assert!(sse_outcome(&mut vm, std::slice::from_ref(&raw)).is_err()); assert!(sse_headers(&mut vm, &[raw]).is_err()); } @@ -505,7 +508,7 @@ mod tests { vec![Value::string("first"), Value::string("second")], ); assert_eq!( - headers_names(&mut vm, &[handle.clone()]).unwrap(), + headers_names(&mut vm, std::slice::from_ref(&handle)).unwrap(), vec![Value::string("x-repeat"), Value::string("x-repeat")] ); assert!(matches!( diff --git a/src/cli.rs b/src/cli.rs index d6aa2831..7336552d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,6 +16,10 @@ use crate::{HostFunctionRegistry, HostImport}; use rustyline::DefaultEditor; use rustyline::error::ReadlineError; +#[cfg(test)] +#[path = "cli_capture_certification_tests.rs"] +mod capture_certification_tests; + pub struct CliRuntime { pub binary_name: &'static str, pub default_source: &'static str, @@ -32,6 +36,106 @@ impl Default for CliRuntime { } } +type VmInitializer<'a> = Box crate::VmResult<()> + 'a>; + +/// Embedding-owned compiler catalog and fresh registry factory. +/// +/// The factory is called once for each VM. Return a fresh registry to keep its +/// binding-plan cache scoped to that VM; cloning a shared registry deliberately +/// retains the caller's existing cache and handler captures. Initialization runs +/// after exact binding and before any guest code. Failure drops the entire VM, +/// including partially initialized resources. This configuration owns no global state. +pub struct CliHostServices<'a> { + catalog: Option>, + registry_factory: Box crate::VmResult + 'a>, + vm_init: VmInitializer<'a>, + legacy: bool, + source_capture_certification: bool, +} + +impl<'a> CliHostServices<'a> { + pub fn new( + catalog: std::sync::Arc, + registry_factory: impl Fn() -> crate::VmResult + 'a, + ) -> Self { + Self { + catalog: Some(catalog), + registry_factory: Box::new(registry_factory), + vm_init: Box::new(|_| Ok(())), + legacy: false, + source_capture_certification: false, + } + } + + pub fn with_vm_init(mut self, init: impl Fn(&mut Vm) -> crate::VmResult<()> + 'a) -> Self { + self.vm_init = Box::new(init); + self + } + + /// Retain compiler-issued capture facts when executing source files. + /// + /// Admission still follows each source producer's ownership policy. REPL, + /// VMBC and standalone AOT remain uncertified; successful companion AOT + /// loading retains its existing authority revocation. A native frame may + /// still lack a checked capture relation; retaining source facts never + /// fabricates runtime owner authority. Defaults are unchanged. + pub fn with_capture_certification(mut self) -> Self { + self.source_capture_certification = true; + self + } + + /// Explicit options must describe the same catalog; they are never merged or replaced. + pub fn compile_options( + &self, + mut options: CompileSourceFileOptions, + ) -> Result { + if let Some(catalog) = &self.catalog { + if let Some(explicit) = options.host_api_catalog() + && explicit.as_ref() != catalog.as_ref() + { + return Err(io::Error::other( + "CLI compiler catalog conflicts with host services", + )); + } + options.set_host_api_catalog(std::sync::Arc::clone(catalog)); + } + Ok(options) + } + + fn initialize_vm(&self, vm: &mut Vm) -> Result<(), io::Error> { + if self.legacy { + let imports = vm.program().imports.clone(); + register_imports(vm, &imports)?; + } else { + if let Some(catalog) = &self.catalog { + for schema in vm.program().host_import_schemas.iter().flatten() { + if !crate::catalog_import_schemas(catalog, &schema.name).contains(schema) { + return Err(io::Error::other(format!( + "host import '{}' conflicts with CLI services catalog", + schema.name + ))); + } + } + } + let registry = (self.registry_factory)().map_err(io::Error::other)?; + registry.bind_vm_cached(vm).map_err(io::Error::other)?; + } + (self.vm_init)(vm).map_err(io::Error::other) + } +} + +impl Default for CliHostServices<'_> { + fn default() -> Self { + Self { + catalog: None, + registry_factory: Box::new(|| Ok(cli_host_registry().clone())), + vm_init: Box::new(|_| Ok(())), + legacy: true, + source_capture_certification: false, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct CliConfig { source: Option, @@ -94,16 +198,29 @@ impl Default for CliConfig { } pub fn main(runtime: CliRuntime) -> Result<(), Box> { - if let Err(err) = run_main(&runtime) { + main_with_services(runtime, CliHostServices::default()) +} + +/// Process entrypoint with embedding-owned host services; retains CLI exit behavior. +pub fn main_with_services( + runtime: CliRuntime, + services: CliHostServices<'_>, +) -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + if let Err(err) = run_with_args(&runtime, &args, &services) { eprintln!("{err}"); std::process::exit(1); } Ok(()) } -fn run_main(runtime: &CliRuntime) -> Result<(), Box> { - let args: Vec = std::env::args().skip(1).collect(); - let cli = parse_cli_args(&args).map_err(io::Error::other)?; +/// Run explicit CLI arguments without terminating the embedding process. +pub fn run_with_args( + runtime: &CliRuntime, + args: &[String], + services: &CliHostServices<'_>, +) -> Result<(), Box> { + let cli = parse_cli_args(args).map_err(io::Error::other)?; if cli.version { println!("{}", binary_version_text(runtime.binary_name)); return Ok(()); @@ -112,11 +229,12 @@ fn run_main(runtime: &CliRuntime) -> Result<(), Box> { print_usage(runtime.binary_name); return Ok(()); } + let options = services.compile_options((runtime.compile_options)())?; if cli.fmt { - return run_fmt(&cli, runtime); + return run_fmt(&cli, runtime, &options); } if cli.repl { - return run_repl(); + return run_repl_with_options(options, services); } if let Some(input_path) = cli.disasm_vmbc_path.as_ref() { let bytes = std::fs::read(input_path)?; @@ -135,7 +253,7 @@ fn run_main(runtime: &CliRuntime) -> Result<(), Box> { return Ok(()); } - if let Some(mut vm) = try_new_cli_vm_from_standalone_aot(&cli)? { + if let Some(mut vm) = try_new_cli_vm_from_standalone_aot_with_services(&cli, services)? { if let Some(output_path) = cli.emit_vmbc_path.as_ref() { let encoded = encode_program(vm.program())?; std::fs::write(output_path, &encoded)?; @@ -143,7 +261,6 @@ fn run_main(runtime: &CliRuntime) -> Result<(), Box> { return Ok(()); } - apply_runtime_flags(&mut vm, &cli)?; run_vm_loop(&mut vm, None, cli.fuel)?; if cli.aot_dump { println!("{}", vm.dump_aot_info()); @@ -158,19 +275,40 @@ fn run_main(runtime: &CliRuntime) -> Result<(), Box> { } let source_path = resolve_source_path(cli.source.as_deref(), runtime.default_source)?; - let compiled = compile_source_file_with_options(&source_path, (runtime.compile_options)()) + // Legacy emit is compilation-only and does not require runtime adapters. + if services.legacy + && let Some(output_path) = cli.emit_vmbc_path.as_ref() + { + let program = load_ordinary_cli_program(&source_path, options)?; + let encoded = encode_program(&program)?; + std::fs::write(output_path, &encoded)?; + println!("wrote {} bytes to {}", encoded.len(), output_path); + return Ok(()); + } + let mut vm = if services.source_capture_certification + && source_path.extension().is_none_or(|ext| ext != "vmbc") + { + let issued = crate::compile_source_file_with_capture_certification_and_options( + &source_path, + options, + ) .map_err(|err| io::Error::other(render_source_path_error(&source_path, &err)))?; + let mut vm = issued.into_vm().map_err(io::Error::other)?; + vm.set_jit_config(cli_jit_config(&cli)); + configure_cli_vm(&mut vm); + vm + } else { + new_cli_vm(load_ordinary_cli_program(&source_path, options)?, &cli) + }; + let recording_program = cli.record_path.as_ref().map(|_| vm.program().clone()); + apply_runtime_flags(&mut vm, &cli)?; + services.initialize_vm(&mut vm)?; if let Some(output_path) = cli.emit_vmbc_path.as_ref() { - let encoded = encode_program(&compiled.program)?; + let encoded = encode_program(vm.program())?; std::fs::write(output_path, &encoded)?; println!("wrote {} bytes to {}", encoded.len(), output_path); return Ok(()); } - let recording_program = cli.record_path.as_ref().map(|_| compiled.program.clone()); - let mut vm = new_cli_vm(compiled.program.with_local_count(compiled.locals), &cli); - apply_runtime_flags(&mut vm, &cli)?; - let imports = vm.program().imports.clone(); - register_imports(&mut vm, &imports)?; prepare_aot_for_cli(&mut vm, &cli)?; if let Some(record_path) = cli.record_path.as_ref() { @@ -217,7 +355,28 @@ fn run_main(runtime: &CliRuntime) -> Result<(), Box> { Ok(()) } +fn load_ordinary_cli_program( + source_path: &Path, + options: CompileSourceFileOptions, +) -> Result> { + if source_path.extension().is_some_and(|ext| ext == "vmbc") { + Ok(crate::decode_program(&std::fs::read(source_path)?)?) + } else { + let compiled = compile_source_file_with_options(source_path, options) + .map_err(|err| io::Error::other(render_source_path_error(source_path, &err)))?; + Ok(compiled.program.with_local_count(compiled.locals)) + } +} + +#[cfg(test)] fn try_new_cli_vm_from_standalone_aot(cli: &CliConfig) -> Result, io::Error> { + try_new_cli_vm_from_standalone_aot_with_services(cli, &CliHostServices::default()) +} + +fn try_new_cli_vm_from_standalone_aot_with_services( + cli: &CliConfig, + services: &CliHostServices<'_>, +) -> Result, io::Error> { let Some(path) = cli.aot_load_path.as_deref() else { return Ok(None); }; @@ -228,8 +387,8 @@ fn try_new_cli_vm_from_standalone_aot(cli: &CliConfig) -> Result, io: let mut vm = Vm::new_from_aot_artifact_file_with_jit_config(path, cli_jit_config(cli)) .map_err(io::Error::other)?; configure_cli_vm(&mut vm); - let imports = vm.program().imports.clone(); - register_imports(&mut vm, &imports)?; + apply_runtime_flags(&mut vm, cli)?; + services.initialize_vm(&mut vm)?; if let Some(save_path) = cli.aot_save_path.as_deref() { vm.save_aot_artifact_to_file(save_path) @@ -405,7 +564,11 @@ fn render_format_path_error(source_path: &Path, source: &str, err: &vm::FormatEr } } -fn run_fmt(cli: &CliConfig, runtime: &CliRuntime) -> Result<(), Box> { +fn run_fmt( + cli: &CliConfig, + runtime: &CliRuntime, + options: &CompileSourceFileOptions, +) -> Result<(), Box> { let source_arg = cli .source .as_deref() @@ -413,8 +576,7 @@ fn run_fmt(cli: &CliConfig, runtime: &CliRuntime) -> Result<(), Box String { } } -fn run_repl() -> Result<(), Box> { +/// Run the supported RustScript REPL with caller options and per-VM host services. +/// Guest callable locals retain the existing program-replacement invalidation rule. +pub fn run_repl_with_options( + options: CompileSourceFileOptions, + services: &CliHostServices<'_>, +) -> Result<(), Box> { + let options = services.compile_options(options)?; println!("pd-vm REPL (RustScript)"); println!("features: {}", cli_build_feature_summary()); println!("history: up/down arrows, commands: .help, .quit, .cancel"); @@ -1010,13 +1178,14 @@ fn run_repl() -> Result<(), Box> { } let _ = editor.add_history_entry(&snippet); - let compiled = match compile_repl_snippet(&snippet, &session.locals) { - Ok(compiled) => compiled, - Err(err) => { - println!("{}", render_repl_compile_error(&snippet, &err)); - continue; - } - }; + let compiled = + match compile_repl_snippet_with_options(&snippet, &session.locals, &options) { + Ok(compiled) => compiled, + Err(err) => { + println!("{}", render_repl_compile_error(&snippet, &err)); + continue; + } + }; let moved_by_rebinding = repl_locals_moved_by_rebinding(&compiled.compiled.program, &session.locals); let no_repl_moves = BTreeSet::new(); @@ -1028,8 +1197,7 @@ fn run_repl() -> Result<(), Box> { JitConfig::default(), ); configure_cli_vm(&mut vm); - let imports = vm.program().imports.clone(); - if let Err(err) = register_imports(&mut vm, &imports) { + if let Err(err) = services.initialize_vm(&mut vm) { println!("{err}"); continue; } @@ -1238,7 +1406,13 @@ fn sync_repl_session( if repl_value_contains_callable(value) { continue; } - let (schema, optional) = repl_local_schema_from_vm(vm, index as usize, value); + let (schema, optional) = if binding.schema.is_some() { + (binding.schema.clone(), binding.optional) + } else { + // Legacy callers can supply bindings without checked schema facts. + let (schema, optional) = repl_local_schema_from_vm(vm, index as usize, value); + (schema, optional || binding.optional) + }; let moved = moved_by_rebinding.contains(&binding.name) || (!optional && value == &Value::Null @@ -1289,9 +1463,18 @@ fn handle_repl_command(line: &str) -> Option { } } +#[cfg(test)] fn compile_repl_snippet( input: &str, locals: &BTreeMap, +) -> Result { + compile_repl_snippet_with_options(input, locals, &CompileSourceFileOptions::default()) +} + +fn compile_repl_snippet_with_options( + input: &str, + locals: &BTreeMap, + options: &CompileSourceFileOptions, ) -> Result { let trimmed = input.trim_end(); let bindings = locals @@ -1306,14 +1489,22 @@ fn compile_repl_snippet( moved: local.moved, }) .collect::>(); - match vm::compiler::compile_source_for_repl_with_state(trimmed, &bindings) { + match vm::compiler::compile_source_for_repl_with_state_and_options( + trimmed, + &bindings, + options.clone(), + ) { Ok(compiled) => Ok(compiled), Err(first_err) => { if trimmed.ends_with(';') { return Err(first_err); } let fallback = format!("{trimmed};"); - match vm::compiler::compile_source_for_repl_with_state(&fallback, &bindings) { + match vm::compiler::compile_source_for_repl_with_state_and_options( + &fallback, + &bindings, + options.clone(), + ) { Ok(compiled) => Ok(compiled), Err(err @ vm::SourceError::Parse(vm::ParseError { code: Some(_), .. })) => Err(err), Err(err @ vm::SourceError::Compile(_)) => Err(err), @@ -1706,6 +1897,567 @@ mod tests { vm } + #[test] + fn root_result_join_float_production_sync_and_nullable_effects() { + for (source, value, effect) in [ + ( + "let maybe: float? = 2.5; let y = if true => { maybe } else => { null };", + Value::Float(2.5), + false, + ), + ( + "let flag = true; let mut x = 1; let maybe: float? = 3.5; let y = if flag => { x = 2.5; maybe } else => { x = 2.5; null };", + Value::Float(3.5), + true, + ), + ( + "let flag = false; let mut x = 1; let maybe: float? = 3.5; let y = if flag => { x = 2.5; maybe } else => { x = 2.5; null };", + Value::Null, + true, + ), + ] { + let mut session = super::ReplSession::default(); + run_repl_snippet_and_sync(&mut session, source); + assert_eq!(session.locals["y"].schema, Some(crate::TypeSchema::Float)); + assert!(session.locals["y"].optional); + assert_eq!(session.locals["y"].value, value); + if effect { + assert_eq!(session.locals["x"].schema, Some(crate::TypeSchema::Float)); + assert!(!session.locals["x"].optional); + let next = + run_repl_snippet_and_sync(&mut session, "let checked: float = x; checked;"); + assert_eq!(next.stack(), &[Value::Float(2.5)]); + } + let next = run_repl_snippet_and_sync(&mut session, "let following = y; following;"); + assert_eq!(next.stack(), &[value]); + assert_eq!( + session.locals["following"].schema, + Some(crate::TypeSchema::Float) + ); + assert!(session.locals["following"].optional); + } + } + + #[test] + fn root_result_join_named_production_sync_keeps_host_identity() { + let named = crate::HostStructSchema::new( + "ResultJoinRoot", + vec![crate::HostStructField::new( + "value", + crate::HostTypeSchema::Int, + )], + ); + let mut builder = crate::HostApiBuilder::new(); + builder.named_struct(named.clone()); + builder.function(crate::HostFunctionSchema::with_return( + "resultjoin::origin", + vec![], + crate::HostTypeSchema::Optional(Box::new(named.as_type())), + )); + let catalog = std::sync::Arc::new(builder.build().unwrap()); + let options = CompileSourceFileOptions::default() + .with_host_api_catalog(std::sync::Arc::clone(&catalog)); + fn origin(_: &mut Vm, _: &[Value]) -> crate::VmResult { + Ok(crate::CallOutcome::Return(crate::return_one(Value::map( + vec![(Value::string("value"), Value::Int(42))], + )))) + } + let mut registry = crate::HostFunctionRegistry::new(); + registry + .register_catalog_static( + crate::catalog_import_schemas(&catalog, "resultjoin::origin").remove(0), + origin, + ) + .unwrap(); + for (expression, flag, is_null) in [ + ("if flag => { maybe } else => { null }", true, false), + ("if flag => { null } else => { maybe }", false, false), + ("if flag => { maybe } else => { null }", false, true), + ("if flag => { null } else => { maybe }", true, true), + ("match choice { 1 => maybe, _ => null }", true, false), + ("match choice { 1 => null, _ => maybe }", false, false), + ] { + let source = format!( + "let flag = {flag}; let choice = if flag => {{ 1 }} else => {{ 2 }}; let maybe: ResultJoinRoot? = resultjoin::origin(); let y = {expression};" + ); + let mut session = super::ReplSession::default(); + for snippet in [&source, "let following = y; following == null;"] { + let compiled = + super::compile_repl_snippet_with_options(snippet, &session.locals, &options) + .unwrap(); + let moved = super::repl_locals_moved_by_rebinding( + &compiled.compiled.program, + &session.locals, + ); + let mut machine = Vm::new(compiled.compiled.program); + registry.bind_vm_cached(&mut machine).unwrap(); + super::seed_repl_vm_locals(&mut machine, &session.locals).unwrap(); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + super::sync_repl_session(&machine, &compiled.bindings, &moved, &mut session); + let name = if snippet == source { "y" } else { "following" }; + assert_eq!( + session.locals[name].schema, + Some(crate::TypeSchema::Named("ResultJoinRoot".into(), vec![])) + ); + assert!(session.locals[name].optional); + assert_eq!(session.locals[name].value == Value::Null, is_null); + if name == "following" { + assert_eq!(machine.stack(), &[Value::Bool(is_null)]); + } + } + } + } + + #[test] + fn root_expression_effects_conditional_float_binding_session_and_next_snippet() { + let source = + "let mut x = 1; let ignored = if true => { x = 2.5; 0 } else => { x = 2.5; 0 };"; + let mut session = super::ReplSession::default(); + let compiled = super::compile_repl_snippet(source, &session.locals).unwrap(); + let binding = compiled.bindings.iter().find(|b| b.name == "x").unwrap(); + let schema = binding.schema.clone(); + #[cfg(all(feature = "cranelift-jit", target_arch = "x86_64"))] + { + let mut native = Vm::new(compiled.compiled.program.clone()); + native.compile_aot().unwrap(); + assert!(native.has_aot_program()); + assert_eq!(native.run().unwrap(), VmStatus::Halted); + let slot = native.debug_info().unwrap().local_index("x").unwrap() as usize; + assert_eq!(native.locals()[slot], Value::Float(2.5)); + assert!(native.aot_exec_count() > 0); + } + let moved = + super::repl_locals_moved_by_rebinding(&compiled.compiled.program, &session.locals); + let mut machine = Vm::new(compiled.compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + let slot = machine.debug_info().unwrap().local_index("x").unwrap() as usize; + assert_eq!(machine.locals()[slot], Value::Float(2.5)); + super::sync_repl_session(&machine, &compiled.bindings, &moved, &mut session); + let next = super::compile_repl_snippet("let y: float = x; y;", &session.locals); + println!( + "root expression effects: returned={schema:?}, synced={:?}, value={:?}, next={:?}", + session.locals["x"].schema, + session.locals["x"].value, + next.as_ref() + .map(|_| "compiled") + .map_err(|e| format!("{e:?}")) + ); + assert_eq!(schema, Some(crate::TypeSchema::Float)); + assert_eq!(session.locals["x"].schema, Some(crate::TypeSchema::Float)); + assert!(!session.locals["x"].optional); + assert!(session.locals["x"].mutable); + assert!(!session.locals["x"].moved); + assert!(next.is_ok()); + let next_vm = run_repl_snippet_and_sync(&mut session, "let y: float = x; y;"); + assert_eq!(next_vm.stack(), &[Value::Float(2.5)]); + assert_eq!(session.locals["y"].value, Value::Float(2.5)); + } + + #[test] + fn root_expression_effects_nested_blocks_and_shadow_slots() { + for (source, expected) in [ + ( + "let mut x = 1; let ignored = if true => { let inner = if true => { x = 2.5; 0 } else => { x = 2.5; 0 }; 0 } else => { x = 2.5; 0 };", + crate::TypeSchema::Float, + ), + ( + "let mut x = 1; let ignored = if true => { let inner = 0; if true => { x = 2.5; 0 } else => { x = 2.5; 0 } } else => { x = 2.5; 0 };", + crate::TypeSchema::Float, + ), + ( + "let mut x = 1; let ignored = if true => { let mut local = 2.0; local = 2.5; 0 } else => { 0 };", + crate::TypeSchema::Int, + ), + ] { + let mut session = super::ReplSession::default(); + let compiled = super::compile_repl_snippet(source, &session.locals).unwrap(); + let x = compiled.bindings.iter().find(|b| b.name == "x").unwrap(); + assert_eq!(x.schema, Some(expected.clone()), "{source}"); + // Scope-local observations must not change the existing root. + run_repl_snippet_and_sync(&mut session, source); + assert_eq!(session.locals["x"].schema, Some(expected.clone())); + let next = if expected == crate::TypeSchema::Float { + "let y: float = x; y;" + } else { + "let y: int = x; y;" + }; + let machine = run_repl_snippet_and_sync(&mut session, next); + let value = if expected == crate::TypeSchema::Float { + Value::Float(2.5) + } else { + Value::Int(1) + }; + assert_eq!(machine.stack(), &[value]); + } + } + + #[test] + fn root_expression_effects_initializer_assignment_and_arguments_are_ordered() { + for source in [ + "let mut x = 1; let y = if true => { x = 2.5; x } else => { x = 2.5; x };", + "let mut x = 1; let mut y = 0.5; y = if true => { x = 2.5; x } else => { x = 2.5; x };", + "let mut x = 1; fn f(a: int, b: int) -> int { a + b } let ignored = f(if true => { x = 2.5; 0 } else => { x = 2.5; 0 }, if true => { x = 3.5; 0 } else => { x = 3.5; 0 }); let y = if true => { x = 2.5; x } else => { x = 2.5; x };", + ] { + let mut session = super::ReplSession::default(); + let compiled = super::compile_repl_snippet(source, &session.locals).unwrap(); + for name in ["x", "y"] { + assert_eq!( + compiled + .bindings + .iter() + .find(|b| b.name == name) + .unwrap() + .schema, + Some(crate::TypeSchema::Float), + "{source}: {name}" + ); + } + run_repl_snippet_and_sync(&mut session, source); + assert_eq!(session.locals["x"].value, Value::Float(2.5)); + assert_eq!(session.locals["y"].value, Value::Float(2.5)); + let machine = run_repl_snippet_and_sync(&mut session, "let z: float = y; z;"); + assert_eq!(machine.stack(), &[Value::Float(2.5)]); + } + } + + #[test] + fn root_expression_effects_preserve_evaluated_operands_before_later_writes() { + let source = + "let mut x = 1; let y = x + (if true => { x = 2.5; 1 } else => { x = 2.5; 1 });"; + let mut session = super::ReplSession::default(); + let compiled = super::compile_repl_snippet(source, &session.locals).unwrap(); + assert_eq!( + compiled + .bindings + .iter() + .find(|b| b.name == "x") + .unwrap() + .schema, + Some(crate::TypeSchema::Float) + ); + assert_eq!( + compiled + .bindings + .iter() + .find(|b| b.name == "y") + .unwrap() + .schema, + Some(crate::TypeSchema::Int) + ); + run_repl_snippet_and_sync(&mut session, source); + assert_eq!(session.locals["x"].value, Value::Float(2.5)); + assert_eq!(session.locals["y"].value, Value::Int(2)); + let machine = run_repl_snippet_and_sync(&mut session, "let z: int = y; z;"); + assert_eq!(machine.stack(), &[Value::Int(2)]); + } + + #[test] + fn root_expression_effects_optional_named_alternatives_remain_checked() { + for assign in [ + "let ignored = if true => { x = 2.5; 0 } else => { x = 2.5; 0 };", + "let ignored = if true => { x = 2.5; 0 } else => { 0 };", + "let ignored = if false => { x = 2.5; 0 } else => { 0 };", + ] { + let source = format!("let mut x: float? = null; {assign}"); + let mut session = super::ReplSession::default(); + let compiled = super::compile_repl_snippet(&source, &session.locals).unwrap(); + let x = compiled.bindings.iter().find(|b| b.name == "x").unwrap(); + assert_eq!(x.schema, Some(crate::TypeSchema::Float)); + assert!(x.optional); + run_repl_snippet_and_sync(&mut session, &source); + assert_eq!(session.locals["x"].schema, Some(crate::TypeSchema::Float)); + assert!(session.locals["x"].optional); + let value = session.locals["x"].value.clone(); + run_repl_snippet_and_sync(&mut session, "let y: float? = x;"); + assert_eq!(session.locals["y"].value, value); + } + for condition in ["true", "false"] { + let source = format!( + "struct EffectRoot {{ value: int }} let mut x: EffectRoot? = null; let ignored = if {condition} => {{ x = {{ value: 41 }}; 0 }} else => {{ 0 }};" + ); + let mut session = super::ReplSession::default(); + let compiled = super::compile_repl_snippet(&source, &session.locals).unwrap(); + let x = compiled.bindings.iter().find(|b| b.name == "x").unwrap(); + let control = super::compile_repl_snippet( + "struct EffectRoot { value: int } let mut x: EffectRoot? = null;", + &BTreeMap::new(), + ) + .unwrap(); + let checked_named = control + .bindings + .iter() + .find(|b| b.name == "x") + .unwrap() + .schema + .clone(); + assert!( + matches!(&checked_named, Some(crate::TypeSchema::Named(name, args)) if !name.is_empty() && args.is_empty()) + ); + assert_eq!(x.schema, checked_named); + assert!(x.optional); + run_repl_snippet_and_sync(&mut session, &source); + assert_eq!(session.locals["x"].schema, x.schema); + assert!(session.locals["x"].optional); + run_repl_snippet_and_sync(&mut session, "x == null;"); + } + } + + #[test] + fn root_expression_effects_keep_named_field_literal_key_schema() { + let source = "struct EffectRoot { value: int } let root: EffectRoot = { value: 41 }; let field = root?.value;"; + let mut session = super::ReplSession::default(); + let compiled = super::compile_repl_snippet(source, &session.locals).unwrap(); + let field = compiled + .bindings + .iter() + .find(|b| b.name == "field") + .unwrap(); + assert_eq!( + field.schema.as_ref().map(|s| s.split_optional().0), + Some(crate::TypeSchema::Int) + ); + assert!(field.optional); + run_repl_snippet_and_sync(&mut session, source); + assert_eq!(session.locals["field"].value, Value::Int(41)); + assert!(session.locals["field"].optional); + let machine = + run_repl_snippet_and_sync(&mut session, "let y: int? = field; y.unwrap_or(0);"); + assert_eq!(machine.stack(), &[Value::Int(41)]); + } + + #[test] + fn root_expression_effects_declared_violation_denies_guest_effects() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + static CALLS: AtomicUsize = AtomicUsize::new(0); + fn hit(_: &mut Vm, _: &[Value]) -> crate::VmResult { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(crate::CallOutcome::Return(crate::return_one(Value::Int(0)))) + } + let mut builder = crate::HostApiBuilder::new(); + builder.function(crate::HostFunctionSchema::with_return( + "effects::hit", + vec![], + crate::HostTypeSchema::Int, + )); + let catalog = Arc::new(builder.build().unwrap()); + let options = + CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)); + let mut registry = crate::HostFunctionRegistry::new(); + registry + .register_catalog_static( + crate::catalog_import_schemas(&catalog, "effects::hit").remove(0), + hit, + ) + .unwrap(); + let source = "let mut x: int = 1; let ignored = if true => { effects::hit(); x = 2.5; 0 } else => { effects::hit(); x = 2.5; 0 };"; + let result = super::compile_repl_snippet_with_options(source, &BTreeMap::new(), &options); + if let Ok(compiled) = &result { + let mut machine = Vm::new(compiled.compiled.program.clone()); + registry.bind_vm_cached(&mut machine).unwrap(); + machine.run().unwrap(); + } + assert!( + result.is_err(), + "declared schema violation must reject compilation" + ); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); + } + + #[test] + fn final_layout_repl_sync_preserves_checked_named_optional_state() { + use crate::{ + HostApiBuilder, HostFunctionRegistry, HostFunctionSchema, HostStructField, + HostStructSchema, HostTypeSchema, + }; + let named = HostStructSchema::new( + "RootNamedOptional", + vec![HostStructField::new("value", HostTypeSchema::Int)], + ); + let mut builder = HostApiBuilder::new(); + builder.named_struct(named.clone()); + builder.function(HostFunctionSchema::with_return( + "layout::root", + vec![], + named.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "layout::maybe", + vec![], + HostTypeSchema::Optional(Box::new(named.as_type())), + )); + builder.function(HostFunctionSchema::with_return( + "layout::read", + vec![crate::HostParamSchema::value("root", named.as_type())], + HostTypeSchema::Int, + )); + let catalog = std::sync::Arc::new(builder.build().unwrap()); + let options = CompileSourceFileOptions::default().with_host_api_catalog(std::sync::Arc::clone(&catalog)) + .with_module_override_source("calibration.rss", "let a = 101; let b = 102; let c = 103; let d = 104; let e = 105; let f = 106; let g = 107; let h = 108; let i = 109; pub fn value() -> int { 1 }"); + fn root(_: &mut Vm, _: &[Value]) -> crate::VmResult { + Ok(crate::CallOutcome::Return(crate::return_one(Value::map( + vec![(Value::string("value"), Value::Int(41))], + )))) + } + fn maybe(_: &mut Vm, _: &[Value]) -> crate::VmResult { + Ok(crate::CallOutcome::Return(crate::return_one(Value::Null))) + } + fn read(_: &mut Vm, args: &[Value]) -> crate::VmResult { + let Value::Map(fields) = &args[0] else { + return Err(crate::VmError::TypeMismatch("RootNamedOptional")); + }; + Ok(crate::CallOutcome::Return(crate::return_one( + fields.get(&Value::string("value")).unwrap().clone(), + ))) + } + let mut registry = HostFunctionRegistry::new(); + for (name, handler) in [ + ("layout::root", root as crate::StaticHostFunction), + ("layout::maybe", maybe), + ("layout::read", read), + ] { + registry + .register_catalog_static( + crate::catalog_import_schemas(&catalog, name).remove(0), + handler, + ) + .unwrap(); + } + let large_callee = format!( + "use calibration; fn f() -> int {{ {} t256 }} f();", + (0..257) + .map(|i| format!("let t{i} = {i};")) + .collect::>() + .join(" ") + ); + let first_combined = format!( + "let mut root = layout::root(); let mut maybe = layout::maybe(); {large_callee}" + ); + let recursive_capture = format!( + "use calibration; fn recurse(n: int) -> int {{ if n == 0 => {{ 0 }} else => {{ 1 + recurse(n - 1) }} }} fn large() -> int {{ let delta = layout::read(root); let add = |value| value + delta; {} add(t256) + recurse(3) }} large();", + (0..257) + .map(|i| format!("let t{i} = {i};")) + .collect::>() + .join(" ") + ); + for (initial, mutable) in [ + ( + "let root = layout::root(); let maybe = layout::maybe();", + false, + ), + (first_combined.as_str(), true), + ] { + let mut session = super::ReplSession::default(); + for source in [ + initial, + "use calibration; layout::read(root); maybe == null; calibration::value();", + "use calibration; calibration::value();", + large_callee.as_str(), + recursive_capture.as_str(), + if mutable { + "root = layout::root();" + } else { + "layout::read(root); maybe == null;" + }, + "layout::read(root); maybe == null;", + ] { + let first_snippet = session.locals.is_empty(); + let compiled = + super::compile_repl_snippet_with_options(source, &session.locals, &options) + .unwrap(); + let moved = super::repl_locals_moved_by_rebinding( + &compiled.compiled.program, + &session.locals, + ); + let mut machine = Vm::new(compiled.compiled.program); + registry.bind_vm_cached(&mut machine).unwrap(); + super::seed_repl_vm_locals(&mut machine, &session.locals).unwrap(); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_ne!( + machine.debug_info().unwrap().local_index("root"), + machine.debug_info().unwrap().local_index("maybe"), + "independent persistent roots require distinct physical slots" + ); + if source == large_callee { + assert_eq!(machine.stack(), &[Value::Int(256)]); + assert!( + machine + .program() + .callable_prototypes + .iter() + .all(|p| p.frame_local_count < 257) + ); + } + let map = machine.program().type_map.as_ref().unwrap(); + println!("backend-map source={source:?} map={map:?}"); + let mut expected = Vec::new(); + for (name, optional, value) in [ + ( + "root", + false, + Value::map(vec![(Value::string("value"), Value::Int(41))]), + ), + ("maybe", true, Value::Null), + ] { + let slot = machine.debug_info().unwrap().local_index(name).unwrap() as usize; + let schema = crate::TypeSchema::Named("RootNamedOptional".into(), vec![]); + // New optional host results retain their Optional wrapper; + // entry reseeding uses the base schema plus the optional bit. + let schema = if optional && first_snippet { + crate::TypeSchema::Optional(Box::new(schema)) + } else { + schema + }; + let binding = compiled + .bindings + .iter() + .find(|binding| binding.name == name) + .unwrap(); + assert_eq!( + binding.schema, + Some(schema.clone()), + "{source}: projected {name} at final debug slot {slot}" + ); + assert_eq!( + binding.optional, optional, + "{source}: projected {name} at {slot}" + ); + if source == large_callee && name == "maybe" { + // The same physical index also summarizes an Int callee + // local. Backend observations remain frame-aggregate. + assert_eq!(map.local_schemas[slot], Some(crate::TypeSchema::Int)); + assert!(map.optional_slots[slot]); + } + assert_eq!(machine.locals()[slot], value, "{source}: {name} at {slot}"); + println!( + "final-layout source={source:?} name={name} slot={slot} locals={} schema={:?} optional={} value={:?}", + machine.locals().len(), + binding.schema, + binding.optional, + machine.locals()[slot] + ); + expected.push(( + name, + super::ReplSessionLocal { + value, + mutable, + schema: Some(schema), + optional, + moved: false, + }, + )); + } + super::sync_repl_session(&machine, &compiled.bindings, &moved, &mut session); + for (name, state) in expected { + assert_eq!(session.locals[name], state, "{source}: {name}"); + println!("final-layout synced {name}={:?}", session.locals[name]); + } + } + } + } + #[test] fn register_imports_binds_cached_cli_host_registry_plan() { let imports = vec![ diff --git a/src/cli_capture_certification_tests.rs b/src/cli_capture_certification_tests.rs new file mode 100644 index 00000000..84cbc98c --- /dev/null +++ b/src/cli_capture_certification_tests.rs @@ -0,0 +1,751 @@ +use super::*; +use crate::ResourceTypeKey; +use crate::resource::{HostResource, ResourceHandle}; +use crate::{ + CaptureQueryError, CaptureRootState, HostApiBuilder, HostFunctionSchema, HostParamPassing, + HostParamSchema, HostTypeSchema, ResourceTypeSchema, +}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +struct Payload(i64); +impl HostResource for Payload { + fn resource_type_key() -> Option { + Some(key()) + } +} +fn key() -> ResourceTypeKey { + ResourceTypeKey::new("neutral.cli-cell").unwrap() +} +#[derive(Default)] +struct Observations { + init_certified: Vec, + thresholds: Vec, + fuels: Vec>, + depths: Vec, + deadlines: Vec>, + calls: usize, + live: usize, + unavailable: usize, + query_certified: Vec, +} +struct Probe { + observations: Arc>, + handle: Option, +} +struct Scratch(PathBuf); +impl Scratch { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::var_os("TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + let path = root.join(format!( + "neutral-cli-capture-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn source(&self) -> PathBuf { + let path = self.0.join("owner.rss"); + std::fs::write(&path, "let owner = neutral::open(); let dispatch = || if true => { neutral::check(); 41 } else => { neutral::close(owner); 41 }; dispatch();\n").unwrap(); + path + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn catalog() -> Arc { + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(key(), "neutral CLI payload")); + builder.function(HostFunctionSchema::with_return( + "neutral::open", + Vec::::new(), + HostTypeSchema::Resource(key()), + )); + builder.function(HostFunctionSchema::with_return( + "neutral::check", + Vec::::new(), + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "neutral::close", + vec![HostParamSchema::with_passing( + "owner", + HostTypeSchema::Resource(key()), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + Arc::new(builder.build().unwrap()) +} +fn services(observations: Arc>) -> CliHostServices<'static> { + let catalog = catalog(); + let registered = Arc::clone(&catalog); + CliHostServices::new(catalog, move || { + let mut registry = HostFunctionRegistry::new(); + for schema in crate::catalog_import_schemas(®istered, "neutral::open") { + registry + .register_exact_static("neutral::open", 0, schema, |vm, _| { + let token = vm.host_context().push_resource(Payload(41)).unwrap(); + let handle = token.handle(); + vm.host_context() + .module_state_mut::() + .unwrap() + .handle = Some(handle); + Ok(CallOutcome::Return(CallReturn::one(Value::Int( + handle.raw() as i64, + )))) + }) + .unwrap(); + } + for schema in crate::catalog_import_schemas(®istered, "neutral::check") { + registry + .register_exact_static("neutral::check", 0, schema, |vm, _| { + let certified_now = vm + .capture_certification_stats() + .is_some_and(|stats| stats.valid); + let handle = vm + .host_context() + .module_state::() + .unwrap() + .handle + .unwrap(); + let context = vm.host_context(); + let candidate = context + .typed_resource_with_key::(handle, &key()) + .unwrap(); + assert_eq!(context.resource(&candidate).unwrap().get().0, 41); + let live = match context.checked_move_resource_capture::(&key()) { + Ok(checked) => { + assert_eq!(checked.token().handle(), candidate.handle()); + assert_eq!(checked.root().observe(&context), CaptureRootState::Live); + true + } + Err(CaptureQueryError::Unavailable) => false, + Err(other) => panic!("unexpected checked capture outcome: {other:?}"), + }; + let observations = + Arc::clone(&context.module_state::().unwrap().observations); + let mut state = observations.lock().unwrap(); + state.calls += 1; + state.query_certified.push(certified_now); + if live { + state.live += 1; + } else { + state.unavailable += 1; + } + Ok(CallOutcome::Return(CallReturn::one(Value::Int(41)))) + }) + .unwrap(); + } + for schema in crate::catalog_import_schemas(®istered, "neutral::close") { + registry + .register_exact_static("neutral::close", 1, schema, |vm, args| { + let Value::Int(raw) = args[0] else { + panic!("typed carrier"); + }; + vm.host_context() + .close_resource::( + ResourceHandle::from_raw(raw as u64).unwrap(), + crate::resource::ResourceCloseReason::Requested, + ) + .unwrap(); + Ok(CallOutcome::Return(CallReturn::none())) + }) + .unwrap(); + } + Ok(registry) + }) + .with_vm_init(move |vm| { + let certified = vm + .capture_certification_stats() + .is_some_and(|stats| stats.valid); + let threshold = vm.jit_config().hot_loop_threshold; + let fuel = vm.get_fuel(); + { + let mut state = observations.lock().unwrap(); + state.init_certified.push(certified); + state.thresholds.push(threshold); + state.fuels.push(fuel); + state.depths.push(vm.max_script_call_depth()); + state.deadlines.push(vm.epoch_deadline()); + } + vm.host_context().set_module_state(Probe { + observations: Arc::clone(&observations), + handle: None, + }); + Ok(()) + }) +} +struct NeutralDialect; +impl crate::ParserDialect for NeutralDialect {} +fn parse_neutral( + source: &str, + context: &crate::SourceFrontendContext<'_>, +) -> Result { + let mut ir = crate::parse_source_with_dialect( + source, + &NeutralDialect, + crate::SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + import_scan_mode: true, + enforce_mutable_bindings: true, + ..Default::default() + }, + )?; + let placeholders = ir + .functions + .iter() + .filter(|f| { + ir.implicit_extern_names.contains(&f.name) + && matches!(f.name.as_str(), "open" | "close" | "check") + }) + .cloned() + .collect::>(); + ir.functions + .retain(|f| !placeholders.iter().any(|p| p.index == f.index)); + ir.implicit_extern_names + .retain(|name| !placeholders.iter().any(|p| &p.name == name)); + let mut mapping = std::collections::HashMap::new(); + for old in placeholders { + mapping.insert( + old.index, + context.install_host_function(&mut ir, &format!("neutral::{}", old.name), old.arity)?, + ); + } + fn remap(expr: &mut crate::Expr, mapping: &std::collections::HashMap) { + if let crate::Expr::Call(index, _, arguments, ..) = expr { + if let Some(new) = mapping.get(index) { + *index = *new; + } + for arg in arguments { + remap(arg, mapping); + } + } + match expr { + crate::Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + remap(condition, mapping); + remap(then_expr, mapping); + remap(else_expr, mapping); + } + crate::Expr::Closure(closure) | crate::Expr::ClosureCall(closure, _) => { + remap(&mut closure.body, mapping); + } + crate::Expr::Block { stmts, expr } => { + for stmt in stmts { + if let crate::Stmt::Let { expr, .. } | crate::Stmt::Expr { expr, .. } = stmt { + remap(expr, mapping); + } + } + remap(expr, mapping); + } + _ => {} + } + } + for statement in &mut ir.stmts { + if let crate::Stmt::Let { expr, .. } | crate::Stmt::Expr { expr, .. } = statement { + remap(expr, &mapping); + } + } + for implementation in ir.function_impls.values_mut() { + for statement in &mut implementation.body_stmts { + if let crate::Stmt::Let { expr, .. } | crate::Stmt::Expr { expr, .. } = statement { + remap(expr, &mapping); + } + } + remap(&mut implementation.body_expr, &mapping); + } + Ok(ir) +} + +struct CliPlugin { + id: &'static str, + extension: &'static [&'static str], + ownership: bool, +} +impl crate::SourcePlugin for CliPlugin { + fn descriptor(&self) -> crate::SourceFrontendDescriptor { + crate::SourceFrontendDescriptor { + id: crate::FrontendId::new(self.id), + extensions: self.extension, + typing_policy: crate::FrontendTypingPolicy::Strict, + supports_ownership_checking: self.ownership, + } + } + fn parse_source( + &self, + source: &str, + context: &crate::SourceFrontendContext<'_>, + ) -> Result { + parse_neutral(source, context) + } +} +static CLI_PLUGIN_A: CliPlugin = CliPlugin { + id: "neutral-cli-owner-a", + extension: &["cap-a"], + ownership: true, +}; +static CLI_PLUGIN_B: CliPlugin = CliPlugin { + id: "neutral-cli-owner-b", + extension: &["cap-b"], + ownership: false, +}; +fn plugin_options_a() -> CompileSourceFileOptions { + CompileSourceFileOptions::new() + .with_source_plugin(&CLI_PLUGIN_A) + .with_source_plugin(&CLI_PLUGIN_B) + .with_module_override_path("dep.rss", "dep.cap-a") +} +fn plugin_options_b() -> CompileSourceFileOptions { + CompileSourceFileOptions::new() + .with_source_plugin(&CLI_PLUGIN_A) + .with_source_plugin(&CLI_PLUGIN_B) + .with_module_override_path("dep.rss", "dep.cap-b") +} +#[test] +fn linked_cli_uses_actual_plugin_producer_policy_not_entry_policy() { + for (extension, options, expected_live) in [ + ( + "cap-a", + plugin_options_a as fn() -> CompileSourceFileOptions, + 1, + ), + ( + "cap-b", + plugin_options_b as fn() -> CompileSourceFileOptions, + 0, + ), + ] { + let scratch = Scratch::new(); + let dependency = scratch.0.join(format!("dep.{extension}")); + let source = scratch.0.join("main.rss"); + std::fs::write( + &dependency, + "let owner=open(); pub fn dispatch()->int {check();close(owner);41}", + ) + .unwrap(); + std::fs::write(&source, "use self::dep; dep::dispatch();").unwrap(); + let observations = Arc::new(Mutex::new(Observations::default())); + let selected = services(Arc::clone(&observations)).with_capture_certification(); + let runtime = CliRuntime { + compile_options: options, + ..CliRuntime::default() + }; + run_with_args(&runtime, &[source.display().to_string()], &selected).unwrap(); + let state = observations.lock().unwrap(); + assert_eq!(state.init_certified, [true]); + assert_eq!( + (state.calls, state.live, state.unavailable), + (1, expected_live, 1 - expected_live) + ); + } +} + +#[test] +fn opted_in_debug_keeps_issued_owner_and_configured_print_sink() { + let scratch = Scratch::new(); + let source = scratch.source(); + let original = std::fs::read_to_string(&source).unwrap(); + std::fs::write(&source, format!("{original} print(41);")).unwrap(); + let observations = Arc::new(Mutex::new(Observations::default())); + run_with_args( + &CliRuntime::default(), + &[ + "--debug".into(), + "--no-stop-on-entry".into(), + "--fuel".into(), + "100000".into(), + source.display().to_string(), + ], + &services(Arc::clone(&observations)).with_capture_certification(), + ) + .unwrap(); + let state = observations.lock().unwrap(); + assert_eq!(state.init_certified, [true]); + assert_eq!(state.live, 1); + assert_eq!(state.fuels, [Some(100000)]); +} + +#[test] +fn explicit_catalog_conflict_refuses_before_registry_or_initialization() { + let selected = + services(Arc::new(Mutex::new(Observations::default()))).with_capture_certification(); + let other = Arc::new(HostApiBuilder::new().build().unwrap()); + let result = + selected.compile_options(CompileSourceFileOptions::new().with_host_api_catalog(other)); + let error = match result { + Ok(_) => panic!("conflicting compiler catalog admitted"), + Err(error) => error, + }; + assert!(error.to_string().contains("conflicts with host services")); +} + +#[test] +#[ignore = "exact child-process REPL probe"] +fn opted_in_repl_child_probe() { + if std::env::var_os("PD_NEUTRAL_CERT_REPL_CHILD").is_none() { + return; + } + let observations = Arc::new(Mutex::new(Observations::default())); + run_with_args( + &CliRuntime::default(), + &["--repl".into()], + &services(Arc::clone(&observations)).with_capture_certification(), + ) + .unwrap(); + let state = observations.lock().unwrap(); + assert!(!state.init_certified.is_empty()); + assert!(state.init_certified.iter().all(|valid| !valid)); + assert_eq!((state.calls, state.live, state.unavailable), (1, 0, 1)); +} + +#[test] +fn scripted_opted_in_repl_stays_uncertified_with_real_resource_closure() { + use std::io::{Read, Write}; + use std::process::{Command, Stdio}; + let source = "let owner = neutral::open(); let dispatch = || if true => { neutral::check(); 41 } else => { neutral::close(owner); 41 }; dispatch();\n"; + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "cli::capture_certification_tests::opted_in_repl_child_probe", + "--nocapture", + ]) + .env("PD_NEUTRAL_CERT_REPL_CHILD", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(source.as_bytes()) + .unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let out = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).unwrap(); + bytes + }); + let err = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).unwrap(); + bytes + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() >= deadline { + child.kill().unwrap(); + let _ = child.wait(); + panic!("scripted REPL timed out"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + let stdout = out.join().unwrap(); + let stderr = err.join().unwrap(); + assert!( + status.success(), + "REPL child failed: {}\n{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + assert!(String::from_utf8_lossy(&stdout).contains("1 passed; 0 failed")); +} + +#[test] +fn opted_in_epoch_configuration_and_interrupt_conflict_keep_legacy_rules() { + let scratch = Scratch::new(); + let source = scratch.source(); + let observations = Arc::new(Mutex::new(Observations::default())); + let selected = services(Arc::clone(&observations)).with_capture_certification(); + run_with_args( + &CliRuntime::default(), + &[ + "--epoch-deadline".into(), + "32".into(), + source.display().to_string(), + ], + &selected, + ) + .unwrap(); + { + let state = observations.lock().unwrap(); + assert_eq!(state.init_certified, [true]); + assert_eq!(state.deadlines, [Some(32)]); + assert_eq!(state.fuels, [None]); + assert_eq!(state.live, 1); + } + let error = run_with_args( + &CliRuntime::default(), + &[ + "--fuel".into(), + "100000".into(), + "--epoch-deadline".into(), + "32".into(), + source.display().to_string(), + ], + &selected, + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "--fuel and --epoch-deadline are mutually exclusive" + ); + assert_eq!(observations.lock().unwrap().calls, 1); +} + +#[test] +fn malformed_vmbc_keeps_wire_error_type_in_all_ordinary_cli_routes() { + let scratch = Scratch::new(); + let path = scratch.0.join("malformed.vmbc"); + std::fs::write(&path, b"invalid VMBC bytes").unwrap(); + let expected = crate::decode_program(&std::fs::read(&path).unwrap()) + .unwrap_err() + .to_string(); + for selected in [ + CliHostServices::default(), + CliHostServices::default().with_capture_certification(), + services(Arc::new(Mutex::new(Observations::default()))), + services(Arc::new(Mutex::new(Observations::default()))).with_capture_certification(), + ] { + for emit in [false, true] { + let mut args = Vec::new(); + if emit { + args.extend([ + "--emit-vmbc".to_owned(), + scratch.0.join("rejected.vmbc").display().to_string(), + ]); + } + args.push(path.display().to_string()); + let error = run_with_args(&CliRuntime::default(), &args, &selected).unwrap_err(); + assert_eq!(error.to_string(), expected); + assert!( + error.downcast_ref::().is_some(), + "WireError was wrapped: {error:?}" + ); + assert!(error.downcast_ref::().is_none()); + assert!(!scratch.0.join("rejected.vmbc").exists()); + } + } +} + +#[test] +fn default_services_remain_uncertified_with_actual_owner_candidate() { + let scratch = Scratch::new(); + let observations = Arc::new(Mutex::new(Observations::default())); + run_with_args( + &CliRuntime::default(), + &[scratch.source().display().to_string()], + &services(Arc::clone(&observations)), + ) + .unwrap(); + let state = observations.lock().unwrap(); + assert_eq!(state.init_certified, [false]); + assert_eq!((state.calls, state.live, state.unavailable), (1, 0, 1)); +} + +#[test] +fn opted_in_vmbc_retains_ordinary_bytes_and_no_capture_authority() { + let scratch = Scratch::new(); + let source = scratch.source(); + let artifact = scratch.0.join("owner.vmbc"); + let observations = Arc::new(Mutex::new(Observations::default())); + let selected = services(Arc::clone(&observations)).with_capture_certification(); + run_with_args( + &CliRuntime::default(), + &[ + "--emit-vmbc".into(), + artifact.display().to_string(), + source.display().to_string(), + ], + &selected, + ) + .unwrap(); + let ordinary = compile_source_file_with_options( + &source, + CompileSourceFileOptions::new().with_host_api_catalog(catalog()), + ) + .unwrap(); + assert_eq!( + std::fs::read(&artifact).unwrap(), + encode_program(&ordinary.program).unwrap() + ); + run_with_args( + &CliRuntime::default(), + &[artifact.display().to_string()], + &selected, + ) + .unwrap(); + let state = observations.lock().unwrap(); + assert_eq!(state.init_certified, [true, false]); + assert_eq!((state.calls, state.live, state.unavailable), (1, 0, 1)); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn source_companion_and_source_deleted_aot_never_reissue_capture_facts() { + let scratch = Scratch::new(); + let source = scratch.source(); + let artifact = scratch.0.join("owner.pat"); + let observations = Arc::new(Mutex::new(Observations::default())); + let selected = services(Arc::clone(&observations)).with_capture_certification(); + run_with_args( + &CliRuntime::default(), + &[ + "--aot-save".into(), + artifact.display().to_string(), + source.display().to_string(), + ], + &selected, + ) + .unwrap(); + run_with_args( + &CliRuntime::default(), + &[ + "--aot-load".into(), + artifact.display().to_string(), + source.display().to_string(), + ], + &selected, + ) + .unwrap(); + std::fs::remove_file(source).unwrap(); + run_with_args( + &CliRuntime::default(), + &["--aot-load".into(), artifact.display().to_string()], + &selected, + ) + .unwrap(); + let state = observations.lock().unwrap(); + assert_eq!(state.init_certified, [true, true, false]); + assert_eq!(state.query_certified, [true, false, false]); + eprintln!( + "AOT owner bounds: init={:?}, query={:?}, calls/live/unavailable={:?}", + state.init_certified, + state.query_certified, + (state.calls, state.live, state.unavailable) + ); + assert_eq!((state.calls, state.live, state.unavailable), (3, 1, 2)); +} + +#[test] +fn failed_initialization_drops_resources_before_any_guest_effect() { + struct Cleanup(Arc); + impl HostResource for Cleanup {} + impl Drop for Cleanup { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let scratch = Scratch::new(); + let source = scratch.source(); + let observations = Arc::new(Mutex::new(Observations::default())); + let dropped = Arc::new(AtomicUsize::new(0)); + let observed_drop = Arc::clone(&dropped); + let selected = services(Arc::clone(&observations)) + .with_capture_certification() + .with_vm_init(move |vm| { + assert!(vm.capture_certification_stats().unwrap().valid); + vm.host_context() + .push_resource(Cleanup(Arc::clone(&observed_drop))) + .unwrap(); + Err(VmError::HostError("neutral initialization refusal".into())) + }); + let error = run_with_args( + &CliRuntime::default(), + &[source.display().to_string()], + &selected, + ) + .unwrap_err(); + assert!(error.to_string().contains("neutral initialization refusal")); + assert_eq!(observations.lock().unwrap().calls, 0); + assert_eq!(dropped.load(Ordering::SeqCst), 1); +} + +#[test] +fn certified_recording_keeps_owner_and_legacy_emit_requires_no_binding() { + let scratch = Scratch::new(); + let source = scratch.source(); + let recording = scratch.0.join("owner.recording"); + let observations = Arc::new(Mutex::new(Observations::default())); + run_with_args( + &CliRuntime::default(), + &[ + "--record".into(), + recording.display().to_string(), + source.display().to_string(), + ], + &services(Arc::clone(&observations)).with_capture_certification(), + ) + .unwrap(); + assert!(VmRecording::load_from_file(&recording).is_ok()); + assert_eq!(observations.lock().unwrap().live, 1); + fn compile_catalog() -> CompileSourceFileOptions { + CompileSourceFileOptions::new().with_host_api_catalog(catalog()) + } + let runtime = CliRuntime { + compile_options: compile_catalog, + ..CliRuntime::default() + }; + let output = scratch.0.join("unbound.vmbc"); + run_with_args( + &runtime, + &[ + "--emit-vmbc".into(), + output.display().to_string(), + source.display().to_string(), + ], + &CliHostServices::default().with_capture_certification(), + ) + .unwrap(); + assert!(crate::decode_program(&std::fs::read(output).unwrap()).is_ok()); +} + +#[test] +fn certified_source_cli_preserves_issued_owner_and_configuration() { + let scratch = Scratch::new(); + let source = scratch.source(); + let observations = Arc::new(Mutex::new(Observations::default())); + let services = services(Arc::clone(&observations)).with_capture_certification(); + run_with_args( + &CliRuntime::default(), + &[ + "--fuel".into(), + "100000".into(), + "--jit-hot-loop".into(), + "7".into(), + "--max-call-depth".into(), + "32".into(), + source.display().to_string(), + ], + &services, + ) + .unwrap(); + let state = observations.lock().unwrap(); + assert_eq!( + state.init_certified, + [true], + "source facts must precede vm_init" + ); + assert_eq!(state.thresholds, [7]); + assert_eq!(state.depths, [32]); + assert_eq!(state.deadlines, [None]); + assert_eq!(state.fuels, [Some(100000)]); + assert_eq!((state.calls, state.live, state.unavailable), (1, 1, 0)); +} diff --git a/src/compiler/capture_certification.rs b/src/compiler/capture_certification.rs new file mode 100644 index 00000000..ed21ea41 --- /dev/null +++ b/src/compiler/capture_certification.rs @@ -0,0 +1,246 @@ +//! Compiler-private declaration observations; never serialized with Program. +use super::{CompiledProgram, LocalSlot, TypeSchema}; +use crate::{CallablePrototype, CaptureBindingMode}; + +/// An immutable compiler-issued pair. Extracting the ordinary compilation +/// deliberately discards all capture authority. +pub struct CertifiedCompilation { + pub(super) compiled: CompiledProgram, + pub(super) facts: CaptureCertification, +} +impl CertifiedCompilation { + pub fn into_uncertified(self) -> CompiledProgram { + let Self { compiled, facts } = self; + drop(facts); + compiled + } + #[cfg(feature = "runtime")] + pub fn into_vm(self) -> crate::vm::VmResult { + let mut vm = self.compiled.into_vm()?; + vm.attach_capture_certification(self.facts); + Ok(vm) + } +} + +#[derive(Clone, Default)] +pub(crate) struct CaptureCertification { + pub(crate) prototypes: Vec>, + pub(crate) creation_sites: Vec<(usize, u32)>, +} +#[derive(Clone)] +pub(crate) struct CaptureFact { + pub(crate) source: LocalSlot, + pub(crate) target: LocalSlot, + pub(crate) mode: CaptureBindingMode, + pub(crate) schema: Option, +} +#[derive(Clone, Default)] +pub(super) struct DeclarationCaptures { + // Capture layouts refer to logical slots until allocation remaps them. + observations: Vec<(CaptureDeclaration, Vec)>, +} +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum CaptureDeclaration { + Named(u16), + AnonymousIn(Option), +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum CaptureOwner { + Function(u16), + RootStatement(usize), +} + +/// The standalone parser registers exactly one producer; linked compilation +/// instead requires the preserved per-declaration/per-statement source map. +/// Neither variant supplies a fallback owner for linked IR. +#[derive(Clone, Copy)] +pub(super) enum CaptureProducerPolicies<'a> { + Linked(&'a std::collections::HashMap), + Standalone { + source: &'a str, + policies: &'a std::collections::HashMap, + }, +} +impl DeclarationCaptures { + pub(super) fn retain_admitted_producers( + &mut self, + ir: &super::FrontendIr, + admission: CaptureProducerPolicies<'_>, + ) { + self.observations.retain(|(declaration, _)| { + let source = match declaration { + CaptureDeclaration::Named(index) + | CaptureDeclaration::AnonymousIn(Some(CaptureOwner::Function(index))) => { + ir.function_sources.get(index).map(String::as_str) + } + CaptureDeclaration::AnonymousIn(Some(CaptureOwner::RootStatement(index))) => ir + .stmt_sources + .get(*index) + .and_then(|source| source.as_deref()), + CaptureDeclaration::AnonymousIn(None) => return false, + }; + let (source, policies) = match admission { + CaptureProducerPolicies::Linked(policies) => (source, policies), + CaptureProducerPolicies::Standalone { source, policies } => { + (Some(source), policies) + } + }; + source.and_then(|source| policies.get(source)).copied() == Some(true) + }); + } + + pub(super) fn observe(&mut self, declaration: CaptureDeclaration, facts: Vec) { + if facts.is_empty() { + return; + } + if let Some((_, existing)) = self.observations.iter_mut().find(|(id, captures)| { + *id == declaration + && captures.len() == facts.len() + && captures + .iter() + .zip(&facts) + .all(|(a, b)| a.source == b.source && a.target == b.target && a.mode == b.mode) + }) { + for (old, new) in existing.iter_mut().zip(facts) { + if old.schema != new.schema { + old.schema = None; + } + } + } else { + self.observations.push((declaration, facts)); + } + } + pub(super) fn extend(&mut self, other: Self) { + for (id, captures) in other.observations { + self.observe(id, captures); + } + } + pub(super) fn remap(&mut self, mapping: &[LocalSlot]) { + for (_, captures) in &mut self.observations { + for capture in captures { + capture.source = mapping[capture.source as usize]; + capture.target = mapping[capture.target as usize]; + } + } + } + pub(super) fn for_prototype( + &self, + declaration: CaptureDeclaration, + prototype: &CallablePrototype, + ) -> Vec { + let mut result: Option> = None; + for (id, captures) in &self.observations { + if *id != declaration + || captures.len() != prototype.capture_slots.len() + || captures.iter().enumerate().any(|(i, c)| { + prototype.capture_source_slots.get(i) != Some(&c.source) + || prototype.capture_slots.get(i) != Some(&c.target) + || prototype.capture_modes.get(i) != Some(&c.mode) + }) + { + continue; + } + match &mut result { + None => result = Some(captures.clone()), + Some(existing) => { + for (old, new) in existing.iter_mut().zip(captures) { + if old.schema != new.schema { + old.schema = None; + } + } + } + } + } + result.unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + #[test] + fn missing_producer_or_policy_cannot_certify_named_or_root_captures() { + struct Dialect; + impl crate::ParserDialect for Dialect {} + let parsed = crate::parse_source_with_dialect( + "let value=41; fn named()->int {value} let anonymous=||value; named(); anonymous();", + &Dialect, + crate::SharedParserOptions::default(), + ) + .unwrap(); + let policies = std::collections::HashMap::from([("producer".to_owned(), true)]); + let mut ir = parsed.clone(); + ir.stmt_sources = vec![Some("producer".to_owned()); ir.stmts.len()]; + ir.function_sources = ir + .function_impls + .keys() + .map(|index| (*index, "producer".to_owned())) + .collect(); + let infer = |ir: &crate::FrontendIr| { + super::super::typing::infer_types_with_capture_origins( + ir, + super::super::TypingMode::DynamicHints, + &[], + &[], + ) + .2 + }; + let mut known = infer(&ir); + known.retain_admitted_producers(&ir, super::CaptureProducerPolicies::Linked(&policies)); + assert!( + known + .observations + .iter() + .any(|(id, _)| matches!(id, super::CaptureDeclaration::Named(_))) + ); + assert!( + known + .observations + .iter() + .any(|(id, _)| matches!(id, super::CaptureDeclaration::AnonymousIn(_))) + ); + let mut unknown = infer(&parsed); + unknown + .retain_admitted_producers(&parsed, super::CaptureProducerPolicies::Linked(&policies)); + assert!(unknown.observations.is_empty()); + for policies in [ + std::collections::HashMap::new(), + std::collections::HashMap::from([("producer".to_owned(), false)]), + ] { + let mut denied = infer(&ir); + denied + .retain_admitted_producers(&ir, super::CaptureProducerPolicies::Linked(&policies)); + assert!(denied.observations.is_empty()); + } + ir.function_sources.clear(); + let mut root_only = infer(&ir); + root_only.retain_admitted_producers(&ir, super::CaptureProducerPolicies::Linked(&policies)); + assert!(!root_only.observations.is_empty()); + assert!(root_only.observations.iter().all(|(id, _)| matches!( + id, + super::CaptureDeclaration::AnonymousIn(Some(super::CaptureOwner::RootStatement(_))) + ))); + ir.stmt_sources.clear(); + let mut absent = infer(&ir); + absent.retain_admitted_producers(&ir, super::CaptureProducerPolicies::Linked(&policies)); + assert!(absent.observations.is_empty()); + } + + #[test] + fn ordinary_conversion_preserves_source_program_and_errors() { + let source = "let value = 41; value + 1;"; + let ordinary = crate::compile_source(source).unwrap(); + let issued = crate::compile_source_with_capture_certification(source) + .unwrap() + .into_uncertified(); + assert_eq!(ordinary.program.code, issued.program.code); + assert_eq!(ordinary.program.constants, issued.program.constants); + assert_eq!(ordinary.locals, issued.locals); + let old = crate::compile_source("let =;").err().unwrap().to_string(); + let new = crate::compile_source_with_capture_certification("let =;") + .err() + .unwrap() + .to_string(); + assert_eq!(old, new); + } +} diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 65728b04..74a059ba 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -35,6 +35,7 @@ pub struct Compiler { typing_mode: TypingMode, type_state: typing::LocalTypeState, type_map: TypeMap, + function_schemas: HashMap, root_local_count: usize, frame_local_count: usize, function_slots: HashMap, @@ -56,6 +57,9 @@ pub struct Compiler { callable_use_facts: HashMap, script_functions: Vec, callable_prototypes: Vec, + capture_creation_sites: Vec<(usize, u32)>, + capture_owner: Option, + closure_capture_owners: HashMap>, function_regions: Vec, root_callable_bindings: Vec, pending_closures: Vec<(u32, ClosureExpr)>, @@ -101,6 +105,7 @@ impl Compiler { typing_mode: TypingMode::DynamicHints, type_state: typing::LocalTypeState::default(), type_map: TypeMap::default(), + function_schemas: HashMap::new(), root_local_count: 0, frame_local_count: 0, function_slots: HashMap::new(), @@ -110,6 +115,9 @@ impl Compiler { callable_use_facts: HashMap::new(), script_functions: Vec::new(), callable_prototypes: Vec::new(), + capture_creation_sites: Vec::new(), + capture_owner: None, + closure_capture_owners: HashMap::new(), function_regions: Vec::new(), root_callable_bindings: Vec::new(), pending_closures: Vec::new(), @@ -202,11 +210,38 @@ impl Compiler { self.type_map.local_schemas = type_info.local_schemas; self.type_map.callable_slots = type_info.callable_slots; self.type_map.optional_slots = type_info.optional_slots; + self.function_schemas = type_info.function_schemas; } - pub fn compile_program(mut self, stmts: &[Stmt]) -> Result { + pub fn compile_program(self, stmts: &[Stmt]) -> Result { + self.compile_program_impl(stmts, None) + } + + pub(super) fn compile_program_certified( + self, + stmts: &[Stmt], + origins: &super::capture_certification::DeclarationCaptures, + output: &mut super::capture_certification::CaptureCertification, + ) -> Result { + self.compile_program_impl(stmts, Some((origins, output))) + } + + fn compile_program_impl( + mut self, + stmts: &[Stmt], + certification: Option<( + &super::capture_certification::DeclarationCaptures, + &mut super::capture_certification::CaptureCertification, + )>, + ) -> Result { let named_functions = self.prepare_named_callables()?; - self.compile_stmts(stmts)?; + for (index, stmt) in stmts.iter().enumerate() { + self.capture_owner = Some(super::capture_certification::CaptureOwner::RootStatement( + index, + )); + self.compile_stmt(stmt)?; + } + self.capture_owner = None; self.assembler.ret(); let root_end = self.assembler.position(); if root_end > 0 { @@ -229,6 +264,33 @@ impl Compiler { for prototype in &mut self.callable_prototypes { prototype.frame_local_count = self.frame_local_count; } + if let Some((origins, output)) = certification { + output.prototypes = self + .callable_prototypes + .iter() + .enumerate() + .map(|(id, prototype)| { + let declaration = self + .function_prototype_ids + .iter() + .find_map(|(index, candidate)| { + (*candidate as usize == id).then_some( + super::capture_certification::CaptureDeclaration::Named(*index), + ) + }) + .unwrap_or( + super::capture_certification::CaptureDeclaration::AnonymousIn( + self.closure_capture_owners + .get(&(id as u32)) + .copied() + .flatten(), + ), + ); + origins.for_prototype(declaration, prototype) + }) + .collect(); + output.creation_sites = self.capture_creation_sites; + } let mut exported_callables = self .function_decls .values() @@ -381,14 +443,22 @@ impl Compiler { }) .collect(), self_slot: hidden_slot, - schema: decl.map(|decl| TypeSchema::Callable { - params: decl - .arg_schemas - .iter() - .map(|schema| schema.clone().unwrap_or(TypeSchema::Unknown)) - .collect(), - result: Box::new(decl.return_schema.clone().unwrap_or(TypeSchema::Unknown)), - }), + schema: self + .function_schemas + .get(&function_index) + .cloned() + .or_else(|| { + decl.map(|decl| TypeSchema::Callable { + params: decl + .arg_schemas + .iter() + .map(|schema| schema.clone().unwrap_or(TypeSchema::Unknown)) + .collect(), + result: Box::new( + decl.return_schema.clone().unwrap_or(TypeSchema::Unknown), + ), + }) + }), }); if function_impl.capture_copies.is_empty() && let Some(hidden_slot) = hidden_slot @@ -403,6 +473,11 @@ impl Compiler { } fn compile_named_function_body(&mut self, function_index: u16) -> Result<(), CompileError> { + let owner_snapshot = + self.capture_owner + .replace(super::capture_certification::CaptureOwner::Function( + function_index, + )); let function_impl = self .function_impls .get(&function_index) @@ -439,6 +514,7 @@ impl Compiler { self.callable_bindings = callable_snapshot; self.type_state = type_snapshot; let end_ip = self.assembler.position(); + self.capture_owner = owner_snapshot; self.script_functions .push(ScriptFunction { entry_ip, end_ip }); self.function_regions.push(FunctionRegion { @@ -454,6 +530,12 @@ impl Compiler { prototype_id: u32, closure: &ClosureExpr, ) -> Result<(), CompileError> { + let owner_snapshot = self.capture_owner; + self.capture_owner = self + .closure_capture_owners + .get(&prototype_id) + .copied() + .flatten(); let entry_ip = self.assembler.position(); let callable_snapshot = self.callable_bindings.clone(); let type_snapshot = self.type_state.clone(); @@ -476,6 +558,7 @@ impl Compiler { .push(ScriptFunction { entry_ip, end_ip }); self.callable_prototypes[prototype_id as usize].target = CallableTarget::ScriptFunction(function_id); + self.capture_owner = owner_snapshot; self.function_regions.push(FunctionRegion { start_ip: entry_ip, end_ip, @@ -736,6 +819,7 @@ impl Compiler { self.assembler.push_const(Value::bytes(value.clone())); } Expr::OptionalGet { + field_projection: _, container, key, container_slot, @@ -1585,6 +1669,9 @@ impl Compiler { .cloned() .zip(type_args.iter().cloned()) .collect::>(); + if let Some(schema) = self.function_schemas.get(&index) { + return Some(substitute_type_schema(schema, &bindings)); + } Some(TypeSchema::Callable { params: decl .arg_schemas @@ -1725,6 +1812,8 @@ impl Compiler { binding_slot: Option, ) -> Result { let prototype_id = self.callable_prototypes.len() as u32; + self.closure_capture_owners + .insert(prototype_id, self.capture_owner); self.callable_prototypes.push(CallablePrototype { kind: CallableKind::Closure, target: CallableTarget::ScriptFunction(u32::MAX), @@ -1796,6 +1885,8 @@ impl Compiler { } self.assembler .call(BuiltinFunction::BindCallable.call_index(), 2); + self.capture_creation_sites + .push((self.assembler.position() as usize, prototype_id)); Ok(()) } diff --git a/src/compiler/frontends/mod.rs b/src/compiler/frontends/mod.rs index cb3bd0e2..ee65b2ae 100644 --- a/src/compiler/frontends/mod.rs +++ b/src/compiler/frontends/mod.rs @@ -1,14 +1,16 @@ mod rustscript; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::path::Path; use std::sync::Arc; use crate::compiler::source_map::{LoweredSource, SourceMap, Span}; -use crate::host_api::HostApiCatalog; +use crate::host_api::{HostApiCatalog, HostFunctionSchema, HostTypeSchema}; use super::{ - CompileSourceFileOptions, ParseError, ReplLocalBinding, SharedParserOptions, SourceFlavor, - ir::FrontendIr, + CompileSourceFileOptions, FrontendImportedDeclaration, ParseError, ParsePhase, + ReplLocalBinding, SharedParserOptions, SourceFlavor, SourceFrontendContext, + ir::{FrontendIr, FunctionDecl, HostApiIrMetadata, StructDecl, StructDeclOrigin, TypeSchema}, parser::{Parser, ParserDialect}, }; @@ -18,6 +20,262 @@ pub(super) struct ParsedRustScriptReplSource { pub bindings: Vec, } +impl<'a> SourceFrontendContext<'a> { + /// Installs the complete catalog candidate family for one logical host + /// function name and arity, returning its frontend IR function index. + /// Source aliases stay in the plugin's binding environment. + pub fn install_host_function( + &self, + ir: &mut FrontendIr, + catalog_name: &str, + arity: u8, + ) -> Result { + let catalog = self.host_api_catalog.ok_or_else(|| { + ParseError::new("host function installation requires the caller catalog") + })?; + let candidates = catalog_function_candidates(catalog, catalog_name, arity)?; + let fingerprint = catalog.fingerprint(); + if let Some(metadata) = &ir.host_api_metadata { + if metadata.fingerprint() != fingerprint { + return Err(ParseError::new( + "frontend IR host metadata belongs to a different caller catalog", + )); + } + for index in metadata.function_indices() { + let mut declarations = ir + .functions + .iter() + .filter(|function| function.index == index); + let Some(function) = declarations.next() else { + return Err(ParseError::new(format!( + "host metadata references missing function index {index}" + ))); + }; + if declarations.next().is_some() || ir.function_impls.contains_key(&index) { + return Err(ParseError::new(format!( + "host metadata index {index} is not a unique host import" + ))); + } + metadata.validate_binding(index, catalog, &function.name, function.arity)?; + } + } + + // A matching spelling/arity does not confer host authority. Reuse + // only a slot already bound to this complete catalog family. + if let Some(metadata) = ir.host_api_metadata.as_ref() { + for function in &ir.functions { + if function.name == catalog_name + && function.arity == arity + && metadata.candidates(function.index).is_some() + { + metadata.validate_binding(function.index, catalog, catalog_name, arity)?; + return Ok(function.index); + } + } + } + let index = next_available_function_index(ir)?; + if ir.host_api_metadata.is_none() { + ir.host_api_metadata = Some(HostApiIrMetadata::new(fingerprint)); + } + ir.host_api_metadata + .as_mut() + .expect("catalog metadata initialized above") + .record_candidates(index, candidates)?; + + if let Some(function) = ir + .functions + .iter_mut() + .find(|function| function.index == index) + { + function.args = (0..arity).map(|slot| format!("arg{slot}")).collect(); + function.arg_schemas = vec![None; usize::from(arity)]; + function.return_schema = None; + function.type_params.clear(); + function.exported = false; + function.return_type = crate::ValueType::Unknown; + function.symbol = None; + } else { + ir.functions.push(FunctionDecl { + name: catalog_name.to_string(), + arity, + index, + args: (0..arity).map(|slot| format!("arg{slot}")).collect(), + arg_schemas: vec![None; usize::from(arity)], + return_schema: None, + type_params: Vec::new(), + exported: false, + return_type: crate::ValueType::Unknown, + symbol: None, + }); + } + ir.host_api_metadata + .as_ref() + .expect("catalog metadata initialized above") + .validate_binding(index, catalog, catalog_name, arity)?; + Ok(index) + } + + /// Installs a catalog named type and its recursively referenced named + /// declarations, returning the nominal compiler schema. + pub fn install_host_type( + &self, + ir: &mut FrontendIr, + catalog_type_name: &str, + ) -> Result { + let catalog = self + .host_api_catalog + .ok_or_else(|| ParseError::new("host type installation requires the caller catalog"))?; + if ir + .host_api_metadata + .as_ref() + .is_some_and(|metadata| metadata.fingerprint() != catalog.fingerprint()) + { + return Err(ParseError::new( + "frontend IR host metadata belongs to a different caller catalog", + )); + } + let root = catalog.struct_named(catalog_type_name).ok_or_else(|| { + ParseError::new(format!( + "host type '{catalog_type_name}' is not declared in the caller catalog" + )) + })?; + let mut pending = Vec::new(); + let mut visited = HashSet::new(); + collect_catalog_struct(catalog_type_name, catalog, &mut visited, &mut pending)?; + for declaration in &pending { + if let Some(existing) = ir.struct_schemas.get(&declaration.name) { + if existing.origin == StructDeclOrigin::Guest { + return Err(ParseError::new(format!( + "catalog host type '{}' conflicts with a guest declaration", + declaration.name + ))); + } + if existing != declaration { + return Err(ParseError::new(format!( + "catalog host type '{}' conflicts with an installed catalog declaration", + declaration.name + ))); + } + } + } + for declaration in pending { + ir.struct_schemas + .entry(declaration.name.clone()) + .or_insert(declaration); + } + Ok(root.as_type().to_compiler_schema()) + } +} + +fn catalog_function_candidates( + catalog: &HostApiCatalog, + name: &str, + arity: u8, +) -> Result, ParseError> { + let declared = catalog.functions_named(name); + if declared.is_empty() { + return Err(ParseError::new(format!( + "host function '{name}' is not declared in the caller catalog" + ))); + } + let candidates = declared + .iter() + .copied() + .filter(|function| function.params.len() == usize::from(arity)) + .cloned() + .collect::>(); + if candidates.is_empty() { + let mut arities = declared + .iter() + .map(|function| function.params.len()) + .collect::>(); + arities.sort_unstable(); + arities.dedup(); + return Err(ParseError::new(format!( + "host function '{name}' has no overload with {arity} argument(s); declared arities: {}", + arities + .iter() + .map(usize::to_string) + .collect::>() + .join(", ") + ))); + } + Ok(candidates) +} + +fn next_available_function_index(ir: &FrontendIr) -> Result { + let mut used = ir + .functions + .iter() + .map(|function| function.index) + .chain(ir.function_impls.keys().copied()) + .collect::>(); + if let Some(metadata) = &ir.host_api_metadata { + used.extend(metadata.function_indices()); + } + used.extend(ir.stmts.iter().filter_map(|stmt| match stmt { + super::ir::Stmt::FuncDecl { index, .. } => Some(*index), + _ => None, + })); + (0..=u16::MAX) + .find(|index| !used.contains(index)) + .ok_or_else(|| ParseError::new("function index overflow")) +} + +fn collect_catalog_struct( + name: &str, + catalog: &HostApiCatalog, + visited: &mut HashSet, + pending: &mut Vec, +) -> Result<(), ParseError> { + if !visited.insert(name.to_string()) { + return Ok(()); + } + let declaration = catalog.struct_named(name).ok_or_else(|| { + ParseError::new(format!( + "host type '{name}' referenced by the caller catalog is not declared" + )) + })?; + for field in &declaration.fields { + collect_named_host_types(&field.ty, catalog, visited, pending)?; + } + pending.push(declaration.to_struct_decl()); + Ok(()) +} + +fn collect_named_host_types( + schema: &HostTypeSchema, + catalog: &HostApiCatalog, + visited: &mut HashSet, + pending: &mut Vec, +) -> Result<(), ParseError> { + match schema { + HostTypeSchema::Named { name, .. } => { + collect_catalog_struct(name, catalog, visited, pending) + } + HostTypeSchema::Array(inner) + | HostTypeSchema::Map(inner) + | HostTypeSchema::Optional(inner) => { + collect_named_host_types(inner, catalog, visited, pending) + } + HostTypeSchema::Callable { params, result } => { + for param in params { + collect_named_host_types(param, catalog, visited, pending)?; + } + collect_named_host_types(result, catalog, visited, pending) + } + HostTypeSchema::Unknown + | HostTypeSchema::Null + | HostTypeSchema::Int + | HostTypeSchema::Float + | HostTypeSchema::Number + | HostTypeSchema::Bool + | HostTypeSchema::String + | HostTypeSchema::Bytes + | HostTypeSchema::Resource(_) => Ok(()), + } +} + fn effective_host_api_catalog(options: &CompileSourceFileOptions) -> Option> { if let Some(catalog) = options.host_api_catalog() { return Some(Arc::clone(catalog)); @@ -52,13 +310,21 @@ pub(super) fn parse_source_for_compile( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { + let host_catalog = explicit_host_api_catalog(options); + let context = SourceFrontendContext { + source_id: 0, + original_path: None, + phase: ParsePhase::Standalone, + host_api_catalog: host_catalog.as_deref(), + imports: &[], + }; parse_source_with_source_id_and_externs_with_catalog( source, flavor, options, - 0, false, - explicit_host_api_catalog(options), + host_catalog.clone(), + &context, ) } @@ -92,14 +358,25 @@ pub(super) fn parse_module_source_with_source_id( flavor: SourceFlavor, options: &CompileSourceFileOptions, original_source_id: u32, + original_path: Option<&Path>, + phase: ParsePhase, + imports: &[FrontendImportedDeclaration], ) -> Result { + let host_catalog = explicit_host_api_catalog(options); + let context = SourceFrontendContext { + source_id: original_source_id, + original_path, + phase, + host_api_catalog: host_catalog.as_deref(), + imports, + }; parse_source_with_source_id_and_externs_with_catalog( source, flavor, options, - original_source_id, true, - explicit_host_api_catalog(options), + host_catalog.clone(), + &context, ) } @@ -110,13 +387,21 @@ pub(super) fn parse_source_with_source_id_and_externs( original_source_id: u32, allow_implicit_externs: bool, ) -> Result { + let host_catalog = effective_host_api_catalog(options); + let context = SourceFrontendContext { + source_id: original_source_id, + original_path: None, + phase: ParsePhase::Standalone, + host_api_catalog: host_catalog.as_deref(), + imports: &[], + }; parse_source_with_source_id_and_externs_with_catalog( source, flavor, options, - original_source_id, allow_implicit_externs, - effective_host_api_catalog(options), + host_catalog.clone(), + &context, ) } @@ -124,9 +409,9 @@ fn parse_source_with_source_id_and_externs_with_catalog( source: &str, flavor: SourceFlavor, options: &CompileSourceFileOptions, - original_source_id: u32, allow_implicit_externs: bool, host_catalog: Option>, + context: &SourceFrontendContext<'_>, ) -> Result { match flavor { SourceFlavor::RustScript => { @@ -137,18 +422,39 @@ fn parse_source_with_source_id_and_externs_with_catalog( allow_implicit_externs, false, true, - original_source_id, + context.source_id, false, host_catalog, ) } - SourceFlavor::JavaScript | SourceFlavor::Lua => { + SourceFlavor::Plugin(_) => { let Some(plugin) = options.source_plugin_for_flavor(flavor) else { return Err(ParseError::new(format!( "no frontend plugin registered for {flavor:?} source" ))); }; - plugin.parse_source(source) + let mut ir = plugin.parse_source(source, context)?; + match context.host_api_catalog { + Some(catalog) => { + HostApiIrMetadata::validate_catalog_bindings( + ir.host_api_metadata.as_ref(), + &ir.functions, + &ir.function_impls, + catalog, + )?; + // Bind host-free/type-only producers after validation so + // unbound catalog calls cannot acquire authority here. + ir.host_api_metadata + .get_or_insert_with(|| HostApiIrMetadata::new(catalog.fingerprint())); + } + None if ir.host_api_metadata.is_some() => { + return Err(ParseError::new( + "frontend IR carries host metadata without a caller catalog", + )); + } + None => {} + } + Ok(ir) } } } @@ -159,7 +465,7 @@ pub(crate) fn parser_dialect_for_flavor( ) -> Option<&'static dyn ParserDialect> { match flavor { SourceFlavor::RustScript => Some(rustscript::parser_dialect()), - SourceFlavor::JavaScript | SourceFlavor::Lua => options + SourceFlavor::Plugin(_) => options .source_plugin_for_flavor(flavor) .and_then(|plugin| plugin.parser_dialect()), } @@ -193,17 +499,28 @@ pub(super) fn parse_rustscript_repl_source( /// REPL parse with an optional catalog snapshot: when `Some`, the parsed IR /// carries `host_api_metadata` so standard host calls compile to exact V13 /// `HostImport` schemas (never a name-only fallback). +#[cfg(test)] pub(super) fn parse_rustscript_repl_source_with_catalog( source: &str, predefined_locals: &[ReplLocalBinding], host_catalog: Option>, +) -> Result { + parse_rustscript_repl_source_with_context(source, predefined_locals, host_catalog, 0, false) +} + +pub(super) fn parse_rustscript_repl_source_with_context( + source: &str, + predefined_locals: &[ReplLocalBinding], + host_catalog: Option>, + source_id: u32, + allow_implicit_externs: bool, ) -> Result { let lowered = rustscript::lower(source)?; parse_lowered_repl_with_mapping( source, lowered, predefined_locals, - false, + (source_id, allow_implicit_externs), false, true, host_catalog, @@ -426,13 +743,14 @@ fn parse_lowered_repl_with_mapping( original_source: &str, lowered: LoweredSource, predefined_locals: &[ReplLocalBinding], - allow_implicit_externs: bool, + entry_context: (u32, bool), allow_implicit_semicolons: bool, enforce_mutable_bindings: bool, host_catalog: Option>, ) -> Result { let mut source_map = SourceMap::new(); - let original_source_id = source_map.add_source("", original_source.to_string()); + let (original_source_id, allow_implicit_externs) = entry_context; + source_map.add_source_at(original_source_id, "", original_source.to_string()); let lowered_source_id = source_map.add_source("", lowered.text.clone()); match parse_repl_with_parser( diff --git a/src/compiler/ir.rs b/src/compiler/ir.rs index 5cc26766..9ad97dfe 100644 --- a/src/compiler/ir.rs +++ b/src/compiler/ir.rs @@ -4,7 +4,8 @@ use std::hash::{Hash, Hasher}; use crate::ValueType; use crate::builtins::default_host_callable; use crate::host_api::{ - HostApiFingerprint, HostFunctionSchema, HostParamPassing, HostTypeSchema, ResourceTypeKey, + HostApiCatalog, HostApiFingerprint, HostFunctionSchema, HostParamPassing, HostTypeSchema, + ResourceTypeKey, }; use super::ParseError; @@ -543,7 +544,14 @@ pub enum Expr { name: String, type_args: Vec, }, + /// Missing-key lookup. Value mode retains optional-container ownership; + /// projection mode reads a literal child of a direct local with the same + /// availability, copyability and resource rules as ordinary `Get`. OptionalGet { + /// Explicit producer intent. A projection requires `Var` plus a + /// literal String/Int key; borrowed, temporary and dynamic-key + /// receivers must use value mode. Native `?.` remains value mode. + field_projection: bool, container: Box, key: Box, container_slot: LocalSlot, @@ -922,6 +930,100 @@ impl HostApiIrMetadata { self.candidates_by_function_index.insert(index, candidates); Ok(()) } + + /// Confirms an installed index still carries the complete catalog family + /// for the exact logical name and arity that owns it. + pub(crate) fn validate_binding( + &self, + index: u16, + catalog: &HostApiCatalog, + name: &str, + arity: u8, + ) -> Result<(), ParseError> { + if self.fingerprint != catalog.fingerprint() { + return Err(ParseError::new(format!( + "host catalog identity mismatch while validating '{name}'" + ))); + } + let expected = catalog + .functions_named(name) + .into_iter() + .filter(|candidate| candidate.params.len() == usize::from(arity)) + .cloned() + .collect::>(); + if expected.is_empty() { + return Err(ParseError::new(format!( + "host function '{name}' has no overload with {arity} argument(s)" + ))); + } + let candidates = self.candidates(index).ok_or_else(|| { + ParseError::new(format!( + "catalog-bound host function '{name}' at index {index} has no candidate metadata" + )) + })?; + if candidates != expected.as_slice() { + return Err(ParseError::new(format!( + "catalog-bound host function '{name}' at index {index} does not retain its complete {arity}-argument candidate family" + ))); + } + Ok(()) + } + + /// Fails closed before code generation when a plugin emits an import for a + /// catalog-owned name without the exact caller-catalog candidate binding. + pub(crate) fn validate_catalog_bindings( + metadata: Option<&Self>, + functions: &[FunctionDecl], + function_impls: &HashMap, + catalog: &HostApiCatalog, + ) -> Result<(), ParseError> { + let Some(metadata) = metadata else { + if let Some(function) = functions.iter().find(|function| { + !function_impls.contains_key(&function.index) + && !catalog.functions_named(&function.name).is_empty() + }) { + return Err(ParseError::new(format!( + "catalog-bound host function '{}' at index {} has no host catalog metadata", + function.name, function.index + ))); + } + return Ok(()); + }; + if metadata.fingerprint != catalog.fingerprint() { + return Err(ParseError::new( + "frontend IR host metadata belongs to a different caller catalog", + )); + } + for index in metadata.function_indices() { + let mut declarations = functions.iter().filter(|function| function.index == index); + let Some(function) = declarations.next() else { + return Err(ParseError::new(format!( + "host metadata references missing function index {index}" + ))); + }; + if declarations.next().is_some() { + return Err(ParseError::new(format!( + "host metadata function index {index} is declared more than once" + ))); + } + if function_impls.contains_key(&index) { + return Err(ParseError::new(format!( + "host metadata is attached to implemented function '{}' at index {index}", + function.name + ))); + } + metadata.validate_binding(index, catalog, &function.name, function.arity)?; + } + for function in functions { + if function_impls.contains_key(&function.index) + || catalog.functions_named(&function.name).is_empty() + { + continue; + } + metadata.validate_binding(function.index, catalog, &function.name, function.arity)?; + } + Ok(()) + } } #[derive(Clone, Debug)] @@ -1039,6 +1141,13 @@ impl SemanticIndex { /// leaves [`None`]. In that case the caller gets a minimal index with no /// provenance records. pub fn build(slot_schemas: Vec>, ir: &FrontendIr) -> Self { + let typed = super::FrontendTypedIr { + source_id: None, + ir, + local_schemas: &slot_schemas, + host_api_catalog: None, + source_map: None, + }; let mut resolved_calls = HashMap::new(); let mut function_return_schemas = HashMap::new(); let mut func_params = HashMap::new(); @@ -1046,7 +1155,11 @@ impl SemanticIndex { // Per-function declaration metadata from the flat function table. for decl in &ir.functions { func_params.insert(decl.index, decl.args.clone()); - function_return_schemas.insert(decl.index, decl.return_schema.clone()); + let result = match typed.schema_of_function(decl.index) { + Some(TypeSchema::Callable { result, .. }) => Some(*result), + _ => None, + }; + function_return_schemas.insert(decl.index, result); } // Pair every parsed call site with the typed/resolved Expr node that @@ -1066,26 +1179,16 @@ impl SemanticIndex { } // Walk the legalized IR and attach resolved facts by node id. for stmt in &ir.stmts { - collect_resolved_calls_in_stmt( - stmt, - &mut by_id, - &function_return_schemas, - &slot_schemas, - ); + collect_resolved_calls_in_stmt(stmt, &mut by_id, &typed, &slot_schemas); } for function_impl in ir.function_impls.values() { for stmt in &function_impl.body_stmts { - collect_resolved_calls_in_stmt( - stmt, - &mut by_id, - &function_return_schemas, - &slot_schemas, - ); + collect_resolved_calls_in_stmt(stmt, &mut by_id, &typed, &slot_schemas); } collect_resolved_calls_in_expr( &function_impl.body_expr, &mut by_id, - &function_return_schemas, + &typed, &slot_schemas, ); } @@ -1112,20 +1215,15 @@ impl SemanticIndex { fn collect_resolved_calls_in_stmt( stmt: &Stmt, by_id: &mut HashMap, - function_return_schemas: &HashMap>, + typed: &super::FrontendTypedIr<'_>, slot_schemas: &[Option], ) { match stmt { Stmt::Let { expr, .. } | Stmt::Expr { expr, .. } | Stmt::Assign { expr, .. } => { - collect_resolved_calls_in_expr(expr, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(expr, by_id, typed, slot_schemas); } Stmt::ClosureLet { closure, .. } => { - collect_resolved_calls_in_expr( - &closure.body, - by_id, - function_return_schemas, - slot_schemas, - ); + collect_resolved_calls_in_expr(&closure.body, by_id, typed, slot_schemas); } Stmt::IfElse { condition, @@ -1133,9 +1231,9 @@ fn collect_resolved_calls_in_stmt( else_branch, .. } => { - collect_resolved_calls_in_expr(condition, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(condition, by_id, typed, slot_schemas); for s in then_branch.iter().chain(else_branch.iter()) { - collect_resolved_calls_in_stmt(s, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_stmt(s, by_id, typed, slot_schemas); } } Stmt::For { @@ -1145,19 +1243,19 @@ fn collect_resolved_calls_in_stmt( body, .. } => { - collect_resolved_calls_in_stmt(init, by_id, function_return_schemas, slot_schemas); - collect_resolved_calls_in_expr(condition, by_id, function_return_schemas, slot_schemas); - collect_resolved_calls_in_stmt(post, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_stmt(init, by_id, typed, slot_schemas); + collect_resolved_calls_in_expr(condition, by_id, typed, slot_schemas); + collect_resolved_calls_in_stmt(post, by_id, typed, slot_schemas); for s in body { - collect_resolved_calls_in_stmt(s, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_stmt(s, by_id, typed, slot_schemas); } } Stmt::While { condition, body, .. } => { - collect_resolved_calls_in_expr(condition, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(condition, by_id, typed, slot_schemas); for s in body { - collect_resolved_calls_in_stmt(s, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_stmt(s, by_id, typed, slot_schemas); } } _ => {} @@ -1168,23 +1266,23 @@ fn collect_resolved_calls_in_stmt( fn collect_resolved_calls_in_expr( expr: &Expr, by_id: &mut HashMap, - function_return_schemas: &HashMap>, + typed: &super::FrontendTypedIr<'_>, slot_schemas: &[Option], ) { match expr { - Expr::Call(index, _type_args, args, host, semantic_id) => { + Expr::Call(_index, _type_args, args, host, semantic_id) => { if let Some(id) = semantic_id && let Some(entry) = by_id.get_mut(id) { if let Some(resolved) = host { entry.return_type = resolved.return_type.clone(); entry.host = Some((**resolved).clone()); - } else if let Some(schema) = function_return_schemas.get(index).cloned() { - entry.return_type = schema.unwrap_or(TypeSchema::Unknown); + } else { + entry.return_type = typed.schema_of_expr(expr).unwrap_or(TypeSchema::Unknown); } } for arg in args { - collect_resolved_calls_in_expr(arg, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(arg, by_id, typed, slot_schemas); } } Expr::ModuleCall(_symbol, _type_args, args, semantic_id) => { @@ -1198,7 +1296,7 @@ fn collect_resolved_calls_in_expr( entry.return_type = TypeSchema::Unknown; } for arg in args { - collect_resolved_calls_in_expr(arg, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(arg, by_id, typed, slot_schemas); } } Expr::LocalCall(slot, _type_args, args, semantic_id) => { @@ -1220,14 +1318,14 @@ fn collect_resolved_calls_in_expr( .unwrap_or(TypeSchema::Unknown); } for arg in args { - collect_resolved_calls_in_expr(arg, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(arg, by_id, typed, slot_schemas); } } Expr::Block { stmts, expr: inner } => { for s in stmts { - collect_resolved_calls_in_stmt(s, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_stmt(s, by_id, typed, slot_schemas); } - collect_resolved_calls_in_expr(inner, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(inner, by_id, typed, slot_schemas); } Expr::IfElse { condition, @@ -1235,9 +1333,9 @@ fn collect_resolved_calls_in_expr( else_expr, .. } => { - collect_resolved_calls_in_expr(condition, by_id, function_return_schemas, slot_schemas); - collect_resolved_calls_in_expr(then_expr, by_id, function_return_schemas, slot_schemas); - collect_resolved_calls_in_expr(else_expr, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(condition, by_id, typed, slot_schemas); + collect_resolved_calls_in_expr(then_expr, by_id, typed, slot_schemas); + collect_resolved_calls_in_expr(else_expr, by_id, typed, slot_schemas); } Expr::Match { value, @@ -1245,35 +1343,20 @@ fn collect_resolved_calls_in_expr( default, .. } => { - collect_resolved_calls_in_expr(value, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(value, by_id, typed, slot_schemas); for (_, arm_expr) in arms { - collect_resolved_calls_in_expr( - arm_expr, - by_id, - function_return_schemas, - slot_schemas, - ); + collect_resolved_calls_in_expr(arm_expr, by_id, typed, slot_schemas); } - collect_resolved_calls_in_expr(default, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(default, by_id, typed, slot_schemas); } Expr::Closure(closure) => { - collect_resolved_calls_in_expr( - &closure.body, - by_id, - function_return_schemas, - slot_schemas, - ); + collect_resolved_calls_in_expr(&closure.body, by_id, typed, slot_schemas); } Expr::ClosureCall(closure, args) => { for arg in args { - collect_resolved_calls_in_expr(arg, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(arg, by_id, typed, slot_schemas); } - collect_resolved_calls_in_expr( - &closure.body, - by_id, - function_return_schemas, - slot_schemas, - ); + collect_resolved_calls_in_expr(&closure.body, by_id, typed, slot_schemas); } Expr::Add(l, r) | Expr::Sub(l, r) @@ -1285,25 +1368,25 @@ fn collect_resolved_calls_in_expr( | Expr::Eq(l, r) | Expr::Lt(l, r) | Expr::Gt(l, r) => { - collect_resolved_calls_in_expr(l, by_id, function_return_schemas, slot_schemas); - collect_resolved_calls_in_expr(r, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(l, by_id, typed, slot_schemas); + collect_resolved_calls_in_expr(r, by_id, typed, slot_schemas); } Expr::Neg(inner) | Expr::Not(inner) | Expr::ToOwned(inner) | Expr::Borrow(inner) | Expr::BorrowMut(inner) => { - collect_resolved_calls_in_expr(inner, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(inner, by_id, typed, slot_schemas); } Expr::OptionalGet { container, key, .. } => { - collect_resolved_calls_in_expr(container, by_id, function_return_schemas, slot_schemas); - collect_resolved_calls_in_expr(key, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(container, by_id, typed, slot_schemas); + collect_resolved_calls_in_expr(key, by_id, typed, slot_schemas); } Expr::OptionUnwrapOr { value, fallback, .. } => { - collect_resolved_calls_in_expr(value, by_id, function_return_schemas, slot_schemas); - collect_resolved_calls_in_expr(fallback, by_id, function_return_schemas, slot_schemas); + collect_resolved_calls_in_expr(value, by_id, typed, slot_schemas); + collect_resolved_calls_in_expr(fallback, by_id, typed, slot_schemas); } _ => {} } @@ -1525,6 +1608,8 @@ pub struct LexerToken { /// collision-free during unit merge. #[derive(Clone, Debug, Default)] pub struct ParsedSemanticIndex { + /// Canonical nominal identities joined to their source declaration/alias. + pub type_provenance: Vec, /// All parsed call sites, in allocation order. pub call_sites: Vec, /// All parsed local declarations, in allocation order. @@ -1563,6 +1648,40 @@ pub struct StmtSpanSite { pub span: Span, } +/// One source spelling of a module-owned guest type. Display never changes +/// the canonical schema identity or the original parser declaration name. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GuestTypeProvenance { + pub canonical_name: String, + pub source_name: String, + pub source_id: u32, + pub declaration_span: Option, +} + +impl FrontendIr { + /// Canonical schema with source-owned spellings for language-neutral labels. + pub fn source_schema(&self, schema: &TypeSchema, source_id: u32) -> TypeSchema { + let mut names = HashMap::new(); + if let Some(index) = &self.parsed_semantic_index { + for provenance in &index.type_provenance { + if provenance.source_id == source_id { + names + .entry(provenance.canonical_name.clone()) + .or_insert_with(|| provenance.source_name.clone()); + } + } + for provenance in &index.type_provenance { + names + .entry(provenance.canonical_name.clone()) + .or_insert_with(|| provenance.source_name.clone()); + } + } + let mut display = schema.clone(); + super::linker::remap_type_schema_names(&mut display, &names); + display + } +} + impl ParsedSemanticIndex { /// Allocate a new monotonic [`SemanticNodeId`]. Exhaustion of the u32 id /// space is a parser-level resource failure: it is asserted explicitly diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index e555d1e9..c9c67ae8 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -103,11 +103,31 @@ impl FlowState { } pub(super) fn enforce_local_availability( + ir: FrontendIr, + entry_locals: &[EntryLocalAvailability], + clear_dead_locals: bool, + enable_local_move_semantics: bool, + owned_local_slots: &[bool], +) -> Result { + enforce_source_availability( + ir, + entry_locals, + clear_dead_locals, + enable_local_move_semantics, + owned_local_slots, + None, + None, + ) +} + +pub(super) fn enforce_source_availability( mut ir: FrontendIr, entry_locals: &[EntryLocalAvailability], clear_dead_locals: bool, enable_local_move_semantics: bool, owned_local_slots: &[bool], + source_policies: Option<&HashMap>, + sources: Option<&super::super::source_map::SourceMap>, ) -> Result { // Pad the post-legalize ownership metadata to the analyzer's local space. // Availability runs pre-compaction, so the logical slot indices align with @@ -116,31 +136,91 @@ pub(super) fn enforce_local_availability( for (slot, is_owned) in owned_local_slots.iter().enumerate().take(ir.locals) { owned_slots[slot] = *is_owned; } + let attach_owner = |mut error: ParseError, source: Option<&str>| { + if let Some(sources) = sources { + let id = source + .and_then(|name| sources.source_id_by_name(name)) + .unwrap_or(0); + if error.span.is_none() { + error.span = ir.parsed_semantic_index.as_ref().and_then(|index| { + index + .stmt_spans + .iter() + .find(|site| site.line as usize == error.line && site.span.source_id == id) + .map(|site| site.span) + }); + } + error = error.with_line_span_from_source(sources, id); + } + error + }; let initial_impls = std::mem::take(&mut ir.function_impls); - let bootstrap_analyzer = AvailabilityAnalyzer::new( - ir.locals, - &ir.local_bindings, - &initial_impls, - enable_local_move_semantics, - &owned_slots, - ); let mut rewritten_impls = HashMap::with_capacity(initial_impls.len()); - for (index, function_impl) in initial_impls { - let rewritten = bootstrap_analyzer.analyze_function_impl(function_impl)?; - rewritten_impls.insert(index, rewritten); + for (index, function_impl) in &initial_impls { + let policy = source_policies + .and_then(|policies| { + ir.function_sources + .get(index) + .and_then(|source| policies.get(source)) + }) + .copied() + .unwrap_or(enable_local_move_semantics); + let analyzer = AvailabilityAnalyzer::new( + ir.locals, + &ir.local_bindings, + &initial_impls, + policy, + &owned_slots, + ); + let rewritten = analyzer + .analyze_function_impl(function_impl.clone()) + .map_err(|error| { + attach_owner(error, ir.function_sources.get(index).map(String::as_str)) + })?; + rewritten_impls.insert(*index, rewritten); } - let analyzer = AvailabilityAnalyzer::new( - ir.locals, - &ir.local_bindings, - &rewritten_impls, - enable_local_move_semantics, - &owned_slots, - ); - let entry_state = FlowState::reachable_with_entry_locals(ir.locals, entry_locals); - let (rewritten_stmts, _) = analyzer.analyze_block(&ir.stmts, entry_state, true)?; + let mut state = FlowState::reachable_with_entry_locals(ir.locals, entry_locals); + let mut rewritten_stmts = Vec::new(); + let mut rewritten_sources = Vec::new(); + let mut start = 0; + while start < ir.stmts.len() { + let source = ir + .stmt_sources + .get(start) + .and_then(|source| source.as_deref()); + let mut end = start + 1; + while end < ir.stmts.len() + && ir + .stmt_sources + .get(end) + .and_then(|source| source.as_deref()) + == source + { + end += 1; + } + let policy = source_policies + .and_then(|policies| source.and_then(|source| policies.get(source))) + .copied() + .unwrap_or(enable_local_move_semantics); + let analyzer = AvailabilityAnalyzer::new( + ir.locals, + &ir.local_bindings, + &rewritten_impls, + policy, + &owned_slots, + ); + let (stmts, next) = analyzer + .analyze_block(&ir.stmts[start..end], state, true) + .map_err(|error| attach_owner(error, source))?; + rewritten_sources.extend(std::iter::repeat_n(source.map(str::to_owned), stmts.len())); + rewritten_stmts.extend(stmts); + state = next; + start = end; + } ir.stmts = rewritten_stmts; + ir.stmt_sources = rewritten_sources; ir.function_impls = rewritten_impls; if clear_dead_locals { @@ -151,7 +231,8 @@ pub(super) fn enforce_local_availability( &owned_slots, ); let persistent_slots = persistent_capture_slots(&ir.stmts, &ir.function_impls); - ir.stmts = liveness.rewrite_program_block(&ir.stmts); + (ir.stmts, ir.stmt_sources) = + liveness.rewrite_program_block_with_sources(&ir.stmts, &ir.stmt_sources); for function_impl in ir.function_impls.values_mut() { *function_impl = liveness.rewrite_function_impl(function_impl.clone(), &persistent_slots); @@ -174,12 +255,22 @@ pub(super) fn enforce_local_availability( /// spurious dynamic-target facts. Pre-compaction slots are the true /// frame-relative value identities, so the classification is strictly more /// precise on the unallocated IR. -pub(crate) fn allocate_local_slots(mut ir: FrontendIr) -> Result { +pub(crate) fn allocate_local_slots(ir: FrontendIr) -> Result { + allocate_local_slots_with_root_slots(ir, &[]).map(|allocation| allocation.ir) +} + +pub(in crate::compiler) fn allocate_local_slots_with_root_slots( + ir: FrontendIr, + root_slots: &[LocalSlot], +) -> Result { if ir.locals > LOCAL_SLOT_ALLOCATOR_COMPAT_THRESHOLD { let allocator = LocalSlotAllocator::new(ir.locals, &ir.local_bindings, &ir.function_impls); - ir = allocator.allocate(ir)?; + allocator.allocate(ir, root_slots) + } else { + // Tiny programs retain their original layout, including the entry seeds. + let mapping = (0..ir.locals).map(|slot| slot as LocalSlot).collect(); + Ok(super::LocalSlotAllocation { ir, mapping }) } - Ok(ir) } pub(crate) fn function_capture_binding_mode( @@ -806,21 +897,7 @@ impl AvailabilityAnalyzer { self.mark_field_moved(&mut out, *root, field_key); Ok(out) } - Expr::OptionalGet { - container, - key, - container_slot, - key_slot, - semantic_id: _, - } => { - let container_state = self.analyze_expr(container, state, line)?; - let mut out = container_state; - self.mark_owned_value_reads_moved(container, &mut out); - out = self.analyze_expr(key, &out, line)?; - self.mark_available(&mut out, *container_slot, line)?; - self.mark_available(&mut out, *key_slot, line)?; - Ok(out) - } + Expr::OptionalGet { .. } => self.analyze_optional_get(expr, state, line, false), Expr::OptionUnwrapOr { value, value_slot, @@ -1048,6 +1125,54 @@ impl AvailabilityAnalyzer { } } + fn analyze_optional_get( + &self, + expr: &Expr, + state: &FlowState, + line: u32, + copy_projection: bool, + ) -> Result { + let Expr::OptionalGet { + field_projection, + container, + key, + container_slot, + key_slot, + .. + } = expr + else { + unreachable!("optional lookup expression"); + }; + let mut out = if *field_projection { + let Some((root, field_key)) = self.extract_optional_field_projection(expr) else { + return Err(ParseError { + span: None, + code: Some("E_OPTIONAL_FIELD_PROJECTION".to_string()), + line: line as usize, + message: "optional field projection requires a direct local and a literal key" + .to_string(), + }); + }; + let mut out = self.analyze_projection_root_expr(container, state, line)?; + out = self.analyze_expr(key, &out, line)?; + self.require_field_available(root, &field_key, &out, line)?; + if self.enable_local_move_semantics + && !copy_projection + && !self.is_copyable_field(root, &field_key, &out) + { + self.mark_field_moved(&mut out, root, field_key); + } + out + } else { + let mut out = self.analyze_expr(container, state, line)?; + self.mark_owned_value_reads_moved(container, &mut out); + self.analyze_expr(key, &out, line)? + }; + self.mark_available(&mut out, *container_slot, line)?; + self.mark_available(&mut out, *key_slot, line)?; + Ok(out) + } + fn analyze_expr_to_owned( &self, inner: &Expr, @@ -1070,6 +1195,9 @@ impl AvailabilityAnalyzer { self.require_field_available(root_slot, &field_key, &out, line)?; return Ok(out); } + if self.extract_optional_field_projection(inner).is_some() { + return self.analyze_optional_get(inner, state, line, true); + } self.analyze_expr(inner, state, line) } @@ -1222,8 +1350,16 @@ impl AvailabilityAnalyzer { self.mark_field_moved(state, *root, MovedFieldKey::Index(*index)); } } - Expr::OptionalGet { container, .. } - | Expr::OptionUnwrapOr { + Expr::OptionalGet { container, .. } => { + if let Some((root, key)) = self.extract_optional_field_projection(expr) { + if !self.is_copyable_field(root, &key, state) { + self.mark_field_moved(state, root, key); + } + } else { + self.mark_owned_value_reads_moved(container, state); + } + } + Expr::OptionUnwrapOr { value: container, .. } => { self.mark_owned_value_reads_moved(container, state); @@ -1265,6 +1401,7 @@ impl AvailabilityAnalyzer { self.is_owned_slot(*root) } Expr::OptionalGet { + field_projection: _, container, key, container_slot, @@ -1512,7 +1649,7 @@ impl AvailabilityAnalyzer { }) } _ => { - let rewritten = self.rewrite_expr_for_ownership(expr)?; + let rewritten = self.rewrite_expr_for_ownership(expr, state)?; Ok(self.rewrite_runtime_field_move_expr(&rewritten, state)) } } @@ -1529,20 +1666,25 @@ impl AvailabilityAnalyzer { /// `MoveVar` (aggregate insertion transfers ownership). /// /// Plain (non-resource) programs are structurally preserved. - fn rewrite_expr_for_ownership(&self, expr: &Expr) -> Result { - self.rewrite_expr_ownership_inner(expr, false) + fn rewrite_expr_for_ownership( + &self, + expr: &Expr, + state: &FlowState, + ) -> Result { + self.rewrite_expr_ownership_inner(expr, false, state) } fn rewrite_expr_ownership_inner( &self, expr: &Expr, in_call_arg: bool, + state: &FlowState, ) -> Result { match expr { Expr::Call(index, type_args, args, resolution, source_node_id) => { let mut rewritten_args = Vec::with_capacity(args.len()); for arg in args { - rewritten_args.push(self.rewrite_expr_ownership_inner(arg, true)?); + rewritten_args.push(self.rewrite_expr_ownership_inner(arg, true, state)?); } if let Some(resolution) = resolution.as_deref() { for (position, arg) in rewritten_args.iter_mut().enumerate() { @@ -1601,14 +1743,20 @@ impl AvailabilityAnalyzer { index: *index, }), Expr::OptionalGet { + field_projection: true, + .. + } => Ok(self.rewrite_runtime_field_move_expr(expr, state)), + Expr::OptionalGet { + field_projection, container, key, container_slot, key_slot, semantic_id, } => Ok(Expr::OptionalGet { - container: Box::new(self.rewrite_expr_ownership_inner(container, false)?), - key: Box::new(self.rewrite_expr_ownership_inner(key, false)?), + field_projection: *field_projection, + container: Box::new(self.rewrite_expr_ownership_inner(container, false, state)?), + key: Box::new(self.rewrite_expr_ownership_inner(key, false, state)?), container_slot: *container_slot, key_slot: *key_slot, semantic_id: *semantic_id, @@ -1619,36 +1767,50 @@ impl AvailabilityAnalyzer { fallback, semantic_id, } => Ok(Expr::OptionUnwrapOr { - value: Box::new(self.rewrite_expr_ownership_inner(value, false)?), + value: Box::new(self.rewrite_expr_ownership_inner(value, false, state)?), value_slot: *value_slot, - fallback: Box::new(self.rewrite_expr_ownership_inner(fallback, false)?), + fallback: Box::new(self.rewrite_expr_ownership_inner(fallback, false, state)?), semantic_id: *semantic_id, }), Expr::LocalCall(index, type_args, args, semantic_id) => Ok(Expr::LocalCall( *index, type_args.clone(), - self.rewrite_call_args(args)?, + self.rewrite_call_args(args, state)?, *semantic_id, )), Expr::ModuleCall(index, type_args, args, semantic_id) => Ok(Expr::ModuleCall( *index, type_args.clone(), - self.rewrite_call_args(args)?, + self.rewrite_call_args(args, state)?, *semantic_id, )), - Expr::Closure(closure) => Ok(Expr::Closure(ClosureExpr { - param_slots: closure.param_slots.clone(), - capture_copies: closure.capture_copies.clone(), - body: Box::new(self.rewrite_expr_ownership_inner(&closure.body, false)?), - })), - Expr::ClosureCall(closure, args) => Ok(Expr::ClosureCall( - ClosureExpr { + Expr::Closure(closure) => { + let closure_state = self.closure_flow_state(closure, state, 0)?; + Ok(Expr::Closure(ClosureExpr { param_slots: closure.param_slots.clone(), capture_copies: closure.capture_copies.clone(), - body: Box::new(self.rewrite_expr_ownership_inner(&closure.body, false)?), - }, - self.rewrite_call_args(args)?, - )), + body: Box::new(self.rewrite_expr_ownership_inner( + &closure.body, + false, + &closure_state, + )?), + })) + } + Expr::ClosureCall(closure, args) => { + let closure_state = self.closure_flow_state(closure, state, 0)?; + Ok(Expr::ClosureCall( + ClosureExpr { + param_slots: closure.param_slots.clone(), + capture_copies: closure.capture_copies.clone(), + body: Box::new(self.rewrite_expr_ownership_inner( + &closure.body, + false, + &closure_state, + )?), + }, + self.rewrite_call_args(args, state)?, + )) + } Expr::Add(lhs, rhs) | Expr::Sub(lhs, rhs) | Expr::Mul(lhs, rhs) @@ -1659,8 +1821,8 @@ impl AvailabilityAnalyzer { | Expr::Eq(lhs, rhs) | Expr::Lt(lhs, rhs) | Expr::Gt(lhs, rhs) => { - let lhs = self.rewrite_expr_ownership_inner(lhs, false)?; - let rhs = self.rewrite_expr_ownership_inner(rhs, false)?; + let lhs = self.rewrite_expr_ownership_inner(lhs, false, state)?; + let rhs = self.rewrite_expr_ownership_inner(rhs, false, state)?; Ok(match expr { Expr::Add(..) => Expr::Add(Box::new(lhs), Box::new(rhs)), Expr::Sub(..) => Expr::Sub(Box::new(lhs), Box::new(rhs)), @@ -1676,19 +1838,24 @@ impl AvailabilityAnalyzer { }) } Expr::Neg(inner) | Expr::Not(inner) => { - let inner = self.rewrite_expr_ownership_inner(inner, false)?; + let inner = self.rewrite_expr_ownership_inner(inner, false, state)?; Ok(match expr { Expr::Neg(..) => Expr::Neg(Box::new(inner)), _ => Expr::Not(Box::new(inner)), }) } + Expr::ToOwned(inner) if self.extract_optional_field_projection(inner).is_some() => { + // An explicit non-resource copy checks availability without + // removing the projected child from its original record. + Ok(expr.clone()) + } Expr::ToOwned(inner) | Expr::Borrow(inner) | Expr::BorrowMut(inner) => { // Non-resource borrow/copy wrappers are preserved verbatim; // resource-bearing ones were already rejected during analysis. // The inner read keeps the call-argument context when nested // inside one, so a borrow of an owned local in a host-call // argument stays a plain read (never a MoveVar). - let inner = self.rewrite_expr_ownership_inner(inner, in_call_arg)?; + let inner = self.rewrite_expr_ownership_inner(inner, in_call_arg, state)?; Ok(match expr { Expr::ToOwned(..) => Expr::ToOwned(Box::new(inner)), Expr::Borrow(..) => Expr::Borrow(Box::new(inner)), @@ -1700,9 +1867,9 @@ impl AvailabilityAnalyzer { then_expr, else_expr, } => Ok(Expr::IfElse { - condition: Box::new(self.rewrite_expr_ownership_inner(condition, false)?), - then_expr: Box::new(self.rewrite_expr_ownership_inner(then_expr, false)?), - else_expr: Box::new(self.rewrite_expr_ownership_inner(else_expr, false)?), + condition: Box::new(self.rewrite_expr_ownership_inner(condition, false, state)?), + then_expr: Box::new(self.rewrite_expr_ownership_inner(then_expr, false, state)?), + else_expr: Box::new(self.rewrite_expr_ownership_inner(else_expr, false, state)?), }), Expr::Match { value_slot, @@ -1715,25 +1882,18 @@ impl AvailabilityAnalyzer { for (pattern, arm_expr) in arms { rewritten_arms.push(( pattern.clone(), - self.rewrite_expr_ownership_inner(arm_expr, false)?, + self.rewrite_expr_ownership_inner(arm_expr, false, state)?, )); } Ok(Expr::Match { value_slot: *value_slot, result_slot: *result_slot, - value: Box::new(self.rewrite_expr_ownership_inner(value, false)?), + value: Box::new(self.rewrite_expr_ownership_inner(value, false, state)?), arms: rewritten_arms, - default: Box::new(self.rewrite_expr_ownership_inner(default, false)?), + default: Box::new(self.rewrite_expr_ownership_inner(default, false, state)?), }) } - Expr::Block { stmts, expr } => Ok(Expr::Block { - // Statement-level rewrites run through the stmt handlers - // during analysis. Preserve those transformations when the - // block is nested inside an expression instead of retaining - // the pre-analysis statements. - stmts: self.rewrite_block_stmts_for_ownership(stmts)?, - expr: Box::new(self.rewrite_expr_ownership_inner(expr, false)?), - }), + Expr::Block { .. } => self.rewrite_expr_for_ownership_with_state(expr, state, 0), Expr::Null | Expr::Int(_) | Expr::Float(_) @@ -1746,94 +1906,10 @@ impl AvailabilityAnalyzer { } } - fn rewrite_block_stmts_for_ownership(&self, stmts: &[Stmt]) -> Result, ParseError> { - stmts - .iter() - .map(|stmt| self.rewrite_stmt_for_ownership(stmt)) - .collect() - } - - fn rewrite_stmt_for_ownership(&self, stmt: &Stmt) -> Result { - match stmt { - Stmt::Noop { .. } - | Stmt::FuncDecl { .. } - | Stmt::Break { .. } - | Stmt::Continue { .. } - | Stmt::Drop { .. } => Ok(stmt.clone()), - Stmt::Let { - index, - declared_schema, - expr, - line, - } => Ok(Stmt::Let { - index: *index, - declared_schema: declared_schema.clone(), - expr: self.rewrite_expr_ownership_inner(expr, false)?, - line: *line, - }), - Stmt::Assign { - kind, - index, - expr, - line, - } => Ok(Stmt::Assign { - kind: kind.clone(), - index: *index, - expr: self.rewrite_expr_ownership_inner(expr, false)?, - line: *line, - }), - Stmt::ClosureLet { line, closure } => Ok(Stmt::ClosureLet { - line: *line, - closure: ClosureExpr { - param_slots: closure.param_slots.clone(), - capture_copies: closure.capture_copies.clone(), - body: Box::new(self.rewrite_expr_ownership_inner(&closure.body, false)?), - }, - }), - Stmt::Expr { expr, line } => Ok(Stmt::Expr { - expr: self.rewrite_expr_ownership_inner(expr, false)?, - line: *line, - }), - Stmt::IfElse { - condition, - then_branch, - else_branch, - line, - } => Ok(Stmt::IfElse { - condition: self.rewrite_expr_ownership_inner(condition, false)?, - then_branch: self.rewrite_block_stmts_for_ownership(then_branch)?, - else_branch: self.rewrite_block_stmts_for_ownership(else_branch)?, - line: *line, - }), - Stmt::For { - init, - condition, - post, - body, - line, - } => Ok(Stmt::For { - init: Box::new(self.rewrite_stmt_for_ownership(init)?), - condition: self.rewrite_expr_ownership_inner(condition, false)?, - post: Box::new(self.rewrite_stmt_for_ownership(post)?), - body: self.rewrite_block_stmts_for_ownership(body)?, - line: *line, - }), - Stmt::While { - condition, - body, - line, - } => Ok(Stmt::While { - condition: self.rewrite_expr_ownership_inner(condition, false)?, - body: self.rewrite_block_stmts_for_ownership(body)?, - line: *line, - }), - } - } - - fn rewrite_call_args(&self, args: &[Expr]) -> Result, ParseError> { + fn rewrite_call_args(&self, args: &[Expr], state: &FlowState) -> Result, ParseError> { let mut rewritten = Vec::with_capacity(args.len()); for arg in args { - rewritten.push(self.rewrite_expr_ownership_inner(arg, true)?); + rewritten.push(self.rewrite_expr_ownership_inner(arg, true, state)?); } Ok(rewritten) } @@ -2068,6 +2144,82 @@ impl AvailabilityAnalyzer { if !self.enable_local_move_semantics { return expr.clone(); } + if let Some((root, key)) = self.extract_optional_field_projection(expr) { + if self.is_copyable_field(root, &key, state) { + return expr.clone(); + } + let Expr::OptionalGet { container_slot, .. } = expr else { + unreachable!("optional field projection"); + }; + // Save the missing-to-Null result, then detach the same child as + // ordinary Get. Do not insert a missing child or clone the record. + let mut lookup = expr.clone(); + if let Expr::OptionalGet { + field_projection, .. + } = &mut lookup + { + *field_projection = false; + } + let key_expr = match key { + MovedFieldKey::String(value) => Expr::String(value), + MovedFieldKey::Index(value) => Expr::Int(value), + _ => unreachable!("literal projection key"), + }; + let call = |builtin: BuiltinFunction, args| { + Expr::Call(builtin.call_index(), vec![], args, None, None) + }; + let has = call( + BuiltinFunction::Has, + vec![Expr::Var(root), key_expr.clone()], + ); + let present = if matches!(key_expr, Expr::Int(_)) { + Expr::IfElse { + condition: Box::new(Expr::Eq( + Box::new(call(BuiltinFunction::TypeOf, vec![Expr::Var(root)])), + Box::new(Expr::String("map".to_string())), + )), + then_expr: Box::new(has), + else_expr: Box::new(Expr::And( + Box::new(Expr::Not(Box::new(Expr::Lt( + Box::new(key_expr.clone()), + Box::new(Expr::Int(0)), + )))), + Box::new(Expr::Lt( + Box::new(key_expr.clone()), + Box::new(call(BuiltinFunction::Len, vec![Expr::Var(root)])), + )), + )), + } + } else { + has + }; + return Expr::Block { + stmts: vec![ + Stmt::Let { + index: *container_slot, + expr: lookup, + line: 0, + declared_schema: None, + }, + Stmt::IfElse { + condition: present, + then_branch: vec![Stmt::Assign { + kind: super::super::ir::AssignmentKind::Set, + index: root, + expr: call( + BuiltinFunction::Set, + vec![Expr::Var(root), key_expr, Expr::Null], + ), + line: 0, + }], + else_branch: vec![], + line: 0, + }, + ], + expr: Box::new(Expr::Var(*container_slot)), + }; + } + let Expr::Call(index, _, args, _, _) = expr else { return expr.clone(); }; diff --git a/src/compiler/lifetime/availability/captures.rs b/src/compiler/lifetime/availability/captures.rs index e498dbdd..f5759e48 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -91,6 +91,18 @@ impl AvailabilityAnalyzer { self.require_local_not_partially_moved(*source_slot, state, line)?; } + let closure_state = self.closure_flow_state(closure, state, line)?; + self.analyze_expr(&closure.body, &closure_state, line)?; + Ok(()) + } + + /// Reuse the exact capture/parameter facts for nested ownership lowering. + pub(super) fn closure_flow_state( + &self, + closure: &ClosureExpr, + state: &FlowState, + line: u32, + ) -> Result { let mut closure_state = FlowState::reachable(self.local_count); for slot in &closure.param_slots { self.mark_available(&mut closure_state, *slot, line)?; @@ -145,8 +157,7 @@ impl AvailabilityAnalyzer { }); } } - self.analyze_expr(&closure.body, &closure_state, line)?; - Ok(()) + Ok(closure_state) } #[allow(clippy::too_many_arguments)] diff --git a/src/compiler/lifetime/availability/consumption.rs b/src/compiler/lifetime/availability/consumption.rs index c6f44843..3d1dbf74 100644 --- a/src/compiler/lifetime/availability/consumption.rs +++ b/src/compiler/lifetime/availability/consumption.rs @@ -177,6 +177,7 @@ pub(super) fn expr_uses_slot(expr: &Expr, slot: LocalSlot) -> bool { Expr::Var(index) | Expr::MoveVar(index) => *index == slot, Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => *root == slot, Expr::OptionalGet { + field_projection: _, container, key, container_slot, diff --git a/src/compiler/lifetime/availability/field_moves.rs b/src/compiler/lifetime/availability/field_moves.rs index 2e698b09..3897ad47 100644 --- a/src/compiler/lifetime/availability/field_moves.rs +++ b/src/compiler/lifetime/availability/field_moves.rs @@ -1,6 +1,25 @@ use super::*; impl AvailabilityAnalyzer { + pub(super) fn extract_optional_field_projection( + &self, + expr: &Expr, + ) -> Option<(LocalSlot, MovedFieldKey)> { + let Expr::OptionalGet { + field_projection: true, + container, + key, + .. + } = expr + else { + return None; + }; + let Expr::Var(root) = container.as_ref() else { + return None; + }; + Some((*root, self.extract_literal_moved_field_key(key)?)) + } + pub(super) fn extract_moved_field_access( &self, call_index: u16, @@ -547,6 +566,10 @@ impl AvailabilityAnalyzer { .extract_moved_field_access(*index, args) .map(|(root_slot, field_key)| self.is_copyable_field(root_slot, &field_key, state)) .unwrap_or(false), + Expr::OptionalGet { .. } => self + .extract_optional_field_projection(expr) + .map(|(root, key)| self.is_copyable_field(root, &key, state)) + .unwrap_or(false), Expr::IfElse { then_expr, else_expr, diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index 69f6f26b..741f26a0 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -46,12 +46,23 @@ impl LivenessRewriter { } } - pub(super) fn rewrite_program_block(&self, stmts: &[Stmt]) -> Vec { + #[cfg(test)] + fn rewrite_program_block(&self, stmts: &[Stmt]) -> Vec { + self.rewrite_program_block_with_sources(stmts, &[]).0 + } + + pub(super) fn rewrite_program_block_with_sources( + &self, + stmts: &[Stmt], + sources: &[Option], + ) -> (Vec, Vec>) { let mut live_out = self.empty_set(); for slot in persistent_capture_slots(stmts, &self.function_impls) { self.mark_live(&mut live_out, slot); } - self.rewrite_block(stmts, &live_out, false).0 + let (stmts, _, sources) = + self.rewrite_block_with_sources(stmts, &live_out, false, Some(sources)); + (stmts, sources) } pub(super) fn rewrite_function_impl( @@ -83,9 +94,26 @@ impl LivenessRewriter { live_out: &LiveSet, suppress_clears: bool, ) -> (Vec, LiveSet) { + let (stmts, live, _) = + self.rewrite_block_with_sources(stmts, live_out, suppress_clears, None); + (stmts, live) + } + + fn rewrite_block_with_sources( + &self, + stmts: &[Stmt], + live_out: &LiveSet, + suppress_clears: bool, + sources: Option<&[Option]>, + ) -> (Vec, LiveSet, Vec>) { let mut live_after = live_out.clone(); let mut rewritten_rev = Vec::::new(); - for stmt in stmts.iter().rev() { + let mut sources_rev = Vec::new(); + for (index, stmt) in stmts.iter().enumerate().rev() { + let source = sources + .and_then(|sources| sources.get(index)) + .cloned() + .flatten(); let (rewritten_stmt, live_before, defs) = self.rewrite_stmt(stmt, &live_after, suppress_clears); let clear_slots = if suppress_clears { @@ -103,12 +131,19 @@ impl LivenessRewriter { index: *slot, line: clear_line, }); + if sources.is_some() { + sources_rev.push(source.clone()); + } } rewritten_rev.push(rewritten_stmt); + if sources.is_some() { + sources_rev.push(source); + } live_after = live_before; } rewritten_rev.reverse(); - (rewritten_rev, live_after) + sources_rev.reverse(); + (rewritten_rev, live_after, sources_rev) } fn rewrite_stmt( @@ -525,6 +560,7 @@ impl LivenessRewriter { self.mark_live(live, *root) } Expr::OptionalGet { + field_projection: _, container, key, container_slot, @@ -825,13 +861,20 @@ impl LocalSlotAllocator { } } - pub(super) fn allocate(mut self, mut ir: FrontendIr) -> Result { + pub(super) fn allocate( + mut self, + mut ir: FrontendIr, + root_slots: &[LocalSlot], + ) -> Result { let persistent_slots = persistent_capture_slots(&ir.stmts, &ir.function_impls); let mut live_out = self.liveness.empty_set(); - for slot in &persistent_slots { + for slot in persistent_slots.iter().chain(root_slots) { self.liveness.mark_live(&mut live_out, *slot); } - let _ = self.collect_block(&ir.stmts, &live_out, &[])?; + // Session bindings are observable after the snippet, including unused + // entries and first-snippet definitions. Protect them throughout ROOT + // execution; function/closure frames retain only their own constraints. + let _ = self.collect_block(&ir.stmts, &live_out, root_slots)?; for function_impl in ir.function_impls.values() { let mut live_after = self.liveness.function_body_live_out_precise( &function_impl.body_expr, @@ -906,7 +949,7 @@ impl LocalSlotAllocator { let (mapping, compacted_local_count) = self.color_slots()?; remap_frontend_ir(&mut ir, &mapping, compacted_local_count)?; - Ok(ir) + Ok(super::LocalSlotAllocation { ir, mapping }) } fn collect_block( @@ -921,10 +964,9 @@ impl LocalSlotAllocator { let mut live_before = self .liveness .compute_live_before_stmt_precise(stmt, &live_after); - // Parameter slots stay live for the whole body no matter what the - // statement does to them (see `allocate`); re-mark them so the - // interference invariants never depend on def-use precision for - // caller-written frame-entry state. + // Frame parameters or persistent ROOT slots stay live throughout + // their own block, regardless of assignments or drops. Re-mark + // them so protection never depends on def-use precision. for slot in protected_slots { self.liveness.mark_live(&mut live_before, *slot); } @@ -1065,6 +1107,7 @@ impl LocalSlotAllocator { self.add_slot_live_edges(*root, &live_during); } Expr::OptionalGet { + field_projection: _, container, key, container_slot, @@ -1340,6 +1383,7 @@ impl LocalSlotAllocator { self.mark_set_slot(set, *root); } Expr::OptionalGet { + field_projection: _, container, key, container_slot, @@ -1934,6 +1978,7 @@ fn remap_expr_slots(expr: &mut Expr, mapping: &[LocalSlot]) -> Result<(), ParseE *root = remap_slot(*root, mapping)?; } Expr::OptionalGet { + field_projection: _, container, key, container_slot, @@ -2039,6 +2084,57 @@ mod loop_owned_drop_tests { use crate::compiler::ir::{Expr, Stmt}; use std::collections::HashMap; + #[test] + fn root_sources_preserve_cross_module_last_use_and_drop_order() { + let stmts = vec![ + Stmt::Let { + index: 0, + declared_schema: None, + expr: Expr::Int(20), + line: 1, + }, + Stmt::Let { + index: 1, + declared_schema: None, + expr: Expr::Int(21), + line: 2, + }, + Stmt::Expr { + expr: Expr::Add(Box::new(Expr::Var(0)), Box::new(Expr::Var(1))), + line: 3, + }, + ]; + let sources = vec![ + Some("dependency".to_owned()), + Some("dependency".to_owned()), + Some("entry".to_owned()), + ]; + let rewriter = LivenessRewriter::new(2, &[], &HashMap::new(), &[false, false]); + let (rewritten, origins) = rewriter.rewrite_program_block_with_sources(&stmts, &sources); + let legacy = rewriter + .rewrite_block(&stmts, &rewriter.empty_set(), false) + .0; + assert_eq!(format!("{rewritten:?}"), format!("{legacy:?}")); + assert_eq!(rewritten.len(), 5); + assert!(matches!(rewritten[0], Stmt::Let { index: 0, .. })); + assert!(matches!(rewritten[1], Stmt::Let { index: 1, .. })); + assert!(matches!(rewritten[2], Stmt::Expr { line: 3, .. })); + assert!(matches!(rewritten[3], Stmt::Drop { index: 0, line: 3 })); + assert!(matches!(rewritten[4], Stmt::Drop { index: 1, line: 3 })); + assert_eq!( + origins, + vec![ + sources[0].clone(), + sources[1].clone(), + sources[2].clone(), + sources[2].clone(), + sources[2].clone() + ] + ); + let (_, missing) = rewriter.rewrite_program_block_with_sources(&stmts, &sources[..1]); + assert_eq!(missing, vec![sources[0].clone(), None, None, None, None]); + } + /// A resource-owned local defined and last-used inside a loop body must /// get a `Stmt::Drop` at its per-iteration last death, even though loop /// bodies suppress ordinary clears (loop-carried values must survive). diff --git a/src/compiler/lifetime/mod.rs b/src/compiler/lifetime/mod.rs index be02c0a1..cbec5d19 100644 --- a/src/compiler/lifetime/mod.rs +++ b/src/compiler/lifetime/mod.rs @@ -69,4 +69,30 @@ pub(super) fn enforce_local_availability_with_entry_locals( ) } -pub(super) use availability::allocate_local_slots; +pub(super) fn enforce_source_availability_with_entry_locals( + ir: FrontendIr, + entry_locals: &[EntryLocalAvailability], + clear_dead_locals: bool, + owned_local_slots: &[bool], + policies: &std::collections::HashMap, + default_policy: bool, + sources: &super::source_map::SourceMap, +) -> Result { + availability::enforce_source_availability( + ir, + entry_locals, + clear_dead_locals, + default_policy, + owned_local_slots, + Some(policies), + Some(sources), + ) +} + +pub(super) struct LocalSlotAllocation { + pub(super) ir: FrontendIr, + /// Logical pre-allocation slot to physical slot, shared by IR and entry seeds. + pub(super) mapping: Vec, +} + +pub(super) use availability::{allocate_local_slots, allocate_local_slots_with_root_slots}; diff --git a/src/compiler/linker.rs b/src/compiler/linker.rs index a3b7bbe2..25aa3bd9 100644 --- a/src/compiler/linker.rs +++ b/src/compiler/linker.rs @@ -9,11 +9,12 @@ use super::{ CatalogVisibility, Expr, FrontendIr, FunctionDecl, FunctionDeclSite, FunctionImpl, FunctionRefSite, FunctionRefTarget, HostApiIrMetadata, LocalDeclSite, LocalRefSite, LocalSlot, ModuleNamespaceAlias, ParsedCallSite, ParsedCallTarget, ParsedLexicalScope, - ParsedSemanticIndex, ScopeId, SemanticNodeId, Stmt, StructDecl, StructDeclSite, + ParsedSemanticIndex, ScopeId, SemanticNodeId, Stmt, StructDecl, StructDeclSite, TypeSchema, }, modules::{ModuleId, SymbolId}, }; +#[derive(Clone)] pub(super) struct ParsedUnit { pub(super) parsed: FrontendIr, /// Deterministic scope identity for the unit's local bindings at the flat @@ -38,6 +39,8 @@ pub(super) struct ParsedUnit { /// Whether this unit was parsed without an explicit host catalog. This /// distinguishes legacy builtin metadata from catalog-backed host imports. pub(super) host_catalog_supplied: bool, + /// Semantic discovery does not imply runtime reachability. + pub(super) runtime_reachable: bool, } /// Deterministic flat-boundary scope identity for a non-root module's local @@ -70,7 +73,300 @@ fn deterministic_flat_name(name: &str, symbol: SymbolId) -> String { format!("{}__m{}", name, symbol.module.0) } +pub(super) fn internal_guest_type_name(symbol: SymbolId) -> String { + format!("__guest_type_m{}_s{}", symbol.module.0, symbol.index) +} + +pub(super) fn remap_guest_type_schemas(parsed: &mut FrontendIr, names: &HashMap) { + if names.is_empty() { + return; + } + if let Some(index) = parsed.parsed_semantic_index.as_mut() { + let source_id = index + .scopes + .first() + .map(|scope| scope.range.source_id) + .or_else(|| index.stmt_spans.first().map(|site| site.span.source_id)) + .unwrap_or(0); + let mut spellings: Vec<_> = names.iter().collect(); + spellings.sort_by_key(|(name, _)| *name); + for (name, canonical) in spellings { + let span = index + .struct_decls + .iter() + .find(|site| site.name == *name) + .map(|site| site.ident_span); + index.type_provenance.push(super::ir::GuestTypeProvenance { + canonical_name: canonical.clone(), + source_name: name.clone(), + source_id, + declaration_span: span, + }); + } + } + let mut schemas = HashMap::new(); + for (name, mut declaration) in std::mem::take(&mut parsed.struct_schemas) { + if declaration.is_guest() + && let Some(internal) = names.get(&name).or_else(|| names.get(&declaration.name)) + { + declaration.name = internal.clone(); + remap_type_schema_names(&mut declaration.body_schema, names); + schemas.entry(internal.clone()).or_insert(declaration); + continue; + } + schemas.insert(name, declaration); + } + parsed.struct_schemas = schemas; + + let catalog_import_indices = parsed + .host_api_metadata + .as_ref() + .map(|metadata| { + parsed + .functions + .iter() + .filter(|function| metadata.candidates(function.index).is_some()) + .map(|function| function.index) + .collect::>() + }) + .unwrap_or_default(); + for function in &mut parsed.functions { + if catalog_import_indices.contains(&function.index) { + continue; + } + for schema in function.arg_schemas.iter_mut().flatten() { + remap_type_schema_names(schema, names); + } + if let Some(schema) = &mut function.return_schema { + remap_type_schema_names(schema, names); + } + } + for statement in &mut parsed.stmts { + remap_statement_type_names(statement, names); + } + for function in parsed.function_impls.values_mut() { + for statement in &mut function.body_stmts { + remap_statement_type_names(statement, names); + } + remap_expression_type_names(&mut function.body_expr, names); + } +} + +pub(super) fn remap_type_schema_names(schema: &mut TypeSchema, names: &HashMap) { + match schema { + TypeSchema::Optional(inner) | TypeSchema::Array(inner) | TypeSchema::Map(inner) => { + remap_type_schema_names(inner, names); + } + TypeSchema::Named(name, args) => { + if let Some(internal) = names.get(name) { + *name = internal.clone(); + } + for argument in args { + remap_type_schema_names(argument, names); + } + } + TypeSchema::ArrayTuple(items) => { + for item in items { + remap_type_schema_names(item, names); + } + } + TypeSchema::ArrayTupleRest { prefix, rest } => { + for item in prefix { + remap_type_schema_names(item, names); + } + remap_type_schema_names(rest, names); + } + TypeSchema::Object(properties) => { + for schema in properties.values_mut() { + remap_type_schema_names(schema, names); + } + } + TypeSchema::Callable { params, result } => { + for parameter in params { + remap_type_schema_names(parameter, names); + } + remap_type_schema_names(result, names); + } + TypeSchema::Unknown + | TypeSchema::Null + | TypeSchema::Int + | TypeSchema::Float + | TypeSchema::Number + | TypeSchema::Bool + | TypeSchema::String + | TypeSchema::Bytes + | TypeSchema::GenericParam(_) + | TypeSchema::Resource(_) => {} + } +} + +fn remap_statement_type_names(statement: &mut Stmt, names: &HashMap) { + match statement { + Stmt::Let { + declared_schema, + expr, + .. + } => { + if let Some(schema) = declared_schema { + remap_type_schema_names(schema, names); + } + remap_expression_type_names(expr, names); + } + Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + remap_expression_type_names(expr, names); + } + Stmt::ClosureLet { closure, .. } => remap_expression_type_names(&mut closure.body, names), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + remap_expression_type_names(condition, names); + for nested in then_branch.iter_mut().chain(else_branch) { + remap_statement_type_names(nested, names); + } + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + remap_statement_type_names(init, names); + remap_expression_type_names(condition, names); + remap_statement_type_names(post, names); + for nested in body { + remap_statement_type_names(nested, names); + } + } + Stmt::While { + condition, body, .. + } => { + remap_expression_type_names(condition, names); + for nested in body { + remap_statement_type_names(nested, names); + } + } + Stmt::Noop { .. } + | Stmt::FuncDecl { .. } + | Stmt::Break { .. } + | Stmt::Continue { .. } + | Stmt::Drop { .. } => {} + } +} + +fn remap_expression_type_names(expression: &mut Expr, names: &HashMap) { + match expression { + Expr::FunctionRef(_, types) + | Expr::ModuleFunctionRef(_, types) + | Expr::UnresolvedFunctionRef { + type_args: types, .. + } => { + for schema in types { + remap_type_schema_names(schema, names); + } + } + Expr::Call(_, types, args, _, _) + | Expr::ModuleCall(_, types, args, _) + | Expr::LocalCall(_, types, args, _) => { + for schema in types { + remap_type_schema_names(schema, names); + } + for argument in args { + remap_expression_type_names(argument, names); + } + } + Expr::Closure(closure) => remap_expression_type_names(&mut closure.body, names), + Expr::ClosureCall(closure, args) => { + remap_expression_type_names(&mut closure.body, names); + for argument in args { + remap_expression_type_names(argument, names); + } + } + Expr::OptionalGet { container, key, .. } => { + remap_expression_type_names(container, names); + remap_expression_type_names(key, names); + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + remap_expression_type_names(value, names); + remap_expression_type_names(fallback, names); + } + Expr::Add(left, right) + | Expr::Sub(left, right) + | Expr::Mul(left, right) + | Expr::Div(left, right) + | Expr::Mod(left, right) + | Expr::And(left, right) + | Expr::Or(left, right) + | Expr::Eq(left, right) + | Expr::Lt(left, right) + | Expr::Gt(left, right) => { + remap_expression_type_names(left, names); + remap_expression_type_names(right, names); + } + Expr::Neg(value) + | Expr::Not(value) + | Expr::ToOwned(value) + | Expr::Borrow(value) + | Expr::BorrowMut(value) => remap_expression_type_names(value, names), + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + remap_expression_type_names(condition, names); + remap_expression_type_names(then_expr, names); + remap_expression_type_names(else_expr, names); + } + Expr::Match { + value, + arms, + default, + .. + } => { + remap_expression_type_names(value, names); + for (_, arm) in arms { + remap_expression_type_names(arm, names); + } + remap_expression_type_names(default, names); + } + Expr::Block { stmts, expr } => { + for statement in stmts { + remap_statement_type_names(statement, names); + } + remap_expression_type_names(expr, names); + } + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => {} + } +} + pub(super) fn merge_units(units: Vec) -> Result { + merge_units_for_view(units, false) +} + +/// Link every reachable source for admission without granting runtime authority. +/// The runtime projection still uses the original Value reachability flags. +pub(super) fn merge_admission_units(units: Vec) -> Result { + merge_units_for_view(units, true) +} + +fn merge_units_for_view( + units: Vec, + admission: bool, +) -> Result { let mut merged_stmts = Vec::new(); let mut merged_stmt_sources = Vec::new(); let mut merged_local_bindings = Vec::new(); @@ -132,6 +428,44 @@ pub(super) fn merge_units(units: Vec) -> Result = Vec::new(); for unit in units { + if !admission && !unit.runtime_reachable { + // Keep type facts and source tokens, but allocate no executable + // function/local slots or host import families for semantic edges. + for (name, schema) in unit.parsed.struct_schemas { + if let Some(existing) = merged_struct_schemas.get(&name) + && existing != &schema + { + return Err(SourcePathError::Source(SourceError::Parse( + ParseError::new(format!("conflicting type declaration '{name}'")), + ))); + } + merged_struct_schemas.insert(name, schema); + } + merged_unknown_type_spans.extend(unit.parsed.unknown_type_spans); + merged_lexer_tokens.extend(unit.parsed.lexer_tokens); + if let Some(index) = unit.parsed.parsed_semantic_index { + // Type-only sources retain declaration/scope ownership, while + // local/call/function sites never allocate executable slots. + let types = ParsedSemanticIndex { + scopes: index.scopes, + struct_decls: index.struct_decls, + type_provenance: index.type_provenance, + next_node_id: index.next_node_id, + next_scope_id: index.next_scope_id, + ..ParsedSemanticIndex::default() + }; + let merged = merged_parsed_index.get_or_insert_with(ParsedSemanticIndex::default); + let rebased = rebase_parsed_semantic_index( + &types, + merged.next_node_id, + merged.next_scope_id, + 0, + &HashMap::new(), + )?; + merge_parsed_semantic_index(merged, rebased); + } + continue; + } let source_name = unit.source_name.clone(); let function_map = register_unit_functions( &unit, @@ -543,6 +877,29 @@ fn register_unit_functions( let mut map = HashMap::new(); for func in &unit.parsed.functions { + if unit + .parsed + .host_api_metadata + .as_ref() + .is_some_and(|metadata| metadata.candidates(func.index).is_some()) + { + let identity = (func.name.clone(), func.arity); + let flat = if let Some(&existing) = host_index_by_arity.get(&identity) { + merge_host_import_metadata(&mut merged_functions[existing as usize], func)?; + existing + } else { + let flat = next_flat_index(merged_functions)?; + let mut declaration = func.clone(); + declaration.index = flat; + declaration.symbol = None; + merged_functions.push(declaration); + host_index_by_arity.insert(identity, flat); + claimed_flat_names.insert(func.name.clone()); + flat + }; + map.insert(func.index, flat); + continue; + } let Some(symbol) = func.symbol else { // Synthetic prelude/stub declaration; resolved by the loader. continue; @@ -1018,6 +1375,7 @@ fn remap_expr_indices( ); } Expr::OptionalGet { + field_projection: _, container, key, container_slot, @@ -1437,6 +1795,7 @@ fn rebase_parsed_semantic_index( let stmt_spans = unit.stmt_spans.clone(); Ok(ParsedSemanticIndex { + type_provenance: unit.type_provenance.clone(), call_sites, local_decls, local_refs, @@ -1487,6 +1846,7 @@ fn merge_parsed_semantic_index(merged: &mut ParsedSemanticIndex, unit: ParsedSem merged.local_refs.extend(unit.local_refs); merged.func_decls.extend(unit.func_decls); merged.struct_decls.extend(unit.struct_decls); + merged.type_provenance.extend(unit.type_provenance); merged.func_refs.extend(unit.func_refs); merged.scopes.extend(unit.scopes); merged.stmt_spans.extend(unit.stmt_spans); @@ -1734,6 +2094,7 @@ mod linker_metadata_remap_tests { module: ModuleId(module), source_id: 0, host_catalog_supplied: false, + runtime_reachable: true, } } @@ -2198,6 +2559,7 @@ mod linker_provenance_merge_tests { module: ModuleId(module), source_id, host_catalog_supplied: false, + runtime_reachable: true, } } @@ -2265,6 +2627,7 @@ mod linker_provenance_merge_tests { }], stmt_spans: Vec::new(), struct_decls: Vec::new(), + type_provenance: Vec::new(), next_node_id, next_scope_id, } @@ -2418,6 +2781,7 @@ mod linker_provenance_merge_tests { }], stmt_spans: Vec::new(), struct_decls: Vec::new(), + type_provenance: Vec::new(), next_node_id: 3, next_scope_id: 1, }; @@ -2443,6 +2807,7 @@ mod linker_provenance_merge_tests { }], stmt_spans: Vec::new(), struct_decls: Vec::new(), + type_provenance: Vec::new(), next_node_id: 1, next_scope_id: 1, }; @@ -2559,6 +2924,7 @@ mod linker_provenance_merge_tests { local_refs: Vec::new(), func_decls: Vec::new(), struct_decls: Vec::new(), + type_provenance: Vec::new(), func_refs: Vec::new(), scopes: Vec::new(), stmt_spans: Vec::new(), @@ -2579,6 +2945,7 @@ mod linker_provenance_merge_tests { local_refs: Vec::new(), func_decls: Vec::new(), struct_decls: Vec::new(), + type_provenance: Vec::new(), func_refs: Vec::new(), scopes: Vec::new(), stmt_spans: Vec::new(), @@ -2649,6 +3016,7 @@ mod linker_provenance_merge_tests { local_refs: Vec::new(), func_decls: Vec::new(), struct_decls: Vec::new(), + type_provenance: Vec::new(), func_refs: Vec::new(), scopes: Vec::new(), stmt_spans: Vec::new(), @@ -2978,10 +3346,7 @@ mod linker_provenance_merge_tests { UsePathSegment::Ident("a".to_string()), UsePathSegment::Ident("util".to_string()), ], - clause: ImportClause::Named(vec![NamedImport { - imported: "helper".to_string(), - local: "h".to_string(), - }]), + clause: ImportClause::Named(vec![NamedImport::value("helper", "h")]), span: span(source_id, 0, 20), line, }; @@ -3120,6 +3485,7 @@ mod linker_provenance_merge_tests { module: ModuleId(1), source_id: 1, host_catalog_supplied: false, + runtime_reachable: true, }; let merged = merge_units(vec![a]).expect("provenance-less unit must merge"); assert!(merged.parsed_semantic_index.is_none()); diff --git a/src/compiler/materialization.rs b/src/compiler/materialization.rs index ecb05b2d..3ee7bc5c 100644 --- a/src/compiler/materialization.rs +++ b/src/compiler/materialization.rs @@ -1489,6 +1489,7 @@ mod tests { module: ModuleId(2), source_id: 1, host_catalog_supplied: false, + runtime_reachable: true, }; let root_unit = ParsedUnit { @@ -1512,6 +1513,7 @@ mod tests { module: ModuleId(1), source_id: 0, host_catalog_supplied: false, + runtime_reachable: true, }; let merged = diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index d8c5dc15..4b67b041 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -8,6 +8,8 @@ use crate::assembler::AssemblerError; #[cfg(feature = "runtime")] use crate::vm::Vm; +pub(crate) mod capture_certification; +pub use capture_certification::CertifiedCompilation; mod codegen; pub mod diagnostics; mod format; @@ -38,8 +40,9 @@ pub use self::frontends::parse_source_with_dialect; pub use self::host_call_resolve::{HostCallResolveError, HostCallResolver}; pub use self::ir::{ AssignmentKind, ClosureExpr, Expr, FrontendIr, FunctionDecl, FunctionImpl, FunctionParam, - LocalIrBuilder, LocalSlot, MatchPattern, MatchTypePattern, ResolvedHostCall, ResolvedHostParam, - SemanticIndex, Stmt, StructDecl, StructDeclOrigin, TypeSchema, + GuestTypeProvenance, LocalIrBuilder, LocalSlot, MatchPattern, MatchTypePattern, + ResolvedHostCall, ResolvedHostParam, SemanticIndex, Stmt, StructDecl, StructDeclOrigin, + TypeSchema, }; pub use self::modules::{ DeclSymbol, ExportEntry, ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ModuleNode, @@ -53,17 +56,22 @@ pub use self::pipeline::{ collect_inferred_local_type_hints_at_path_with_options, collect_inferred_local_type_hints_with_options, compile_source, compile_source_at_path_with_flavor_and_options, compile_source_file, - compile_source_file_with_options, compile_source_for_repl, compile_source_for_repl_with_locals, - compile_source_for_repl_with_state, compile_source_with_flavor, - compile_source_with_flavor_and_options, lint_trailing_function_return_semicolons, - lint_unknown_inferred_local_types, lint_unknown_inferred_local_types_at_path_with_options, + compile_source_file_with_capture_certification, + compile_source_file_with_capture_certification_and_options, compile_source_file_with_options, + compile_source_for_repl, compile_source_for_repl_with_locals, + compile_source_for_repl_with_state, compile_source_for_repl_with_state_and_options, + compile_source_with_capture_certification, compile_source_with_flavor, + compile_source_with_flavor_and_options, + compile_source_with_flavor_and_options_with_capture_certification, + lint_trailing_function_return_semicolons, lint_unknown_inferred_local_types, + lint_unknown_inferred_local_types_at_path_with_options, lint_unknown_inferred_local_types_with_options, lint_unknown_type_annotations, }; pub use self::semantic_model::{ CompletionItemKind, Definition, SemanticCompletion, SemanticDiagnostic, SemanticModel, SourcePosition, }; -pub use self::source_loader::{FrontendImportSyntax, ImportClause, ModuleImport, NamedImport}; +pub use self::source_loader::{DeclarationNamespace, ImportClause, ModuleImport, NamedImport}; #[derive(Debug)] pub enum CompileError { @@ -300,9 +308,18 @@ impl ParseError { } pub fn with_line_span_from_source(mut self, source_map: &SourceMap, source_id: u32) -> Self { - if self.span.is_some() { + if self.span.is_some_and(|span| { + span.source_id == source_id + && span.lo <= span.hi + && source_map.source(source_id).is_some_and(|text| { + span.hi <= text.len() + && text.is_char_boundary(span.lo) + && text.is_char_boundary(span.hi) + }) + }) { return self; } + self.span = None; if let Some(span) = source_map.line_span(source_id, self.line) { self.span = Some(span); } @@ -386,7 +403,7 @@ impl fmt::Display for SourcePathError { SourcePathError::MissingExtension => write!(f, "source file must have an extension"), SourcePathError::UnsupportedExtension(ext) => write!( f, - "unsupported source extension '.{ext}', expected .rss, .js, or .lua" + "unsupported source extension '.{ext}', expected .rss or a registered frontend extension" ), SourcePathError::MissingFrontendPlugin(flavor) => { write!(f, "no frontend plugin registered for {flavor:?} source") @@ -433,21 +450,107 @@ impl From for SourcePathError { } } +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct FrontendId(&'static str); + +impl FrontendId { + pub const fn new(id: &'static str) -> Self { + Self(id) + } + + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FrontendTypingPolicy { + Strict, + Dynamic, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct SourceFrontendDescriptor { + pub id: FrontendId, + pub extensions: &'static [&'static str], + pub typing_policy: FrontendTypingPolicy, + pub supports_ownership_checking: bool, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ParsePhase { + ImportDiscovery, + Module, + Standalone, +} + +#[derive(Clone, Debug)] +pub enum FrontendImportedDeclaration { + Function { + local: String, + symbol: SymbolId, + declaration: FunctionDecl, + }, + Type { + local: String, + symbol: SymbolId, + schema: TypeSchema, + declaration: Option, + }, +} + +#[derive(Clone, Debug)] +pub struct FrontendTypeExport { + pub source_name: String, + pub schema: TypeSchema, + pub declaration: Option, +} + +pub struct SourceFrontendContext<'a> { + pub source_id: u32, + pub original_path: Option<&'a Path>, + pub phase: ParsePhase, + pub host_api_catalog: Option<&'a crate::host_api::HostApiCatalog>, + pub imports: &'a [FrontendImportedDeclaration], +} + +pub struct FrontendTypedIr<'a> { + /// Source whose producer is being admitted. The IR and logical schemas are + /// shared across the compilation; validators scope their rules to this id. + pub source_id: Option, + pub ir: &'a FrontendIr, + pub local_schemas: &'a [Option], + pub host_api_catalog: Option<&'a crate::host_api::HostApiCatalog>, + pub source_map: Option<&'a SourceMap>, +} + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum SourceFlavor { RustScript, - JavaScript, - Lua, + Plugin(FrontendId), } pub trait SourcePlugin: Sync { - fn flavor(&self) -> SourceFlavor; + fn descriptor(&self) -> SourceFrontendDescriptor; - fn extensions(&self) -> &'static [&'static str]; + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result; - fn import_syntax(&self) -> FrontendImportSyntax; + fn module_type_exports( + &self, + _source: &str, + _context: &SourceFrontendContext<'_>, + _ir: &FrontendIr, + ) -> Result, ParseError> { + Ok(Vec::new()) + } - fn parse_source(&self, source: &str) -> Result; + fn validate_typed_ir(&self, _typed_ir: &FrontendTypedIr<'_>) -> Result<(), CompileError> { + Ok(()) + } fn parser_dialect(&self) -> Option<&'static dyn ParserDialect> { None @@ -457,6 +560,7 @@ pub trait SourcePlugin: Sync { &self, _source: &str, _path: &Path, + _context: &SourceFrontendContext<'_>, ) -> Result, SourcePathError> { Ok(Vec::new()) } @@ -483,19 +587,18 @@ pub struct SharedParserOptions { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum TypingMode { DynamicHints, - StrictRustScript, + Strict, } -impl TypingMode { - pub(crate) fn for_flavor(flavor: SourceFlavor) -> Self { - match flavor { - SourceFlavor::RustScript => Self::StrictRustScript, - SourceFlavor::JavaScript | SourceFlavor::Lua => Self::DynamicHints, - } - } +#[derive(Copy, Clone, Debug)] +pub(crate) struct FrontendPolicy { + pub typing_mode: TypingMode, + pub ownership_checking: bool, +} +impl TypingMode { pub(crate) fn is_strict(self) -> bool { - matches!(self, Self::StrictRustScript) + matches!(self, Self::Strict) } } @@ -503,8 +606,6 @@ impl SourceFlavor { pub fn from_extension(ext: &str) -> Option { match ext.to_ascii_lowercase().as_str() { "rss" => Some(Self::RustScript), - "js" => Some(Self::JavaScript), - "lua" => Some(Self::Lua), _ => None, } } @@ -527,7 +628,7 @@ impl SourceFlavor { .and_then(|value| value.to_str()) .ok_or(SourcePathError::MissingExtension)?; if let Some(plugin) = options.source_plugin_for_extension(ext) { - return Ok(plugin.flavor()); + return Ok(SourceFlavor::Plugin(plugin.descriptor().id)); } SourceFlavor::from_extension(ext) .ok_or_else(|| SourcePathError::UnsupportedExtension(ext.to_string())) @@ -704,10 +805,13 @@ impl CompileSourceFileOptions { &self, flavor: SourceFlavor, ) -> Option<&'static dyn SourcePlugin> { + let SourceFlavor::Plugin(id) = flavor else { + return None; + }; self.source_plugins .iter() .copied() - .find(|plugin| plugin.flavor() == flavor) + .find(|plugin| plugin.descriptor().id == id) } pub(crate) fn source_plugin_for_extension( @@ -716,11 +820,32 @@ impl CompileSourceFileOptions { ) -> Option<&'static dyn SourcePlugin> { self.source_plugins.iter().copied().find(|plugin| { plugin - .extensions() + .descriptor() + .extensions .iter() .any(|candidate| candidate.eq_ignore_ascii_case(ext)) }) } + + pub(crate) fn frontend_policy(&self, flavor: SourceFlavor) -> Option { + match flavor { + SourceFlavor::RustScript => Some(FrontendPolicy { + typing_mode: TypingMode::Strict, + ownership_checking: true, + }), + SourceFlavor::Plugin(id) => { + let plugin = self.source_plugin_for_flavor(SourceFlavor::Plugin(id))?; + let descriptor = plugin.descriptor(); + Some(FrontendPolicy { + typing_mode: match descriptor.typing_policy { + FrontendTypingPolicy::Strict => TypingMode::Strict, + FrontendTypingPolicy::Dynamic => TypingMode::DynamicHints, + }, + ownership_checking: descriptor.supports_ownership_checking, + }) + } + } + } } const STDLIB_PRINT_NAME: &str = "print"; diff --git a/src/compiler/modules.rs b/src/compiler/modules.rs index 7b65e1d7..8cf25602 100644 --- a/src/compiler/modules.rs +++ b/src/compiler/modules.rs @@ -24,12 +24,12 @@ //! text is registered in the compilation-wide `SourceMap` at its graph //! `SourceId`, so spans survive unit merge with their owning source. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::SourcePathError; use super::frontends::{is_ident_continue, is_ident_start}; -use super::source_loader::ImportClause; +use super::source_loader::{DeclarationNamespace, ImportClause}; use super::source_map::Span; /// Deterministic identity of one module within a single compilation. @@ -109,6 +109,7 @@ pub struct ResolvedImport { /// Normalized module specifier (e.g. `./nested.rss`). pub spec: String, pub clause: ImportClause, + pub namespace: DeclarationNamespace, pub span: Span, pub line: usize, pub target: Option, @@ -124,6 +125,7 @@ pub struct ResolvedImport { pub struct DeclSymbol { pub symbol: SymbolId, pub name: String, + pub namespace: DeclarationNamespace, /// `true` when the declaration is marked `pub` and appears in the module's /// public export table. pub public: bool, @@ -139,6 +141,7 @@ pub struct DeclSymbol { pub struct ExportEntry { pub name: String, pub symbol: SymbolId, + pub namespace: DeclarationNamespace, } /// A binding introduced into a module by a resolved import edge. @@ -151,6 +154,7 @@ pub struct ExportEntry { #[derive(Clone, Debug, PartialEq, Eq)] pub struct ImportedBinding { pub local_name: String, + pub namespace: DeclarationNamespace, pub source_module: ModuleId, pub source_symbol: SymbolId, pub source_name: String, @@ -177,6 +181,7 @@ pub struct ModuleNode { pub exports: Vec, /// Bindings introduced by import edges, separate from local declarations. pub imported_bindings: Vec, + next_symbol_index: u32, } /// The module graph for one compilation. @@ -190,6 +195,8 @@ pub struct ModuleNode { pub struct ModuleGraph { nodes: Vec, by_identity: HashMap, + guest_named_types: HashSet, + guest_type_aliases: HashMap, next_source: u32, } @@ -225,6 +232,7 @@ impl ModuleGraph { declarations: Vec::new(), exports: Vec::new(), imported_bindings: Vec::new(), + next_symbol_index: 0, }); module } @@ -271,9 +279,23 @@ impl ModuleGraph { module: ModuleId, name: &str, public: bool, + ) -> Result { + self.add_declaration_in_namespace(module, DeclarationNamespace::Value, name, public) + } + + pub fn add_declaration_in_namespace( + &mut self, + module: ModuleId, + namespace: DeclarationNamespace, + name: &str, + public: bool, ) -> Result { let node = self.node_mut(module)?; - if node.declarations.iter().any(|decl| decl.name == name) { + if node + .declarations + .iter() + .any(|decl| decl.namespace == namespace && decl.name == name) + { return Err(format!( "duplicate local declaration '{name}' in module '{}'", node.source_name @@ -282,30 +304,45 @@ impl ModuleGraph { if node .imported_bindings .iter() - .any(|binding| binding.local_name == name) + .any(|binding| binding.namespace == namespace && binding.local_name == name) { return Err(format!( "local declaration '{name}' conflicts with an imported binding in module '{}'", node.source_name )); } - let index = u32::try_from(node.declarations.len()) - .map_err(|_| format!("too many declarations in module '{}'", node.source_name))?; + let index = node.next_symbol_index; + node.next_symbol_index = index + .checked_add(1) + .ok_or_else(|| format!("too many symbols in module '{}'", node.source_name))?; let symbol = SymbolId { module, index }; node.declarations.push(DeclSymbol { symbol, name: name.to_string(), + namespace, public, }); if public { node.exports.push(ExportEntry { name: name.to_string(), symbol, + namespace, }); } Ok(symbol) } + /// Allocate a symbol for a catalog-owned value import without inserting + /// it into guest declaration or export tables. + pub fn add_catalog_import_symbol(&mut self, module: ModuleId) -> Result { + let node = self.node_mut(module)?; + let index = node.next_symbol_index; + node.next_symbol_index = index + .checked_add(1) + .ok_or_else(|| format!("too many symbols in module '{}'", node.source_name))?; + Ok(SymbolId { module, index }) + } + /// Record one binding introduced by an import edge of `module`. /// /// Imported bindings stay out of `declarations` and `exports`: nothing in @@ -322,44 +359,131 @@ impl ModuleGraph { if node .declarations .iter() - .any(|decl| decl.name == binding.local_name) + .any(|decl| decl.namespace == binding.namespace && decl.name == binding.local_name) { return Err(format!( "imported binding '{}' conflicts with a local declaration in module '{}'", binding.local_name, node.source_name )); } + if binding.namespace == DeclarationNamespace::Type + && node.imported_bindings.iter().any(|existing| { + existing.namespace == binding.namespace && existing.local_name == binding.local_name + }) + { + return Err(format!( + "ambiguous imported type '{}' in module '{}'", + binding.local_name, node.source_name + )); + } node.imported_bindings.push(binding); Ok(()) } + pub(crate) fn mark_guest_named_type(&mut self, symbol: SymbolId) -> Result<(), String> { + let declaration = self + .node(symbol.module) + .and_then(|node| node.declarations.iter().find(|decl| decl.symbol == symbol)) + .ok_or_else(|| format!("unknown type declaration symbol {symbol:?}"))?; + if declaration.namespace != DeclarationNamespace::Type { + return Err(format!("symbol {symbol:?} is not a type declaration")); + } + self.guest_named_types.insert(symbol); + Ok(()) + } + + pub(crate) fn is_guest_named_type(&self, symbol: SymbolId) -> bool { + self.guest_named_types.contains(&symbol) + } + + pub(crate) fn bind_guest_type_alias(&mut self, alias: SymbolId, declaration: SymbolId) { + self.guest_type_aliases.insert(alias, declaration); + } + + /// Export bindings may rename a type without changing its nominal owner. + pub fn canonical_guest_type_symbol(&self, symbol: SymbolId) -> Option { + self.is_guest_named_type(symbol).then(|| { + self.guest_type_aliases + .get(&symbol) + .copied() + .unwrap_or(symbol) + }) + } + pub fn declaration(&self, module: ModuleId, name: &str) -> Option<&DeclSymbol> { + self.declaration_in_namespace(module, DeclarationNamespace::Value, name) + } + + pub fn declaration_in_namespace( + &self, + module: ModuleId, + namespace: DeclarationNamespace, + name: &str, + ) -> Option<&DeclSymbol> { self.node(module)? .declarations .iter() - .find(|decl| decl.name == name) + .find(|decl| decl.namespace == namespace && decl.name == name) } pub fn declaration_symbol(&self, module: ModuleId, name: &str) -> Option { self.declaration(module, name).map(|decl| decl.symbol) } + pub fn declaration_symbol_in_namespace( + &self, + module: ModuleId, + namespace: DeclarationNamespace, + name: &str, + ) -> Option { + self.declaration_in_namespace(module, namespace, name) + .map(|decl| decl.symbol) + } + pub fn export(&self, module: ModuleId, name: &str) -> Option<&ExportEntry> { + self.export_in_namespace(module, DeclarationNamespace::Value, name) + } + + pub fn export_in_namespace( + &self, + module: ModuleId, + namespace: DeclarationNamespace, + name: &str, + ) -> Option<&ExportEntry> { self.node(module)? .exports .iter() - .find(|entry| entry.name == name) + .find(|entry| entry.namespace == namespace && entry.name == name) } pub fn symbol_for_export(&self, module: ModuleId, name: &str) -> Option { self.export(module, name).map(|entry| entry.symbol) } + pub fn symbol_for_export_in_namespace( + &self, + module: ModuleId, + namespace: DeclarationNamespace, + name: &str, + ) -> Option { + self.export_in_namespace(module, namespace, name) + .map(|entry| entry.symbol) + } + pub fn imported_binding(&self, module: ModuleId, name: &str) -> Option<&ImportedBinding> { + self.imported_binding_in_namespace(module, DeclarationNamespace::Value, name) + } + + pub fn imported_binding_in_namespace( + &self, + module: ModuleId, + namespace: DeclarationNamespace, + name: &str, + ) -> Option<&ImportedBinding> { self.node(module)? .imported_bindings .iter() - .find(|binding| binding.local_name == name) + .find(|binding| binding.namespace == namespace && binding.local_name == name) } fn node_mut(&mut self, module: ModuleId) -> Result<&mut ModuleNode, String> { @@ -514,7 +638,7 @@ mod tests { ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ResolvedImport, SourceId, SymbolId, UsePathSegment, use_path_string_to_spec, use_path_to_spec, }; - use crate::compiler::source_loader::ImportClause; + use crate::compiler::source_loader::{DeclarationNamespace, ImportClause}; use crate::compiler::source_map::Span; use std::path::PathBuf; @@ -713,6 +837,7 @@ mod tests { kind: ImportTargetKind::FileModule, spec: "./nested.rss".to_string(), clause: ImportClause::Namespace("nested".to_string()), + namespace: super::DeclarationNamespace::Value, span: Span::new(0, 0, 0), line: 1, target: Some(nested), @@ -724,6 +849,7 @@ mod tests { kind: ImportTargetKind::BuiltinNamespace, spec: "json.rss".to_string(), clause: ImportClause::AllPublic, + namespace: super::DeclarationNamespace::Value, span: Span::new(0, 0, 0), line: 2, target: None, @@ -896,6 +1022,7 @@ mod tests { importer, ImportedBinding { local_name: "helper".to_string(), + namespace: DeclarationNamespace::Value, source_module: source, source_symbol: exported, source_name: "helper".to_string(), @@ -1007,6 +1134,7 @@ mod tests { importer, ImportedBinding { local_name: source_name.to_string(), + namespace: DeclarationNamespace::Value, source_module: source, source_symbol, source_name: source_name.to_string(), @@ -1071,6 +1199,7 @@ mod tests { importer, ImportedBinding { local_name: "helper".to_string(), + namespace: DeclarationNamespace::Value, source_module: source, source_symbol: exported, source_name: "helper".to_string(), @@ -1115,6 +1244,7 @@ mod tests { importer, ImportedBinding { local_name: "helper".to_string(), + namespace: DeclarationNamespace::Value, source_module: first, source_symbol: first_helper, source_name: "helper".to_string(), @@ -1126,6 +1256,7 @@ mod tests { importer, ImportedBinding { local_name: "helper".to_string(), + namespace: DeclarationNamespace::Value, source_module: second, source_symbol: second_helper, source_name: "helper".to_string(), diff --git a/src/compiler/parser/expressions.rs b/src/compiler/parser/expressions.rs index 98f06f9b..42c7c1c1 100644 --- a/src/compiler/parser/expressions.rs +++ b/src/compiler/parser/expressions.rs @@ -1429,6 +1429,7 @@ impl Parser { false, ); Ok(Expr::OptionalGet { + field_projection: false, container: Box::new(container), key: Box::new(key), container_slot: self.allocate_hidden_local()?, diff --git a/src/compiler/parser/statements.rs b/src/compiler/parser/statements.rs index 8ef78c08..87e4291d 100644 --- a/src/compiler/parser/statements.rs +++ b/src/compiler/parser/statements.rs @@ -411,7 +411,7 @@ impl Parser { } self.direct_host_call_aliases.insert(local.clone(), target); } - named.push(NamedImport { imported, local }); + named.push(NamedImport::value(imported, local)); if self.match_kind(&TokenKind::Comma) { if self.check(&TokenKind::RBrace) { break; diff --git a/src/compiler/pipeline.rs b/src/compiler/pipeline.rs index 2dbb21a1..122b17ea 100644 --- a/src/compiler/pipeline.rs +++ b/src/compiler/pipeline.rs @@ -11,15 +11,15 @@ use super::frontends; use super::ir::{ Expr, FrontendIr, FunctionDecl, FunctionImpl, LocalSlot, SemanticIndex, Stmt, TypeSchema, }; -use super::linker::{ParsedUnit, merge_units}; +use super::linker::{ParsedUnit, merge_admission_units, merge_units}; use super::modules::ModuleGraph; use super::semantic_model::SemanticModel; -use super::source_loader::load_units_for_source_file; +use super::source_loader::{load_units_for_source_file, load_units_for_source_file_with_context}; use super::source_map::SourceMap; use super::{ - CompileError, CompileSourceFileOptions, CompiledProgram, CompiledReplProgram, ParseError, - ReplLocalBinding, SourceError, SourceFlavor, SourcePathError, TypingMode, lifetime, - materialization, parser, typing, + CompileError, CompileSourceFileOptions, CompiledProgram, CompiledReplProgram, FrontendPolicy, + FrontendTypedIr, ParseError, ReplLocalBinding, SourceError, SourceFlavor, SourcePathError, + TypingMode, lifetime, materialization, parser, typing, }; #[derive(Clone, Copy, Debug, Default)] @@ -37,24 +37,492 @@ pub struct UnknownInferredLocal { #[derive(Clone, Debug, PartialEq, Eq)] pub struct InferredLocalTypeHint { + pub source_name: Option, pub name: String, pub inferred_type: String, pub declared_line: Option, pub last_line: Option, + pub schema: Option, + pub span: Option, } #[derive(Clone, Copy, Debug)] struct CompileBehavior { clear_dead_locals: bool, + recover_host_errors: bool, } impl CompileBehavior { const DEFAULT: Self = Self { clear_dead_locals: true, + recover_host_errors: false, + }; + const ANALYZE: Self = Self { + recover_host_errors: true, + ..Self::DEFAULT }; const REPL: Self = Self { clear_dead_locals: false, + recover_host_errors: false, + }; +} + +struct PreparedTypedUnits { + ir: FrontendIr, + type_info: typing::TypeInferenceResult, + owned_ir: FrontendIr, + sources: SourceMap, + catalog: Arc, + module_graph: Option, + analysis_errors: Vec, + entry_local_types: Vec, + capture_source_policies: HashMap, +} + +#[derive(Clone, Copy, Default)] +struct EntryLocalContext<'a> { + types: &'a [typing::EntryLocalType], + availability: &'a [lifetime::EntryLocalAvailability], +} + +struct UnitPreparation<'a> { + behavior: CompileBehavior, + module_graph: Option, + repl: Option<(&'a [ReplLocalBinding], &'a [String])>, +} + +fn prepare_typed_units( + units: Vec, + sources: SourceMap, + policy: FrontendPolicy, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + behavior: CompileBehavior, + module_graph: Option, +) -> Result { + prepare_typed_units_with_entry( + units, + sources, + policy, + flavor, + options, + UnitPreparation { + behavior, + module_graph, + repl: None, + }, + ) +} + +fn prepare_typed_units_with_entry( + units: Vec, + sources: SourceMap, + policy: FrontendPolicy, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + preparation: UnitPreparation<'_>, +) -> Result { + let UnitPreparation { + behavior, + module_graph, + repl, + } = preparation; + let diagnostic_path = units + .iter() + .find(|unit| !unit.parsed.unknown_type_spans.is_empty()) + .map(|unit| PathBuf::from(&unit.source_name)); + // Capture admission ownership before flattening. Source identity and policy + // are independent of runtime reachability and of the root frontend. + let mut owners = Vec::new(); + for unit in &units { + let producer = if unit.module.0 == 0 { + flavor + } else { + SourceFlavor::from_path_with_options(Path::new(&unit.source_name), options)? + }; + let producer_policy = if unit.module.0 == 0 { + policy + } else { + frontend_policy_for_flavor(producer, options)? + }; + let plugin = options.source_plugin_for_flavor(producer); + owners.push(TypedSourceOwner { + source_id: unit.source_id, + source_name: unit.source_name.clone(), + policy: producer_policy, + plugin, + }); + } + let has_semantic_only = units.iter().any(|unit| !unit.runtime_reachable); + let runtime = has_semantic_only + .then(|| merge_units(units.clone())) + .transpose()?; + let parsed = merge_admission_units(units)?; + let entry_local_types = repl + .map(|(locals, _)| build_entry_local_types(&parsed, locals)) + .unwrap_or_default(); + let entry_availability = repl + .map(|(locals, moved)| build_entry_local_availability(&parsed, locals, moved)) + .unwrap_or_default(); + let entry = EntryLocalContext { + types: &entry_local_types, + availability: &entry_availability, + }; + let (ir, type_info, admission_owned, analysis_errors) = prepare_owned_typed_ir( + parsed, &owners, options, behavior, &sources, entry, + ) + .map_err(|error| SourcePathError::SourceWithMap { + error: preserve_strict_diagnostic_path(error, diagnostic_path.as_deref()), + sources: sources.clone(), + })?; + let (owned_ir, entry_local_types) = if let Some(runtime) = runtime { + // Admission has already checked every source and invoked each hook once. + // Link/codegen authority is independently restricted to Value paths. + // Removing semantic-only units changes linked local-slot offsets. Seed + // the runtime IR from its own binding table, never admission slot IDs. + let runtime_types = repl + .map(|(locals, _)| build_entry_local_types(&runtime, locals)) + .unwrap_or_default(); + let runtime_availability = repl + .map(|(locals, moved)| build_entry_local_availability(&runtime, locals, moved)) + .unwrap_or_default(); + let (runtime, error) = + typing::legalize_for_admission(runtime, TypingMode::DynamicHints, &runtime_types); + if !behavior.recover_host_errors + && let Some(error) = error + { + return Err(SourcePathError::SourceWithMap { + error: SourceError::Compile(error), + sources, + }); + } + let facts = typing::infer_types(&runtime, TypingMode::DynamicHints, &runtime_types); + let owned = enforce_owned_sources( + runtime, + &facts, + &owners, + behavior, + &sources, + &runtime_availability, + ) + .map_err(|error| SourcePathError::SourceWithMap { + error: SourceError::Parse(error), + sources: sources.clone(), + })?; + (owned, runtime_types) + } else { + (admission_owned, entry_local_types) + }; + let catalog = options + .host_api_catalog() + .cloned() + .unwrap_or_else(default_analyze_catalog); + Ok(PreparedTypedUnits { + ir, + type_info, + owned_ir, + sources, + catalog, + module_graph, + analysis_errors, + entry_local_types, + capture_source_policies: owners + .iter() + .map(|owner| (owner.source_name.clone(), owner.policy.ownership_checking)) + .collect(), + }) +} + +fn preserve_strict_diagnostic_path( + error: SourceError, + diagnostic_path: Option<&Path>, +) -> SourceError { + match (error, diagnostic_path) { + (SourceError::Parse(mut parse), Some(path)) + if parse.code.as_deref() == Some("E_STRICT_UNKNOWN_TYPE") => + { + parse.message = format!("{}: {}", path.display(), parse.message); + SourceError::Parse(parse) + } + (error, _) => error, + } +} + +fn prepare_typed_ir( + parsed: FrontendIr, + policy: FrontendPolicy, + options: &CompileSourceFileOptions, + behavior: CompileBehavior, + plugin: Option<&dyn super::SourcePlugin>, + sources: &SourceMap, +) -> Result<(FrontendIr, typing::TypeInferenceResult, FrontendIr), SourceError> { + let owners = [TypedSourceOwner { + source_id: 0, + source_name: String::new(), + policy, + plugin, + }]; + prepare_owned_typed_ir( + parsed, + &owners, + options, + behavior, + sources, + EntryLocalContext::default(), + ) + .map(|(ir, facts, owned, _)| (ir, facts, owned)) +} + +struct TypedSourceOwner<'a> { + source_id: u32, + source_name: String, + policy: FrontendPolicy, + plugin: Option<&'a dyn super::SourcePlugin>, +} + +// Select admission sites only. The shared IR remains authoritative for all +// inference, imported signatures, nominal bodies and validation queries. +fn owned_admission_sites(ir: &FrontendIr, owner: &TypedSourceOwner<'_>) -> FrontendIr { + let mut owned = ir.clone(); + if owner.source_name.is_empty() { + return owned; + } + let statements: Vec<_> = owned + .stmts + .into_iter() + .zip(&ir.stmt_sources) + .filter(|(_, source)| source.as_deref() == Some(owner.source_name.as_str())) + .collect(); + owned.stmts = statements.iter().map(|(stmt, _)| stmt.clone()).collect(); + owned.stmt_sources = statements + .into_iter() + .map(|(_, source)| source.clone()) + .collect(); + owned + .function_impls + .retain(|index, _| ir.function_sources.get(index) == Some(&owner.source_name)); + owned + .functions + .retain(|decl| owned.function_impls.contains_key(&decl.index)); + owned + .unknown_type_spans + .retain(|span| span.source_id == owner.source_id); + if let Some(index) = &ir.parsed_semantic_index { + owned.local_bindings.retain(|(_, slot)| { + index + .local_decls + .iter() + .any(|decl| decl.slot == *slot && decl.ident_span.source_id == owner.source_id) + }); + owned.struct_schemas.retain(|name, decl| { + !decl.is_guest() + || index.type_provenance.iter().any(|site| { + site.canonical_name == *name + && site.source_id == owner.source_id + && site.declaration_span.is_some() + }) + }); + } + owned +} + +fn prepare_owned_typed_ir( + mut parsed: FrontendIr, + owners: &[TypedSourceOwner<'_>], + options: &CompileSourceFileOptions, + behavior: CompileBehavior, + sources: &SourceMap, + entry: EntryLocalContext<'_>, +) -> Result< + ( + FrontendIr, + typing::TypeInferenceResult, + FrontendIr, + Vec, + ), + SourceError, +> { + for owner in owners + .iter() + .filter(|owner| owner.policy.typing_mode.is_strict()) + { + let owned = owned_admission_sites(&parsed, owner); + reject_strict_unknown_annotations(&owned).map_err(SourceError::Parse)?; + } + // Legalization and inference do not impose the root's language policy on + // dependencies. Admission below uses each producer's own descriptor. + let (legalized, host_error) = + typing::legalize_for_admission(parsed, TypingMode::DynamicHints, entry.types); + parsed = legalized; + let type_info = typing::infer_types(&parsed, TypingMode::DynamicHints, entry.types); + let mut analysis_errors = Vec::new(); + let mut deferred_resolution_error = None; + for owner in owners { + let source_name = (!owner.source_name.is_empty()).then_some(owner.source_name.as_str()); + // Native analysis retains only positioned typing diagnostics from the + // same linked/legalized IR. Plugin admission errors remain fatal; + // recovery never admits unknown schemas, ownership or source hooks. + if behavior.recover_host_errors && owner.plugin.is_none() { + analysis_errors.extend( + typing::analyze_source_type_consistency( + &parsed, + owner.policy.typing_mode, + entry.types, + source_name, + host_error.as_ref(), + ) + .map_err(SourceError::Compile)?, + ); + } else { + typing::validate_source_type_consistency( + &parsed, + owner.policy.typing_mode, + entry.types, + source_name, + ) + .map_err(SourceError::Compile)?; + } + if owner.policy.typing_mode.is_strict() + && let Err(error) = enforce_strict_type_resolution_with_host_recovery( + &owned_admission_sites(&parsed, owner), + &type_info, + behavior.recover_host_errors && host_error.is_some(), + ) + { + // A failed catalog call can leave its result local Unknown. For + // non-recovering entries retain the primary host diagnostic, but + // still run independent ownership and source-hook admission. + // Analysis must continue rejecting independent strict failures. + if host_error.is_some() && !behavior.recover_host_errors { + deferred_resolution_error.get_or_insert(error); + } else { + return Err(SourceError::Compile(error)); + } + } + } + let logical_schemas = type_info.logical_schemas(); + parsed.semantic_index = Some(SemanticIndex::build(logical_schemas.clone(), &parsed)); + let owned_ir = match enforce_owned_sources( + parsed.clone(), + &type_info, + owners, + behavior, + sources, + entry.availability, + ) { + Ok(owned) => owned, + Err(error) + if !behavior.recover_host_errors + && host_error.as_ref().is_some_and(|host| { + resource_copy_duplicates_host_call(&parsed, host, &error) + }) => + { + // The resolver already rejected this exact call's argument passing. + // Retain its primary diagnostic without bypassing source hooks. This + // admission-only IR cannot reach codegen: host_error is returned below. + parsed.clone() + } + Err(error) => return Err(SourceError::Parse(error)), + }; + for owner in owners { + if let Some(plugin) = owner.plugin { + let typed = FrontendTypedIr { + source_id: Some(owner.source_id), + ir: &parsed, + local_schemas: &logical_schemas, + host_api_catalog: options.host_api_catalog().map(Arc::as_ref), + source_map: Some(sources), + }; + plugin + .validate_typed_ir(&typed) + .map_err(SourceError::Compile)?; + } + } + if let Some(error) = host_error { + if !behavior.recover_host_errors { + return Err(SourceError::Compile(error)); + } + analysis_errors.push(error); + } + if let Some(error) = deferred_resolution_error { + return Err(SourceError::Compile(error)); + } + Ok((parsed, type_info, owned_ir, analysis_errors)) +} + +fn resource_copy_duplicates_host_call( + ir: &FrontendIr, + host: &CompileError, + ownership: &ParseError, +) -> bool { + let CompileError::HostCallResolve { + span: Some(callee), .. + } = host + else { + return false; + }; + if ownership.code.as_deref() != Some("E_OWNERSHIP_COPY_RESOURCE") { + return false; + } + let Some(owned_span) = ownership.span else { + return false; }; + // Only a standalone failed call at the very same source site can duplicate + // the copy rejection. Line equality alone would alias independent calls, + // statements or module owners; missing provenance must remain fail-closed. + ir.parsed_semantic_index.as_ref().is_some_and(|index| { + // Availability can fall back to a statement span by source and line. + // Such a fallback is not exact enough to prioritize a call if more + // than one statement occupies that line. + index + .stmt_spans + .iter() + .filter(|site| { + site.span.source_id == owned_span.source_id && site.line as usize == ownership.line + }) + .count() + == 1 + && index.call_sites.iter().any(|site| { + site.callee_span == *callee + && site.expr_span.source_id == owned_span.source_id + && site.expr_span.lo == owned_span.lo + && site.expr_span.hi <= owned_span.hi + }) + }) +} + +fn enforce_owned_sources( + ir: FrontendIr, + facts: &typing::TypeInferenceResult, + owners: &[TypedSourceOwner<'_>], + behavior: CompileBehavior, + sources: &SourceMap, + entry_locals: &[lifetime::EntryLocalAvailability], +) -> Result { + let owned_slots = facts + .local_schemas + .iter() + .map(|schema| { + schema + .as_ref() + .is_some_and(|schema| schema.contains_resource_with_named_types(&ir.struct_schemas)) + }) + .collect::>(); + let policies = owners + .iter() + .map(|owner| (owner.source_name.clone(), owner.policy.ownership_checking)) + .collect(); + lifetime::enforce_source_availability_with_entry_locals( + ir, + entry_locals, + behavior.clear_dead_locals, + &owned_slots, + &policies, + owners + .first() + .is_some_and(|owner| owner.policy.ownership_checking), + sources, + ) } fn collect_named_local_debug_ranges(parsed: &FrontendIr) -> HashMap { @@ -189,6 +657,7 @@ fn record_expr_local_debug_ranges( note_local_use(ranges, *root, line); } Expr::OptionalGet { + field_projection: _, container, key, container_slot, @@ -348,6 +817,16 @@ fn is_compiler_primitive_import(name: &str) -> bool { name.starts_with("__prim_") } +fn frontend_policy_for_flavor( + flavor: SourceFlavor, + options: &CompileSourceFileOptions, +) -> Result { + options + .frontend_policy(flavor) + .ok_or(SourcePathError::MissingFrontendPlugin(flavor)) +} + +#[cfg(all(test, feature = "runtime"))] fn compile_parsed_output( source: String, parsed: FrontendIr, @@ -366,6 +845,7 @@ fn compile_parsed_output( ) } +#[cfg(all(test, feature = "runtime"))] fn compile_parsed_output_with_entry_locals( source: String, parsed: FrontendIr, @@ -374,6 +854,31 @@ fn compile_parsed_output_with_entry_locals( behavior: CompileBehavior, typing_mode: TypingMode, enable_local_move_semantics: bool, +) -> Result { + compile_parsed_output_with_capture_output( + source, + parsed, + entry_locals, + entry_local_types, + behavior, + typing_mode, + enable_local_move_semantics, + None, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn compile_parsed_output_with_capture_output( + source: String, + parsed: FrontendIr, + entry_locals: &[lifetime::EntryLocalAvailability], + entry_local_types: &[typing::EntryLocalType], + behavior: CompileBehavior, + typing_mode: TypingMode, + enable_local_move_semantics: bool, + certification: Option<&mut super::capture_certification::CaptureCertification>, + capture_source_policies: Option>, ) -> Result { // Normal compilation passes no entry locals. The REPL uses this hook to treat // carried-over locals from prior entries as already available at snippet start. @@ -385,19 +890,11 @@ fn compile_parsed_output_with_entry_locals( .map_err(SourceError::Compile)?; typing::validate_if_else_type_consistency(&parsed, typing_mode, entry_local_types) .map_err(SourceError::Compile)?; - // One strict inference run over the post-legalize IR: it feeds both the - // strict RustScript resolution gate and the resource-ownership metadata - // for the lifetime passes. Slot indices are the pre-compaction logical - // locals here, exactly the space availability/liveness analyze in. let pre_lifetime_type_info = typing::infer_types(&parsed, typing_mode, entry_local_types); if typing_mode.is_strict() { - enforce_strict_rustscript_type_resolution(&parsed, &pre_lifetime_type_info) + enforce_strict_type_resolution(&parsed, &pre_lifetime_type_info) .map_err(SourceError::Compile)?; } - // A local slot is resource-owned when its post-legalize logical schema - // contains a resource anywhere (direct or nested). These slots are the - // move-only contract for availability/liveness; plain programs carry no - // resource schemas, so their behavior is untouched. let owned_local_slots = pre_lifetime_type_info .local_schemas .iter() @@ -415,6 +912,74 @@ fn compile_parsed_output_with_entry_locals( &owned_local_slots, ) .map_err(SourceError::Parse)?; + compile_owned_ir_with_capture_output( + source, + parsed, + entry_local_types, + typing_mode, + enable_local_move_semantics, + local_debug_ranges, + LocalLayoutPolicy::Compact, + certification, + capture_source_policies, + ) + .map(|(compiled, _)| compiled) +} + +/// Final layout belongs to the compilation owner, not current-snippet liveness. +/// REPL root bindings remain observable by later snippets, even on the first +/// compilation or when no persisted entry is referenced in this snippet. +#[derive(Clone, Copy)] +enum LocalLayoutPolicy<'a> { + Compact, + PersistentRoots(&'a [ReplLocalBinding]), +} + +fn compile_owned_ir_with_root_projection( + source: String, + parsed: FrontendIr, + entry_local_types: &[typing::EntryLocalType], + typing_mode: TypingMode, + enable_local_move_semantics: bool, + local_debug_ranges: HashMap, + local_layout: LocalLayoutPolicy<'_>, +) -> Result<(CompiledProgram, Vec), SourceError> { + compile_owned_ir_with_capture_output( + source, + parsed, + entry_local_types, + typing_mode, + enable_local_move_semantics, + local_debug_ranges, + local_layout, + None, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn compile_owned_ir_with_capture_output( + source: String, + parsed: FrontendIr, + entry_local_types: &[typing::EntryLocalType], + typing_mode: TypingMode, + enable_local_move_semantics: bool, + local_debug_ranges: HashMap, + local_layout: LocalLayoutPolicy<'_>, + certification: Option<&mut super::capture_certification::CaptureCertification>, + capture_source_policies: Option>, +) -> Result<(CompiledProgram, Vec), SourceError> { + // Authority follows each admitted producer, never the merged entry policy. + let mut origins = certification.as_ref().map(|_| { + let mut origins = + typing::infer_types_with_capture_origins(&parsed, typing_mode, entry_local_types, &[]) + .2; + let Some(policies) = capture_source_policies else { + return Default::default(); + }; + origins.retain_admitted_producers(&parsed, policies); + origins + }); // Classify named callable materialization on the final merged IR // (post-lifetime, so capture metadata and rewritten uses are // authoritative). Codegen consumes `requires_callable_slot` to omit @@ -427,8 +992,77 @@ fn compile_parsed_output_with_entry_locals( // value identities, so the classification is strictly more precise on // the unallocated IR. let callable_use_facts = materialization::classify_named_callables(&parsed); - let parsed = lifetime::allocate_local_slots(parsed).map_err(SourceError::Parse)?; - let type_info = typing::infer_types(&parsed, typing_mode, entry_local_types); + let (parsed, entry_local_types, root_slots) = match local_layout { + LocalLayoutPolicy::Compact if origins.is_none() => ( + lifetime::allocate_local_slots(parsed).map_err(SourceError::Parse)?, + entry_local_types.to_vec(), + Vec::new(), + ), + LocalLayoutPolicy::Compact => { + let allocation = lifetime::allocate_local_slots_with_root_slots(parsed, &[]) + .map_err(SourceError::Parse)?; + if let Some(origins) = &mut origins { + origins.remap(&allocation.mapping); + } + (allocation.ir, entry_local_types.to_vec(), Vec::new()) + } + LocalLayoutPolicy::PersistentRoots(bindings) => { + // The parser/session owner identifies observable root bindings. + // The frontend's cumulative locals count also contains callee + // locals and must never be used as the persistent root set. + let root_slots = bindings + .iter() + .map(|binding| { + parsed + .local_bindings + .iter() + .find(|(name, _)| name == &binding.name) + .map(|(_, slot)| *slot) + .ok_or_else(|| { + SourceError::Parse(ParseError::new(format!( + "missing persistent root binding '{}'", + binding.name + ))) + }) + }) + .collect::, _>>()?; + let allocation = lifetime::allocate_local_slots_with_root_slots(parsed, &root_slots) + .map_err(SourceError::Parse)?; + if let Some(origins) = &mut origins { + origins.remap(&allocation.mapping); + } + let remapped_types = entry_local_types + .iter() + .map(|entry| { + let slot = allocation + .mapping + .get(entry.slot as usize) + .copied() + .ok_or_else(|| { + SourceError::Parse(ParseError::new( + "entry local slot remap out of range", + )) + })?; + Ok(typing::EntryLocalType { + slot, + schema: entry.schema.clone(), + optional: entry.optional, + }) + }) + .collect::, SourceError>>()?; + let root_slots = root_slots + .iter() + .map(|slot| allocation.mapping[*slot as usize]) + .collect(); + (allocation.ir, remapped_types, root_slots) + } + }; + let (type_info, root_projection) = typing::infer_types_with_root_projection( + &parsed, + typing_mode, + &entry_local_types, + &root_slots, + ); let FrontendIr { stmts, locals, @@ -527,18 +1161,25 @@ fn compile_parsed_output_with_entry_locals( .add_local_debug(name, index, range.declared_line, range.last_line) .map_err(SourceError::Compile)?; } - let mut program = compiler - .compile_program(&stmts) - .map_err(SourceError::Compile)?; + let mut program = match (certification, origins) { + (Some(output), Some(origins)) => { + compiler.compile_program_certified(&stmts, &origins, output) + } + _ => compiler.compile_program(&stmts), + } + .map_err(SourceError::Compile)?; program.local_count = program.local_count.max(locals); let runtime_locals = program.local_count; - Ok(CompiledProgram { - program, - locals: runtime_locals, - functions: visible_runtime_import_functions, - #[cfg(test)] - callable_use_facts: callable_use_observations, - }) + Ok(( + CompiledProgram { + program, + locals: runtime_locals, + functions: visible_runtime_import_functions, + #[cfg(test)] + callable_use_facts: callable_use_observations, + }, + root_projection, + )) } #[derive(Clone, Debug)] @@ -556,16 +1197,24 @@ fn reject_strict_unknown_annotations(parsed: &FrontendIr) -> Result<(), ParseErr Err(ParseError { line: 1, message: - "RustScript requires concrete compile-time types; 'unknown' annotations are not allowed" + "strict frontends require concrete compile-time types; 'unknown' annotations are not allowed" .to_string(), span: Some(span), code: Some("E_STRICT_UNKNOWN_TYPE".to_string()), }) } -fn enforce_strict_rustscript_type_resolution( +fn enforce_strict_type_resolution( + parsed: &FrontendIr, + type_info: &typing::TypeInferenceResult, +) -> Result<(), CompileError> { + enforce_strict_type_resolution_with_host_recovery(parsed, type_info, false) +} + +fn enforce_strict_type_resolution_with_host_recovery( parsed: &FrontendIr, type_info: &typing::TypeInferenceResult, + recover_host_results: bool, ) -> Result<(), CompileError> { let parsed_index = parsed.parsed_semantic_index.as_ref(); for schema in parsed.struct_schemas.values() { @@ -576,14 +1225,20 @@ fn enforce_strict_rustscript_type_resolution( index .struct_decls .iter() - .find(|site| site.name == schema.name) + .find(|site| { + site.name == schema.name + || index.type_provenance.iter().any(|provenance| { + provenance.canonical_name == schema.name + && provenance.declaration_span == Some(site.ident_span) + }) + }) .map(|site| site.ident_span) }); return Err(CompileError::StrictTypingRequired { line: None, source_name: None, detail: format!( - "struct '{}' contains non-concrete field types; RustScript requires concrete schemas", + "struct '{}' contains non-concrete field types; strict typing requires concrete schemas", schema.name ), span, @@ -606,7 +1261,7 @@ fn enforce_strict_rustscript_type_resolution( line: function_decl_lines.get(&decl.index).copied(), source_name: parsed.function_sources.get(&decl.index).cloned(), detail: format!( - "function '{}' uses a non-concrete return schema; RustScript requires concrete return types", + "function '{}' uses a non-concrete return schema; strict typing requires concrete return types", decl.name ), span, @@ -618,6 +1273,18 @@ fn enforce_strict_rustscript_type_resolution( if slot_is_fully_typed(slot, type_info) { continue; } + // Only a direct unresolved catalog-call result is diagnostic fallout. + // Keep its schema Unknown and all owner hooks/ownership checks intact; + // unrelated locals and parameters still require strict admission. + if recover_host_results + && site.kind == "local" + && (has_unresolved_host_initializer(&parsed.stmts, slot, parsed) + || parsed.function_impls.values().any(|implementation| { + has_unresolved_host_initializer(&implementation.body_stmts, slot, parsed) + })) + { + continue; + } let span = parsed_index.and_then(|index| { index .local_decls @@ -636,7 +1303,7 @@ fn enforce_strict_rustscript_type_resolution( line: site.line, source_name: site.source_name, detail: format!( - "{} '{}' does not resolve to a concrete compile-time type in RustScript", + "{} '{}' does not resolve to a concrete compile-time type under strict typing", site.kind, site.name ), span, @@ -646,6 +1313,38 @@ fn enforce_strict_rustscript_type_resolution( Ok(()) } +fn has_unresolved_host_initializer(stmts: &[Stmt], slot: LocalSlot, ir: &FrontendIr) -> bool { + stmts.iter().any(|stmt| match stmt { + Stmt::Let { + index, + expr: Expr::Call(function, _, _, None, _), + declared_schema: None, + .. + } if *index == slot => ir + .host_api_metadata + .as_ref() + .and_then(|metadata| metadata.candidates(*function)) + .is_some(), + Stmt::IfElse { + then_branch, + else_branch, + .. + } => { + has_unresolved_host_initializer(then_branch, slot, ir) + || has_unresolved_host_initializer(else_branch, slot, ir) + } + Stmt::For { + init, post, body, .. + } => { + has_unresolved_host_initializer(std::slice::from_ref(init.as_ref()), slot, ir) + || has_unresolved_host_initializer(std::slice::from_ref(post.as_ref()), slot, ir) + || has_unresolved_host_initializer(body, slot, ir) + } + Stmt::While { body, .. } => has_unresolved_host_initializer(body, slot, ir), + _ => false, + }) +} + fn slot_is_fully_typed(slot: LocalSlot, type_info: &typing::TypeInferenceResult) -> bool { let slot_index = usize::from(slot); if type_info @@ -809,6 +1508,23 @@ fn record_local_source_names( } } +/// Retains compiler capture authority while preserving ordinary source admission. +pub fn compile_source_with_capture_certification( + source: &str, +) -> Result { + let source = source.to_owned(); + run_with_compiler_stack(move || { + let mut facts = Default::default(); + let compiled = compile_source_with_flavor_capture_output( + &source, + SourceFlavor::RustScript, + CompileBehavior::DEFAULT, + Some(&mut facts), + )?; + Ok(super::CertifiedCompilation { compiled, facts }) + }) +} + pub fn compile_source(source: &str) -> Result { compile_source_with_flavor(source, SourceFlavor::RustScript) } @@ -834,17 +1550,19 @@ pub fn analyze_source_with_flavor( source: &str, flavor: SourceFlavor, ) -> Result { - let effective = default_standard_catalog_options(&CompileSourceFileOptions::default()); + let effective = default_standard_catalog_options(flavor, &CompileSourceFileOptions::default()); let mut source_map = SourceMap::new(); let source_id = source_map.add_source("", source.to_string()); let parsed = frontends::parse_source(source, flavor, &effective).map_err(|err| { SourceError::Parse(err.with_line_span_from_source(&source_map, source_id)) })?; + let policy = frontend_policy_for_flavor(flavor, &effective) + .map_err(|err| SourceError::Parse(ParseError::new(err.to_string())))?; analyze_parsed_output( source.to_string(), parsed, source_map, - flavor, + policy, effective.host_api_catalog().cloned(), None, ) @@ -922,121 +1640,85 @@ fn analyze_source_string_at_path( source: &str, options: &CompileSourceFileOptions, ) -> Result { - let effective = default_standard_catalog_options(options); - // Module-graph, plugin, and custom-catalog compilations share the same - // frontend pipeline as the compile path: the loader parses every unit - // verbatim (no second parser), the linker merges the provenance carrier, - // and analysis builds the semantic index from the merged IR. - if effective.has_module_overrides() || effective.has_source_plugins() { - let loaded = load_units_for_source_file(path, flavor, source, &effective)?; - let catalog = effective.host_api_catalog().cloned(); - return analyze_loaded_units( - source.to_string(), - loaded.units, - flavor, - loaded.sources, - catalog, - Some(loaded.module_graph), - ); - } - - let mut source_map = SourceMap::new(); - let source_id = source_map.add_source(path.display().to_string(), source.to_string()); - let parsed = frontends::parse_source(source, flavor, &effective).map_err(|err| { - SourcePathError::Source(SourceError::Parse( - err.with_line_span_from_source(&source_map, source_id), - )) - })?; - - let catalog = effective.host_api_catalog().cloned(); - analyze_parsed_output( - source.to_string(), - parsed, - source_map, + let effective = default_standard_catalog_options(flavor, options); + let policy = frontend_policy_for_flavor(flavor, &effective)?; + let loaded = load_units_for_source_file(path, flavor, source, &effective)?; + analyze_loaded_units( + loaded.units, + policy, + loaded.sources, + Some(loaded.module_graph), flavor, - catalog, - None, + &effective, ) - .map_err(SourcePathError::Source) } /// Analyze the merged output of the module loader through the shared /// frontend pipeline: merge units, then legalize + type-check + build the /// provenance-driven semantic index. No second parser is involved. fn analyze_loaded_units( - source: String, units: Vec, - flavor: SourceFlavor, + policy: FrontendPolicy, sources: SourceMap, - custom_catalog: Option>, module_graph: Option, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, ) -> Result { - let merged = merge_units(units)?; - analyze_parsed_output( - source, - merged, + // Diagnostic recovery uses the same finalized owner admission as compile + // and hints. A host error grants no execution authority. + let mut prepared = prepare_typed_units( + units, sources, + policy, flavor, - custom_catalog, - module_graph, - ) - .map_err(SourcePathError::Source) + options, + CompileBehavior::ANALYZE, + None, + )?; + let errors = std::mem::take(&mut prepared.analysis_errors); + prepared.module_graph = module_graph; + Ok(match prepared.module_graph { + Some(module_graph) => SemanticModel::new_with_module_graph( + prepared.ir, + prepared.sources, + prepared.catalog, + errors, + module_graph, + ), + None => SemanticModel::new(prepared.ir, prepared.sources, prepared.catalog, errors), + }) } fn analyze_parsed_output( _source: String, parsed: FrontendIr, source_map: SourceMap, - flavor: SourceFlavor, + policy: FrontendPolicy, custom_catalog: Option>, module_graph: Option, ) -> Result { - let typing_mode = TypingMode::for_flavor(flavor); let catalog = custom_catalog.unwrap_or_else(default_analyze_catalog); - - // Run legalize and type checking. - let legalize_result = - typing::legalize_builtins_and_bind_types(parsed.clone(), typing_mode, &[]); - let mut errors = Vec::new(); - - let (mut parsed_after_legalize, type_info) = match legalize_result { - Ok(legalized) => { - let type_info = typing::infer_types(&legalized, typing_mode, &[]); - (legalized, type_info) - } - Err(compile_err) => { - errors.push(compile_err); - // Even on error, run type inference on the original IR for partial results. - let type_info = typing::infer_types(&parsed, typing_mode, &[]); - (parsed, type_info) - } - }; - - // Run validation, collecting errors. - if let Err(compile_err) = - typing::validate_if_else_type_consistency(&parsed_after_legalize, typing_mode, &[]) - { - errors.push(compile_err); - } - - // Build the semantic index directly from the parser provenance carried - // on the legalized IR plus the typed/resolved IR keyed by SemanticNodeId. - // No source-text reconstruction is involved. - let semantic_index = - SemanticIndex::build(type_info.local_schemas.clone(), &parsed_after_legalize); - - // Attach the semantic index to the IR. - parsed_after_legalize.semantic_index = Some(semantic_index); + let options = CompileSourceFileOptions::new().with_host_api_catalog(Arc::clone(&catalog)); + let owners = [TypedSourceOwner { + source_id: 0, + source_name: String::new(), + policy, + plugin: None, + }]; + let (ir, _, _, errors) = prepare_owned_typed_ir( + parsed, + &owners, + &options, + CompileBehavior::ANALYZE, + &source_map, + EntryLocalContext::default(), + )?; Ok(match module_graph { - Some(module_graph) => SemanticModel::new_with_module_graph( - parsed_after_legalize, - source_map, - catalog, - errors, - module_graph, - ), - None => SemanticModel::new(parsed_after_legalize, source_map, catalog, errors), + Some(module_graph) => { + SemanticModel::new_with_module_graph(ir, source_map, catalog, errors, module_graph) + } + None => SemanticModel::new(ir, source_map, catalog, errors), }) } @@ -1155,13 +1837,23 @@ fn collect_inferred_local_type_hints_impl( source: &str, flavor: SourceFlavor, ) -> Result, SourceError> { + let options = CompileSourceFileOptions::default(); + let policy = frontend_policy_for_flavor(flavor, &options) + .map_err(|error| SourceError::Parse(ParseError::new(error.to_string())))?; let mut source_map = SourceMap::new(); let source_id = source_map.add_source("", source.to_string()); - let parsed = frontends::parse_source(source, flavor, &CompileSourceFileOptions::default()) - .map_err(|err| { - SourceError::Parse(err.with_line_span_from_source(&source_map, source_id)) - })?; - collect_named_local_type_hints(parsed).map_err(SourceError::Compile) + let parsed = frontends::parse_source(source, flavor, &options).map_err(|err| { + SourceError::Parse(err.with_line_span_from_source(&source_map, source_id)) + })?; + let (ir, type_info, _) = prepare_typed_ir( + parsed, + policy, + &options, + CompileBehavior::DEFAULT, + None, + &source_map, + )?; + Ok(collect_named_local_type_hints(&ir, &type_info, &source_map)) } fn lint_unknown_inferred_local_types_with_options_impl( @@ -1177,7 +1869,7 @@ fn lint_unknown_inferred_local_types_with_options_impl( .map_err(SourcePathError::Source); } - let path = virtual_inmemory_entry_path(flavor); + let path = virtual_inmemory_entry_path(flavor, options)?; lint_unknown_inferred_local_types_at_path_with_options_impl(&path, source, flavor, options) } @@ -1186,15 +1878,7 @@ fn collect_inferred_local_type_hints_with_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result, SourcePathError> { - if !options.has_module_overrides() - && !options.has_source_plugins() - && options.host_api_catalog().is_none() - { - return collect_inferred_local_type_hints_impl(source, flavor) - .map_err(SourcePathError::Source); - } - - let path = virtual_inmemory_entry_path(flavor); + let path = virtual_inmemory_entry_path(flavor, options)?; collect_inferred_local_type_hints_at_path_with_options_impl(&path, source, flavor, options) } @@ -1223,15 +1907,22 @@ fn collect_inferred_local_type_hints_at_path_with_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result, SourcePathError> { + let policy = frontend_policy_for_flavor(flavor, options)?; let loaded = load_units_for_source_file(path, flavor, source, options)?; - let parsed = loaded - .units - .into_iter() - .last() - .map(|unit| unit.parsed) - .expect("root parsed unit should always be present"); - collect_named_local_type_hints(parsed) - .map_err(|error| SourcePathError::Source(SourceError::Compile(error))) + let prepared = prepare_typed_units( + loaded.units, + loaded.sources, + policy, + flavor, + options, + CompileBehavior::DEFAULT, + Some(loaded.module_graph), + )?; + Ok(collect_named_local_type_hints( + &prepared.ir, + &prepared.type_info, + &prepared.sources, + )) } fn collect_unknown_inferred_local_types( @@ -1283,46 +1974,144 @@ fn collect_unknown_inferred_local_types( } fn collect_named_local_type_hints( - parsed: FrontendIr, -) -> Result, CompileError> { + parsed: &FrontendIr, + type_info: &typing::TypeInferenceResult, + source_map: &SourceMap, +) -> Vec { + let logical_schemas = type_info.logical_schemas(); let slot_ranges = collect_local_debug_ranges(&parsed.stmts, &parsed.function_impls); - let function_decl_lines = collect_function_decl_lines(&parsed.stmts); - let parsed = typing::legalize_builtins_and_bind_types(parsed, TypingMode::DynamicHints, &[])?; - let type_info = typing::infer_types(&parsed, TypingMode::DynamicHints, &[]); - + let semantic_index = parsed.semantic_index.as_ref(); + let mut seen = std::collections::HashSet::new(); let mut hints = Vec::new(); - for (name, slot) in &parsed.local_bindings { - hints.push(InferredLocalTypeHint { - name: name.clone(), - inferred_type: inferred_slot_type_name(&type_info, *slot), - declared_line: slot_ranges.get(slot).and_then(|range| range.declared_line), - last_line: slot_ranges.get(slot).and_then(|range| range.last_line), + + if let Some(semantic_index) = semantic_index { + // Linked IR is merged in dependency order; editor hints follow source + // provenance instead, retaining every declaration from every owner. + let mut declarations = semantic_index.parsed.local_decls.iter().collect::>(); + declarations.sort_by_key(|decl| { + ( + decl.ident_span.source_id, + decl.ident_span.lo, + decl.ident_span.hi, + ) }); - } + for decl in declarations { + let declaration_identity = ( + decl.ident_span.source_id, + decl.slot, + decl.ident_span.lo, + decl.ident_span.hi, + ); + if !seen.insert(declaration_identity) { + continue; + } + let schema = logical_schemas + .get(usize::from(decl.slot)) + .cloned() + .flatten(); + let declaration_line = source_map + .line_col_for_offset(decl.ident_span.source_id, decl.ident_span.lo) + .and_then(|(line, _)| u32::try_from(line).ok()); + let range = slot_ranges.get(&decl.slot); + hints.push(InferredLocalTypeHint { + source_name: source_map + .file_name(decl.ident_span.source_id) + .map(str::to_string), + name: decl.name.clone(), + inferred_type: schema + .as_ref() + .map(|schema| { + typing::render_schema_label( + &parsed.source_schema(schema, decl.ident_span.source_id), + ) + }) + .unwrap_or_else(|| inferred_slot_type_name(type_info, decl.slot)), + declared_line: declaration_line + .or_else(|| range.and_then(|range| range.declared_line)), + last_line: range.and_then(|range| range.last_line), + schema, + span: source_map + .span_text(decl.ident_span) + .map(|_| decl.ident_span), + }); + } - for decl in &parsed.functions { - let Some(function_impl) = parsed.function_impls.get(&decl.index) else { - continue; - }; - let declared_line = function_decl_lines.get(&decl.index).copied(); - let last_line = function_scope_last_line(function_impl).or(declared_line); - for (name, slot) in decl.args.iter().zip(function_impl.param_slots.iter()) { + for function in &parsed.functions { + let Some(function_impl) = parsed.function_impls.get(&function.index) else { + continue; + }; + let Some(function_site) = semantic_index + .parsed + .func_decls + .iter() + .find(|site| site.function_index == function.index) + else { + continue; + }; + let declared_line = source_map + .line_col_for_offset( + function_site.ident_span.source_id, + function_site.ident_span.lo, + ) + .and_then(|(line, _)| u32::try_from(line).ok()); + let last_line = function_scope_last_line(function_impl).or(declared_line); + for (name, slot) in function.args.iter().zip(function_impl.param_slots.iter()) { + let parameter_has_site = semantic_index.parsed.local_decls.iter().any(|decl| { + decl.ident_span.source_id == function_site.ident_span.source_id + && decl.slot == *slot + && decl.name == *name + && (decl.decl_order as usize) < function.args.len() + }); + if parameter_has_site { + continue; + } + if !seen.insert(( + function_site.ident_span.source_id, + *slot, + usize::MAX, + usize::MAX, + )) { + continue; + } + let schema = logical_schemas.get(usize::from(*slot)).cloned().flatten(); + let range = slot_ranges.get(slot); + hints.push(InferredLocalTypeHint { + source_name: source_map + .file_name(function_site.ident_span.source_id) + .map(str::to_string), + name: name.clone(), + inferred_type: schema + .as_ref() + .map(typing::render_schema_label) + .unwrap_or_else(|| inferred_slot_type_name(type_info, *slot)), + declared_line: range + .and_then(|range| range.declared_line) + .or(declared_line), + last_line: range.and_then(|range| range.last_line).or(last_line), + schema, + span: None, + }); + } + } + } else { + for (name, slot) in &parsed.local_bindings { + let schema = logical_schemas.get(usize::from(*slot)).cloned().flatten(); + let range = slot_ranges.get(slot); hints.push(InferredLocalTypeHint { + source_name: None, name: name.clone(), - inferred_type: inferred_slot_type_name(&type_info, *slot), - declared_line: slot_ranges - .get(slot) - .and_then(|range| range.declared_line) - .or(declared_line), - last_line: slot_ranges - .get(slot) - .and_then(|range| range.last_line) - .or(last_line), + inferred_type: schema + .as_ref() + .map(typing::render_schema_label) + .unwrap_or_else(|| inferred_slot_type_name(type_info, *slot)), + declared_line: range.and_then(|range| range.declared_line), + last_line: range.and_then(|range| range.last_line), + schema, + span: None, }); } } - - Ok(hints) + hints } fn inferred_slot_type_name(type_info: &typing::TypeInferenceResult, slot: LocalSlot) -> String { @@ -1466,13 +2255,36 @@ pub fn compile_source_for_repl_with_locals( let source_owned = source.to_string(); let predefined_locals = predefined_locals.to_vec(); run_with_compiler_stack(move || { - compile_source_for_repl_with_locals_impl(&source_owned, &predefined_locals, &[]) + compile_source_for_repl_with_locals_impl( + &source_owned, + &predefined_locals, + &[], + &CompileSourceFileOptions::default(), + ) }) } pub fn compile_source_for_repl_with_state( source: &str, predefined_locals: &[ReplLocalState], +) -> Result { + compile_source_for_repl_with_state_and_options( + source, + predefined_locals, + CompileSourceFileOptions::default(), + ) +} + +/// Compile the supported RustScript REPL frontend with an explicit catalog. +/// Persisted-local semantics and strict ownership admission are unchanged. +/// Module overrides and selected module producers use the generic checked +/// admission pipeline. The executable snippet root remains native RustScript; +/// registration alone never selects a file producer. Guest callables remain +/// program-owned and resource handles remain scoped to their VM arena. +pub fn compile_source_for_repl_with_state_and_options( + source: &str, + predefined_locals: &[ReplLocalState], + options: CompileSourceFileOptions, ) -> Result { let source_owned = source.to_string(); let predefined_locals = predefined_locals.to_vec(); @@ -1486,7 +2298,7 @@ pub fn compile_source_for_repl_with_state( .filter(|state| state.moved) .map(|state| state.binding.name.clone()) .collect::>(); - compile_source_for_repl_with_locals_impl(&source_owned, &bindings, &moved_names) + compile_source_for_repl_with_locals_impl(&source_owned, &bindings, &moved_names, &options) }) } @@ -1536,41 +2348,73 @@ fn compile_source_for_repl_with_locals_impl( source: &str, predefined_locals: &[ReplLocalBinding], moved_names: &[String], + options: &CompileSourceFileOptions, ) -> Result { - let mut source_map = SourceMap::new(); - let source_id = source_map.add_source("", source.to_string()); - // REPL parsing/compiler entry state is separate from normal program compilation so - // persisted locals do not leak into the generic frontend or IR surface. - #[cfg(all(feature = "http-client", not(target_family = "wasm")))] - let repl_catalog = Some(crate::builtins::runtime::http::http_host_catalog()); - #[cfg(not(all(feature = "http-client", not(target_family = "wasm"))))] - let repl_catalog = None; - let parsed = frontends::parse_rustscript_repl_source_with_catalog( - source, - predefined_locals, - repl_catalog, - ) - .map_err(|err| SourceError::Parse(err.with_line_span_from_source(&source_map, source_id)))?; - let entry_local_types = build_entry_local_types(&parsed.ir, predefined_locals); - let entry_availability = - build_entry_local_availability(&parsed.ir, predefined_locals, moved_names); - let compiled = match compile_parsed_output_with_entry_locals( - source.to_string(), - parsed.ir, - &entry_availability, - &entry_local_types, - CompileBehavior::REPL, - TypingMode::StrictRustScript, - true, - ) { - Err(SourceError::Parse(err)) => Err(SourceError::Parse( - err.with_line_span_from_source(&source_map, source_id), - )), - other => other, - }?; - Ok(CompiledReplProgram { - compiled, - bindings: parsed.bindings, + let effective = default_http_compile_catalog_options(SourceFlavor::RustScript, options); + let compile = || -> Result { + let path = virtual_inmemory_entry_path(SourceFlavor::RustScript, &effective)?; + let loaded = super::source_loader::load_units_with_entry_locals( + &path, + SourceFlavor::RustScript, + source, + &effective, + None, + Some(predefined_locals), + )?; + let bindings = loaded.repl_bindings; + let policy = frontend_policy_for_flavor(SourceFlavor::RustScript, &effective)?; + let prepared = prepare_typed_units_with_entry( + loaded.units, + loaded.sources, + policy, + SourceFlavor::RustScript, + &effective, + UnitPreparation { + behavior: CompileBehavior::REPL, + module_graph: Some(loaded.module_graph), + repl: Some((predefined_locals, moved_names)), + }, + )?; + let debug_ranges = collect_named_local_debug_ranges(&prepared.ir); + let (compiled, root_projection) = compile_owned_ir_with_root_projection( + source.to_string(), + prepared.owned_ir, + &prepared.entry_local_types, + policy.typing_mode, + policy.ownership_checking, + debug_ranges, + LocalLayoutPolicy::PersistentRoots(&bindings), + ) + .map_err(|error| SourcePathError::SourceWithMap { + error, + sources: prepared.sources, + })?; + let mut bindings = bindings; + for binding in &mut bindings { + let fact = compiled + .program + .debug + .as_ref() + .and_then(|debug| debug.local_index(&binding.name)) + .and_then(|slot| { + root_projection + .iter() + .find(|fact| fact.slot == u16::from(slot)) + }) + .ok_or_else(|| { + SourcePathError::Source(SourceError::Parse(ParseError::new(format!( + "missing final root projection for '{}'", + binding.name + )))) + })?; + binding.schema = fact.schema.clone(); + binding.optional = fact.optional; + } + Ok(CompiledReplProgram { compiled, bindings }) + }; + compile().map_err(|error| match error { + SourcePathError::Source(error) | SourcePathError::SourceWithMap { error, .. } => error, + other => SourceError::Parse(ParseError::new(other.to_string())), }) } @@ -1650,20 +2494,45 @@ fn compile_source_with_flavor_impl( source: &str, flavor: SourceFlavor, behavior: CompileBehavior, +) -> Result { + compile_source_with_flavor_capture_output(source, flavor, behavior, None) +} + +fn compile_source_with_flavor_capture_output( + source: &str, + flavor: SourceFlavor, + behavior: CompileBehavior, + certification: Option<&mut super::capture_certification::CaptureCertification>, ) -> Result { let mut source_map = SourceMap::new(); let source_id = source_map.add_source("", source.to_string()); - let effective = default_http_compile_catalog_options(&CompileSourceFileOptions::default()); + let effective = + default_http_compile_catalog_options(flavor, &CompileSourceFileOptions::default()); let parsed = frontends::parse_source_for_compile(source, flavor, &effective).map_err(|err| { SourceError::Parse(err.with_line_span_from_source(&source_map, source_id)) })?; - match compile_parsed_output( + let policy = frontend_policy_for_flavor(flavor, &effective) + .map_err(|err| SourceError::Parse(ParseError::new(err.to_string())))?; + // The registered standalone source owns this parse. Keep its identity + // private so ordinary diagnostic source metadata remains unchanged. + let capture_source_policies = + HashMap::from([("".to_owned(), policy.ownership_checking)]); + match compile_parsed_output_with_capture_output( source.to_string(), parsed, + &[], + &[], behavior, - TypingMode::for_flavor(flavor), - matches!(flavor, SourceFlavor::RustScript), + policy.typing_mode, + policy.ownership_checking, + certification, + Some( + super::capture_certification::CaptureProducerPolicies::Standalone { + source: "", + policies: &capture_source_policies, + }, + ), ) { Err(SourceError::Parse(err)) => Err(SourceError::Parse( err.with_line_span_from_source(&source_map, source_id), @@ -1675,40 +2544,64 @@ fn compile_source_with_flavor_impl( fn compile_loaded_units( source: String, units: Vec, - flavor: SourceFlavor, - // Carried from the loader for Milestone 2+ (structured imports, symbol - // resolution); codegen output is unchanged until then. - _module_graph: ModuleGraph, - // Compilation-wide source map keyed by the module graph's `SourceId` - // space (milestone 5). Every span produced during load/merge references - // this map, so errors are returned with it and render from the owning - // source. + policy: FrontendPolicy, + module_graph: ModuleGraph, sources: SourceMap, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, ) -> Result { - let diagnostic_path = units - .iter() - .find(|unit| !unit.parsed.unknown_type_spans.is_empty()) - .map(|unit| PathBuf::from(&unit.source_name)); - let merged = merge_units(units)?; - compile_parsed_output( + compile_loaded_units_with_capture_output( source, - merged, + units, + policy, + module_graph, + sources, + flavor, + options, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn compile_loaded_units_with_capture_output( + source: String, + units: Vec, + policy: FrontendPolicy, + module_graph: ModuleGraph, + sources: SourceMap, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + certification: Option<&mut super::capture_certification::CaptureCertification>, +) -> Result { + let prepared = prepare_typed_units( + units, + sources, + policy, + flavor, + options, CompileBehavior::DEFAULT, - TypingMode::for_flavor(flavor), - matches!(flavor, SourceFlavor::RustScript), + Some(module_graph), + )?; + let local_debug_ranges = collect_named_local_debug_ranges(&prepared.ir); + let owned_ir = prepared.owned_ir; + let sources = prepared.sources; + compile_owned_ir_with_capture_output( + source, + owned_ir, + &[], + policy.typing_mode, + policy.ownership_checking, + local_debug_ranges, + LocalLayoutPolicy::Compact, + certification, + Some( + super::capture_certification::CaptureProducerPolicies::Linked( + &prepared.capture_source_policies, + ), + ), ) - .map_err(|error| match (error, diagnostic_path) { - (SourceError::Parse(mut parse), Some(path)) - if parse.code.as_deref() == Some("E_STRICT_UNKNOWN_TYPE") => - { - parse.message = format!("{}: {}", path.display(), parse.message); - SourcePathError::SourceWithMap { - error: SourceError::Parse(parse), - sources, - } - } - (error, _) => SourcePathError::SourceWithMap { error, sources }, - }) + .map(|(compiled, _)| compiled) + .map_err(|error| SourcePathError::SourceWithMap { error, sources }) } fn compile_source_with_flavor_and_options_impl( @@ -1719,7 +2612,7 @@ fn compile_source_with_flavor_and_options_impl( // Explicit catalogs are forwarded unchanged. Otherwise the HTTP catalog is // installed so `http::client::sse` named structs and function schemas are // available without converting `io::` / `sqlite::` / `jit::` builtins. - let effective = default_http_compile_catalog_options(options); + let effective = default_http_compile_catalog_options(flavor, options); compile_source_with_flavor_and_options_pipeline(source, flavor, &effective) } @@ -1731,14 +2624,17 @@ fn compile_source_with_flavor_and_options_pipeline( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { - let path = virtual_inmemory_entry_path(flavor); - let loaded = load_units_for_source_file(&path, flavor, source, options)?; + let policy = frontend_policy_for_flavor(flavor, options)?; + let path = virtual_inmemory_entry_path(flavor, options)?; + let loaded = load_units_for_source_file_with_context(&path, flavor, source, options, None)?; compile_loaded_units( source.to_string(), loaded.units, - flavor, + policy, loaded.module_graph, loaded.sources, + flavor, + options, ) } @@ -1746,8 +2642,12 @@ fn compile_source_with_flavor_and_options_pipeline( /// surface is enabled and the caller did not supply a custom catalog. Explicit /// catalogs remain unchanged. fn default_standard_catalog_options( + flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> CompileSourceFileOptions { + if matches!(flavor, SourceFlavor::Plugin(_)) { + return options.clone(); + } #[cfg(feature = "runtime")] { let mut effective = options.clone(); @@ -1767,8 +2667,12 @@ fn default_standard_catalog_options( /// and function schemas without pulling `io::` / `sqlite::` / `jit::` into /// catalog host imports. fn default_http_compile_catalog_options( + flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> CompileSourceFileOptions { + if matches!(flavor, SourceFlavor::Plugin(_)) { + return options.clone(); + } #[cfg(all(feature = "http-client", not(target_family = "wasm")))] { let mut effective = options.clone(); @@ -1800,24 +2704,104 @@ fn compile_source_at_path_with_flavor_and_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { - let effective = default_http_compile_catalog_options(options); + let effective = default_http_compile_catalog_options(flavor, options); + let policy = frontend_policy_for_flavor(flavor, &effective)?; let loaded = load_units_for_source_file(path, flavor, source, &effective)?; compile_loaded_units( source.to_string(), loaded.units, - flavor, + policy, loaded.module_graph, loaded.sources, + flavor, + &effective, ) } -fn virtual_inmemory_entry_path(flavor: SourceFlavor) -> PathBuf { - let ext = match flavor { +fn virtual_inmemory_entry_path( + flavor: SourceFlavor, + options: &CompileSourceFileOptions, +) -> Result { + let extension = match flavor { SourceFlavor::RustScript => "rss", - SourceFlavor::JavaScript => "js", - SourceFlavor::Lua => "lua", + SourceFlavor::Plugin(_) => { + let plugin = options + .source_plugin_for_flavor(flavor) + .ok_or(SourcePathError::MissingFrontendPlugin(flavor))?; + plugin + .descriptor() + .extensions + .first() + .copied() + .ok_or(SourcePathError::MissingExtension)? + } }; - PathBuf::from("__pd_vm_inmemory__").join(format!("main.{ext}")) + Ok(PathBuf::from("__pd_vm_inmemory__").join(format!("main.{extension}"))) +} + +/// Compiles through the ordinary module/host/ownership pipeline, retaining +/// compiler-issued capture facts separately from the unchanged Program. +pub fn compile_source_with_flavor_and_options_with_capture_certification( + source: &str, + flavor: SourceFlavor, + options: CompileSourceFileOptions, +) -> Result { + let source = source.to_owned(); + run_with_compiler_stack(move || { + let effective = default_http_compile_catalog_options(flavor, &options); + let policy = frontend_policy_for_flavor(flavor, &effective)?; + let path = virtual_inmemory_entry_path(flavor, &effective)?; + let loaded = + load_units_for_source_file_with_context(&path, flavor, &source, &effective, None)?; + let mut facts = Default::default(); + let compiled = compile_loaded_units_with_capture_output( + source, + loaded.units, + policy, + loaded.module_graph, + loaded.sources, + flavor, + &effective, + Some(&mut facts), + )?; + Ok(super::CertifiedCompilation { compiled, facts }) + }) +} + +/// Source-file counterpart, with the same options and errors as ordinary compilation. +pub fn compile_source_file_with_capture_certification_and_options( + path: impl AsRef, + options: CompileSourceFileOptions, +) -> Result { + let path = path.as_ref().to_path_buf(); + run_with_compiler_stack(move || { + let flavor = SourceFlavor::from_path_with_options(&path, &options)?; + let effective = default_http_compile_catalog_options(flavor, &options); + let policy = frontend_policy_for_flavor(flavor, &effective)?; + let source = std::fs::read_to_string(&path)?; + let loaded = load_units_for_source_file(&path, flavor, &source, &effective)?; + let mut facts = Default::default(); + let compiled = compile_loaded_units_with_capture_output( + source, + loaded.units, + policy, + loaded.module_graph, + loaded.sources, + flavor, + &effective, + Some(&mut facts), + )?; + Ok(super::CertifiedCompilation { compiled, facts }) + }) +} + +pub fn compile_source_file_with_capture_certification( + path: impl AsRef, +) -> Result { + compile_source_file_with_capture_certification_and_options( + path, + CompileSourceFileOptions::default(), + ) } pub fn compile_source_file(path: impl AsRef) -> Result { @@ -1836,16 +2820,19 @@ fn compile_source_file_impl( path: &Path, options: &CompileSourceFileOptions, ) -> Result { - let effective = default_http_compile_catalog_options(options); - let flavor = SourceFlavor::from_path_with_options(path, &effective)?; + let flavor = SourceFlavor::from_path_with_options(path, options)?; + let effective = default_http_compile_catalog_options(flavor, options); + let policy = frontend_policy_for_flavor(flavor, &effective)?; let source_raw = std::fs::read_to_string(path)?; let loaded = load_units_for_source_file(path, flavor, &source_raw, &effective)?; compile_loaded_units( source_raw, loaded.units, - flavor, + policy, loaded.module_graph, loaded.sources, + flavor, + &effective, ) } @@ -2139,9 +3126,349 @@ mod tests { assert_eq!(status, crate::vm::VmStatus::Halted); } + #[test] + fn rejected_host_return_diagnostics_keep_linked_provenance_and_independent_strict_admission() { + let mut builder = crate::HostApiBuilder::new(); + builder.function(crate::HostFunctionSchema::with_return( + "sample::probe", + vec![crate::HostParamSchema::value( + "value", + crate::HostTypeSchema::Int, + )], + crate::HostTypeSchema::Int, + )); + let catalog = Arc::new(builder.build().unwrap()); + let options = + || CompileSourceFileOptions::new().with_host_api_catalog(Arc::clone(&catalog)); + let source = "use sample;\nfn main() { sample::probe(\"wrong\"); }\n"; + let model = analyze_source_from_string_with_options("main.rss", source, options()) + .expect("failed host return retains its diagnostic model"); + assert_eq!( + model.diagnostics().len(), + 1, + "only the primary host rejection is published" + ); + let host = model + .diagnostics() + .into_iter() + .find(|diagnostic| diagnostic.message.contains("sample::probe")) + .expect("primary host rejection is not masked by return inference"); + assert_eq!( + model.sources().span_text(host.span.unwrap()), + Some("sample::probe") + ); + assert!( + compile_source_at_path_with_flavor_and_options( + "main.rss", + source, + SourceFlavor::RustScript, + options(), + ) + .is_err() + ); + assert!( + collect_inferred_local_type_hints_at_path_with_options( + "main.rss", + source, + SourceFlavor::RustScript, + options(), + ) + .is_err() + ); + let linked = "use a::util;\nfn run() { helper(); }\n"; + let options = || { + options().with_module_override_source( + "a/util.rss", + "use sample;\npub fn helper() { sample::probe(\"wrong\"); }\n", + ) + }; + let model = analyze_source_from_string_with_options("main.rss", linked, options()) + .expect("linked direct return dependency retains the host diagnostic"); + assert_eq!( + model.diagnostics().len(), + 1, + "dependent returns do not add cascade diagnostics" + ); + let host = model + .diagnostics() + .into_iter() + .find(|diagnostic| diagnostic.message.contains("sample::probe")) + .unwrap(); + assert_eq!(host.span.unwrap().source_id, 1); + assert_eq!( + model.sources().span_text(host.span.unwrap()), + Some("sample::probe") + ); + assert!( + compile_source_at_path_with_flavor_and_options( + "main.rss", + linked, + SourceFlavor::RustScript, + options(), + ) + .is_err() + ); + assert!( + collect_inferred_local_type_hints_at_path_with_options( + "main.rss", + linked, + SourceFlavor::RustScript, + options(), + ) + .is_err() + ); + for invalid in [ + "use sample;\nfn main(value) { sample::probe(\"wrong\"); }\n", + "use sample;\nfn main() { sample::probe(\"wrong\"); }\nfn independent(value) -> int { 1 }\n", + "use sample;\nfn main() { sample::probe(\"wrong\"); }\nfn independent(value: unknown) -> int { 1 }\n", + ] { + assert!( + analyze_source_from_string_with_options("main.rss", invalid, options()).is_err() + ); + } + } + + #[test] + fn mixed_policy_root_statement_boundaries_survive_lifetime() { + let source = "use self::dep; dep::value();"; + let options = CompileSourceFileOptions::new().with_module_override_source( + "dep.rss", + "let captured = 1; if captured == 1 { let a = 2; let b = 3; let c = 4; a + b + c; } let observer = || captured; observer(); pub fn value() -> int { 1 }", + ); + let flavor = SourceFlavor::RustScript; + let path = virtual_inmemory_entry_path(flavor, &options).unwrap(); + let loaded = + load_units_for_source_file_with_context(&path, flavor, source, &options, None).unwrap(); + let prepared = prepare_typed_units( + loaded.units, + loaded.sources, + frontend_policy_for_flavor(flavor, &options).unwrap(), + flavor, + &options, + CompileBehavior::DEFAULT, + Some(loaded.module_graph), + ) + .unwrap(); + for (stage, ir) in [("admission", &prepared.ir), ("owned", &prepared.owned_ir)] { + println!( + "{stage}: stmts={} boundaries={}", + ir.stmts.len(), + ir.stmt_sources.len() + ); + for (index, stmt) in ir.stmts.iter().enumerate() { + println!( + "{stage}[{index}] source={:?} stmt={stmt:?}", + ir.stmt_sources.get(index) + ); + } + } + assert_eq!( + prepared.owned_ir.stmts.len(), + prepared.owned_ir.stmt_sources.len() + ); + let dependency = prepared.ir.stmt_sources[2].as_deref().unwrap(); + let assert_origins = |ir: &FrontendIr| { + assert_eq!(ir.stmts.len(), ir.stmt_sources.len()); + let closure = ir + .stmts + .iter() + .position(|stmt| { + matches!( + stmt, + Stmt::Let { + expr: Expr::Closure(_), + .. + } + ) + }) + .unwrap(); + assert_eq!(ir.stmt_sources[closure].as_deref(), Some(dependency)); + for (stmt, source) in ir.stmts.iter().zip(&ir.stmt_sources) { + if matches!(stmt, Stmt::Drop { .. }) { + assert_eq!(source.as_deref(), Some(dependency)); + } + } + }; + assert_origins(&prepared.owned_ir); + let owned = lifetime::enforce_source_availability_with_entry_locals( + prepared.ir.clone(), + &[], + false, + &[], + &prepared.capture_source_policies, + true, + &prepared.sources, + ) + .unwrap(); + assert_origins(&owned); + } + + #[test] + fn linked_local_hints_order_by_source_without_erasing_dependency_provenance() { + let source = "use a::util as util;\nlet value = util::identity(42);\n"; + let dependency = "pub fn identity(value: int) -> int { value }\n"; + let options = + CompileSourceFileOptions::new().with_module_override_source("a/util.rss", dependency); + let hints = collect_inferred_local_type_hints_with_options( + source, + SourceFlavor::RustScript, + options, + ) + .expect("linked hints retain both source owners"); + let values = hints + .iter() + .filter(|hint| hint.name == "value") + .collect::>(); + assert_eq!( + values.len(), + 2, + "root local and dependency parameter survive" + ); + assert_eq!(values[0].span.unwrap().source_id, 0); + assert_eq!(values[0].declared_line, Some(2)); + assert_eq!(values[0].last_line, Some(2)); + assert_eq!(values[1].span.unwrap().source_id, 1); + assert_eq!(values[1].declared_line, Some(1)); + for (hint, text) in values.iter().zip([source, dependency]) { + let span = hint.span.unwrap(); + assert_eq!(&text[span.lo..span.hi], "value"); + assert_eq!(hint.schema, Some(TypeSchema::Int)); + } + assert_ne!(values[0].source_name, values[1].source_name); + } + + #[test] + fn native_ir_available_optional_sibling_after_normal_projection() { + let source = "struct Entry { name: string, note: string? }\nlet entry: Entry = { name: \"first\" };\nlet name = entry.name;\nlet note = entry?.note;\nnote;\n"; + let mut parsed = crate::compiler::frontends::parse_source( + source, + SourceFlavor::RustScript, + &CompileSourceFileOptions::new(), + ) + .unwrap(); + // Native parser IR, with explicit language-neutral field intent. + // Native `?.` syntax itself keeps its established value semantics. + for stmt in &mut parsed.stmts { + if let Stmt::Let { + expr: Expr::OptionalGet { + field_projection, .. + }, + .. + } = stmt + { + *field_projection = true; + } + } + let compiled = compile_parsed_output( + source.to_string(), + parsed, + CompileBehavior::DEFAULT, + TypingMode::Strict, + true, + ) + .unwrap_or_else(|error| panic!("native strict sibling projection: {error}")); + let mut machine = crate::Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), crate::VmStatus::Halted); + assert_eq!(machine.stack(), &[crate::Value::Null]); + } + + #[test] + fn native_ir_projection_detaches_present_child_without_inserting_absent_child() { + for field in ["", ", note: null", ", note: \"ready\""] { + let source = format!( + "struct Entry {{ name: string, note: string? }}\nlet entry: Entry = {{ name: \"first\"{field} }};\nlet note = entry?.note;\nnote;" + ); + let mut parsed = crate::compiler::frontends::parse_source( + &source, + SourceFlavor::RustScript, + &CompileSourceFileOptions::new(), + ) + .unwrap(); + let root = parsed + .local_bindings + .iter() + .find(|(name, _)| name == "entry") + .unwrap() + .1; + for stmt in &mut parsed.stmts { + if let Stmt::Let { + expr: + Expr::OptionalGet { + field_projection, .. + }, + .. + } = stmt + { + *field_projection = true; + } + } + let compiled = compile_parsed_output( + source, + parsed, + CompileBehavior::REPL, + TypingMode::Strict, + true, + ) + .unwrap(); + let mut machine = crate::Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), crate::VmStatus::Halted); + let crate::Value::Map(record) = &machine.locals()[root as usize] else { + panic!("record retained in REPL"); + }; + assert!( + record.get(&crate::Value::string("note")).is_none(), + "moved child must be removed: {record:?}" + ); + } + } + + #[test] + fn native_ir_nested_optional_projection_detaches_noncopy_child() { + let source = "struct Entry { note: string? }\nlet entry: Entry = { note: \"ready\" };\nlet note = (entry?.note).unwrap_or(\"fallback\");\nnote;"; + let mut parsed = crate::compiler::frontends::parse_source( + source, + SourceFlavor::RustScript, + &CompileSourceFileOptions::new(), + ) + .unwrap(); + let root = parsed + .local_bindings + .iter() + .find(|(name, _)| name == "entry") + .unwrap() + .1; + for stmt in &mut parsed.stmts { + if let Stmt::Let { + expr: Expr::OptionUnwrapOr { value, .. }, + .. + } = stmt + && let Expr::OptionalGet { + field_projection, .. + } = value.as_mut() + { + *field_projection = true; + } + } + let compiled = compile_parsed_output( + source.to_string(), + parsed, + CompileBehavior::REPL, + TypingMode::Strict, + true, + ) + .unwrap(); + let mut machine = crate::Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), crate::VmStatus::Halted); + assert_eq!(machine.stack(), &[crate::Value::string("ready")]); + let crate::Value::Map(record) = &machine.locals()[root as usize] else { + panic!("record retained in REPL"); + }; + assert!(record.get(&crate::Value::string("note")).is_none()); + } + #[test] fn strict_non_concrete_struct_diagnostic_carries_exact_decl_span() { - // Strict RustScript rejects struct schemas whose fields are not fully + // Strict typing rejects struct schemas whose fields are not fully // concrete. The diagnostic must carry the exact parser-origin span of // the struct declaration (its name identifier), resolved from the // `StructDeclSite` provenance recorded by the parser — never a @@ -2185,8 +3512,8 @@ mod tests { std::iter::once(("x".to_string(), crate::compiler::ir::TypeSchema::Unknown)).collect(), ); - let type_info = typing::infer_types(&ir, TypingMode::StrictRustScript, &[]); - let err = enforce_strict_rustscript_type_resolution(&ir, &type_info) + let type_info = typing::infer_types(&ir, TypingMode::Strict, &[]); + let err = enforce_strict_type_resolution(&ir, &type_info) .expect_err("non-concrete struct schema must be rejected in strict mode"); match err { CompileError::StrictTypingRequired { span, .. } => { diff --git a/src/compiler/semantic_model.rs b/src/compiler/semantic_model.rs index 3de33105..e486927d 100644 --- a/src/compiler/semantic_model.rs +++ b/src/compiler/semantic_model.rs @@ -430,24 +430,14 @@ impl SemanticModel { .functions .iter() .find(|decl| decl.index == function_index)?; - let params = decl - .arg_schemas - .iter() - .enumerate() - .map(|(i, schema)| { - schema.clone().unwrap_or_else(|| { - decl.args - .get(i) - .map(|_| TypeSchema::Unknown) - .unwrap_or(TypeSchema::Unknown) - }) - }) - .collect::>(); - let result = decl.return_schema.clone().unwrap_or(TypeSchema::Unknown); - return Some(TypeSchema::Callable { - params, - result: Box::new(result), - }); + let typed = super::FrontendTypedIr { + source_id: Some(position.source_id), + ir: &self.ir, + local_schemas: &index.slot_schemas, + host_api_catalog: None, + source_map: None, + }; + return typed.schema_of_function(decl.index); } None } diff --git a/src/compiler/source_loader.rs b/src/compiler/source_loader.rs index 55274393..340a8724 100644 --- a/src/compiler/source_loader.rs +++ b/src/compiler/source_loader.rs @@ -45,18 +45,19 @@ use crate::compiler::source_map::SourceMap; use super::modules::ModuleGraph; use super::{ - CompileSourceFileOptions, SourceError, SourceFlavor, SourcePathError, frontends, - linker::ParsedUnit, + CompileSourceFileOptions, ParseError, ParsePhase, SourceError, SourceFlavor, + SourceFrontendContext, SourcePathError, frontends, + linker::{ParsedUnit, remap_guest_type_schemas, remap_type_schema_names}, }; mod graph; mod imports; mod model; -use graph::{collect_module_units, record_module_symbols}; -use imports::{module_identity, parse_module_imports, strip_import_directives}; +pub use self::model::{DeclarationNamespace, ImportClause, ModuleImport, NamedImport}; +use graph::{frontend_imports_for_module, record_module_symbols}; +use imports::{module_identity, parse_module_imports_with_context, strip_import_directives}; use model::ModuleCollectState; -pub use model::{FrontendImportSyntax, ImportClause, ModuleImport, NamedImport}; pub(super) struct LoadedSourceUnits { pub(super) units: Vec, /// Semantic module graph built during discovery (milestones 1-3). @@ -66,6 +67,7 @@ pub(super) struct LoadedSourceUnits { /// its graph source id, so spans carried by the loaded units and by any /// load-time diagnostic resolve to the owning source. pub(super) sources: SourceMap, + pub(super) repl_bindings: Vec, } fn effective_source_options(options: &CompileSourceFileOptions) -> CompileSourceFileOptions { @@ -77,6 +79,27 @@ pub(super) fn load_units_for_source_file( flavor: SourceFlavor, source_raw: &str, options: &CompileSourceFileOptions, +) -> Result { + load_units_for_source_file_with_context(path, flavor, source_raw, options, Some(path)) +} + +pub(super) fn load_units_for_source_file_with_context( + path: &Path, + flavor: SourceFlavor, + source_raw: &str, + options: &CompileSourceFileOptions, + original_path: Option<&Path>, +) -> Result { + load_units_with_entry_locals(path, flavor, source_raw, options, original_path, None) +} + +pub(super) fn load_units_with_entry_locals( + path: &Path, + flavor: SourceFlavor, + source_raw: &str, + options: &CompileSourceFileOptions, + original_path: Option<&Path>, + entry_locals: Option<&[super::ReplLocalBinding]>, ) -> Result { let effective_options = effective_source_options(options); // The root participates in the same identity scheme as every module: @@ -96,30 +119,47 @@ pub(super) fn load_units_for_source_file( .add_source_at(0, path.display().to_string(), source_raw.to_string()); collect_state.visiting.push(path.to_path_buf()); - let root_imports = parse_module_imports(source_raw, flavor, path, &effective_options, 0) - .map_err(|err| { - // The root's own scan/parse diagnostics attach their span against - // the pre-registered root source and carry the compilation-wide map, - // so they render from the root's text. - match err { - SourcePathError::Source(SourceError::Parse(mut parse)) => { - parse.span = None; - parse = parse.with_line_span_from_source(&collect_state.sources, 0); - SourcePathError::SourceWithMap { - error: SourceError::Parse(parse), - sources: collect_state.sources.clone(), - } + let scan = match entry_locals { + Some(locals) => imports::scan_module_imports_with_entry_locals( + source_raw, + path, + &effective_options, + 0, + locals, + ) + .map(|(imports, _)| imports), + None => parse_module_imports_with_context( + source_raw, + flavor, + path, + &effective_options, + 0, + original_path, + ), + }; + let root_imports = scan.map_err(|err| { + // The root's own scan/parse diagnostics attach their span against + // the pre-registered root source and carry the compilation-wide map, + // so they render from the root's text. + match err { + SourcePathError::Source(SourceError::Parse(mut parse)) => { + parse = parse.with_line_span_from_source(&collect_state.sources, 0); + SourcePathError::SourceWithMap { + error: SourceError::Parse(parse), + sources: collect_state.sources.clone(), } - other => other, } - })?; + other => other, + } + })?; - collect_module_units( + graph::collect_module_units_with_entry_locals( path, source_raw, flavor, &effective_options, &mut collect_state, + entry_locals, ) .map_err(|err| { // Load-time source diagnostics (nested scan/parse errors, symbol @@ -144,25 +184,95 @@ pub(super) fn load_units_for_source_file( .map(|node| node.source.0) .unwrap_or(0); let root_parse_source = strip_import_directives(source_raw, flavor, &effective_options)?; - - let mut root_parsed = frontends::parse_module_source_with_source_id( - &root_parse_source, - flavor, - &effective_options, - root_source_id, - ) - .map_err(|mut err| { + let frontend_imports = + frontend_imports_for_module(path, &root_imports, &collect_state, &effective_options)?; + + let root_phase = if original_path.is_some() { + ParsePhase::Module + } else { + ParsePhase::Standalone + }; + let mut repl_bindings = Vec::new(); + let parsed = match entry_locals { + Some(locals) => frontends::parse_rustscript_repl_source_with_context( + &root_parse_source, + locals, + effective_options.host_api_catalog().cloned(), + root_source_id, + true, + ) + .map(|parsed| { + repl_bindings = parsed.bindings; + parsed.ir + }), + None => frontends::parse_module_source_with_source_id( + &root_parse_source, + flavor, + &effective_options, + root_source_id, + original_path, + root_phase, + &frontend_imports, + ), + }; + let mut root_parsed = parsed.map_err(|err| { // Module sources are parsed verbatim (no synthetic prelude, no // textual rewrite), so parse lines already refer to the owning // source; rebuild the span against the compilation-wide map so the // diagnostic renders from the root's text. - err.span = None; let parse = err.with_line_span_from_source(&collect_state.sources, root_source_id); SourcePathError::SourceWithMap { error: SourceError::Parse(parse), sources: collect_state.sources.clone(), } })?; + let root_type_exports = match flavor { + SourceFlavor::RustScript => graph::native_signature_type_exports(&root_parsed), + SourceFlavor::Plugin(_) => { + let plugin = effective_options + .source_plugin_for_flavor(flavor) + .expect("registered root plugin was selected"); + let context = SourceFrontendContext { + source_id: root_source_id, + original_path, + phase: root_phase, + host_api_catalog: effective_options + .host_api_catalog() + .map(|catalog| catalog.as_ref()), + imports: &frontend_imports, + }; + plugin + .module_type_exports(source_raw, &context, &root_parsed) + .map_err(|error| { + let mut parse = + error.with_line_span_from_source(&collect_state.sources, root_source_id); + parse.message = format!("{}: {}", path.display(), parse.message); + SourcePathError::SourceWithMap { + error: SourceError::Parse(parse), + sources: collect_state.sources.clone(), + } + })? + } + }; + let root_type_exports = graph::index_type_exports(root_type_exports).map_err(|message| { + SourcePathError::SourceWithMap { + error: SourceError::Parse(ParseError { + span: collect_state.sources.line_span(root_source_id, 1), + code: None, + line: 1, + message: format!("{}: {message}", path.display()), + }), + sources: collect_state.sources.clone(), + } + })?; + let root_identity = collect_state + .module_graph + .node(root_module) + .map(|node| node.identity.clone()) + .unwrap_or_else(|| path.to_path_buf()); + collect_state + .module_type_exports + .insert(root_identity.clone(), root_type_exports); record_module_symbols( &mut collect_state, root_module, @@ -182,6 +292,25 @@ pub(super) fn load_units_for_source_file( }, other => other, })?; + let root_guest_type_names = + graph::guest_type_names_for_module(&collect_state.module_graph, root_module); + remap_guest_type_schemas(&mut root_parsed, &root_guest_type_names); + if let Some(exports) = collect_state.module_type_exports.get_mut(&root_identity) { + for export in exports.values_mut() { + remap_type_schema_names(&mut export.schema, &root_guest_type_names); + if let Some(declaration) = &mut export.declaration + && declaration.is_guest() + { + if let Some(internal) = root_guest_type_names + .get(&declaration.name) + .or_else(|| root_guest_type_names.get(&export.source_name)) + { + declaration.name = internal.clone(); + } + remap_type_schema_names(&mut declaration.body_schema, &root_guest_type_names); + } + } + } collect_state.units.push(ParsedUnit { parsed: root_parsed, scope_identity: None, @@ -189,12 +318,40 @@ pub(super) fn load_units_for_source_file( module: root_module, source_id: root_source_id, host_catalog_supplied: effective_options.host_api_catalog().is_some(), + runtime_reachable: true, }); + // Follow only edges containing value imports from the executable root. + // A later value path promotes a previously type-only module exactly once. + let mut runtime_modules = std::collections::HashSet::new(); + let mut pending = vec![root_module]; + while let Some(module) = pending.pop() { + if !runtime_modules.insert(module) { + continue; + } + if let Some(node) = collect_state.module_graph.node(module) { + for edge in &node.imports { + let has_values = edge.namespace == DeclarationNamespace::Value + && match &edge.clause { + ImportClause::Named(names) => names + .iter() + .any(|name| name.namespace == DeclarationNamespace::Value), + _ => true, + }; + if has_values && let Some(target) = edge.target { + pending.push(target); + } + } + } + } + for unit in &mut collect_state.units { + unit.runtime_reachable = runtime_modules.contains(&unit.module); + } Ok(LoadedSourceUnits { units: collect_state.units, module_graph: collect_state.module_graph, sources: collect_state.sources, + repl_bindings, }) } diff --git a/src/compiler/source_loader/graph.rs b/src/compiler/source_loader/graph.rs index ffbd8cff..226770d0 100644 --- a/src/compiler/source_loader/graph.rs +++ b/src/compiler/source_loader/graph.rs @@ -5,12 +5,16 @@ use crate::compiler::source_map::SourceMap; use crate::host_api::HostApiCatalog; use super::super::{ - CompileSourceFileOptions, ParseError, SourceError, SourceFlavor, SourcePathError, frontends, + CompileSourceFileOptions, FrontendImportedDeclaration, FrontendTypeExport, ParseError, + ParsePhase, SourceError, SourceFlavor, SourceFrontendContext, SourcePathError, frontends, ir::{ Expr, FrontendIr, FunctionDecl, FunctionRefTarget, ParsedCallTarget, ParsedSemanticIndex, Stmt, TypeSchema, }, - linker::{ParsedUnit, module_scope_prefix}, + linker::{ + ParsedUnit, internal_guest_type_name, module_scope_prefix, remap_guest_type_schemas, + remap_type_schema_names, + }, modules::{ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ResolvedImport, SymbolId}, }; use super::imports::{ @@ -18,7 +22,34 @@ use super::imports::{ parse_module_imports, resolve_module_path, scan_module_imports, should_treat_missing_module_as_host_namespace, }; -use super::model::{ExportedFunctionSignature, ImportClause, ModuleCollectState, ModuleImport}; +use super::model::{ + DeclarationNamespace, ExportedFunctionSignature, ImportClause, ModuleCollectState, ModuleImport, +}; + +pub(super) fn index_type_exports( + exports: Vec, +) -> Result, String> { + let mut indexed = HashMap::new(); + for export in exports { + let name = export.source_name.clone(); + if indexed.insert(name.clone(), export).is_some() { + return Err(format!("duplicate exported type '{name}'")); + } + } + Ok(indexed) +} + +fn catalog_import_indices(parsed: &FrontendIr) -> HashSet { + let Some(metadata) = &parsed.host_api_metadata else { + return HashSet::new(); + }; + parsed + .functions + .iter() + .filter(|function| metadata.candidates(function.index).is_some()) + .map(|function| function.index) + .collect() +} pub(super) fn collect_module_units( path: &Path, @@ -26,6 +57,17 @@ pub(super) fn collect_module_units( flavor: SourceFlavor, options: &CompileSourceFileOptions, state: &mut ModuleCollectState, +) -> Result<(), SourcePathError> { + collect_module_units_with_entry_locals(path, source, flavor, options, state, None) +} + +pub(super) fn collect_module_units_with_entry_locals( + path: &Path, + source: &str, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + state: &mut ModuleCollectState, + entry_locals: Option<&[super::super::ReplLocalBinding]>, ) -> Result<(), SourcePathError> { // Register this module in the semantic graph. Registration is // identity-keyed and idempotent, so the root and every nested module get @@ -48,31 +90,53 @@ pub(super) fn collect_module_units( path.display().to_string(), source.to_string(), ); - let (imports, decls) = scan_module_imports(source, flavor, path, options, current_source_id.0) - .map_err(|err| { - // Nested module sources surface their parse errors through the same - // path-prefixed diagnostic shape the compile parse uses. The root is - // scanned (and fails, if at all) in `load_units_for_source_file` - // before this point, so it never receives a prefix here. The scan - // parser numbers spans with its own local source id 0, so the span - // is always rebuilt against the owning module's graph source id — - // offsets from one module must never be interpreted in another. - match err { - SourcePathError::Source(SourceError::Parse(mut parse)) => { - parse.message = format!("{}: {}", path.display(), parse.message); - parse.span = None; - parse = parse.with_line_span_from_source(&state.sources, current_source_id.0); - SourcePathError::Source(SourceError::Parse(parse)) - } - other => other, + let scan = match entry_locals { + Some(locals) => super::imports::scan_module_imports_with_entry_locals( + source, + path, + options, + current_source_id.0, + locals, + ), + None => scan_module_imports(source, flavor, path, options, current_source_id.0), + }; + let (imports, decls) = scan.map_err(|err| { + // Nested module sources surface their parse errors through the same + // path-prefixed diagnostic shape the compile parse uses. The root is + // scanned (and fails, if at all) in `load_units_for_source_file` + // before this point, so it never receives a prefix here. The scan + // parser numbers spans with its own local source id 0, so the span + // is always rebuilt against the owning module's graph source id — + // offsets from one module must never be interpreted in another. + match err { + SourcePathError::Source(SourceError::Parse(mut parse)) => { + parse.message = format!("{}: {}", path.display(), parse.message); + parse = parse.with_line_span_from_source(&state.sources, current_source_id.0); + SourcePathError::Source(SourceError::Parse(parse)) } - })?; + other => other, + } + })?; for (import_index, import) in imports.iter().enumerate() { let spec = import.spec.clone(); - let span = decls - .get(import_index) - .map(|decl| decl.span) - .unwrap_or_else(|| crate::compiler::source_map::Span::new(0, 0, 0)); + let span = import + .span + .filter(|span| { + span.source_id == current_source_id.0 + && span.lo <= span.hi + && span.hi <= source.len() + && source.is_char_boundary(span.lo) + && source.is_char_boundary(span.hi) + }) + .or_else(|| decls.get(import_index).map(|decl| decl.span)) + .unwrap_or_else(|| { + state + .sources + .line_span(current_source_id.0, import.line.max(1)) + .unwrap_or_else(|| { + crate::compiler::source_map::Span::new(current_source_id.0, 0, 0) + }) + }); if is_builtin_host_namespace_spec(&spec) { state.module_graph.add_import( current_id, @@ -80,6 +144,7 @@ pub(super) fn collect_module_units( kind: ImportTargetKind::BuiltinNamespace, spec, clause: import.clause.clone(), + namespace: import.namespace, span, line: import.line, target: None, @@ -96,6 +161,7 @@ pub(super) fn collect_module_units( kind: ImportTargetKind::HostNamespace, spec, clause: import.clause.clone(), + namespace: import.namespace, span, line: import.line, target: None, @@ -104,6 +170,7 @@ pub(super) fn collect_module_units( continue; } let resolved = resolve_module_path(path, &spec, options)?; + let dependency_flavor = SourceFlavor::from_path_with_options(&resolved, options)?; let key = resolved.clone(); if key == path && is_virtual_host_namespace_spec(&spec, options) { // `use io;` / `use re;` inside files named `io.rss` / `re.rss` should @@ -114,6 +181,7 @@ pub(super) fn collect_module_units( kind: ImportTargetKind::HostNamespace, spec, clause: import.clause.clone(), + namespace: import.namespace, span, line: import.line, target: None, @@ -134,6 +202,7 @@ pub(super) fn collect_module_units( kind: ImportTargetKind::FileModule, spec, clause: import.clause.clone(), + namespace: import.namespace, span, line: import.line, target, @@ -156,6 +225,7 @@ pub(super) fn collect_module_units( kind: ImportTargetKind::HostNamespace, spec, clause: import.clause.clone(), + namespace: import.namespace, span, line: import.line, target: None, @@ -171,7 +241,7 @@ pub(super) fn collect_module_units( collect_module_units( &resolved, &module_source_raw, - SourceFlavor::RustScript, + dependency_flavor, options, state, )?; @@ -185,36 +255,77 @@ pub(super) fn collect_module_units( .unwrap_or(0); let module_imports = parse_module_imports( &module_source_raw, - SourceFlavor::RustScript, + dependency_flavor, &resolved, options, module_source_id, )?; + let frontend_imports = + frontend_imports_for_module(&resolved, &module_imports, state, options)?; let mut parsed = frontends::parse_module_source_with_source_id( &module_source_raw, - SourceFlavor::RustScript, + dependency_flavor, options, module_source_id, + Some(&resolved), + ParsePhase::Module, + &frontend_imports, ) - .map_err(|mut err| { + .map_err(|err| { // Nested module sources are parsed verbatim (no synthetic // prelude, no textual rewrite), so the parse already reports the // owning module's lines; rebuild the span against the // compilation-wide map and prefix the module path. - err.span = None; let mut parse = err.with_line_span_from_source(&state.sources, module_source_id); parse.message = format!("{}: {}", resolved.display(), parse.message); SourceError::Parse(parse) })?; + let type_exports = match dependency_flavor { + SourceFlavor::Plugin(_) => { + let plugin = options + .source_plugin_for_flavor(dependency_flavor) + .expect("registered dependency plugin was selected"); + let context = SourceFrontendContext { + source_id: module_source_id, + original_path: Some(&resolved), + phase: ParsePhase::Module, + host_api_catalog: options.host_api_catalog().map(|catalog| catalog.as_ref()), + imports: &frontend_imports, + }; + plugin + .module_type_exports(&module_source_raw, &context, &parsed) + .map_err(|err| { + let mut parse = + err.with_line_span_from_source(&state.sources, module_source_id); + parse.message = format!("{}: {}", resolved.display(), parse.message); + SourcePathError::Source(SourceError::Parse(parse)) + })? + } + SourceFlavor::RustScript => native_signature_type_exports(&parsed), + }; + let type_exports = index_type_exports(type_exports).map_err(|message| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: state.sources.line_span(module_source_id, 1), + code: None, + line: 1, + message: format!("{}: {message}", resolved.display()), + })) + })?; + state.module_type_exports.insert(key.clone(), type_exports); let extern_names = parsed .implicit_extern_names .iter() .map(String::as_str) .collect::>(); + let catalog_import_indices = catalog_import_indices(&parsed); let exports = parsed .functions .iter() - .filter(|func| func.exported && !extern_names.contains(func.name.as_str())) + .filter(|func| { + func.exported + && !extern_names.contains(func.name.as_str()) + && !catalog_import_indices.contains(&func.index) + }) .map(|func| { ( func.name.clone(), @@ -237,6 +348,24 @@ pub(super) fn collect_module_units( &mut parsed, options, )?; + let guest_type_names = guest_type_names_for_module(&state.module_graph, target); + remap_guest_type_schemas(&mut parsed, &guest_type_names); + if let Some(exports) = state.module_type_exports.get_mut(&key) { + for export in exports.values_mut() { + remap_type_schema_names(&mut export.schema, &guest_type_names); + if let Some(declaration) = &mut export.declaration + && declaration.is_guest() + { + if let Some(internal) = guest_type_names + .get(&declaration.name) + .or_else(|| guest_type_names.get(&export.source_name)) + { + declaration.name = internal.clone(); + } + remap_type_schema_names(&mut declaration.body_schema, &guest_type_names); + } + } + } state.units.push(ParsedUnit { parsed, scope_identity: Some(module_scope_prefix(&resolved, target)), @@ -244,6 +373,7 @@ pub(super) fn collect_module_units( module: target, source_id: module_source_id, host_catalog_supplied: options.host_api_catalog().is_some(), + runtime_reachable: true, }); state.module_graph.add_import( current_id, @@ -251,6 +381,7 @@ pub(super) fn collect_module_units( kind: ImportTargetKind::FileModule, spec, clause: import.clause.clone(), + namespace: import.namespace, span, line: import.line, target: Some(target), @@ -272,6 +403,292 @@ fn module_source_override<'a>( }) } +pub(super) fn native_signature_type_exports(parsed: &FrontendIr) -> Vec { + let mut names = HashSet::new(); + for function in parsed.functions.iter().filter(|function| function.exported) { + for schema in function.arg_schemas.iter().flatten() { + collect_named_schema_names(schema, &mut names); + } + if let Some(schema) = &function.return_schema { + collect_named_schema_names(schema, &mut names); + } + } + let mut names = names.into_iter().collect::>(); + names.sort(); + names + .into_iter() + .filter_map(|name| { + let declaration = parsed.struct_schemas.get(&name)?.clone(); + if !declaration.is_guest() { + return None; + } + let schema = TypeSchema::Named( + name.clone(), + declaration + .type_params + .iter() + .cloned() + .map(TypeSchema::GenericParam) + .collect(), + ); + Some(FrontendTypeExport { + source_name: name, + schema, + declaration: Some(declaration), + }) + }) + .collect() +} + +fn collect_named_schema_names(schema: &TypeSchema, names: &mut HashSet) { + match schema { + TypeSchema::Named(name, arguments) => { + names.insert(name.clone()); + for argument in arguments { + collect_named_schema_names(argument, names); + } + } + TypeSchema::Optional(inner) | TypeSchema::Array(inner) | TypeSchema::Map(inner) => { + collect_named_schema_names(inner, names); + } + TypeSchema::ArrayTuple(items) => { + for item in items { + collect_named_schema_names(item, names); + } + } + TypeSchema::ArrayTupleRest { prefix, rest } => { + for item in prefix { + collect_named_schema_names(item, names); + } + collect_named_schema_names(rest, names); + } + TypeSchema::Object(fields) => { + for schema in fields.values() { + collect_named_schema_names(schema, names); + } + } + TypeSchema::Callable { params, result } => { + for schema in params { + collect_named_schema_names(schema, names); + } + collect_named_schema_names(result, names); + } + TypeSchema::Unknown + | TypeSchema::Null + | TypeSchema::Int + | TypeSchema::Float + | TypeSchema::Number + | TypeSchema::Bool + | TypeSchema::String + | TypeSchema::Bytes + | TypeSchema::GenericParam(_) + | TypeSchema::Resource(_) => {} + } +} + +/// Build frontend-visible function imports after the DFS has parsed their +/// source modules. Full declarations and graph symbols are passed together so +/// a plugin can bind the complete signature before parsing its own body. +pub(super) fn frontend_imports_for_module( + path: &Path, + imports: &[ModuleImport], + state: &ModuleCollectState, + options: &CompileSourceFileOptions, +) -> Result, SourcePathError> { + let mut frontend_imports = Vec::new(); + for import in imports { + if import.namespace == DeclarationNamespace::Type + || is_builtin_host_namespace_spec(&import.spec) + || !is_module_specifier(&import.spec) + { + continue; + } + let resolved = resolve_module_path(path, &import.spec, options)?; + let Some(exports) = state.module_exports.get(&resolved) else { + continue; + }; + let Some(source_module) = state.module_graph.module_id_for_identity(&resolved) else { + continue; + }; + let mut selected = match &import.clause { + ImportClause::Named(named) => named + .iter() + .filter(|binding| binding.namespace == DeclarationNamespace::Value) + .map(|binding| (binding.imported.clone(), binding.local.clone())) + .collect::>(), + ImportClause::Namespace(alias) => exports + .keys() + .map(|name| (name.clone(), format!("{alias}::{name}"))) + .collect(), + ImportClause::AllPublic => { + let namespace = Path::new(&import.spec) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + exports + .keys() + .map(|name| (name.clone(), format!("{namespace}::{name}"))) + .collect() + } + ImportClause::Prefix(prefix) => exports + .keys() + .map(|name| (name.clone(), format!("{prefix}::{name}"))) + .collect(), + }; + selected.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1))); + let Some(source_unit) = state.units.iter().find(|unit| unit.module == source_module) else { + continue; + }; + for (source_name, local) in selected { + if !exports.contains_key(&source_name) { + continue; + } + let Some(symbol) = state + .module_graph + .symbol_for_export(source_module, &source_name) + else { + continue; + }; + let Some(declaration) = source_unit + .parsed + .functions + .iter() + .find(|function| function.symbol == Some(symbol)) + else { + continue; + }; + frontend_imports.push(FrontendImportedDeclaration::Function { + local, + symbol, + declaration: declaration.clone(), + }); + } + } + frontend_imports.extend( + type_import_bindings(path, imports, state, options)? + .into_iter() + .map(|binding| FrontendImportedDeclaration::Type { + local: binding.local_name, + symbol: binding.source_symbol, + schema: binding.export.schema, + declaration: binding.export.declaration, + }), + ); + Ok(frontend_imports) +} + +struct TypeImportBinding { + local_name: String, + source_name: String, + source_module: ModuleId, + source_symbol: SymbolId, + line: usize, + export: FrontendTypeExport, +} + +fn type_import_bindings( + path: &Path, + imports: &[ModuleImport], + state: &ModuleCollectState, + options: &CompileSourceFileOptions, +) -> Result, SourcePathError> { + let mut bindings = Vec::new(); + for import in imports { + let requested = match &import.clause { + ImportClause::Named(named) => named + .iter() + .filter(|binding| { + import.namespace == DeclarationNamespace::Type + || binding.namespace == DeclarationNamespace::Type + }) + .map(|binding| (binding.imported.clone(), binding.local.clone())) + .collect::>(), + ImportClause::Namespace(alias) if import.namespace == DeclarationNamespace::Type => { + let resolved = resolve_module_path(path, &import.spec, options)?; + state + .module_type_exports + .get(&resolved) + .into_iter() + .flat_map(|exports| exports.keys()) + .map(|name| (name.clone(), format!("{alias}::{name}"))) + .collect() + } + ImportClause::AllPublic if import.namespace == DeclarationNamespace::Type => { + let resolved = resolve_module_path(path, &import.spec, options)?; + let namespace = Path::new(&import.spec) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + state + .module_type_exports + .get(&resolved) + .into_iter() + .flat_map(|exports| exports.keys()) + .map(|name| (name.clone(), format!("{namespace}::{name}"))) + .collect() + } + ImportClause::Prefix(prefix) if import.namespace == DeclarationNamespace::Type => { + let resolved = resolve_module_path(path, &import.spec, options)?; + state + .module_type_exports + .get(&resolved) + .into_iter() + .flat_map(|exports| exports.keys()) + .map(|name| (name.clone(), format!("{prefix}::{name}"))) + .collect() + } + ImportClause::Namespace(_) | ImportClause::AllPublic | ImportClause::Prefix(_) => { + Vec::new() + } + }; + if requested.is_empty() { + continue; + } + if is_builtin_host_namespace_spec(&import.spec) || !is_module_specifier(&import.spec) { + continue; + } + let resolved = resolve_module_path(path, &import.spec, options)?; + let Some(exports) = state.module_type_exports.get(&resolved) else { + return Err(SourcePathError::InvalidImportSyntax { + path: path.to_path_buf(), + line: import.line, + message: format!("module '{}' did not load its public types", import.spec), + }); + }; + let Some(source_module) = state.module_graph.module_id_for_identity(&resolved) else { + continue; + }; + for (source_name, local_name) in requested { + let Some(export) = exports.get(&source_name) else { + return Err(SourcePathError::InvalidImportSyntax { + path: path.to_path_buf(), + line: import.line, + message: format!( + "module '{}' has no public type '{}'", + import.spec, source_name + ), + }); + }; + let Some(source_symbol) = state.module_graph.symbol_for_export_in_namespace( + source_module, + DeclarationNamespace::Type, + &source_name, + ) else { + continue; + }; + bindings.push(TypeImportBinding { + local_name, + source_name, + source_module, + source_symbol, + line: import.line, + export: export.clone(), + }); + } + } + Ok(bindings) +} + /// Build the exported-signature table keyed by [`SymbolId`]. /// /// The loader validates call sites against the exported arity and type @@ -287,6 +704,9 @@ fn exported_signature_table( continue; }; for entry in &node.exports { + if entry.namespace != DeclarationNamespace::Value { + continue; + } if let Some(signature) = exports.get(&entry.name) { table.insert(entry.symbol, signature.clone()); } @@ -332,7 +752,11 @@ fn file_module_targets_for_namespace( }; let mut targets = Vec::new(); for import in &node.imports { - if import.kind != ImportTargetKind::FileModule { + if import.kind != ImportTargetKind::FileModule + || import.namespace != DeclarationNamespace::Value + || matches!(&import.clause, ImportClause::Named(names) + if !names.iter().any(|name| name.namespace == DeclarationNamespace::Value)) + { continue; } let Some(target) = import.target else { @@ -371,6 +795,8 @@ fn namespace_has_file_module_target( /// One function binding introduced by an import edge, before it is recorded /// in the module graph. struct ImportBindingData { + /// Namespace of the introduced binding. + namespace: DeclarationNamespace, /// Name the importing module binds (`as` alias for named imports). local_name: String, /// Name of the declaration in the source module. @@ -398,7 +824,9 @@ fn collect_imported_bindings( ) -> Result, SourcePathError> { let mut bindings = Vec::new(); for import in imports { - if is_builtin_host_namespace_spec(&import.spec) { + if import.namespace == DeclarationNamespace::Type + || is_builtin_host_namespace_spec(&import.spec) + { continue; } if !is_module_specifier(&import.spec) { @@ -422,6 +850,7 @@ fn collect_imported_bindings( ImportClause::AllPublic | ImportClause::Namespace(_) | ImportClause::Prefix(_) => { for name in exports.keys() { bindings.push(ImportBindingData { + namespace: DeclarationNamespace::Value, local_name: name.clone(), source_name: name.clone(), source_module, @@ -431,6 +860,9 @@ fn collect_imported_bindings( } ImportClause::Named(named) => { for binding in named { + if binding.namespace == DeclarationNamespace::Type { + continue; + } let _signature = exports.get(&binding.imported).cloned().ok_or_else(|| { SourcePathError::InvalidImportSyntax { path: path.to_path_buf(), @@ -442,6 +874,7 @@ fn collect_imported_bindings( } })?; bindings.push(ImportBindingData { + namespace: DeclarationNamespace::Value, local_name: binding.local.clone(), source_name: binding.imported.clone(), source_module, @@ -458,15 +891,47 @@ fn collect_imported_bindings( /// unit's module node, then resolve imported call sites to their target /// symbols (milestone 4). /// -/// Runs once per module, right after its unit is parsed: imported-binding -/// mirror declarations and implicit externs are skipped, every remaining -/// function declaration receives a [`SymbolId`] owned by the module, public -/// declarations populate the module's export table, and every import-introduced -/// binding is recorded separately in the module's imported-binding table. -/// The same `symbol` is written back onto the parsed [`FunctionDecl`] so the -/// linker can collect it through `merge_units`. Finally, calls to imported -/// functions and module namespace members are resolved to [`Expr::ModuleCall`] -/// nodes carrying the target [`SymbolId`]. +pub(super) fn guest_type_names_for_module( + graph: &ModuleGraph, + module: ModuleId, +) -> HashMap { + let Some(node) = graph.node(module) else { + return HashMap::new(); + }; + let mut names = HashMap::new(); + for declaration in &node.declarations { + if declaration.namespace == DeclarationNamespace::Type + && graph.is_guest_named_type(declaration.symbol) + { + names.insert( + declaration.name.clone(), + internal_guest_type_name( + graph + .canonical_guest_type_symbol(declaration.symbol) + .unwrap(), + ), + ); + } + } + for binding in &node.imported_bindings { + if binding.namespace == DeclarationNamespace::Type + && graph.is_guest_named_type(binding.source_symbol) + { + names.insert( + binding.local_name.clone(), + internal_guest_type_name( + graph + .canonical_guest_type_symbol(binding.source_symbol) + .unwrap(), + ), + ); + } + } + names +} + +/// Runs once per module after parse: local value/type declarations receive +/// symbols before import bindings and call sites resolve against them. pub(super) fn record_module_symbols( state: &mut ModuleCollectState, module: ModuleId, @@ -475,6 +940,12 @@ pub(super) fn record_module_symbols( parsed: &mut FrontendIr, options: &CompileSourceFileOptions, ) -> Result<(), SourcePathError> { + let identity = state + .module_graph + .node(module) + .map(|node| node.identity.clone()) + .unwrap_or_else(|| path.to_path_buf()); + let type_bindings = type_import_bindings(path, imports, state, options)?; let bindings = collect_imported_bindings( path, imports, @@ -489,6 +960,7 @@ pub(super) fn record_module_symbols( .iter() .cloned() .collect::>(); + let catalog_import_indices = catalog_import_indices(parsed); let module_source_id = state .module_graph @@ -499,9 +971,10 @@ pub(super) fn record_module_symbols( let signatures = exported_signature_table(state, &state.module_graph); for func in &mut parsed.functions { - if extern_names.contains(func.name.as_str()) { - // Implicit extern (module mode): the resolution pass resolves - // (or rejects) its call sites; never a local decl. + if extern_names.contains(func.name.as_str()) || catalog_import_indices.contains(&func.index) + { + // Implicit externs and catalog-declared host imports are resolved + // through their own metadata paths, never as guest declarations. continue; } if func.name.contains("::") @@ -539,6 +1012,82 @@ pub(super) fn record_module_symbols( func.symbol = Some(symbol); } + let exported_type_names = state + .module_type_exports + .get(&identity) + .cloned() + .unwrap_or_default(); + let mut local_type_names = parsed + .struct_schemas + .iter() + .filter(|(_, declaration)| declaration.is_guest()) + .map(|(name, _)| name.clone()) + .collect::>(); + local_type_names.extend(exported_type_names.keys().cloned()); + let mut local_type_names = local_type_names.into_iter().collect::>(); + local_type_names.sort(); + for name in local_type_names { + let type_symbol = state + .module_graph + .add_declaration_in_namespace( + module, + DeclarationNamespace::Type, + &name, + exported_type_names.contains_key(&name), + ) + .map_err(|message| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: state.sources.line_span(module_source_id, 1), + code: None, + line: 1, + message: format!("{}: {message}", path.display()), + })) + })?; + let is_guest_named_type = parsed + .struct_schemas + .get(&name) + .is_some_and(|declaration| declaration.is_guest()) + || exported_type_names + .get(&name) + .and_then(|export| export.declaration.as_ref()) + .is_some_and(|declaration| declaration.is_guest()); + if is_guest_named_type { + state + .module_graph + .mark_guest_named_type(type_symbol) + .map_err(|message| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: state.sources.line_span(module_source_id, 1), + code: None, + line: 1, + message: format!("{}: {message}", path.display()), + })) + })?; + } + } + + for (name, export) in &exported_type_names { + if let Some(declaration) = export + .declaration + .as_ref() + .filter(|declaration| declaration.is_guest()) + { + let alias = state.module_graph.declaration_symbol_in_namespace( + module, + DeclarationNamespace::Type, + name, + ); + let canonical = state.module_graph.declaration_symbol_in_namespace( + module, + DeclarationNamespace::Type, + &declaration.name, + ); + if let (Some(alias), Some(canonical)) = (alias, canonical) { + state.module_graph.bind_guest_type_alias(alias, canonical); + } + } + } + for binding in bindings { let Some(source_module) = binding.source_module else { continue; @@ -570,6 +1119,7 @@ pub(super) fn record_module_symbols( module, ImportedBinding { local_name: binding.local_name, + namespace: binding.namespace, source_module, source_symbol, source_name: binding.source_name, @@ -586,6 +1136,31 @@ pub(super) fn record_module_symbols( })?; } + for binding in type_bindings { + let binding_line = binding.line.max(1); + state + .module_graph + .add_imported_binding( + module, + ImportedBinding { + local_name: binding.local_name, + namespace: DeclarationNamespace::Type, + source_module: binding.source_module, + source_symbol: binding.source_symbol, + source_name: binding.source_name, + }, + ) + .map_err(|message| { + let span = state.sources.line_span(module_source_id, binding_line); + SourcePathError::Source(SourceError::Parse(ParseError { + span, + code: None, + line: binding_line, + message: format!("{}: {message}", path.display()), + })) + })?; + } + resolve_imported_call_sites( module, path, @@ -634,6 +1209,8 @@ fn record_function_decl_lines(stmts: &[Stmt], lines: &mut HashMap) { /// Resolution context for one module's imported-call pass. struct CallResolutionContext<'a> { functions_by_index: HashMap, + /// Exact catalog-bound slots are host identities, never guest symbols. + catalog_import_indices: HashSet, /// Direct call names bound by exactly one source module (keyed by the /// name the importing module binds: `as` alias or source name). plain_symbols: HashMap, @@ -877,6 +1454,9 @@ fn resolve_imported_call_sites( let mut ambiguous_names = HashSet::::new(); if let Some(node) = graph.node(module) { for binding in &node.imported_bindings { + if binding.namespace != DeclarationNamespace::Value { + continue; + } match plain_symbols.entry(binding.local_name.clone()) { std::collections::hash_map::Entry::Vacant(entry) => { entry.insert(binding.source_symbol); @@ -897,8 +1477,22 @@ fn resolve_imported_call_sites( .map(|func| (func.index, func)) .collect::>(); + let catalog_import_indices = parsed + .host_api_metadata + .as_ref() + .map(|metadata| { + parsed + .functions + .iter() + .filter(|decl| metadata.candidates(decl.index).is_some()) + .map(|decl| decl.index) + .collect() + }) + .unwrap_or_default(); + let ctx = CallResolutionContext { functions_by_index, + catalog_import_indices, plain_symbols, ambiguous_names, signatures, @@ -1102,6 +1696,10 @@ fn resolve_expr_imported_calls( // not part of the unit's declaration table. return Ok(()); }; + if ctx.catalog_import_indices.contains(index) { + validate_deferred_host_type_args(ctx, &decl.name, type_args, line)?; + return Ok(()); + } if decl.symbol.is_some() { // Local declaration or host import: resolved by the linker. // Host imports whose type arguments were deferred at parse @@ -1160,7 +1758,7 @@ fn resolve_expr_imported_calls( let Some(decl) = ctx.functions_by_index.get(index) else { return Ok(()); }; - if decl.symbol.is_none() { + if decl.symbol.is_none() && !ctx.catalog_import_indices.contains(index) { return Err(unknown_function_error( ctx.path, &decl.name, @@ -1205,6 +1803,7 @@ fn resolve_expr_imported_calls( | Expr::MoveField { .. } | Expr::MoveIndex { .. } => {} Expr::OptionalGet { + field_projection: _, container, key, container_slot: _, diff --git a/src/compiler/source_loader/imports.rs b/src/compiler/source_loader/imports.rs index 0511dfa2..36c21301 100644 --- a/src/compiler/source_loader/imports.rs +++ b/src/compiler/source_loader/imports.rs @@ -5,7 +5,8 @@ use crate::builtins::is_builtin_namespace; use super::super::frontends::{is_ident_continue, is_ident_start}; use super::super::modules::{UseDecl, use_path_to_spec}; use super::super::{ - CompileSourceFileOptions, SourceError, SourceFlavor, SourcePathError, frontends, + CompileSourceFileOptions, ParsePhase, SourceError, SourceFlavor, SourceFrontendContext, + SourcePathError, frontends, }; use super::model::ModuleImport; @@ -16,8 +17,33 @@ pub(super) fn parse_module_imports( options: &CompileSourceFileOptions, original_source_id: u32, ) -> Result, SourcePathError> { - scan_module_imports(source, flavor, path, options, original_source_id) - .map(|(imports, _)| imports) + parse_module_imports_with_context( + source, + flavor, + path, + options, + original_source_id, + Some(path), + ) +} + +pub(super) fn parse_module_imports_with_context( + source: &str, + flavor: SourceFlavor, + path: &Path, + options: &CompileSourceFileOptions, + original_source_id: u32, + original_path: Option<&Path>, +) -> Result, SourcePathError> { + scan_module_imports_with_context( + source, + flavor, + path, + options, + original_source_id, + original_path, + ) + .map(|(imports, _)| imports) } /// Scan the module imports of one source. @@ -35,6 +61,46 @@ pub(super) fn scan_module_imports( path: &Path, options: &CompileSourceFileOptions, original_source_id: u32, +) -> Result<(Vec, Vec), SourcePathError> { + scan_module_imports_with_context( + source, + flavor, + path, + options, + original_source_id, + Some(path), + ) +} + +pub(super) fn scan_module_imports_with_entry_locals( + source: &str, + path: &Path, + options: &CompileSourceFileOptions, + source_id: u32, + locals: &[super::super::ReplLocalBinding], +) -> Result<(Vec, Vec), SourcePathError> { + if locals.is_empty() { + return scan_module_imports(source, SourceFlavor::RustScript, path, options, source_id); + } + let parsed = frontends::parse_rustscript_repl_source_with_context( + source, + locals, + options.host_api_catalog().cloned(), + source_id, + true, + ) + .map_err(|error| SourcePathError::Source(SourceError::Parse(error)))?; + let decls = parsed.ir.use_declarations; + Ok((use_declarations_to_module_imports(path, &decls)?, decls)) +} + +fn scan_module_imports_with_context( + source: &str, + flavor: SourceFlavor, + path: &Path, + options: &CompileSourceFileOptions, + original_source_id: u32, + original_path: Option<&Path>, ) -> Result<(Vec, Vec), SourcePathError> { match flavor { SourceFlavor::RustScript => { @@ -43,11 +109,18 @@ pub(super) fn scan_module_imports( let imports = use_declarations_to_module_imports(path, &decls)?; Ok((imports, decls)) } - SourceFlavor::JavaScript | SourceFlavor::Lua => { - let imports = options + SourceFlavor::Plugin(_) => { + let plugin = options .source_plugin_for_flavor(flavor) - .ok_or(SourcePathError::MissingFrontendPlugin(flavor))? - .parse_module_imports(source, path)?; + .ok_or(SourcePathError::MissingFrontendPlugin(flavor))?; + let context = SourceFrontendContext { + source_id: original_source_id, + original_path, + phase: ParsePhase::ImportDiscovery, + host_api_catalog: options.host_api_catalog().map(|catalog| catalog.as_ref()), + imports: &[], + }; + let imports = plugin.parse_module_imports(source, path, &context)?; Ok((imports, Vec::new())) } } @@ -93,6 +166,8 @@ fn use_declarations_to_module_imports( spec, clause: decl.clause.clone(), line: decl.line, + namespace: super::model::DeclarationNamespace::Value, + span: Some(decl.span), }) }) .collect() @@ -130,9 +205,6 @@ pub(super) fn resolve_module_path( .ok_or_else(|| SourcePathError::ImportWithoutParent(base_path.to_path_buf()))?; parent.join(override_path) }; - if path.extension().and_then(|value| value.to_str()) != Some("rss") { - return Err(SourcePathError::NonRustScriptModule(path)); - } return Ok(module_identity(path)); } if options.module_override_source(spec).is_some() { @@ -147,9 +219,6 @@ pub(super) fn resolve_module_path( if path.extension().is_none() { path.set_extension("rss"); } - if path.extension().and_then(|value| value.to_str()) != Some("rss") { - return Err(SourcePathError::NonRustScriptModule(path)); - } return Ok(module_identity(path)); } @@ -164,9 +233,6 @@ pub(super) fn resolve_module_path( if path.extension().is_none() { path.set_extension("rss"); } - if path.extension().and_then(|value| value.to_str()) != Some("rss") { - return Err(SourcePathError::NonRustScriptModule(path)); - } Ok(module_identity(path)) } @@ -223,7 +289,7 @@ pub(super) fn strip_import_directives( ) -> Result { match flavor { SourceFlavor::RustScript => Ok(source.to_string()), - SourceFlavor::JavaScript | SourceFlavor::Lua => Ok(options + SourceFlavor::Plugin(_) => Ok(options .source_plugin_for_flavor(flavor) .ok_or(SourcePathError::MissingFrontendPlugin(flavor))? .strip_import_directives(source)), diff --git a/src/compiler/source_loader/model.rs b/src/compiler/source_loader/model.rs index 4199de19..c0415e75 100644 --- a/src/compiler/source_loader/model.rs +++ b/src/compiler/source_loader/model.rs @@ -3,18 +3,38 @@ use std::path::PathBuf; use super::super::linker::ParsedUnit; use super::super::modules::ModuleGraph; -use super::super::source_map::SourceMap; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FrontendImportSyntax { - RustScript, - JavaScript, - Lua, +use super::super::source_map::{SourceMap, Span}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum DeclarationNamespace { + #[default] + Value, + Type, } #[derive(Clone, Debug)] pub struct NamedImport { pub imported: String, pub local: String, + pub namespace: DeclarationNamespace, +} + +impl NamedImport { + pub fn value(imported: impl Into, local: impl Into) -> Self { + Self { + imported: imported.into(), + local: local.into(), + namespace: DeclarationNamespace::Value, + } + } + + pub fn type_only(imported: impl Into, local: impl Into) -> Self { + Self { + imported: imported.into(), + local: local.into(), + namespace: DeclarationNamespace::Type, + } + } } #[derive(Clone, Debug)] @@ -30,6 +50,25 @@ pub struct ModuleImport { pub spec: String, pub clause: ImportClause, pub line: usize, + pub namespace: DeclarationNamespace, + pub span: Option, +} + +impl ModuleImport { + pub fn value(spec: impl Into, clause: ImportClause, line: usize) -> Self { + Self { + spec: spec.into(), + clause, + line, + namespace: DeclarationNamespace::Value, + span: None, + } + } + + pub fn type_only(mut self) -> Self { + self.namespace = DeclarationNamespace::Type; + self + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -44,10 +83,12 @@ pub(super) struct ModuleCollectState { pub(super) seen: HashSet, pub(super) units: Vec, pub(super) module_exports: HashMap>, + pub(super) module_type_exports: + HashMap>, pub(super) module_graph: ModuleGraph, /// Compilation-wide source map keyed by the module graph's /// [`SourceId`](super::super::modules::SourceId) space. Every module's /// raw text is registered here at its graph source id so spans produced - /// during load and merge resolve to the owning source for diagnostics. + /// during load and merge resolve to the owning source. pub(super) sources: SourceMap, } diff --git a/src/compiler/typing.rs b/src/compiler/typing.rs index a65fb980..a6644fbe 100644 --- a/src/compiler/typing.rs +++ b/src/compiler/typing.rs @@ -4,7 +4,7 @@ mod helpers; mod state; mod validate; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::bytecode::ValueType; @@ -24,11 +24,136 @@ pub(crate) use self::state::{ BoundType, HostCallableSignature, LocalTypeState, TypeInferenceResult, }; use super::CompileError; +use super::FrontendTypedIr; use super::TypingMode; use super::ir::{ Expr, FrontendIr, FunctionDecl, FunctionImpl, LocalSlot, Stmt, StructDecl, TypeSchema, }; +impl FrontendTypedIr<'_> { + /// Returns the compiler's existing logical schema inference for `expression`. + pub fn schema_of_expr(&self, expression: &Expr) -> Option { + let state = local_state_from_schemas(self.local_schemas); + let function_names = build_function_names(&self.ir.functions); + let function_decls = build_function_decl_map(&self.ir.functions); + let host_import_return_types = + build_host_import_return_types(&self.ir.functions, &self.ir.function_impls); + let host_import_signatures = + build_host_import_signatures(&self.ir.functions, &self.ir.function_impls); + let mut context = TypeContext::new( + &self.ir.function_impls, + &function_decls, + &self.ir.struct_schemas, + &function_names, + &host_import_return_types, + &host_import_signatures, + TypingMode::DynamicHints, + self.ir.parsed_semantic_index.as_ref(), + ); + let schema = context + .infer_expr_schema(expression, &state) + .or_else(|| scalar_schema_from_bound_type(context.infer_expr_type(expression, &state))); + schema.map(|schema| { + if context.expr_is_optional(expression, &state) + && !matches!(schema, TypeSchema::Optional(_)) + { + TypeSchema::Optional(Box::new(schema)) + } else { + schema + } + }) + } + + /// Returns the complete logical callable schema for a declared function. + pub fn schema_of_function(&self, index: u16) -> Option { + let declaration = self.ir.functions.iter().find(|decl| decl.index == index)?; + let state = local_state_from_schemas(self.local_schemas); + let function_names = build_function_names(&self.ir.functions); + let function_decls = build_function_decl_map(&self.ir.functions); + let host_import_return_types = + build_host_import_return_types(&self.ir.functions, &self.ir.function_impls); + let host_import_signatures = + build_host_import_signatures(&self.ir.functions, &self.ir.function_impls); + let mut context = TypeContext::new( + &self.ir.function_impls, + &function_decls, + &self.ir.struct_schemas, + &function_names, + &host_import_return_types, + &host_import_signatures, + TypingMode::DynamicHints, + self.ir.parsed_semantic_index.as_ref(), + ); + let type_args = declaration + .type_params + .iter() + .cloned() + .map(TypeSchema::GenericParam) + .collect::>(); + let result = match context.infer_expr_schema(&Expr::FunctionRef(index, type_args), &state) { + Some(TypeSchema::Callable { result, .. }) => *result, + _ => TypeSchema::Unknown, + }; + let params = declaration + .args + .iter() + .enumerate() + .map(|(index, _)| { + declaration + .arg_schemas + .get(index) + .cloned() + .flatten() + // Body-local observations are inference hints, not a + // declaration-wide argument contract. Keep unannotated + // parameters dynamic, as FunctionRef does. + .unwrap_or(TypeSchema::Unknown) + }) + .collect(); + Some(TypeSchema::Callable { + params, + result: Box::new(result), + }) + } +} + +fn scalar_schema_from_bound_type(bound_type: BoundType) -> Option { + match bound_type { + BoundType::Null => Some(TypeSchema::Null), + BoundType::Int => Some(TypeSchema::Int), + BoundType::Float => Some(TypeSchema::Float), + BoundType::Number => Some(TypeSchema::Number), + BoundType::Bool => Some(TypeSchema::Bool), + BoundType::String => Some(TypeSchema::String), + BoundType::Bytes => Some(TypeSchema::Bytes), + BoundType::Unknown + | BoundType::Array + | BoundType::ArrayOf(_) + | BoundType::Map + | BoundType::MapOf(_) + | BoundType::Callable => None, + } +} + +fn local_state_from_schemas(local_schemas: &[Option]) -> LocalTypeState { + let entry_local_types = local_schemas + .iter() + .enumerate() + .filter_map(|(index, schema)| { + Some(EntryLocalType { + slot: u16::try_from(index).ok()?, + schema: schema.clone(), + optional: false, + }) + }) + .collect::>(); + let mut state = LocalTypeState::default(); + seed_entry_local_state(&mut state, &entry_local_types); + state +} + +pub(crate) use self::context::render_schema_label; + #[derive(Clone, Debug)] pub(super) struct EntryLocalType { pub(super) slot: LocalSlot, @@ -98,10 +223,24 @@ fn record_entry_local_types( } pub(super) fn legalize_builtins_and_bind_types( - mut ir: FrontendIr, + ir: FrontendIr, typing_mode: TypingMode, entry_local_types: &[EntryLocalType], ) -> Result { + let (ir, error) = legalize_for_admission(ir, typing_mode, entry_local_types); + match error { + Some(error) => Err(error), + None => Ok(ir), + } +} + +// Retain all successfully resolved facts even when another host call fails. +// This view is admission/diagnostic-only until the error is discharged. +pub(super) fn legalize_for_admission( + mut ir: FrontendIr, + typing_mode: TypingMode, + entry_local_types: &[EntryLocalType], +) -> (FrontendIr, Option) { // Exact callee spans for every parsed call site, keyed by the // [`SemanticNodeId`] carried on the typed [`Expr::Call`] nodes. The // host-call resolver attaches these to its failure diagnostic so the @@ -122,7 +261,7 @@ pub(super) fn legalize_builtins_and_bind_types( let mut pass = HostCallResolutionPass::new(None, HostCallResolutionPhase::Disabled) .with_call_site_spans(&call_site_spans); run_legalize_round(&mut ir, typing_mode, entry_local_types, &mut pass); - return Ok(ir); + return (ir, None); }; loop { @@ -134,7 +273,7 @@ pub(super) fn legalize_builtins_and_bind_types( continue; } if refine.unresolved() == 0 { - return Ok(ir); + return (ir, None); } let mut final_pass = @@ -145,11 +284,16 @@ pub(super) fn legalize_builtins_and_bind_types( continue; } if final_pass.unresolved() == 0 { - return Ok(ir); + return (ir, None); } - return Err(final_pass - .take_error() - .expect("a final unresolved catalog call must record a compile error")); + return ( + ir, + Some( + final_pass + .take_error() + .expect("a final unresolved catalog call must record a compile error"), + ), + ); } } @@ -227,6 +371,32 @@ pub(super) fn infer_types( typing_mode: TypingMode, entry_local_types: &[EntryLocalType], ) -> TypeInferenceResult { + infer_types_with_root_projection(ir, typing_mode, entry_local_types, &[]).0 +} + +/// Root bindings describe the final checked root state. The aggregate result +/// continues to summarize physical slots across root and callee frames. +pub(super) fn infer_types_with_root_projection( + ir: &FrontendIr, + typing_mode: TypingMode, + entry_local_types: &[EntryLocalType], + root_slots: &[LocalSlot], +) -> (TypeInferenceResult, Vec) { + let (facts, roots, _) = + infer_types_with_capture_origins(ir, typing_mode, entry_local_types, root_slots); + (facts, roots) +} + +pub(super) fn infer_types_with_capture_origins( + ir: &FrontendIr, + typing_mode: TypingMode, + entry_local_types: &[EntryLocalType], + root_slots: &[LocalSlot], +) -> ( + TypeInferenceResult, + Vec, + super::capture_certification::DeclarationCaptures, +) { let function_names = build_function_names(&ir.functions); let function_decls = build_function_decl_map(&ir.functions); let host_import_return_types = @@ -258,16 +428,60 @@ pub(super) fn infer_types( &mut callable_slots, &mut optional_slots, ); - collect_stmt_types( - &ir.stmts, - &mut top_state, - &mut local_types, - &mut local_schemas, - &mut local_schema_labels, - &mut callable_slots, - &mut optional_slots, - &mut context, - ); + for (index, stmt) in ir.stmts.iter().enumerate() { + context.capture_owner = Some(super::capture_certification::CaptureOwner::RootStatement( + index, + )); + collect_stmt_types( + std::slice::from_ref(stmt), + &mut top_state, + &mut local_types, + &mut local_schemas, + &mut local_schema_labels, + &mut callable_slots, + &mut optional_slots, + &mut context, + ); + } + // The backend collector intentionally summarizes expressions without + // exporting their writes. Replay only the requested root view with mutable + // expression state and a separate context, leaving backend observations intact. + let root_projection = if root_slots.is_empty() { + Vec::new() + } else { + let mut root_state = LocalTypeState::default(); + seed_entry_local_state(&mut root_state, entry_local_types); + let mut root_context = TypeContext::new( + &ir.function_impls, + &function_decls, + &ir.struct_schemas, + &function_names, + &host_import_return_types, + &host_import_signatures, + typing_mode, + ir.parsed_semantic_index.as_ref(), + ); + let entry_slots = entry_local_types + .iter() + .map(|entry| entry.slot) + .collect::>(); + collect::collect_root_stmt_state( + &ir.stmts, + &mut root_state, + &entry_slots, + &mut root_context, + ); + root_slots + .iter() + .map(|&slot| EntryLocalType { + slot, + schema: root_context + .infer_expr_schema(&Expr::Var(slot), &root_state) + .or_else(|| scalar_schema_from_bound_type(root_state.get(slot))), + optional: root_state.is_optional(slot), + }) + .collect() + }; let observed_function_param_types = context.observed_function_param_types.clone(); let observed_function_param_schemas = context.observed_function_param_schemas.clone(); let observed_function_param_callables = context.observed_function_param_callables.clone(); @@ -288,6 +502,7 @@ pub(super) fn infer_types( observed_function_capture_states: &observed_function_capture_states, }; + let mut origins = context.capture_origins.clone(); for decl in &ir.functions { let Some(function_impl) = ir.function_impls.get(&decl.index) else { continue; @@ -299,16 +514,41 @@ pub(super) fn infer_types( callable_slots: &mut callable_slots, optional_slots: &mut optional_slots, }; - collect_function_types(decl.index, function_impl, decl, &mut outputs, &env); + origins.extend(collect_function_types( + decl.index, + function_impl, + decl, + &mut outputs, + &env, + )); } - TypeInferenceResult { + let mut facts = TypeInferenceResult { local_types, local_schemas, local_schema_labels, callable_slots, optional_slots, - } + function_schemas: HashMap::new(), + }; + let schemas = facts.logical_schemas(); + let typed = FrontendTypedIr { + source_id: None, + ir, + local_schemas: &schemas, + host_api_catalog: None, + source_map: None, + }; + facts.function_schemas = ir + .functions + .iter() + .filter_map(|decl| { + typed + .schema_of_function(decl.index) + .map(|schema| (decl.index, schema)) + }) + .collect(); + (facts, root_projection, origins) } pub(super) fn validate_if_else_type_consistency( @@ -316,6 +556,41 @@ pub(super) fn validate_if_else_type_consistency( typing_mode: TypingMode, entry_local_types: &[EntryLocalType], ) -> Result<(), CompileError> { + validate_source_type_consistency(ir, typing_mode, entry_local_types, None) +} + +pub(super) fn validate_source_type_consistency( + ir: &FrontendIr, + typing_mode: TypingMode, + entry_local_types: &[EntryLocalType], + owner: Option<&str>, +) -> Result<(), CompileError> { + check_source_type_consistency(ir, typing_mode, entry_local_types, owner, false, None) + .map(|_| ()) +} + +pub(super) fn analyze_source_type_consistency( + ir: &FrontendIr, + typing_mode: TypingMode, + entry_local_types: &[EntryLocalType], + owner: Option<&str>, + host_error: Option<&CompileError>, +) -> Result, CompileError> { + check_source_type_consistency(ir, typing_mode, entry_local_types, owner, true, host_error) +} + +fn check_source_type_consistency( + ir: &FrontendIr, + typing_mode: TypingMode, + entry_local_types: &[EntryLocalType], + owner: Option<&str>, + recover_typing_diagnostics: bool, + host_error: Option<&CompileError>, +) -> Result, CompileError> { + let mut diagnostics = Vec::new(); + let dependent_host_returns = host_error + .map(|error| host_error_dependent_returns(ir, error)) + .unwrap_or_default(); let function_names = build_function_names(&ir.functions); let function_decls = build_function_decl_map(&ir.functions); let host_import_return_types = @@ -334,7 +609,16 @@ pub(super) fn validate_if_else_type_consistency( ir.parsed_semantic_index.as_ref(), ); for (index, stmt) in ir.stmts.iter().enumerate() { - validate_stmts( + if owner.is_some_and(|owner| { + ir.stmt_sources + .get(index) + .and_then(|source| source.as_deref()) + != Some(owner) + }) { + context.apply_stmts(std::slice::from_ref(stmt), &mut top_state); + continue; + } + if let Err(error) = validate_stmts( std::slice::from_ref(stmt), &mut top_state, None, @@ -343,22 +627,116 @@ pub(super) fn validate_if_else_type_consistency( .and_then(|source| source.as_deref()), &mut context, false, - )?; + ) { + retain_native_typing_diagnostic(error, recover_typing_diagnostics, &mut diagnostics)?; + // A failed statement still contributes its real type flow to later + // validation sites; an earlier diagnostic cannot mask a hard error. + context.apply_stmts(std::slice::from_ref(stmt), &mut top_state); + } } for decl in &ir.functions { let Some(function_impl) = ir.function_impls.get(&decl.index) else { continue; }; - validate_function_impl( + if owner.is_some_and(|owner| { + ir.function_sources.get(&decl.index).map(String::as_str) != Some(owner) + }) { + continue; + } + if let Err(error) = validate_function_impl( decl.index, function_impl, ir.function_sources.get(&decl.index).map(String::as_str), &mut context, - )?; + ) { + // Only the final inferred-return error of a provenance-proven + // direct call chain is fallout from this rejected host call. + // Body validation already ran; independent Strict slot admission + // and source hooks still run in the shared pipeline afterwards. + if recover_typing_diagnostics + && dependent_host_returns.contains(&decl.index) + && matches!(&error, CompileError::StrictTypingRequired { + line: Some(line), span: Some(span), .. + } if *line == function_impl.body_expr_line + && ir.parsed_semantic_index.as_ref().is_some_and(|index| { + index.func_decls.iter().any(|site| { + site.function_index == decl.index && site.ident_span == *span + }) + })) + { + // Its unresolved facts remain unchanged; the exact host error + // is retained by the pipeline instead of a return cascade. + continue; + } + retain_native_typing_diagnostic(error, recover_typing_diagnostics, &mut diagnostics)?; + } + } + + Ok(diagnostics) +} + +fn host_error_dependent_returns(ir: &FrontendIr, error: &CompileError) -> HashSet { + let CompileError::HostCallResolve { + span: Some(callee), .. + } = error + else { + return HashSet::new(); + }; + let Some(index) = ir.parsed_semantic_index.as_ref() else { + return HashSet::new(); + }; + let mut affected = HashSet::new(); + loop { + let before = affected.len(); + for declaration in &ir.functions { + if declaration.return_schema.is_some() || affected.contains(&declaration.index) { + continue; + } + let Some(implementation) = ir.function_impls.get(&declaration.index) else { + continue; + }; + let Expr::Call(target, _, _, None, Some(id)) = &implementation.body_expr else { + continue; + }; + let Some(site) = index.call_sites.iter().find(|site| site.id == *id) else { + continue; + }; + let failed_host = site.callee_span == *callee + && ir + .host_api_metadata + .as_ref() + .and_then(|metadata| metadata.candidates(*target)) + .is_some(); + if failed_host || affected.contains(target) { + affected.insert(declaration.index); + } + } + if before == affected.len() { + break; + } } + affected +} - Ok(()) +fn retain_native_typing_diagnostic( + error: CompileError, + recover: bool, + diagnostics: &mut Vec, +) -> Result<(), CompileError> { + if recover + && matches!( + error, + CompileError::BinaryOperandTypeMismatch { .. } + | CompileError::IfElseBranchTypeMismatch { .. } + | CompileError::CallableArgumentTypeMismatch { .. } + ) + { + diagnostics.push(error); + Ok(()) + } else { + Err(error) + } } pub(crate) fn infer_expr_type(expr: &Expr, state: &LocalTypeState) -> BoundType { @@ -576,6 +954,324 @@ mod catalog_call_resolution_tests { ir.stmts.iter().filter_map(stmt_expr).collect() } + #[test] + fn root_result_join_preserves_context_guards_and_aggregate() { + for (source, expected, optional) in [ + ( + "let maybe: float? = 2.5; let y = if true => { maybe } else => { null };", + Some(TypeSchema::Float), + true, + ), + ( + "let maybe: float? = 2.5; let y = match 1 { 1 => maybe, _ => null };", + Some(TypeSchema::Float), + true, + ), + // Match keeps its existing explicit-arm disagreement rule. + ( + "let maybe: float? = 2.5; let y = match 1 { 1 => maybe, 2 => null, _ => maybe };", + None, + true, + ), + ( + "struct Left { value: int } struct Right { value: int } let left: Left? = { value: 1 }; let right: Right? = { value: 2 }; let y = if true => { left } else => { right };", + None, + true, + ), + ( + "struct Left { value: int } struct Right { value: int } let left: Left? = { value: 1 }; let right: Right? = { value: 2 }; let y = match 1 { 1 => left, _ => right };", + None, + true, + ), + ( + "let y = if true => { null } else => { null };", + Some(TypeSchema::Null), + false, + ), + ] { + let ir = parse(source, catalog(vec![], vec![])); + let slot = ir + .local_bindings + .iter() + .find(|(name, _)| name == "y") + .unwrap() + .1; + let before = format!("{ir:?}"); + let aggregate = infer_types(&ir, TypingMode::DynamicHints, &[]); + let (projected, roots) = + infer_types_with_root_projection(&ir, TypingMode::DynamicHints, &[], &[slot]); + assert_eq!(aggregate, projected, "{source}"); + assert_eq!(before, format!("{ir:?}")); + let declarations = HashMap::new(); + let names = HashMap::new(); + let returns = HashMap::new(); + let signatures = HashMap::new(); + let mut reference = TypeContext::new( + &ir.function_impls, + &declarations, + &ir.struct_schemas, + &names, + &returns, + &signatures, + TypingMode::DynamicHints, + None, + ); + let mut reference_state = LocalTypeState::default(); + reference.apply_stmts(&ir.stmts, &mut reference_state); + let reference_schema = reference + .infer_expr_schema(&Expr::Var(slot), &reference_state) + .or_else(|| scalar_schema_from_bound_type(reference_state.get(slot))); + assert_eq!( + roots[0].schema, reference_schema, + "existing context: {source}" + ); + assert_eq!(roots[0].optional, reference_state.is_optional(slot)); + assert_eq!(roots[0].schema, expected, "{source}"); + assert_eq!(roots[0].optional, optional, "{source}"); + } + } + + #[test] + fn root_result_join_unknown_snapshot_is_not_null_or_a_reused_slot() { + for reverse in [false, true] { + let mut ir = parse( + "let maybe: float? = 2.5; let y = if true => { maybe } else => { null };", + catalog(vec![], vec![]), + ); + let Stmt::Let { index, expr, .. } = &mut ir.stmts[1] else { + panic!("result binding") + }; + let result_slot = *index; + let Expr::IfElse { + then_expr, + else_expr, + .. + } = expr + else { + panic!("conditional") + }; + // An unbound inference-only value carries schema=None, not Null. + **else_expr = Expr::Var(60000); + if reverse { + std::mem::swap(then_expr, else_expr); + } + let aggregate = infer_types(&ir, TypingMode::DynamicHints, &[]); + let (projected, roots) = infer_types_with_root_projection( + &ir, + TypingMode::DynamicHints, + &[], + &[result_slot], + ); + assert_eq!(aggregate, projected); + assert_eq!(roots[0].schema, None); + assert!(roots[0].optional); + } + } + + #[test] + fn root_expression_effects_block_local_shadow_is_a_distinct_slot() { + use crate::compiler::ir::AssignmentKind; + let mut ir = parse("let mut x = 1; let ignored = 0;", catalog(vec![], vec![])); + ir.locals = 3; + let shadow = 2; + let Expr::Int(_) = stmt_expr(&ir.stmts[1]).unwrap() else { + panic!("literal initializer"); + }; + let Stmt::Let { expr, .. } = &mut ir.stmts[1] else { + panic!("let initializer"); + }; + *expr = Expr::Block { + stmts: vec![ + Stmt::Let { + index: shadow, + declared_schema: None, + expr: Expr::Int(2), + line: 1, + }, + Stmt::Assign { + index: shadow, + kind: AssignmentKind::Set, + expr: Expr::Float(2.5), + line: 1, + }, + ], + expr: Box::new(Expr::Int(0)), + }; + // A frontend can attach the same spelling to distinct lexical slots. + ir.local_bindings.push(("x".into(), shadow)); + let aggregate = infer_types(&ir, TypingMode::DynamicHints, &[]); + let (projected, roots) = + infer_types_with_root_projection(&ir, TypingMode::DynamicHints, &[], &[0, shadow]); + assert_eq!(aggregate, projected); + assert_eq!(roots[0].schema, Some(TypeSchema::Int)); + assert_eq!( + roots[1].schema, None, + "a block-only slot must not enter final root state" + ); + } + + #[test] + fn root_expression_effects_preserve_aggregate_and_conservative_paths() { + for (source, expected) in [ + ( + "let mut x = 1; let ignored = if true => { x = 2.5; 0 } else => { x = 2.5; 0 };", + Some(TypeSchema::Float), + ), + ( + "let mut x = 1; let ignored = if (if true => { x = 2.5; true } else => { x = 2.5; true }) => { 0 } else => { 0 };", + Some(TypeSchema::Float), + ), + ( + "let mut x = 1; let ignored = if true => { x = 2.5; 0 } else => { 0 };", + None, + ), + ( + "let mut x = 1; let ignored = if false => { x = 2.5; 0 } else => { 0 };", + None, + ), + ( + "let mut x = 1; let ignored = false && (if true => { x = 2.5; true } else => { x = 2.5; true });", + None, + ), + ( + "let mut x = 1; let ignored = true || (if true => { x = 2.5; false } else => { x = 2.5; false });", + None, + ), + ( + "let mut x = 1; while false { let ignored = if true => { x = 2.5; 0 } else => { x = 2.5; 0 }; }", + None, + ), + ( + "let mut x = 1; let f = |n| if true => { let mut local = x; local = 2.5; n } else => { n };", + Some(TypeSchema::Int), + ), + ( + "let mut x = 1; fn f() -> int { let mut x = 1; x = 2.5; 0 }", + Some(TypeSchema::Int), + ), + ] { + println!("root expression effects probe source={source}"); + let ir = parse(source, catalog(vec![], vec![])); + let ir = legalize_builtins_and_bind_types(ir, TypingMode::DynamicHints, &[]).unwrap(); + validate_if_else_type_consistency(&ir, TypingMode::DynamicHints, &[]).unwrap(); + let root = ir + .local_bindings + .iter() + .find(|(name, _)| name == "x") + .unwrap() + .1; + let allocated = + crate::compiler::lifetime::allocate_local_slots_with_root_slots(ir, &[root]) + .unwrap(); + let root = allocated.mapping[root as usize]; + let before = allocated.ir.clone(); + let aggregate = infer_types(&allocated.ir, TypingMode::DynamicHints, &[]); + let (projected_aggregate, projection) = infer_types_with_root_projection( + &allocated.ir, + TypingMode::DynamicHints, + &[], + &[root], + ); + assert_eq!(aggregate, projected_aggregate, "{source}"); + assert_eq!( + format!("{before:?}"), + format!("{:?}", allocated.ir), + "projection must not rewrite backend IR" + ); + assert_eq!(projection[0].schema, expected, "{source}"); + assert!(!projection[0].optional, "{source}"); + if source.contains("else => { x = 2.5;") { + // Both executed alternatives update the root, but aggregate + // observations still use the original physical-slot merge. + assert_eq!( + aggregate.local_schemas[root as usize], + Some(TypeSchema::Float) + ); + } + } + } + + #[test] + fn root_projection_preserves_aggregate_callee_and_closure_facts() { + let named = crate::HostStructSchema::new( + "ProjectionRoot", + vec![crate::HostStructField::new("value", HostTypeSchema::Int)], + ); + let mut builder = HostApiBuilder::new(); + builder.named_struct(named.clone()); + builder.function(HostFunctionSchema::with_return( + "layout::root", + vec![], + named.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "layout::maybe", + vec![], + HostTypeSchema::Optional(Box::new(named.as_type())), + )); + let catalog = Arc::new(builder.build().unwrap()); + let locals = (0..257) + .map(|i| format!("let t{i} = {i};")) + .collect::>() + .join(" "); + for body in [ + format!("fn f() -> int {{ {locals} t256 }} f();"), + format!("let f = |n| if n > 0 => {{ {locals} t256 + n }} else => {{ n }}; f(1);"), + ] { + let ir = parse( + &format!( + "use layout; let root = layout::root(); let maybe = layout::maybe(); {body}" + ), + Arc::clone(&catalog), + ); + let ir = legalize_builtins_and_bind_types(ir, TypingMode::DynamicHints, &[]).unwrap(); + let roots = ["root", "maybe"] + .map(|name| ir.local_bindings.iter().find(|(n, _)| n == name).unwrap().1); + let allocated = + crate::compiler::lifetime::allocate_local_slots_with_root_slots(ir, &roots) + .unwrap(); + let roots = roots.map(|slot| allocated.mapping[slot as usize]); + let aggregate = infer_types(&allocated.ir, TypingMode::DynamicHints, &[]); + let (projected_aggregate, projection) = infer_types_with_root_projection( + &allocated.ir, + TypingMode::DynamicHints, + &[], + &roots, + ); + assert_eq!(aggregate.local_types, projected_aggregate.local_types); + assert_eq!(aggregate.local_schemas, projected_aggregate.local_schemas); + assert_eq!( + aggregate.local_schema_labels, + projected_aggregate.local_schema_labels + ); + assert_eq!(aggregate.callable_slots, projected_aggregate.callable_slots); + assert_eq!(aggregate.optional_slots, projected_aggregate.optional_slots); + assert_eq!( + aggregate.function_schemas, + projected_aggregate.function_schemas + ); + let schema = TypeSchema::Named("ProjectionRoot".into(), vec![]); + assert_eq!(projection[0].slot, roots[0]); + assert_eq!(projection[0].schema, Some(schema.clone())); + assert!(!projection[0].optional); + assert_eq!(projection[1].slot, roots[1]); + assert_eq!( + projection[1].schema, + Some(TypeSchema::Optional(Box::new(schema))) + ); + assert!(projection[1].optional); + let expected_backend = if body.starts_with("fn ") { + TypeSchema::Int + } else { + TypeSchema::Named("ProjectionRoot".into(), vec![]) + }; + assert_eq!( + aggregate.local_schemas[roots[0] as usize], + Some(expected_backend) + ); + } + } + #[test] fn catalog_calls_at_one_flat_index_resolve_per_site() { let catalog = catalog( @@ -796,23 +1492,101 @@ mod catalog_call_resolution_tests { let hint_error = crate::compiler::collect_inferred_local_type_hints_with_options( source, SourceFlavor::RustScript, - CompileSourceFileOptions::default().with_host_api_catalog(catalog), + CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)), ) .expect_err("hint collection must return the resolver error"); - for error in [compile_error, hint_error] { + for (entrypoint, error) in [("compile", compile_error), ("hint", hint_error)] { let source_error = match error { crate::compiler::SourcePathError::Source(error) => error, crate::compiler::SourcePathError::SourceWithMap { error, .. } => error, other => panic!("unexpected path error: {other}"), }; - assert!(matches!( - source_error, - crate::compiler::SourceError::Compile(CompileError::HostCallResolve { - line: Some(3), + assert!( + matches!( + source_error, + crate::compiler::SourceError::Compile(CompileError::HostCallResolve { + line: Some(3), + .. + }) + ), + "{entrypoint}: {source_error:?}" + ); + } + + let configured = + || CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)); + let valid = "use acme;\nlet file = acme::open(\"x\");\nacme::consume(file);\n"; + crate::compiler::compile_source_with_flavor_and_options( + valid, + SourceFlavor::RustScript, + configured(), + ) + .expect("the original owned resource remains admissible"); + let hints = crate::compiler::collect_inferred_local_type_hints_with_options( + valid, + SourceFlavor::RustScript, + configured(), + ) + .unwrap(); + assert!(hints.iter().any(|hint| { + hint.name == "file" + && hint.schema + == Some(TypeSchema::Resource( + ResourceTypeKey::new("acme.file").unwrap(), + )) + })); + + // Recovery still runs ownership admission. A standalone copy or a + // use-after-move is independent of an invalid catalog call, including + // when the independent statement shares the same source line. + for invalid in [ + source, + "use acme;\nlet file = acme::open(\"x\");\nfile.copy(); acme::consume(42);\n", + "use acme;\nlet file = acme::open(\"x\");\nacme::consume(42); file.copy();\n", + "use acme;\nlet file = acme::open(\"x\");\nacme::consume(file);\nfile;\nacme::consume(42);\n", + ] { + let analyzed = crate::compiler::analyze_source_from_string_with_options( + "main.rss", + invalid, + configured(), + ) + .err() + .expect("host recovery must retain ownership rejection"); + let mut errors = vec![analyzed]; + if invalid != source { + errors.push( + crate::compiler::compile_source_with_flavor_and_options( + invalid, + SourceFlavor::RustScript, + configured(), + ) + .err() + .expect("independent ownership rejection in compile"), + ); + errors.push( + crate::compiler::collect_inferred_local_type_hints_with_options( + invalid, + SourceFlavor::RustScript, + configured(), + ) + .expect_err("independent ownership rejection in hints"), + ); + } + for error in errors { + let crate::compiler::SourcePathError::SourceWithMap { + error: crate::compiler::SourceError::Parse(error), .. - }) - )); + } = error + else { + panic!("independent ownership diagnostic required: {error:?}"); + }; + assert!( + error.code.as_deref() == Some("E_OWNERSHIP_COPY_RESOURCE") + || error.message.contains("moved"), + "{error:?}" + ); + } } } diff --git a/src/compiler/typing/collect.rs b/src/compiler/typing/collect.rs index bb62b177..8234f350 100644 --- a/src/compiler/typing/collect.rs +++ b/src/compiler/typing/collect.rs @@ -14,6 +14,381 @@ use super::state::{ }; use super::validate::refine_state_for_condition; +// This private replay publishes final root facts only. It never records into +// aggregate slot vectors and never executes/inlines callable bodies. +pub(super) fn collect_root_stmt_state( + stmts: &[Stmt], + state: &mut LocalTypeState, + entry_slots: &[LocalSlot], + context: &mut TypeContext<'_>, +) { + let mut visible = entry_slots.iter().copied().collect(); + collect_root_stmts(stmts, state, &mut visible, context); +} + +fn commit_root_scope( + state: &mut LocalTypeState, + nested: &LocalTypeState, + visible: &std::collections::HashSet, +) { + for &slot in visible { + state.copy_exact_binding_from(nested, slot, slot); + } +} + +fn collect_root_stmts( + stmts: &[Stmt], + state: &mut LocalTypeState, + visible: &mut std::collections::HashSet, + context: &mut TypeContext<'_>, +) { + for stmt in stmts { + match stmt { + Stmt::Let { + index, + declared_schema, + expr, + .. + } => { + let value = collect_root_expr(expr, state, visible, context); + bind_root_value(state, *index, declared_schema.as_ref(), &value, context); + visible.insert(*index); + } + Stmt::Assign { index, expr, .. } => { + let value = collect_root_expr(expr, state, visible, context); + bind_root_value(state, *index, None, &value, context); + } + Stmt::Expr { expr, .. } => { + collect_root_expr(expr, state, visible, context); + } + Stmt::Drop { index, .. } => state.set(*index, BoundType::Null), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + collect_root_expr(condition, state, visible, context); + let mut then_state = refine_state_for_condition(state, condition, true); + let mut else_state = refine_state_for_condition(state, condition, false); + collect_root_stmts(then_branch, &mut then_state, &mut visible.clone(), context); + collect_root_stmts(else_branch, &mut else_state, &mut visible.clone(), context); + let mut joined = LocalTypeState::default(); + joined.merge_from_branches(&then_state, &else_state); + commit_root_scope(state, &joined, visible); + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + let mut nested = state.clone(); + let mut loop_scope = visible.clone(); + collect_root_stmts( + std::slice::from_ref(init), + &mut nested, + &mut loop_scope, + context, + ); + // The condition runs even on the zero-body path. + collect_root_expr(condition, &mut nested, &loop_scope, context); + stabilize_loop_state(&mut nested, |iteration| { + let mut scope = loop_scope.clone(); + collect_root_stmts(body, iteration, &mut scope, context); + collect_root_stmts(std::slice::from_ref(post), iteration, &mut scope, context); + collect_root_expr(condition, iteration, &loop_scope, context); + }); + commit_root_scope(state, &nested, visible); + } + Stmt::While { + condition, body, .. + } => { + collect_root_expr(condition, state, visible, context); + let mut nested = state.clone(); + stabilize_loop_state(&mut nested, |iteration| { + collect_root_stmts(body, iteration, &mut visible.clone(), context); + collect_root_expr(condition, iteration, visible, context); + }); + commit_root_scope(state, &nested, visible); + } + Stmt::FuncDecl { .. } => { + // Retain the existing capture-copy rules without visiting bodies. + collect_stmt_types( + std::slice::from_ref(stmt), + state, + &mut [], + &mut [], + &mut [], + &mut [], + &mut [], + context, + ); + } + Stmt::ClosureLet { .. } + | Stmt::Noop { .. } + | Stmt::Break { .. } + | Stmt::Continue { .. } => {} + } + } +} + +// Expression values occupy slot zero in a standalone facts state. Operand +// snapshots use inference-only slots, never allocated IR/runtime slots, so a +// later operand cannot change the checked value of an earlier operand. +fn root_expr_value( + expr: &Expr, + state: &LocalTypeState, + context: &mut TypeContext<'_>, +) -> LocalTypeState { + let mut inferred = LocalTypeState::default(); + let temporary = root_operand_slot(state, None); + let ty = context.infer_expr_type(expr, state); + bind_expr_result_to_slot(&mut inferred, temporary, None, expr, state, ty, context); + let mut value = LocalTypeState::default(); + value.copy_exact_binding_from(&inferred, temporary, 0); + value +} + +fn bind_root_value( + state: &mut LocalTypeState, + slot: LocalSlot, + declared: Option<&TypeSchema>, + value: &LocalTypeState, + context: &mut TypeContext<'_>, +) { + if declared.is_none() && !state.has_declared_schema(slot) { + state.copy_exact_binding_from(value, 0, slot); + return; + } + let mut expr_state = state.clone(); + let temporary = root_operand_slot(&expr_state, Some(slot)); + expr_state.copy_exact_binding_from(value, 0, temporary); + bind_expr_result_to_slot( + state, + slot, + declared, + &Expr::Var(temporary), + &expr_state, + value.get(0), + context, + ); +} + +fn root_operand_slot(state: &LocalTypeState, excluded: Option) -> LocalSlot { + (0..=LocalSlot::MAX) + .rev() + .find(|&slot| Some(slot) != excluded && !state.has_binding(slot)) + .expect("private expression facts require a free inference slot") +} + +// Result alternatives use expression inference, not binding-state joins. Reserve +// snapshot slots even when an unknown value has no binding to copy. +fn root_result_operand( + value: &LocalTypeState, + inferred: &mut LocalTypeState, + reserved: &mut std::collections::HashSet, +) -> Expr { + let slot = (0..=LocalSlot::MAX) + .rev() + .find(|slot| !inferred.has_binding(*slot) && !reserved.contains(slot)) + .expect("private expression results require a free inference slot"); + reserved.insert(slot); + inferred.copy_exact_binding_from(value, 0, slot); + Expr::Var(slot) +} + +fn collect_root_expr( + expr: &Expr, + state: &mut LocalTypeState, + visible: &std::collections::HashSet, + context: &mut TypeContext<'_>, +) -> LocalTypeState { + match expr { + Expr::Block { stmts, expr } => { + let mut nested = state.clone(); + let mut scope = visible.clone(); + collect_root_stmts(stmts, &mut nested, &mut scope, context); + let value = collect_root_expr(expr, &mut nested, &scope, context); + commit_root_scope(state, &nested, visible); + return value; + } + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + collect_root_expr(condition, state, visible, context); + let mut then_state = refine_state_for_condition(state, condition, true); + let mut else_state = refine_state_for_condition(state, condition, false); + let then_value = collect_root_expr(then_expr, &mut then_state, visible, context); + let else_value = collect_root_expr(else_expr, &mut else_state, visible, context); + let mut joined = LocalTypeState::default(); + joined.merge_from_branches(&then_state, &else_state); + commit_root_scope(state, &joined, visible); + let mut inferred = LocalTypeState::default(); + let mut reserved = std::collections::HashSet::new(); + let result_expr = Expr::IfElse { + // Branch refinements and effects have already been applied. + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(root_result_operand( + &then_value, + &mut inferred, + &mut reserved, + )), + else_expr: Box::new(root_result_operand( + &else_value, + &mut inferred, + &mut reserved, + )), + }; + return root_expr_value(&result_expr, &inferred, context); + } + Expr::Match { + value_slot, + result_slot, + value, + arms, + default, + } => { + let value = collect_root_expr(value, state, visible, context); + let mut nested = state.clone(); + bind_root_value(&mut nested, *value_slot, None, &value, context); + let mut scope = visible.clone(); + scope.insert(*value_slot); + let mut default_state = nested.clone(); + let default_value = collect_root_expr(default, &mut default_state, &scope, context); + let mut joined = default_state; + let mut inferred = LocalTypeState::default(); + let mut reserved = std::collections::HashSet::from([*value_slot, *result_slot]); + reserved.extend( + arms.iter() + .filter_map(|(pattern, _)| pattern.binding_slot()), + ); + let value_expr = root_result_operand(&value, &mut inferred, &mut reserved); + let default_expr = root_result_operand(&default_value, &mut inferred, &mut reserved); + let mut result_arms = Vec::with_capacity(arms.len()); + for (pattern, expr) in arms { + let mut arm_state = refine_state_for_match_pattern(&nested, pattern, *value_slot); + let mut arm_scope = scope.clone(); + if let Some(slot) = pattern.binding_slot() { + arm_scope.insert(slot); + } + let arm_value = collect_root_expr(expr, &mut arm_state, &arm_scope, context); + let previous = joined.clone(); + joined.merge_from_branches(&previous, &arm_state); + result_arms.push(( + pattern.clone(), + root_result_operand(&arm_value, &mut inferred, &mut reserved), + )); + } + commit_root_scope(state, &joined, visible); + // Preserve Match's arm/default inference rules (which are distinct + // from IfElse), with no original branch expressions left to replay. + let result_expr = Expr::Match { + value_slot: *value_slot, + result_slot: *result_slot, + value: Box::new(value_expr), + arms: result_arms, + default: Box::new(default_expr), + }; + return root_expr_value(&result_expr, &inferred, context); + } + _ => {} + } + let mut inferred_expr = expr.clone(); + let mut operand_state = state.clone(); + let mut operand_slots = std::collections::HashSet::new(); + // Only children evaluated by this expression are visited. A callable's + // declaration/body is not a root effect, even when inference examines it. + let mut operand = |child: &mut Expr, conditional: bool| { + let incoming = state.clone(); + let value = collect_root_expr(child, state, visible, context); + if conditional { + let executed = state.clone(); + state.merge_from_branches(&incoming, &executed); + } + // Literal keys retain their structural/nominal meaning for existing + // get/set/container inference. Later operands cannot change them. + if matches!( + child, + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::Bytes(_) + | Expr::String(_) + | Expr::FunctionRef(..) + ) { + return; + } + let slot = (0..=LocalSlot::MAX) + .rev() + .find(|slot| !operand_state.has_binding(*slot) && !operand_slots.contains(slot)) + .expect("private expression operands require a free inference slot"); + operand_slots.insert(slot); + operand_state.copy_exact_binding_from(&value, 0, slot); + *child = Expr::Var(slot); + }; + match &mut inferred_expr { + Expr::OptionalGet { container, key, .. } => { + operand(container, false); + operand(key, true); + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + operand(value, false); + operand(fallback, true); + } + Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => { + operand(lhs, false); + operand(rhs, true); + } + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) => { + operand(lhs, false); + operand(rhs, false); + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => operand(inner, false), + Expr::Call(_, _, args, _, _) + | Expr::LocalCall(_, _, args, _) + | Expr::ModuleCall(_, _, args, _) + | Expr::ClosureCall(_, args) => { + for arg in args { + operand(arg, false); + } + } + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::Bytes(_) + | Expr::String(_) + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Closure(_) => {} + Expr::IfElse { .. } | Expr::Match { .. } | Expr::Block { .. } => unreachable!(), + } + root_expr_value(&inferred_expr, &operand_state, context) +} + pub(super) fn observed_function_param_slice( observed: &HashMap>, function_index: u16, @@ -103,11 +478,19 @@ pub(super) fn seed_function_param_state( .flatten() }); if let Some(schema) = schema { - let ty = if ty == BoundType::Unknown { - super::context::bound_type_from_schema(&schema) - } else { - ty - }; + let declared_type = declared_schema + .as_ref() + .map(super::context::bound_type_from_schema); + let ty = + if let Some(declared_type) = declared_type.filter(|ty| *ty != BoundType::Unknown) { + // A bad imported argument cannot overwrite its producer's + // authored parameter contract before call-site admission. + declared_type + } else if ty == BoundType::Unknown { + super::context::bound_type_from_schema(&schema) + } else { + ty + }; state.set_with_optional_schema_origin( *slot, ty, @@ -146,7 +529,7 @@ pub(super) fn collect_function_types( function_decl: &FunctionDecl, outputs: &mut CollectFunctionTypeOutputs<'_>, env: &CollectFunctionTypesEnv<'_>, -) { +) -> super::super::capture_certification::DeclarationCaptures { let mut state = LocalTypeState::default(); let mut context = TypeContext::new( env.function_impls, @@ -158,6 +541,9 @@ pub(super) fn collect_function_types( TypingMode::DynamicHints, None, ); + context.capture_owner = Some(super::super::capture_certification::CaptureOwner::Function( + function_index, + )); seed_function_param_state( &mut state, &function_impl.param_slots, @@ -234,6 +620,7 @@ pub(super) fn collect_function_types( &mut context, ); let _ = context.infer_expr_type(&function_impl.body_expr, &state); + context.capture_origins } #[allow(clippy::too_many_arguments)] @@ -257,6 +644,7 @@ pub(super) fn collect_stmt_types( state.set(*index, BoundType::Null); } Stmt::ClosureLet { closure, .. } => { + context.observe_closure_origin(closure, state); collect_closure_capture_types( closure, state, @@ -325,8 +713,9 @@ pub(super) fn collect_stmt_types( .. } => { let expr_state = state.clone(); - collect_expr_types( + collect_contextual_expr_types( expr, + declared_schema.as_ref(), &expr_state, local_types, local_schemas, @@ -511,6 +900,57 @@ pub(super) fn collect_stmt_types( } } +#[allow(clippy::too_many_arguments)] +fn collect_contextual_expr_types( + expr: &Expr, + expected: Option<&TypeSchema>, + state: &LocalTypeState, + local_types: &mut [ValueType], + local_schemas: &mut [Option], + local_schema_labels: &mut [Option], + callable_slots: &mut [bool], + optional_slots: &mut [bool], + context: &mut TypeContext<'_>, +) { + if let Expr::Closure(closure) = expr + && let Some(TypeSchema::Callable { params, .. }) = + expected.map(|schema| context.substitute_schema_generics(schema)) + && params.len() == closure.param_slots.len() + { + context.observe_closure_origin(closure, state); + let params = params.into_iter().map(Some).collect::>(); + if let Some(nested) = context.build_callable_state( + &closure.param_slots, + Some(¶ms), + &closure.capture_copies, + None, + state, + ) { + collect_callable_body_types( + closure, + &nested, + local_types, + local_schemas, + local_schema_labels, + callable_slots, + optional_slots, + context, + ); + return; + } + } + collect_expr_types( + expr, + state, + local_types, + local_schemas, + local_schema_labels, + callable_slots, + optional_slots, + context, + ); +} + #[allow(clippy::too_many_arguments)] fn collect_expr_types( expr: &Expr, @@ -589,9 +1029,45 @@ fn collect_expr_types( Expr::Call(_, _, args, _, _) | Expr::LocalCall(_, _, args, _) | Expr::ModuleCall(_, _, args, _) => { - for arg in args { - collect_expr_types( + let expected = match expr { + Expr::Call(_, _, _, Some(resolved), _) => Some( + resolved + .params + .iter() + .map(|param| Some(param.schema.clone())) + .collect::>(), + ), + Expr::Call(index, type_args, _, _, _) => { + context.resolve_function_arg_schemas(*index, type_args) + } + Expr::LocalCall(slot, type_args, _, _) => match state.callable(*slot).cloned() { + Some(InferredCallable::Function(index, bound_args)) => context + .resolve_function_arg_schemas( + index, + if type_args.is_empty() { + &bound_args + } else { + type_args + }, + ), + _ => state.callable_schema(*slot).and_then(|schema| { + match context.resolve_schema(schema) { + TypeSchema::Callable { params, .. } => { + Some(params.into_iter().map(Some).collect()) + } + _ => None, + } + }), + }, + _ => None, + }; + for (position, arg) in args.iter().enumerate() { + collect_contextual_expr_types( arg, + expected + .as_ref() + .and_then(|params| params.get(position)) + .and_then(Option::as_ref), state, local_types, local_schemas, @@ -604,6 +1080,7 @@ fn collect_expr_types( let _ = context.infer_expr_type(expr, state); } Expr::Closure(closure) => { + context.observe_closure_origin(closure, state); if let Some(nested) = context.build_callable_state( &closure.param_slots, None, @@ -626,6 +1103,7 @@ fn collect_expr_types( } } Expr::ClosureCall(closure, args) => { + context.observe_closure_origin(closure, state); for arg in args { collect_expr_types( arg, diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index b05d771f..2bb4844c 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -112,6 +112,8 @@ pub(super) struct TypeContext<'a> { pub(super) observed_function_param_callables: HashMap>>, pub(super) observed_function_param_capture_states: HashMap>>, pub(super) observed_function_capture_states: HashMap, + pub(super) capture_origins: super::super::capture_certification::DeclarationCaptures, + pub(super) capture_owner: Option, pub(super) function_param_conflicts: HashMap, observed_return_types: HashMap<(u16, Vec), BoundType>, observed_return_schemas: HashMap<(u16, Vec), Option>, @@ -159,6 +161,8 @@ impl<'a> TypeContext<'a> { observed_function_param_callables: HashMap::new(), observed_function_param_capture_states: HashMap::new(), observed_function_capture_states: HashMap::new(), + capture_origins: Default::default(), + capture_owner: None, function_param_conflicts: HashMap::new(), observed_return_types: HashMap::new(), observed_return_schemas: HashMap::new(), @@ -712,7 +716,7 @@ impl<'a> TypeContext<'a> { || self.function_returns_optional(*index) } Expr::LocalCall(slot, _, _, _) => match state.callable(*slot) { - Some(InferredCallable::Function(index)) => { + Some(InferredCallable::Function(index, _)) => { BuiltinFunction::from_call_index(*index) == Some(BuiltinFunction::ReFind) || self.function_returns_optional(*index) } @@ -1001,6 +1005,22 @@ impl<'a> TypeContext<'a> { capture_copies: &[(LocalSlot, LocalSlot)], state: &LocalTypeState, ) { + let modes = self.function_impls.get(&index).map(|implementation| { + capture_copies + .iter() + .map(|(_, target)| { + super::super::lifetime::function_capture_binding_mode(implementation, *target) + }) + .collect::>() + }); + if let Some(modes) = modes { + self.observe_capture_origin( + super::super::capture_certification::CaptureDeclaration::Named(index), + capture_copies, + &modes, + state, + ); + } let mut observed = LocalTypeState::default(); for (source_slot, captured_slot) in capture_copies { observed.copy_binding_from(state, *source_slot, *captured_slot, None, false); @@ -1016,6 +1036,54 @@ impl<'a> TypeContext<'a> { } } + pub(super) fn observe_closure_origin(&mut self, closure: &ClosureExpr, state: &LocalTypeState) { + let modes = closure + .capture_copies + .iter() + .map(|(_, target)| { + super::super::lifetime::closure_capture_binding_mode(closure, *target) + }) + .collect::>(); + self.observe_capture_origin( + super::super::capture_certification::CaptureDeclaration::AnonymousIn( + self.capture_owner, + ), + &closure.capture_copies, + &modes, + state, + ); + } + + fn observe_capture_origin( + &mut self, + declaration: super::super::capture_certification::CaptureDeclaration, + captures: &[(LocalSlot, LocalSlot)], + modes: &[crate::CaptureBindingMode], + state: &LocalTypeState, + ) { + let facts = captures + .iter() + .zip(modes) + .map(|((source, target), mode)| { + let schema = if state.is_optional(*source) { + None + } else { + state + .schema(*source) + .cloned() + .or_else(|| super::scalar_schema_from_bound_type(state.get(*source))) + }; + super::super::capture_certification::CaptureFact { + source: *source, + target: *target, + mode: *mode, + schema, + } + }) + .collect(); + self.capture_origins.observe(declaration, facts); + } + fn callable_capture_state_from_expr( &mut self, expr: &Expr, @@ -1031,7 +1099,7 @@ impl<'a> TypeContext<'a> { state: &LocalTypeState, ) -> Option { let capture_copies = match callable { - InferredCallable::Function(index) => self + InferredCallable::Function(index, _) => self .function_impls .get(index) .map(|function_impl| function_impl.capture_copies.as_slice())?, @@ -1093,14 +1161,22 @@ impl<'a> TypeContext<'a> { .map(|schema| { schema .as_ref() - .map(|schema| self.resolve_schema(schema)) + .map(|schema| self.substitute_schema_generics(schema)) .unwrap_or(TypeSchema::Unknown) }) .collect(), result: Box::new( decl.return_schema .as_ref() - .map(|schema| self.resolve_schema(schema)) + .map(|schema| self.substitute_schema_generics(schema)) + .or_else(|| { + let schema = + self.infer_observed_function_return_schema(*index, type_args)?; + Some(logical_optional_schema( + schema, + self.function_returns_optional(*index), + )) + }) .unwrap_or(TypeSchema::Unknown), ), }; @@ -1109,10 +1185,47 @@ impl<'a> TypeContext<'a> { } Some(schema) } - Expr::Closure(closure) => Some(TypeSchema::Callable { - params: vec![TypeSchema::Unknown; closure.param_slots.len()], - result: Box::new(TypeSchema::Unknown), - }), + Expr::Closure(closure) => { + let param_schemas = closure + .param_slots + .iter() + .map(|slot| { + state.schema(*slot).cloned().map(|schema| { + if state.is_optional(*slot) + && !matches!(schema, TypeSchema::Optional(_)) + { + TypeSchema::Optional(Box::new(schema)) + } else { + schema + } + }) + }) + .collect::>(); + let nested = self.build_callable_state( + &closure.param_slots, + Some(¶m_schemas), + &closure.capture_copies, + None, + state, + )?; + let result = self + .infer_expr_schema(&closure.body, &nested) + .or_else(|| { + super::scalar_schema_from_bound_type( + self.infer_expr_type(&closure.body, &nested), + ) + }) + .unwrap_or(TypeSchema::Unknown); + let result = + logical_optional_schema(result, self.expr_is_optional(&closure.body, &nested)); + Some(TypeSchema::Callable { + params: param_schemas + .into_iter() + .map(|schema| schema.unwrap_or(TypeSchema::Unknown)) + .collect(), + result: Box::new(result), + }) + } Expr::Call(index, type_args, args, resolution, _) => { if let Some(resolved) = resolution { Some(resolved.return_type.clone()) @@ -1123,7 +1236,12 @@ impl<'a> TypeContext<'a> { } } Expr::LocalCall(slot, type_args, args, _) => match state.callable(*slot).cloned() { - Some(InferredCallable::Function(index)) => { + Some(InferredCallable::Function(index, bound_args)) => { + let type_args = if type_args.is_empty() { + &bound_args + } else { + type_args + }; self.infer_named_call_schema(index, type_args, args, state) } Some(InferredCallable::Closure(closure)) => { @@ -1355,7 +1473,12 @@ impl<'a> TypeContext<'a> { } } Expr::LocalCall(slot, type_args, args, _) => match state.callable(*slot).cloned() { - Some(InferredCallable::Function(index)) => { + Some(InferredCallable::Function(index, bound_args)) => { + let type_args = if type_args.is_empty() { + &bound_args + } else { + type_args + }; if let Some(decl) = self.function_decls.get(&index) && let inferred = infer_host_passthrough_return_type(&decl.name, args, state, self) @@ -1800,7 +1923,10 @@ impl<'a> TypeContext<'a> { self.push_generic_bindings(&function_decl.type_params, type_args); } let result = if let Some(schema) = function_decl.return_schema.as_ref() { - Some(self.resolve_schema(schema).clone_inner_if_optional()) + Some( + self.substitute_schema_generics(schema) + .clone_inner_if_optional(), + ) } else { self.function_impls .get(&index) @@ -1815,7 +1941,7 @@ impl<'a> TypeContext<'a> { )?; self.apply_stmts(&function_impl.body_stmts, &mut nested); self.infer_expr_schema(&function_impl.body_expr, &nested) - .map(|schema| self.resolve_schema(&schema)) + .map(|schema| self.substitute_schema_generics(&schema)) }) }; if !function_decl.type_params.is_empty() { @@ -1913,7 +2039,7 @@ impl<'a> TypeContext<'a> { .and_then(|mut nested| { self.apply_stmts(&function_impl.body_stmts, &mut nested); self.infer_expr_schema(&function_impl.body_expr, &nested) - .map(|schema| self.resolve_schema(&schema)) + .map(|schema| self.substitute_schema_generics(&schema)) }); if !function_decl.type_params.is_empty() { self.pop_generic_bindings(); @@ -2056,7 +2182,11 @@ impl<'a> TypeContext<'a> { let resolved = function_decl .arg_schemas .iter() - .map(|schema| schema.as_ref().map(|schema| self.resolve_schema(schema))) + .map(|schema| { + schema + .as_ref() + .map(|schema| self.substitute_schema_generics(schema)) + }) .collect::>(); if !function_decl.type_params.is_empty() { self.pop_generic_bindings(); @@ -2069,11 +2199,17 @@ impl<'a> TypeContext<'a> { callable: &InferredCallable, ) -> Option { match callable { - InferredCallable::Function(index) => { + InferredCallable::Function(index, type_args) => { let function_decl = self.function_decls.get(index).cloned()?; - if !function_decl.type_params.is_empty() { + if function_decl.type_params.len() != type_args.len() { return None; } + if !type_args.is_empty() { + return self.infer_expr_schema( + &Expr::FunctionRef(*index, type_args.clone()), + &LocalTypeState::default(), + ); + } let params = function_decl .arg_schemas .iter() @@ -2085,7 +2221,7 @@ impl<'a> TypeContext<'a> { .or_else(|| self.infer_observed_function_return_schema(*index, &[]))?; Some(TypeSchema::Callable { params, - result: Box::new(self.resolve_schema(&result)), + result: Box::new(self.substitute_schema_generics(&result)), }) } InferredCallable::Closure(_) => None, @@ -2198,7 +2334,12 @@ impl<'a> TypeContext<'a> { } } Expr::LocalCall(slot, type_args, args, _) => match state.callable(*slot).cloned() { - Some(InferredCallable::Function(index)) => { + Some(InferredCallable::Function(index, bound_args)) => { + let type_args = if type_args.is_empty() { + &bound_args + } else { + type_args + }; if let Some(builtin) = BuiltinFunction::from_call_index(index) { self.validate_builtin_argument_types( builtin, @@ -2488,7 +2629,9 @@ impl<'a> TypeContext<'a> { ) -> Option { match expr { Expr::Closure(closure) => Some(InferredCallable::Closure(closure.clone())), - Expr::FunctionRef(index, _) => Some(InferredCallable::Function(*index)), + Expr::FunctionRef(index, type_args) => { + Some(InferredCallable::Function(*index, type_args.clone())) + } Expr::Var(slot) | Expr::MoveVar(slot) => state.callable(*slot).cloned(), _ => None, } @@ -2629,11 +2772,10 @@ fn merge_observed_function_param_callable( match (current, next) { (None, rhs) => rhs, (lhs, None) => lhs, - (Some(InferredCallable::Function(lhs)), Some(InferredCallable::Function(rhs))) - if lhs == rhs => - { - Some(InferredCallable::Function(lhs)) - } + ( + Some(InferredCallable::Function(lhs, lhs_args)), + Some(InferredCallable::Function(rhs, rhs_args)), + ) if lhs == rhs && lhs_args == rhs_args => Some(InferredCallable::Function(lhs, lhs_args)), (Some(InferredCallable::Closure(lhs)), Some(InferredCallable::Closure(_))) => { Some(InferredCallable::Closure(lhs)) } @@ -2679,6 +2821,19 @@ fn schema_from_bound_type(ty: BoundType) -> Option { } } +fn logical_optional_schema(schema: TypeSchema, optional: bool) -> TypeSchema { + if optional + && !matches!( + schema, + TypeSchema::Optional(_) | TypeSchema::Unknown | TypeSchema::Null + ) + { + TypeSchema::Optional(Box::new(schema)) + } else { + schema + } +} + fn observed_return_key(index: u16, type_args: &[TypeSchema]) -> (u16, Vec) { ( index, @@ -2814,17 +2969,20 @@ pub(super) fn infer_access_schema( context: &mut TypeContext<'_>, state: &LocalTypeState, ) -> Result { + let source_schema = context.substitute_schema_generics(schema); let resolved = context.resolve_schema(schema); if resolved.array_prefix_and_rest().is_some() { let key_ty = context.infer_expr_type(key, state); if matches!(key_ty, BoundType::Unknown | BoundType::Int) { if let Some(index) = literal_int_index(key) { - return Ok(resolved + return Ok(source_schema .array_item_schema_at(index) + .or_else(|| resolved.array_item_schema_at(index)) .unwrap_or(TypeSchema::Unknown)); } - return Ok(resolved + return Ok(source_schema .collapsed_array_item_schema() + .or_else(|| resolved.collapsed_array_item_schema()) .unwrap_or(TypeSchema::Unknown)); } return Err(format!( @@ -2841,14 +2999,27 @@ pub(super) fn infer_access_schema( )), TypeSchema::Named(name, _) => Err(format!("unknown struct schema '{name}'")), TypeSchema::Object(fields) => match key { - Expr::String(name) => fields - .get(name) - .cloned() - .ok_or_else(|| format!("field '{name}' is not declared in the object schema")), + Expr::String(name) => { + let field_schema = match &source_schema { + TypeSchema::Object(source_fields) => source_fields.get(name).cloned(), + TypeSchema::Named(struct_name, type_args) => { + source_named_struct_field_schema(struct_name, type_args, name, context) + .or_else(|| fields.get(name).cloned()) + } + _ => fields.get(name).cloned(), + }; + field_schema + .ok_or_else(|| format!("field '{name}' is not declared in the object schema")) + } other => { let key_ty = context.infer_expr_type(other, state); if matches!(key_ty, BoundType::String | BoundType::Unknown) { - Ok(collapse_object_field_schema(&fields)) + match &source_schema { + TypeSchema::Object(source_fields) => { + Ok(collapse_object_field_schema(source_fields)) + } + _ => Ok(collapse_object_field_schema(&fields)), + } } else { Err(format!( "schema-typed object access requires a string field name, got {}", @@ -2879,7 +3050,10 @@ pub(super) fn infer_access_schema( )) } } - TypeSchema::Map(value) => Ok(*value), + TypeSchema::Map(value) => match source_schema { + TypeSchema::Map(source_value) => Ok(*source_value), + _ => Ok(*value), + }, other => Err(format!( "cannot access fields on schema type '{}'", schema_label(&other) @@ -2887,6 +3061,31 @@ pub(super) fn infer_access_schema( } } +fn source_named_struct_field_schema( + struct_name: &str, + type_args: &[TypeSchema], + field_name: &str, + context: &mut TypeContext<'_>, +) -> Option { + let declaration = context.struct_schemas.get(struct_name)?.clone(); + if declaration.type_params.len() != type_args.len() { + return None; + } + let TypeSchema::Object(fields) = declaration.body_schema else { + return None; + }; + if !declaration.type_params.is_empty() { + context.push_generic_bindings(&declaration.type_params, type_args); + } + let schema = fields + .get(field_name) + .map(|schema| context.substitute_schema_generics(schema)); + if !declaration.type_params.is_empty() { + context.pop_generic_bindings(); + } + schema +} + fn collapse_object_field_schema(fields: &HashMap) -> TypeSchema { let mut values = fields.values(); let Some(first) = values.next() else { @@ -3171,7 +3370,7 @@ mod tests { &empty_names, &empty_returns, &empty_signatures, - TypingMode::StrictRustScript, + TypingMode::Strict, None, ); let state = LocalTypeState::default(); @@ -3228,7 +3427,7 @@ mod tests { &empty_names, &empty_returns, &empty_signatures, - TypingMode::StrictRustScript, + TypingMode::Strict, None, ); let state = LocalTypeState::default(); @@ -3322,7 +3521,7 @@ mod tests { &empty_names, &returns, &empty_signatures, - TypingMode::StrictRustScript, + TypingMode::Strict, None, ); let state = LocalTypeState::default(); @@ -3387,7 +3586,7 @@ mod tests { &empty_names, &empty_returns, &signatures, - TypingMode::StrictRustScript, + TypingMode::Strict, None, ); let state = LocalTypeState::default(); diff --git a/src/compiler/typing/helpers.rs b/src/compiler/typing/helpers.rs index 29fea410..28a85939 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -735,6 +735,22 @@ pub(super) fn validate_stmts( context, strict_function_add_types, )?; + if matches!(expr, Expr::Closure(_)) + && let Some(schema @ TypeSchema::Callable { .. }) = declared_schema.as_ref() + { + validate_callable_expr_against_schema( + "local", + schema, + expr, + &expr_state, + DiagnosticSite { + line: Some(*line), + source_name, + span: context.stmt_span(*line), + }, + context, + )?; + } let actual_schema = inferred_expr_assignment_schema(expr, &expr_state, context); validate_declared_local_schema( declared_schema.as_ref(), @@ -787,6 +803,22 @@ pub(super) fn validate_stmts( .has_declared_schema(*index) .then(|| state.schema(*index).cloned()) .flatten(); + if matches!(expr, Expr::Closure(_)) + && let Some(schema @ TypeSchema::Callable { .. }) = declared_schema.as_ref() + { + validate_callable_expr_against_schema( + "local", + schema, + expr, + &expr_state, + DiagnosticSite { + line: Some(*line), + source_name, + span: context.stmt_span(*line), + }, + context, + )?; + } let actual_schema = inferred_expr_assignment_schema(expr, &expr_state, context); validate_declared_local_schema( declared_schema.as_ref(), @@ -1197,6 +1229,15 @@ fn find_declared_schema_mismatch_with_recursion( }; } TypeSchema::Named(name, type_args) => { + if let Some(declaration) = context.struct_schemas.get(name) + && declaration.type_params.len() != type_args.len() + { + return Some(format!( + "named type '{name}' expects {} type argument(s), got {}", + declaration.type_params.len(), + type_args.len() + )); + } let instance = render_schema_path_segment(name, type_args); let is_recursive = !recursive_named.insert(instance.clone()); let resolved = context.resolve_schema(expected); @@ -1334,6 +1375,9 @@ fn find_declared_schema_mismatch_with_recursion( for (name, expected_field) in expected_fields { let field_path = extend_schema_path(&path, name); let Some(actual_field) = actual_fields.get(name) else { + if matches!(expected_field, TypeSchema::Optional(_)) { + continue; + } return Some(format!( "field '{}' is required by the declared schema but is missing", field_path @@ -2334,12 +2378,82 @@ mod tests { &names, &host_returns, &host_sigs, - TypingMode::StrictRustScript, + TypingMode::Strict, None, ); find_declared_schema_mismatch(expected, actual, &mut context, String::new()) } + #[test] + fn omitted_optional_object_field_is_compatible() { + let expected = TypeSchema::Object(HashMap::from([ + ("count".to_string(), TypeSchema::Int), + ( + "note".to_string(), + TypeSchema::Optional(Box::new(TypeSchema::String)), + ), + ])); + let actual = TypeSchema::Object(HashMap::from([("count".to_string(), TypeSchema::Int)])); + assert_eq!(schema_mismatch(&expected, &actual), None); + } + + #[test] + fn optional_object_field_does_not_hide_missing_required_resource() { + let expected = TypeSchema::Object(HashMap::from([ + ("handle".to_string(), sqlite()), + ( + "note".to_string(), + TypeSchema::Optional(Box::new(TypeSchema::String)), + ), + ])); + let actual = TypeSchema::Object(HashMap::new()); + assert_eq!( + schema_mismatch(&expected, &actual).as_deref(), + Some("field 'handle' is required by the declared schema but is missing") + ); + } + + #[test] + fn optional_resource_object_field_accepts_absence_null_and_exact_key_only() { + let expected = TypeSchema::Object(HashMap::from([( + "handle".to_string(), + TypeSchema::Optional(Box::new(sqlite())), + )])); + assert_eq!( + schema_mismatch(&expected, &TypeSchema::Object(HashMap::new())), + None + ); + for valid in [TypeSchema::Null, sqlite()] { + let actual = TypeSchema::Object(HashMap::from([("handle".to_string(), valid)])); + assert_eq!(schema_mismatch(&expected, &actual), None); + } + for invalid in [io_file(), TypeSchema::Int] { + let actual = TypeSchema::Object(HashMap::from([("handle".to_string(), invalid)])); + let detail = schema_mismatch(&expected, &actual).expect("present resource must match"); + assert!(detail.contains("field 'handle'"), "{detail}"); + assert!(detail.contains("resource"), "{detail}"); + } + } + + #[test] + fn optional_object_field_keeps_present_nested_required_diagnostic() { + let expected = TypeSchema::Object(HashMap::from([( + "parcel".to_string(), + TypeSchema::Optional(Box::new(TypeSchema::Object(HashMap::from([( + "count".to_string(), + TypeSchema::Int, + )])))), + )])); + let actual = TypeSchema::Object(HashMap::from([( + "parcel".to_string(), + TypeSchema::Object(HashMap::new()), + )])); + assert_eq!( + schema_mismatch(&expected, &actual).as_deref(), + Some("field 'parcel.count' is required by the declared schema but is missing") + ); + } + #[test] fn resource_exact_key_is_compatible() { assert_eq!(schema_mismatch(&sqlite(), &sqlite()), None); diff --git a/src/compiler/typing/state.rs b/src/compiler/typing/state.rs index 391db46c..ef07c487 100644 --- a/src/compiler/typing/state.rs +++ b/src/compiler/typing/state.rs @@ -167,7 +167,7 @@ pub(super) fn are_compatible_bound_types(lhs: BoundType, rhs: BoundType) -> bool #[derive(Clone, Debug)] pub(super) enum InferredCallable { - Function(u16), + Function(u16, Vec), Closure(ClosureExpr), } @@ -316,7 +316,7 @@ impl LocalTypeState { } pub(crate) fn bind_function(&mut self, slot: LocalSlot, index: u16) { - self.bind_callable(slot, InferredCallable::Function(index)); + self.bind_callable(slot, InferredCallable::Function(index, Vec::new())); } pub(crate) fn bind_closure(&mut self, slot: LocalSlot, closure: &ClosureExpr) { @@ -348,6 +348,32 @@ impl LocalTypeState { } } + /// Copy checked facts without losing a callable's authored schema/optionality. + pub(super) fn copy_exact_binding_from( + &mut self, + source: &LocalTypeState, + source_slot: LocalSlot, + slot: LocalSlot, + ) { + if let Some(callable) = source.callable(source_slot).cloned() { + self.bind_callable_with_schema( + slot, + callable, + source.schema(source_slot).cloned(), + source.has_declared_schema(source_slot), + source.is_optional(source_slot), + ); + } else { + self.set_with_optional_schema_origin( + slot, + source.get(source_slot), + source.schema(source_slot).cloned(), + source.has_declared_schema(source_slot), + source.is_optional(source_slot), + ); + } + } + pub(crate) fn merge_from_branches(&mut self, lhs: &LocalTypeState, rhs: &LocalTypeState) { self.by_slot.clear(); self.schemas.clear(); @@ -376,11 +402,13 @@ impl LocalTypeState { for slot in lhs.callables.keys().chain(rhs.callables.keys()) { match (lhs.callable(*slot), rhs.callable(*slot)) { ( - Some(InferredCallable::Function(lhs_index)), - Some(InferredCallable::Function(rhs_index)), - ) if lhs_index == rhs_index => { - self.callables - .insert(*slot, InferredCallable::Function(*lhs_index)); + Some(InferredCallable::Function(lhs_index, lhs_args)), + Some(InferredCallable::Function(rhs_index, rhs_args)), + ) if lhs_index == rhs_index && lhs_args == rhs_args => { + self.callables.insert( + *slot, + InferredCallable::Function(*lhs_index, lhs_args.clone()), + ); } _ => {} } @@ -429,6 +457,28 @@ pub(crate) struct TypeInferenceResult { pub local_schema_labels: Vec>, pub callable_slots: Vec, pub optional_slots: Vec, + /// Canonical logical function contracts, separate from authored annotations. + pub function_schemas: HashMap, +} + +impl TypeInferenceResult { + pub(crate) fn logical_schemas(&self) -> Vec> { + self.local_schemas + .iter() + .enumerate() + .map(|(slot, schema)| { + schema.clone().map(|schema| { + if self.optional_slots.get(slot).copied().unwrap_or(false) + && !matches!(schema, TypeSchema::Optional(_)) + { + TypeSchema::Optional(Box::new(schema)) + } else { + schema + } + }) + }) + .collect() + } } #[derive(Clone, Debug)] diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index 2494709d..96c505c6 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -44,7 +44,7 @@ fn observe_direct_function_call_types( let function_index = match expr { Expr::Call(index, _, _, _, _) if context.function_impls.contains_key(index) => Some(*index), Expr::LocalCall(slot, _, _, _) => match state.callable(*slot).cloned() { - Some(InferredCallable::Function(index)) + Some(InferredCallable::Function(index, _)) if context.function_impls.contains_key(&index) => { Some(index) @@ -1285,6 +1285,35 @@ fn validate_optional_get_access( return Ok(()); }; let span = semantic_id.and_then(|id| context.node_span(id)); + if let Expr::OptionalGet { + field_projection: true, + .. + } = expr + && (!matches!(container.as_ref(), Expr::Var(_)) + || !matches!(key.as_ref(), Expr::String(_) | Expr::Int(_))) + { + return Err(CompileError::InvalidFieldAccess { + line: line_context, + source_name: owned_source_name(source_name), + detail: "optional field projection requires a direct local and a literal key" + .to_string(), + span, + }); + } + if let Expr::OptionalGet { + field_projection: true, + .. + } = expr + { + ensure_expr_not_optional( + container, + state, + line_context, + source_name, + context, + "field projection receiver", + )?; + } if context.is_strict() && !context.expr_has_declared_schema(container, state) { return Err(CompileError::InvalidFieldAccess { line: line_context, diff --git a/src/lib.rs b/src/lib.rs index 4081914d..aca9b61d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,25 +112,34 @@ pub use compiler::diagnostics::{ pub use compiler::source_map::{LineSpanMapping, LoweredSource, SourceId, SourceMap, Span}; pub use compiler::{ AssignmentKind, ClosureExpr, CompileError, CompileSourceFileOptions, CompiledProgram, - CompiledReplProgram, Compiler, CompletionItemKind, DeclSymbol, Definition, ExportEntry, Expr, - FormatError, FrontendImportSyntax, FrontendIr, FunctionDecl, ImportClause, ImportTargetKind, - ImportedBinding, InferredLocalTypeHint, LocalIrBuilder, LocalSlot, ModuleGraph, ModuleId, - ModuleImport, ModuleNode, NamedImport, ParseError, ParserDialect, ReplLocalBinding, - ReplLocalState, ResolvedImport, SemanticCompletion, SemanticDiagnostic, SemanticModel, - SharedParserOptions, SourceError, SourceFlavor, SourcePathError, SourcePlugin, SourcePosition, - Stmt, SymbolId, TypeSchema, UnknownInferredLocal, UseDecl, UsePathSegment, analyze_source, - analyze_source_from_string_with_options, analyze_source_with_flavor, + CompiledReplProgram, Compiler, CompletionItemKind, DeclSymbol, DeclarationNamespace, + Definition, ExportEntry, Expr, FormatError, FrontendId, FrontendImportedDeclaration, + FrontendIr, FrontendTypeExport, FrontendTypedIr, FrontendTypingPolicy, FunctionDecl, + GuestTypeProvenance, ImportClause, ImportTargetKind, ImportedBinding, InferredLocalTypeHint, + LocalIrBuilder, LocalSlot, ModuleGraph, ModuleId, ModuleImport, ModuleNode, NamedImport, + ParseError, ParsePhase, ParserDialect, ReplLocalBinding, ReplLocalState, ResolvedImport, + SemanticCompletion, SemanticDiagnostic, SemanticModel, SharedParserOptions, SourceError, + SourceFlavor, SourceFrontendContext, SourceFrontendDescriptor, SourcePathError, SourcePlugin, + SourcePosition, Stmt, SymbolId, TypeSchema, UnknownInferredLocal, UseDecl, UsePathSegment, + analyze_source, analyze_source_from_string_with_options, analyze_source_with_flavor, collect_inferred_local_type_hints, collect_inferred_local_type_hints_at_path_with_options, collect_inferred_local_type_hints_with_options, compile_source, compile_source_at_path_with_flavor_and_options, compile_source_file, compile_source_file_with_options, compile_source_for_repl, compile_source_for_repl_with_locals, - compile_source_for_repl_with_state, compile_source_with_flavor, - compile_source_with_flavor_and_options, format_source, format_source_with_flavor, - format_source_with_flavor_and_options, lint_trailing_function_return_semicolons, - lint_unknown_inferred_local_types, lint_unknown_inferred_local_types_at_path_with_options, + compile_source_for_repl_with_state, compile_source_for_repl_with_state_and_options, + compile_source_with_flavor, compile_source_with_flavor_and_options, format_source, + format_source_with_flavor, format_source_with_flavor_and_options, + lint_trailing_function_return_semicolons, lint_unknown_inferred_local_types, + lint_unknown_inferred_local_types_at_path_with_options, lint_unknown_inferred_local_types_with_options, lint_unknown_type_annotations, parse_source_with_dialect, }; +pub use compiler::{ + CertifiedCompilation, compile_source_file_with_capture_certification, + compile_source_file_with_capture_certification_and_options, + compile_source_with_capture_certification, + compile_source_with_flavor_and_options_with_capture_certification, +}; pub use debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; #[cfg(feature = "runtime")] pub use debugger::{ @@ -158,7 +167,8 @@ pub use vm::host_extension::{ pub use vm::resource::{Resource, ResourceHandle, ResourceMut, ResourceOwned, ResourceRef}; #[cfg(feature = "runtime")] pub use vm::{ - AotArtifactError, CallOutcome, CallReturn, CapabilityProfile, CapabilityProfileBuilder, + AotArtifactError, CallOutcome, CallReturn, CallableIdentityMaintenanceConfig, + CallableIdentityMaintenanceStats, CapabilityProfile, CapabilityProfileBuilder, CaptureAsyncHostContext, CatalogRegistrationError, CatalogSchemaSelection, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostAsyncOpTerminal, HostBindingPlan, HostContext, HostContextError, @@ -176,6 +186,11 @@ pub use vm::{ validate_catalog_import_schemas, validate_catalog_import_schemas_with_fingerprints, }; #[cfg(feature = "runtime")] +pub use vm::{ + CaptureCertificationStats, CaptureQueryError, CaptureRootState, CheckedResourceCapture, + ResourceCaptureRoot, +}; +#[cfg(feature = "runtime")] pub use vmbc::{ DisassembleOptions, ValidationError, WireError, decode_program, disassemble_program, disassemble_program_with_options, disassemble_vmbc, disassemble_vmbc_with_options, diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index 13711b07..b08302e6 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -8,7 +8,7 @@ use crate::vm::native::{ use crate::vm::{Vm, VmError}; use super::super::jit::JitConfig; -use super::compile::CompiledProgram; +use super::compile::{CompiledProgram, compile_artifact_interpreter_boundary, compile_program}; const MAGIC: [u8; 4] = *b"PAT\0"; const VERSION: u16 = 8; @@ -129,13 +129,19 @@ impl Vm { } pub fn load_aot_artifact(&mut self, bytes: &[u8]) -> Result<(), AotArtifactError> { - let expected_program_hash = self.ensure_program_cache_key(); - let decoded = decode_artifact(bytes, Some(expected_program_hash))?; - let compiled = if decoded.interpreter_boundary_only { - CompiledProgram::from_interpreter_boundary_code(decoded.code, decoded.resume_ips)? + // Do not populate even the cache-key fields until materialization has + // succeeded: a rejected artifact must leave the entire engine intact. + let expected_program_hash = if self.engine.program_cache_key_ready { + self.engine.program_cache_key } else { - CompiledProgram::from_code(decoded.code, decoded.resume_ips)? + super::super::compute_program_cache_key(self.program()) }; + let decoded = decode_artifact(bytes, Some(expected_program_hash))?; + let compiled = materialize_native_program(&decoded)?; + // Rebuilding native code from persisted metadata issues no source + // certificate and cannot authenticate one from a cache key alone. + self.revoke_capture_certification(); + self.ensure_program_cache_key(); self.engine.aot_program = Some(compiled); self.engine.aot_exec_count = 0; Ok(()) @@ -154,12 +160,8 @@ impl Vm { jit_config: JitConfig, ) -> Result { let decoded = decode_artifact(bytes, None)?; + let compiled = materialize_native_program(&decoded)?; let mut vm = Vm::new_with_jit_config(decoded.program, jit_config); - let compiled = if decoded.interpreter_boundary_only { - CompiledProgram::from_interpreter_boundary_code(decoded.code, decoded.resume_ips)? - } else { - CompiledProgram::from_code(decoded.code, decoded.resume_ips)? - }; vm.engine.aot_program = Some(compiled); vm.engine.aot_exec_count = 0; Ok(vm) @@ -176,11 +178,36 @@ impl Vm { struct DecodedArtifact { program: Program, - code: Vec, - resume_ips: Vec, + // Keep decoding the v8 payload in full for wire validation. These bytes + // contain writer-process helper addresses and must never be executable. + _code: Vec, + _resume_ips: Vec, interpreter_boundary_only: bool, } +fn materialize_native_program( + decoded: &DecodedArtifact, +) -> Result { + // Preserve the executable-buffer length rejection without mapping any + // persisted bytes. The full decoder validation has already completed. + #[cfg(any(windows, unix))] + if decoded._code.is_empty() { + return Err(AotArtifactError::Vm(VmError::JitNative( + "native trace produced empty machine code".to_string(), + ))); + } + if decoded.interpreter_boundary_only { + return compile_artifact_interpreter_boundary().map_err(AotArtifactError::Vm); + } + let compiled = compile_program(&decoded.program)?; + if compiled.interpreter_boundary_only { + return Err(AotArtifactError::Vm(VmError::JitNative( + "native AOT artifact rebuild produced an interpreter boundary".to_string(), + ))); + } + Ok(compiled) +} + fn encode_artifact( source_program: &Program, aot_program: &CompiledProgram, @@ -339,8 +366,8 @@ fn decode_artifact( Ok(DecodedArtifact { program, - code, - resume_ips, + _code: code, + _resume_ips: resume_ips, interpreter_boundary_only, }) } @@ -461,6 +488,465 @@ mod tests { )))) } + #[cfg(not(feature = "cranelift-jit"))] + #[test] + fn artifact_disabled_backend_rejects_both_modes_without_installation() { + let mut bc = BytecodeBuilder::new(); + bc.ret(); + let program = Program::new(Vec::new(), bc.finish()); + // Process-local storage is permitted; these fixture bytes are never run. + let mut native = CompiledProgram::from_code(vec![0], vec![0]).unwrap(); + for boundary in [false, true] { + native.interpreter_boundary_only = boundary; + let bytes = encode_artifact( + &program, + &native, + super::super::super::compute_program_cache_key(&program), + ) + .unwrap(); + let mut vm = Vm::new(program.clone()); + assert!( + matches!(vm.load_aot_artifact(&bytes), Err(AotArtifactError::Vm(VmError::JitNative(message))) if message.contains("disabled")) + ); + assert!(!vm.has_aot_program()); + assert!(!vm.engine.program_cache_key_ready); + assert_eq!(vm.aot_exec_count(), 0); + assert!( + matches!(Vm::new_from_aot_artifact_with_jit_config(&bytes, JitConfig::default()), Err(AotArtifactError::Vm(VmError::JitNative(message))) if message.contains("disabled")) + ); + } + } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_same_process_callable_and_fuel_match_source_native() { + let compiled = + crate::compile_source_for_repl("pub fn add_one(value: int) -> int { value + 1 }") + .unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + for fuel in [0, 1, 100000] { + let mut source = Vm::new(program.clone()); + source.compile_aot().unwrap(); + let bytes = source.encode_aot_artifact().unwrap(); + let mut companion = Vm::new(program.clone()); + companion.load_aot_artifact(&bytes).unwrap(); + let standalone = Vm::new_from_aot_artifact_with_jit_config( + &bytes, + JitConfig { + enabled: false, + ..Default::default() + }, + ) + .unwrap(); + let mut observations = Vec::new(); + for mut vm in [source, companion, standalone] { + assert_eq!(vm.aot_exec_count(), 0); + vm.set_fuel(fuel); + let root = vm.run().unwrap(); + let callable = vm.resolve_exported_callable("add_one").unwrap(); + let result = vm.invoke_callable(callable, &[Value::Int(41)]); + observations.push(( + root, + format!("{result:?}"), + vm.get_fuel(), + vm.aot_exec_count(), + )); + assert!( + !vm.engine + .aot_program + .as_ref() + .unwrap() + .interpreter_boundary_only + ); + assert_eq!( + std::sync::Arc::strong_count(&vm.engine.aot_program.as_ref().unwrap().code), + 1 + ); + } + assert_eq!(observations[0], observations[1], "companion fuel={fuel}"); + assert_eq!(observations[0], observations[2], "standalone fuel={fuel}"); + if fuel == 100000 { + assert_eq!(observations[0].1, "Ok(Int(42))"); + assert!(observations[0].3 > 0); + } + } + } + + #[cfg(feature = "cranelift-jit")] + fn yield_once(vm: &mut Vm, _args: &[Value]) -> VmResult { + let calls = vm + .host_context() + .module_state::() + .copied() + .unwrap_or(0); + vm.host_context().set_module_state(calls + 1); + if calls == 0 { + Ok(CallOutcome::Yield) + } else { + Ok(CallOutcome::Return(CallReturn::one(Value::Int(42)))) + } + } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_native_host_yield_resumes_with_existing_replay_semantics() { + let compiled = crate::compile_source_for_repl( + "fn pause() -> int; fn body() -> int { pause() } body();", + ) + .unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + let mut source = Vm::new(program.clone()); + source.compile_aot().unwrap(); + let bytes = source.encode_aot_artifact().unwrap(); + let mut companion = Vm::new(program); + companion.load_aot_artifact(&bytes).unwrap(); + let standalone = Vm::new_from_aot_artifact_with_jit_config( + &bytes, + JitConfig { + enabled: false, + ..Default::default() + }, + ) + .unwrap(); + let mut counts = Vec::new(); + for mut vm in [source, companion, standalone] { + vm.bind_static_stack_function("pause", yield_once); + vm.set_fuel(100000); + assert_eq!(vm.run().unwrap(), VmStatus::Yielded); + assert_eq!(vm.last_yield_reason(), Some(crate::VmYieldReason::Host)); + assert_eq!(vm.resume().unwrap(), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert_eq!(vm.host_context().module_state::(), Some(&2)); + counts.push(vm.aot_exec_count()); + } + assert!(counts[0] > 0); + assert_eq!(counts[0], counts[1]); + assert_eq!(counts[0], counts[2]); + } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_runtime_error_matches_native_source_without_claiming_interpreter_parity() { + let compiled = crate::compile_source_for_repl( + "let numerator = 1; let denominator = 0; numerator / denominator;", + ) + .unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + let mut source = Vm::new(program.clone()); + source.compile_aot().unwrap(); + let bytes = source.encode_aot_artifact().unwrap(); + let mut companion = Vm::new(program.clone()); + companion.load_aot_artifact(&bytes).unwrap(); + let standalone = + Vm::new_from_aot_artifact_with_jit_config(&bytes, JitConfig::default()).unwrap(); + let mut errors = Vec::new(); + for mut vm in [source, companion, standalone] { + vm.set_fuel(100000); + errors.push(vm.run().unwrap_err().to_string()); + assert_eq!( + vm.aot_exec_count(), + 1, + "native error status returned once; no successful guest result" + ); + } + assert_eq!(errors[0], errors[1]); + assert_eq!(errors[0], errors[2]); + let mut interpreted = Vm::new_with_jit_config( + program, + JitConfig { + enabled: false, + ..Default::default() + }, + ); + interpreted.set_fuel(100000); + assert!(matches!(interpreted.run(), Err(VmError::DivisionByZero))); + eprintln!( + "KNOWN native error={}; interpreter error={}", + errors[0], + VmError::DivisionByZero + ); + } + + #[cfg(feature = "cranelift-jit")] + fn native_fixture() -> (Program, CompiledProgram) { + let compiled = crate::compile_source_for_repl("let value = 1 + 2; value;").unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + let native = compile_program(&program).unwrap(); + assert!(!native.interpreter_boundary_only); + (program, native) + } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_empty_native_payload_keeps_legacy_rejection_in_both_modes() { + let (program, mut native) = native_fixture(); + native.code = std::sync::Arc::from([]); + for boundary in [false, true] { + native.interpreter_boundary_only = boundary; + let bytes = encode_artifact( + &program, + &native, + super::super::super::compute_program_cache_key(&program), + ) + .unwrap(); + assert!(decode_artifact(&bytes, None).is_ok()); + let mut vm = Vm::new(program.clone()); + assert!(matches!(vm.load_aot_artifact(&bytes), + Err(AotArtifactError::Vm(VmError::JitNative(message))) if message == "native trace produced empty machine code")); + assert!( + matches!(Vm::new_from_aot_artifact_with_jit_config(&bytes, JitConfig::default()), + Err(AotArtifactError::Vm(VmError::JitNative(message))) if message == "native trace produced empty machine code") + ); + assert!(!vm.has_aot_program()); + assert!(!vm.engine.program_cache_key_ready); + assert_eq!(vm.aot_exec_count(), 0); + } + } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_rebuild_failure_preserves_engine_state_and_source_authority() { + // This is the remaining unsupported dynamic Number addition boundary, + // not a synthetic failure marker. Its valid Program survives wire decoding. + let issued = crate::compile_source_with_flavor_and_options_with_capture_certification( + "fn successor(n: number) -> number { n + 1 } successor(6);", + crate::SourceFlavor::RustScript, + crate::CompileSourceFileOptions::new(), + ) + .unwrap(); + let mut vm = issued.into_vm().unwrap(); + assert!(matches!( + compile_program(vm.program()), + Err(VmError::JitNative(_)) + )); + vm.set_fuel(100000); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let (_, native) = native_fixture(); + let bytes = encode_artifact( + vm.program(), + &native, + super::super::super::compute_program_cache_key(vm.program()), + ) + .unwrap(); + assert!(decode_artifact(&bytes, None).is_ok()); + // A previously installed real boundary engine must remain owned on error. + vm.engine.aot_program = Some(compile_artifact_interpreter_boundary().unwrap()); + let entry = vm.engine.aot_program.as_ref().unwrap().entry as usize; + let program = vm.program.clone(); + let ip = vm.instance.ip; + let stack = vm.stack().to_vec(); + let locals = vm.instance.locals.clone(); + let cert = vm.capture_certification_stats().unwrap(); + let cache = ( + vm.engine.program_cache_key, + vm.engine.program_cache_key_ready, + ); + let native_count = vm.aot_exec_count(); + let fuel = vm.fuel_checkpoint(); + let mut incompatible = bytes.clone(); + incompatible[10] ^= 0x40; + assert!(matches!( + vm.load_aot_artifact(&incompatible), + Err(AotArtifactError::IncompatibleRuntime { + field: "pointer width", + .. + }) + )); + assert_eq!(vm.capture_certification_stats().unwrap(), cert); + assert_eq!( + vm.engine.aot_program.as_ref().unwrap().entry as usize, + entry + ); + let error = vm.load_aot_artifact(&bytes).unwrap_err(); + assert!( + matches!(error, AotArtifactError::Vm(VmError::JitNative(_))), + "{error}" + ); + assert!(std::sync::Arc::ptr_eq(&vm.program, &program)); + assert_eq!( + vm.engine.aot_program.as_ref().unwrap().entry as usize, + entry + ); + assert_eq!(vm.instance.ip, ip); + assert_eq!(vm.stack(), stack); + assert_eq!(vm.instance.locals, locals); + assert_eq!(vm.capture_certification_stats().unwrap(), cert); + assert_eq!( + ( + vm.engine.program_cache_key, + vm.engine.program_cache_key_ready + ), + cache + ); + assert_eq!(vm.aot_exec_count(), native_count); + assert_eq!(vm.fuel_checkpoint(), fuel); + assert!(matches!( + Vm::new_from_aot_artifact_with_jit_config(&bytes, JitConfig::default()), + Err(AotArtifactError::Vm(VmError::JitNative(_))) + )); + } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_explicit_boundary_regenerates_ignoring_persisted_instructions() { + let (program, mut boundary) = native_fixture(); + // Legacy flag1 can carry SSA bytes, but its declared boundary semantics + // must be regenerated. These same-process bytes are safe as a control. + let persisted_code = boundary.code_bytes().to_vec(); + boundary.interpreter_boundary_only = true; + let bytes = encode_artifact( + &program, + &boundary, + super::super::super::compute_program_cache_key(&program), + ) + .unwrap(); + let mut companion = Vm::new(program); + companion.load_aot_artifact(&bytes).unwrap(); + let standalone = Vm::new_from_aot_artifact_with_jit_config( + &bytes, + JitConfig { + enabled: false, + ..Default::default() + }, + ) + .unwrap(); + for mut vm in [companion, standalone] { + assert!( + vm.engine + .aot_program + .as_ref() + .unwrap() + .interpreter_boundary_only + ); + assert_ne!( + vm.engine.aot_program.as_ref().unwrap().code_bytes(), + persisted_code + ); + vm.set_fuel(100000); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(3)]); + assert_eq!( + vm.aot_exec_count(), + 1, + "only the regenerated boundary stub returns natively" + ); + } + } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_flag_zero_rejects_real_oversized_boundary_rebuild() { + let (mut program, native) = native_fixture(); + // The real compiler budget includes frame locals in block parameters. + // Exercise its actual boundary result without replacing compiler logic. + program.local_count = 1_000_001; + let rebuilt = compile_program(&program).unwrap(); + assert!(rebuilt.interpreter_boundary_only); + let bytes = encode_artifact( + &program, + &native, + super::super::super::compute_program_cache_key(&program), + ) + .unwrap(); + assert!( + matches!(Vm::new_from_aot_artifact_with_jit_config(&bytes, JitConfig::default()), + Err(AotArtifactError::Vm(VmError::JitNative(message))) if message.contains("interpreter boundary")) + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.load_aot_artifact(&bytes), + Err(AotArtifactError::Vm(VmError::JitNative(_))) + )); + assert!(!vm.has_aot_program()); + assert!(!vm.engine.program_cache_key_ready); + assert_eq!(vm.aot_exec_count(), 0); + } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_validation_precedes_rebuild_and_preserves_uninitialized_cache() { + let (program, native) = native_fixture(); + let bytes = encode_artifact( + &program, + &native, + super::super::super::compute_program_cache_key(&program), + ) + .unwrap(); + let mut cursor = Cursor::new(&bytes); + cursor.read_exact(11).unwrap(); + let mut runtime_offsets = vec![(10, "pointer width")]; + for field in ["arch", "os", "backend"] { + let len = cursor.read_u32().unwrap() as usize; + runtime_offsets.push((cursor.offset, field)); + cursor.read_exact(len).unwrap(); + } + for field in [ + "vm ip offset", + "native helper offset", + "interrupt helper offset", + ] { + runtime_offsets.push((cursor.offset, field)); + cursor.read_u32().unwrap(); + } + let integrity_offset = cursor.offset; + cursor.read_u64().unwrap(); + cursor.read_u32().unwrap(); + let program_length_offset = cursor.offset; + let len = cursor.read_u32().unwrap() as usize; + cursor.read_exact(len).unwrap(); + let resumes = cursor.read_u32().unwrap() as usize; + cursor.read_exact(resumes * 4).unwrap(); + let code_length_offset = cursor.offset; + let mut vm = Vm::new(program); + for (offset, field) in runtime_offsets { + let mut bad = bytes.clone(); + bad[offset] ^= 0x40; + assert!( + matches!(vm.load_aot_artifact(&bad), Err(AotArtifactError::IncompatibleRuntime { field: found, .. }) if found == field) + ); + assert!(matches!( + Vm::new_from_aot_artifact_with_jit_config(&bad, JitConfig::default()), + Err(AotArtifactError::IncompatibleRuntime { .. }) + )); + } + let mut invalid_flags = bytes.clone(); + invalid_flags[8..10].copy_from_slice(&2u16.to_le_bytes()); + assert!(matches!( + vm.load_aot_artifact(&invalid_flags), + Err(AotArtifactError::UnsupportedFlags(2)) + )); + let mut integrity = bytes.clone(); + integrity[integrity_offset] ^= 1; + assert!(matches!( + vm.load_aot_artifact(&integrity), + Err(AotArtifactError::EmbeddedProgramHashMismatch { .. }) + )); + let mut missing = bytes.clone(); + missing[program_length_offset..program_length_offset + 4].fill(0); + assert!(matches!( + vm.load_aot_artifact(&missing), + Err(AotArtifactError::Wire(_)) + )); + let mut oversized = bytes.clone(); + oversized[code_length_offset..code_length_offset + 4] + .copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!( + vm.load_aot_artifact(&oversized), + Err(AotArtifactError::UnexpectedEof) + )); + for end in 0..bytes.len() { + assert!( + vm.load_aot_artifact(&bytes[..end]).is_err(), + "truncation {end}" + ); + } + assert!(!vm.engine.program_cache_key_ready); + assert!(!vm.has_aot_program()); + assert_eq!(vm.aot_exec_count(), 0); + assert!(vm.stack().is_empty()); + assert_eq!(vm.instance.ip, 0); + } + #[test] fn aot_artifact_preserves_interpreter_boundary_mode() { let mut bc = BytecodeBuilder::new(); diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index a296f35f..2c9d603f 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -23,12 +23,12 @@ use crate::vm::native::{ enter_call_value_signature, entry_signature, frame_state_entry_address, frame_state_signature, free_buffer_signature, helper_entry_offset, helper_signature, init_null_value_slot_entry_address, jump_with_status, leave_frame_entry_address, - leave_frame_signature, pack_shared_signature, resolve_offsets, + leave_frame_signature, note_local_write_entry_address, pack_shared_signature, resolve_offsets, restore_active_exit_state_entry_address, restore_exit_signature, restore_exit_state_entry_address, shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, - value_eq_entry_address, value_eq_signature, value_slot_signature, - write_heap_value_to_slot_entry_address, zero_bytes_entry_address, + synchronize_active_exit_state_entry_address, value_eq_entry_address, value_eq_signature, + value_slot_signature, write_heap_value_to_slot_entry_address, zero_bytes_entry_address, }; use crate::vm::{Program, Value, Vm, VmError, VmResult}; #[cfg(feature = "cranelift-jit")] @@ -181,6 +181,19 @@ pub(crate) fn compile_program(program: &Program) -> VmResult { compile_program_inner(program).map_err(|err| VmError::JitNative(err.to_string())) } +// Artifact flag1 explicitly requests the existing boundary mode; regenerate +// its process-local stub without making ordinary flag0 rebuilds fall back. +pub(crate) fn compile_artifact_interpreter_boundary() -> VmResult { + #[cfg(feature = "cranelift-jit")] + { + compile_interpreter_boundary_program().map_err(|err| VmError::JitNative(err.to_string())) + } + #[cfg(not(feature = "cranelift-jit"))] + { + compile_program(&Program::new(Vec::new(), Vec::new())) + } +} + #[cfg(feature = "cranelift-jit")] fn compile_program_inner(program: &Program) -> Result { let ssa = build_aot_ssa(program)?; @@ -293,6 +306,7 @@ struct AotDeoptHelperRefs { array_push_ref: cranelift_codegen::ir::SigRef, collection_set_ref: cranelift_codegen::ir::SigRef, restore_exit_ref: cranelift_codegen::ir::SigRef, + local_write_ref: cranelift_codegen::ir::SigRef, } #[cfg(feature = "cranelift-jit")] @@ -311,6 +325,7 @@ struct AotDeoptHelperAddrs { array_push: usize, collection_set: usize, restore_exit: usize, + local_write: usize, } #[cfg(feature = "cranelift-jit")] @@ -479,7 +494,8 @@ fn compile_ssa(program: &Program, ssa: &AotSsaProgram) -> Result Result { + let local = b.ins().iconst(pointer_type, i64::from(*index)); + call_status_helper( + b, + ctx.exit_block, + pointer_type, + ctx.helper_refs.local_write_ref, + ctx.helper_addrs.local_write, + &[vm_ptr, local], + )?; + b.ins().iconst(types::I8, 0) + } AotSsaInstKind::IntConst(value) => b.ins().iconst(types::I64, *value), AotSsaInstKind::FloatConst(value) => b.ins().f64const(*value), AotSsaInstKind::BoolConst(value) => b.ins().iconst(types::I8, i64::from(*value as u8)), @@ -1100,6 +1130,21 @@ fn lower_aot_ssa_inst( value_reprs, )?, AotSsaInstKind::IntAdd { lhs, rhs } => b.ins().iadd(values[lhs], values[rhs]), + AotSsaInstKind::TaggedNumberSub { lhs, rhs } + | AotSsaInstKind::TaggedNumberMul { lhs, rhs } => { + let lhs = aot_box_numeric_operand(b, ctx, values[lhs], value_reprs[lhs])?; + let rhs = aot_box_numeric_operand(b, ctx, values[rhs], value_reprs[rhs])?; + aot_lower_tagged_number_arithmetic( + b, + ctx, + AotBinaryOp { + ip: inst.ip, + lhs, + rhs, + }, + matches!(inst.kind, AotSsaInstKind::TaggedNumberMul { .. }), + )? + } AotSsaInstKind::IntSub { lhs, rhs } => b.ins().isub(values[lhs], values[rhs]), AotSsaInstKind::IntMul { lhs, rhs } => b.ins().imul(values[lhs], values[rhs]), AotSsaInstKind::IntDiv { lhs, rhs } => aot_lower_int_divrem( @@ -1171,6 +1216,21 @@ fn lower_aot_ssa_inst( AotSsaInstKind::BoolCmpEq { lhs, rhs } => { b.ins().icmp(IntCC::Equal, values[lhs], values[rhs]) } + AotSsaInstKind::TaggedNumberCmpLt { lhs, rhs } + | AotSsaInstKind::TaggedNumberCmpGt { lhs, rhs } => { + let lhs = aot_box_numeric_operand(b, ctx, values[lhs], value_reprs[lhs])?; + let rhs = aot_box_numeric_operand(b, ctx, values[rhs], value_reprs[rhs])?; + aot_lower_tagged_number_compare( + b, + ctx, + AotBinaryOp { + ip: inst.ip, + lhs, + rhs, + }, + matches!(inst.kind, AotSsaInstKind::TaggedNumberCmpLt { .. }), + )? + } AotSsaInstKind::TaggedCmpEq { lhs, rhs } => aot_lower_tagged_eq( b, ctx, @@ -2171,6 +2231,166 @@ fn aot_lower_int_divrem( b.block_params(cont)[0] } +#[cfg(feature = "cranelift-jit")] +fn aot_box_numeric_operand( + b: &mut FunctionBuilder, + ctx: AotLowerCtx<'_>, + value: cranelift_codegen::ir::Value, + repr: AotSsaValueRepr, +) -> Result { + if repr == AotSsaValueRepr::Tagged { + return Ok(value); + } + let slot = alloc_single_value_slot(b, ctx.pointer_type, ctx.layout.value.size)?; + match repr { + AotSsaValueRepr::I64 => ssa_store_int_in_value(b, ctx.layout.value, slot, value), + AotSsaValueRepr::F64 => ssa_store_float_in_value(b, ctx.layout.value, slot, value), + _ => { + return Err(AotCompileError::Codegen( + "non-numeric operand representation".into(), + )); + } + } + Ok(slot) +} + +#[cfg(feature = "cranelift-jit")] +fn aot_tagged_number_dispatch( + b: &mut FunctionBuilder, + ctx: AotLowerCtx<'_>, + op: AotBinaryOp, +) -> Result<(Block, Block), AotCompileError> { + let AotBinaryOp { ip, lhs, rhs } = op; + let layout = ctx.layout.value; + let lhs_tag = ssa_load_tag_i32(b, layout, lhs); + let rhs_tag = ssa_load_tag_i32(b, layout, rhs); + let lhs_int = b + .ins() + .icmp_imm(IntCC::Equal, lhs_tag, i64::from(layout.int_tag)); + let rhs_int = b + .ins() + .icmp_imm(IntCC::Equal, rhs_tag, i64::from(layout.int_tag)); + let lhs_float = b + .ins() + .icmp_imm(IntCC::Equal, lhs_tag, i64::from(layout.float_tag)); + let rhs_float = b + .ins() + .icmp_imm(IntCC::Equal, rhs_tag, i64::from(layout.float_tag)); + let lhs_numeric = b.ins().bor(lhs_int, lhs_float); + let rhs_numeric = b.ins().bor(rhs_int, rhs_float); + let valid = b.ins().band(lhs_numeric, rhs_numeric); + let dispatch = b.create_block(); + let fail = b.create_block(); + let ints = b.create_block(); + let floats = b.create_block(); + b.ins().brif(valid, dispatch, &[], fail, &[]); + + b.switch_to_block(fail); + aot_emit_error_status( + b, + ctx.vm_ptr, + ctx.exit_block, + ctx.pointer_type, + ctx.offsets, + ip, + )?; + + b.switch_to_block(dispatch); + let both_int = b.ins().band(lhs_int, rhs_int); + b.ins().brif(both_int, ints, &[], floats, &[]); + Ok((ints, floats)) +} + +#[cfg(feature = "cranelift-jit")] +fn aot_lower_tagged_number_arithmetic( + b: &mut FunctionBuilder, + ctx: AotLowerCtx<'_>, + op: AotBinaryOp, + is_mul: bool, +) -> Result { + let AotBinaryOp { ip, lhs, rhs } = op; + let layout = ctx.layout.value; + // The result can only hold Int or Float, so this private scratch Value + // has no resource ownership to initialize, clone, or release. + let out = alloc_single_value_slot(b, ctx.pointer_type, layout.size)?; + let (ints, floats) = aot_tagged_number_dispatch(b, ctx, op)?; + let done = b.create_block(); + + b.switch_to_block(ints); + let lhs_value = b + .ins() + .load(types::I64, MemFlags::new(), lhs, layout.int_payload_offset); + let rhs_value = b + .ins() + .load(types::I64, MemFlags::new(), rhs, layout.int_payload_offset); + let result = if is_mul { + b.ins().imul(lhs_value, rhs_value) + } else { + b.ins().isub(lhs_value, rhs_value) + }; + ssa_store_int_in_value(b, layout, out, result); + b.ins().jump(done, &[]); + + b.switch_to_block(floats); + let lhs_value = aot_lower_tagged_number_to_float(b, ctx, AotTaggedValueOp { ip, value: lhs })?; + let rhs_value = aot_lower_tagged_number_to_float(b, ctx, AotTaggedValueOp { ip, value: rhs })?; + let result = if is_mul { + b.ins().fmul(lhs_value, rhs_value) + } else { + b.ins().fsub(lhs_value, rhs_value) + }; + ssa_store_float_in_value(b, layout, out, result); + b.ins().jump(done, &[]); + + b.switch_to_block(done); + Ok(out) +} + +#[cfg(feature = "cranelift-jit")] +fn aot_lower_tagged_number_compare( + b: &mut FunctionBuilder, + ctx: AotLowerCtx<'_>, + op: AotBinaryOp, + is_lt: bool, +) -> Result { + let AotBinaryOp { ip, lhs, rhs } = op; + let layout = ctx.layout.value; + let (ints, floats) = aot_tagged_number_dispatch(b, ctx, op)?; + let done = b.create_block(); + b.append_block_param(done, types::I8); + + // Payloads are read only after both existing tags have been checked. + // Int/Int must not lose precision through an f64 conversion. + b.switch_to_block(ints); + let lhs_value = b + .ins() + .load(types::I64, MemFlags::new(), lhs, layout.int_payload_offset); + let rhs_value = b + .ins() + .load(types::I64, MemFlags::new(), rhs, layout.int_payload_offset); + let cc = if is_lt { + IntCC::SignedLessThan + } else { + IntCC::SignedGreaterThan + }; + let result = b.ins().icmp(cc, lhs_value, rhs_value); + b.ins().jump(done, &[BlockArg::Value(result)]); + + b.switch_to_block(floats); + let lhs_value = aot_lower_tagged_number_to_float(b, ctx, AotTaggedValueOp { ip, value: lhs })?; + let rhs_value = aot_lower_tagged_number_to_float(b, ctx, AotTaggedValueOp { ip, value: rhs })?; + let cc = if is_lt { + cranelift_codegen::ir::condcodes::FloatCC::LessThan + } else { + cranelift_codegen::ir::condcodes::FloatCC::GreaterThan + }; + let result = b.ins().fcmp(cc, lhs_value, rhs_value); + b.ins().jump(done, &[BlockArg::Value(result)]); + + b.switch_to_block(done); + Ok(b.block_params(done)[0]) +} + #[cfg(feature = "cranelift-jit")] fn aot_lower_tagged_eq( b: &mut FunctionBuilder, diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index 3f427c0b..9209b8e3 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -104,6 +104,10 @@ pub(crate) struct AotSsaBlockParam { #[derive(Clone, Debug, PartialEq)] pub(crate) enum AotSsaInstKind { + // Private semantic effect retained even when SSA aliases the value. + LocalWrite { + index: u8, + }, IntConst(i64), FloatConst(f64), BoolConst(bool), @@ -178,6 +182,14 @@ pub(crate) enum AotSsaInstKind { lhs: AotSsaValueId, rhs: AotSsaValueId, }, + TaggedNumberSub { + lhs: AotSsaValueId, + rhs: AotSsaValueId, + }, + TaggedNumberMul { + lhs: AotSsaValueId, + rhs: AotSsaValueId, + }, IntMul { lhs: AotSsaValueId, rhs: AotSsaValueId, @@ -268,6 +280,14 @@ pub(crate) enum AotSsaInstKind { lhs: AotSsaValueId, rhs: AotSsaValueId, }, + TaggedNumberCmpLt { + lhs: AotSsaValueId, + rhs: AotSsaValueId, + }, + TaggedNumberCmpGt { + lhs: AotSsaValueId, + rhs: AotSsaValueId, + }, StringCmpEq { lhs: AotSsaValueId, rhs: AotSsaValueId, @@ -297,7 +317,8 @@ pub(crate) enum AotSsaInstKind { impl AotSsaInstKind { fn inputs(&self) -> Vec { match self { - Self::IntConst(_) + Self::LocalWrite { .. } + | Self::IntConst(_) | Self::FloatConst(_) | Self::BoolConst(_) | Self::ConstSlot { .. } => Vec::new(), @@ -329,6 +350,8 @@ impl AotSsaInstKind { Self::MapSet { map, key, value } => vec![*map, *key, *value], Self::IntAdd { lhs, rhs } | Self::IntSub { lhs, rhs } + | Self::TaggedNumberSub { lhs, rhs } + | Self::TaggedNumberMul { lhs, rhs } | Self::IntMul { lhs, rhs } | Self::IntDiv { lhs, rhs } | Self::IntMod { lhs, rhs } @@ -347,6 +370,8 @@ impl AotSsaInstKind { | Self::IntCmpGt { lhs, rhs } | Self::BoolCmpEq { lhs, rhs } | Self::TaggedCmpEq { lhs, rhs } + | Self::TaggedNumberCmpLt { lhs, rhs } + | Self::TaggedNumberCmpGt { lhs, rhs } | Self::StringCmpEq { lhs, rhs } | Self::BytesCmpEq { lhs, rhs } | Self::NullCmpEq { lhs, rhs } @@ -1816,6 +1841,28 @@ fn apply_direct_instruction( Ok(true) } AotInstruction::Stloc { index } => { + // Only a capturing script region can hold a cell at this local. + // Ordinary scalar functions retain their existing native fast path. + let prototype = program + .function_regions + .iter() + .find(|region| (region.start_ip as usize) <= ip && ip < region.end_ip as usize) + .and_then(|region| region.prototype_id) + .and_then(|id| program.callable_prototypes.get(id as usize)); + if prototype.is_some_and(|p| { + p.capture_slots + .iter() + .zip(&p.capture_modes) + .any(|(slot, mode)| { + *slot == u16::from(*index) && *mode == crate::CaptureBindingMode::Move + }) + }) { + emitter.emit( + ip, + AotSsaInstKind::LocalWrite { index: *index }, + AotSsaValueRepr::Bool, + ); + } let value = frame.pop(ip, "stloc")?; let local = frame .locals @@ -2580,6 +2627,29 @@ where }); Ok(true) } + _ if matches!(instruction, "sub" | "mul") + && is_numeric_repr(lhs.value.repr) + && is_numeric_repr(rhs.value.repr) => + { + frame.stack.push(FrameValue { + value: emitter.emit( + ip, + if instruction == "sub" { + AotSsaInstKind::TaggedNumberSub { + lhs: lhs.value.id, + rhs: rhs.value.id, + } + } else { + AotSsaInstKind::TaggedNumberMul { + lhs: lhs.value.id, + rhs: rhs.value.id, + } + }, + AotSsaValueRepr::Tagged, + ), + }); + Ok(true) + } _ => { frame.stack.push(lhs); frame.stack.push(rhs); @@ -2629,6 +2699,17 @@ fn emit_generic_compare( } }) } + _ if is_numeric_repr(lhs.value.repr) && is_numeric_repr(rhs.value.repr) => Some(if is_lt { + AotSsaInstKind::TaggedNumberCmpLt { + lhs: lhs.value.id, + rhs: rhs.value.id, + } + } else { + AotSsaInstKind::TaggedNumberCmpGt { + lhs: lhs.value.id, + rhs: rhs.value.id, + } + }), _ => None, }; if let Some(kind) = kind { @@ -2843,6 +2924,79 @@ mod tests { use super::*; use crate::{BytecodeBuilder, HostImport, Value}; + #[test] + fn aot_ssa_number_relations_retain_dynamic_operands_without_hints() { + for (body, expected) in [ + ("a < 2", "TaggedNumberCmpLt"), + ("2 < a", "TaggedNumberCmpLt"), + ("a > b", "TaggedNumberCmpGt"), + ] { + let source = + format!("fn compare(a: number, b: number) -> bool {{ {body} }} compare(1, 2);"); + let compiled = crate::compile_source(&source).unwrap(); + let ssa = build_aot_ssa(&compiled.program).unwrap(); + ssa.verify().unwrap(); + let text = ssa.text(); + assert!(text.contains(expected), "{text}"); + assert!(!text.contains("InterpreterBoundary"), "{text}"); + for inst in ssa.blocks.iter().flat_map(|block| &block.insts) { + if matches!( + inst.kind, + AotSsaInstKind::TaggedNumberCmpLt { .. } + | AotSsaInstKind::TaggedNumberCmpGt { .. } + ) { + assert_eq!(inst.output.repr, AotSsaValueRepr::Bool); + assert_eq!(inst.kind.inputs().len(), 2); + assert_eq!( + operand_types_at(&compiled.program, inst.ip), + (ValueType::Unknown, ValueType::Unknown) + ); + } + } + } + } + + #[test] + fn aot_ssa_number_factorial_uses_only_measured_dynamic_arithmetic() { + let compiled = crate::compile_source("fn factorial(n: number) -> number { if n < 2 => { 1 } else => { n * factorial(n - 1) } } factorial(6);").unwrap(); + let ssa = build_aot_ssa(&compiled.program).unwrap(); + ssa.verify().unwrap(); + let text = ssa.text(); + for kind in ["TaggedNumberCmpLt", "TaggedNumberSub", "TaggedNumberMul"] { + assert!(text.contains(kind), "{text}"); + } + assert!(!text.contains("InterpreterBoundary"), "{text}"); + for inst in ssa.blocks.iter().flat_map(|block| &block.insts) { + if matches!( + inst.kind, + AotSsaInstKind::TaggedNumberSub { .. } | AotSsaInstKind::TaggedNumberMul { .. } + ) { + assert_eq!(inst.output.repr, AotSsaValueRepr::Tagged); + assert_eq!(inst.kind.inputs().len(), 2); + } + } + } + + #[test] + fn aot_ssa_retains_static_numeric_fast_paths_and_checked_float_coercion() { + for (parameter, literal, expected) in [ + ("int", "2", "IntCmpLt"), + ("float", "2.0", "FloatCmpLt"), + ("number", "2.0", "TaggedNumberToFloat"), + ] { + let argument = if parameter == "float" { "1.0" } else { "1" }; + let source = format!( + "fn compare(n: {parameter}) -> bool {{ n < {literal} }} compare({argument});" + ); + let compiled = crate::compile_source(&source).unwrap(); + let ssa = build_aot_ssa(&compiled.program).unwrap(); + ssa.verify().unwrap(); + let text = ssa.text(); + assert!(text.contains(expected), "{text}"); + assert!(!text.contains("TaggedNumberCmpLt"), "{text}"); + } + } + #[test] fn aot_ssa_exposes_call_and_resume_checkpoints() { let mut bc = BytecodeBuilder::new(); diff --git a/src/vm/async_host/stream.rs b/src/vm/async_host/stream.rs index 20d8e6fc..6fede02a 100644 --- a/src/vm/async_host/stream.rs +++ b/src/vm/async_host/stream.rs @@ -1136,43 +1136,43 @@ mod tests { items: VecDeque::new(), actions: Arc::new(Mutex::new(vec![])), }; - let rejection = vm - .submit_callable_stream_callbacks( - event.clone(), - None, - &[TypeSchema::String], - &[], - driver(), - ) - .err() - .expect("arity rejection"); + let rejection = match vm.submit_callable_stream_callbacks( + event.clone(), + None, + &[TypeSchema::String], + &[], + driver(), + ) { + Ok(_) => panic!("arity rejection"), + Err(rejection) => rejection, + }; assert!(matches!( rejection.primary, VmError::CallableArityMismatch { .. } )); vm.rollback_rejected_callable_stream(rejection); - let rejection = vm - .submit_callable_stream_callbacks( - event.clone(), - Some(open), - &[TypeSchema::String, TypeSchema::String], - &[TypeSchema::Int, TypeSchema::String], - driver(), - ) - .err() - .expect("open result rejection"); + let rejection = match vm.submit_callable_stream_callbacks( + event.clone(), + Some(open), + &[TypeSchema::String, TypeSchema::String], + &[TypeSchema::Int, TypeSchema::String], + driver(), + ) { + Ok(_) => panic!("open result rejection"), + Err(rejection) => rejection, + }; assert!(matches!(rejection.primary, VmError::TypeMismatch(_))); vm.rollback_rejected_callable_stream(rejection); - let rejection = vm - .submit_callable_stream_callbacks( - event, - None, - &[TypeSchema::Int, TypeSchema::String], - &[], - driver(), - ) - .err() - .expect("event parameter rejection"); + let rejection = match vm.submit_callable_stream_callbacks( + event, + None, + &[TypeSchema::Int, TypeSchema::String], + &[], + driver(), + ) { + Ok(_) => panic!("event parameter rejection"), + Err(rejection) => rejection, + }; assert!(matches!(rejection.primary, VmError::TypeMismatch(_))); vm.rollback_rejected_callable_stream(rejection); } diff --git a/src/vm/callable_maintenance.rs b/src/vm/callable_maintenance.rs new file mode 100644 index 00000000..68d83ea0 --- /dev/null +++ b/src/vm/callable_maintenance.rs @@ -0,0 +1,156 @@ +//! Opt-in maintenance of expired callable identity records only. +//! +//! The policy lives in the existing out-of-line typed module store. It never +//! owns guest values, capture cells, resources, or a second identity index. + +use std::sync::Weak; + +use super::host_state::ModuleStateStore; +use super::{CallableValue, Vm}; + +/// Fixed inspection budget applied after each new callable identity record. +/// +/// Maintenance is disabled by default. At least three probes are necessary: +/// a removal can swap a fresh live tail into the cursor, requiring another +/// probe before advancing; additional work must pass other live records. +/// A configured budget is independent of the historical registration count. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CallableIdentityMaintenanceConfig { + probes_per_registration: usize, +} + +impl CallableIdentityMaintenanceConfig { + /// Returns `None` for budgets below three, without changing any VM policy. + pub const fn new(probes_per_registration: usize) -> Option { + if probes_per_registration < 3 { + None + } else { + Some(Self { + probes_per_registration, + }) + } + } + + /// Maximum Weak entries inspected per newly registered identity record. + pub const fn probes_per_registration(self) -> usize { + self.probes_per_registration + } +} + +/// O(1) snapshot of the configured policy and the existing identity vector. +/// +/// Entries count records, not distinct callable pointers or resources. Owned +/// graph walks visit each pointer once; recovery clears identities before +/// readoption, while constructor and frame bindings mint fresh pointers. +/// Capacity is the Vec allocation's high-water mark; pruning releases expired +/// Weak references (freeing an Arc allocation when its last Weak is dropped) +/// but does not shrink or copy the vector. Counters saturate at `u64::MAX` and count +/// only work since the most recent configuration, including across resets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CallableIdentityMaintenanceStats { + pub entries: usize, + pub capacity: usize, + pub probes_per_registration: usize, + pub registrations: u64, + pub probes: u64, + pub retired: u64, +} + +struct MaintenanceState { + config: CallableIdentityMaintenanceConfig, + cursor: usize, + registrations: u64, + probes: u64, + retired: u64, +} + +impl Vm { + /// Enables/reconfigures maintenance, or disables it with `None`. + /// + /// This infallible metadata-only operation neither scans existing records + /// nor changes callable authority. Enabling creates one private typed + /// module-state entry, so `HostContext::is_module_state_empty()` can become + /// false only after explicit opt-in. Scope resource/operation counts are + /// unaffected. The policy survives reset/reuse and shutdown; its cursor is + /// reset when identities are cleared. Replacing the entire VM uses the + /// replacement VM's policy. Disabling removes the private entry. + /// + /// Each registration then probes at most the configured budget and drops + /// only expired Weak records. No maintenance occurs while idle. For a + /// fixed bound on simultaneously live identities, continued registrations + /// bound retained history; enabling after a large backlog retires it + /// incrementally. This is not resource or capture-cell collection. + pub fn set_callable_identity_maintenance( + &mut self, + config: Option, + ) { + if let Some(config) = config { + self.host.set_module_state(MaintenanceState { + config, + cursor: 0, + registrations: 0, + probes: 0, + retired: 0, + }); + } else { + self.host.remove_module_state::(); + } + } + + /// Returns an O(1) policy snapshot, or `None` when maintenance is disabled. + pub fn callable_identity_maintenance_stats(&self) -> Option { + let state = self.host.get_module_state::()?; + Some(CallableIdentityMaintenanceStats { + entries: self.instance.owned_callables.len(), + capacity: self.instance.owned_callables.capacity(), + probes_per_registration: state.config.probes_per_registration, + registrations: state.registrations, + probes: state.probes, + retired: state.retired, + }) + } +} + +pub(super) fn register( + entries: &mut Vec>, + store: &mut ModuleStateStore, + callable: Weak, +) { + entries.push(callable); + after_registration(entries, store); +} + +pub(super) fn after_registration( + entries: &mut Vec>, + store: &mut ModuleStateStore, +) { + let Some(state) = store.get_mut::() else { + return; + }; + state.registrations = state.registrations.saturating_add(1); + for _ in 0..state.config.probes_per_registration { + if entries.is_empty() { + state.cursor = 0; + break; + } + if state.cursor >= entries.len() { + state.cursor = 0; + } + state.probes = state.probes.saturating_add(1); + // Failure to upgrade proves expiry, without retaining a guest root. + // A successful temporary Arc is dropped before the next probe. + if entries[state.cursor].upgrade().is_none() { + entries.swap_remove(state.cursor); + state.retired = state.retired.saturating_add(1); + // Probe the swapped entry next, rather than skipping it. + } else { + state.cursor += 1; + } + } +} + +pub(super) fn identities_cleared(store: &mut ModuleStateStore) { + if let Some(state) = store.get_mut::() { + state.cursor = 0; + } +} diff --git a/src/vm/capture_origin.rs b/src/vm/capture_origin.rs new file mode 100644 index 00000000..15a882bf --- /dev/null +++ b/src/vm/capture_origin.rs @@ -0,0 +1,1089 @@ +//! Out-of-line source authority and weak observations of real capture cells. +use super::resource::{HostResource, Resource, ResourceHandle}; +use super::{HostContext, HostContextError, Vm}; +use crate::compiler::capture_certification::CaptureCertification; +use crate::host_api::ResourceTypeKey; +use crate::{CallableValue, CaptureBindingMode, Program, TypeSchema, Value}; +use std::collections::HashMap; +use std::sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, Ordering}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaptureRootState { + Live, + Released, + Unavailable, +} + +/// A weak cell observation. It owns no guest value, resource, environment or cell. +#[derive(Clone)] +pub struct ResourceCaptureRoot { + epoch: Weak, + validity: Arc, +} +impl ResourceCaptureRoot { + pub fn observe(&self, context: &HostContext<'_>) -> CaptureRootState { + context.observe_capture_root(self) + } +} + +pub struct CheckedResourceCapture { + token: Resource, + root: ResourceCaptureRoot, +} +impl CheckedResourceCapture { + pub fn token(&self) -> &Resource { + &self.token + } + pub fn root(&self) -> &ResourceCaptureRoot { + &self.root + } + pub(super) fn new(token: Resource, root: ResourceCaptureRoot) -> Self { + Self { token, root } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CaptureQueryError { + Unavailable, + NoMatchingCapture, + AmbiguousCapture, + Resource(Box), +} +impl std::fmt::Display for CaptureQueryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "capture query: {self:?}") + } +} +impl std::error::Error for CaptureQueryError {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CaptureCertificationStats { + pub capture_facts: usize, + pub entries: usize, + pub capacity: usize, + pub registrations: u64, + pub probes: u64, + pub retired: u64, + pub valid: bool, +} +struct CellOrigin { + cell: Weak>, + issued: Weak, + prototype: u32, + ordinal: usize, + key: ResourceTypeKey, + handle: ResourceHandle, + valid: AtomicBool, +} +struct CaptureState { + program: Weak, + epoch: Arc, + facts: CaptureCertification, + capture_facts: usize, + sites: HashMap, + native_site: Option, + entries: Vec>, + index: HashMap, + frames: HashMap>>>, + cursor: usize, + registrations: u64, + probes: u64, + retired: u64, +} +impl CaptureState { + fn valid_for(&self, program: &Arc) -> bool { + self.epoch.load(Ordering::Acquire) && self.program.ptr_eq(&Arc::downgrade(program)) + } + fn register(&mut self, origin: Arc) { + let pointer = origin.cell.as_ptr() as usize; + if self.index.contains_key(&pointer) { + return; + } + self.index.insert(pointer, self.entries.len()); + self.entries.push(origin); + self.registrations = self.registrations.saturating_add(1); + // Exactly a fixed maximum of four probes per new cell. Swap removal + // updates the index and rechecks the swapped record on the next probe. + for _ in 0..4 { + if self.entries.is_empty() { + self.cursor = 0; + break; + } + if self.cursor >= self.entries.len() { + self.cursor = 0; + } + self.probes = self.probes.saturating_add(1); + if self.entries[self.cursor].cell.upgrade().is_none() { + let expired = self.entries.swap_remove(self.cursor); + self.index.remove(&(expired.cell.as_ptr() as usize)); + if let Some(swapped) = self.entries.get(self.cursor) { + self.index + .insert(swapped.cell.as_ptr() as usize, self.cursor); + } + self.retired = self.retired.saturating_add(1); + } else { + self.cursor += 1; + } + } + } +} +impl Vm { + pub(crate) fn attach_capture_certification(&mut self, facts: CaptureCertification) { + self.revoke_capture_certification(); + let sites = facts.creation_sites.iter().copied().collect(); + let capture_facts = facts.prototypes.iter().map(Vec::len).sum(); + self.host.set_module_state(CaptureState { + program: Arc::downgrade(&self.program), + epoch: Arc::new(AtomicBool::new(true)), + facts, + capture_facts, + sites, + native_site: None, + entries: Vec::new(), + index: HashMap::new(), + frames: HashMap::new(), + cursor: 0, + registrations: 0, + probes: 0, + retired: 0, + }); + } + pub(super) fn revoke_capture_certification(&mut self) { + if let Some(state) = self.host.get_module_state_mut::() { + state.epoch.store(false, Ordering::Release); + state.frames.clear(); + } + } + pub fn capture_certification_stats(&self) -> Option { + let state = self.host.get_module_state::()?; + Some(CaptureCertificationStats { + capture_facts: state.capture_facts, + entries: state.entries.len(), + capacity: state.entries.capacity(), + registrations: state.registrations, + probes: state.probes, + retired: state.retired, + valid: state.valid_for(&self.program), + }) + } + pub(super) fn certify_capture_cells(&mut self, callable: &Arc) { + let Some(state) = self.host.get_module_state_mut::() else { + return; + }; + if !state.valid_for(&self.program) + || state + .sites + .get(&state.native_site.unwrap_or(self.instance.ip)) + != Some(&callable.prototype_id) + { + return; + } + let Some(facts) = state.facts.prototypes.get(callable.prototype_id as usize) else { + return; + }; + let Some(prototype) = self + .program + .callable_prototypes + .get(callable.prototype_id as usize) + else { + return; + }; + let Some(environment) = &callable.env else { + return; + }; + let Ok(cells) = environment.cells.lock() else { + return; + }; + if facts.len() != cells.len() || cells.len() != prototype.capture_slots.len() { + return; + } + let mut origins = Vec::new(); + for (ordinal, (fact, cell)) in facts.iter().zip(cells.iter()).enumerate() { + if prototype.capture_source_slots.get(ordinal) != Some(&fact.source) + || prototype.capture_slots.get(ordinal) != Some(&fact.target) + || prototype.capture_modes.get(ordinal) != Some(&fact.mode) + || fact.mode != CaptureBindingMode::Move + { + continue; + } + let Some(TypeSchema::Resource(key)) = &fact.schema else { + continue; + }; + let Ok(value) = cell.lock() else { + continue; + }; + let Value::Int(raw) = &*value else { + continue; + }; + let Ok(handle) = ResourceHandle::from_raw(*raw as u64) else { + continue; + }; + origins.push(Arc::new(CellOrigin { + cell: Arc::downgrade(cell), + issued: Arc::downgrade(callable), + prototype: callable.prototype_id, + ordinal, + key: key.clone(), + handle, + valid: AtomicBool::new(true), + })); + } + for origin in origins { + state.register(origin); + } + } + pub(super) fn certify_capture_frame( + &mut self, + base: usize, + callable: Option<&Arc>, + prototype_id: u32, + ) { + let Some(state) = self.host.get_module_state_mut::() else { + return; + }; + state.frames.remove(&base); + if !state.valid_for(&self.program) { + return; + } + let Some(callable) = callable else { + return; + }; + let Some(prototype) = self.program.callable_prototypes.get(prototype_id as usize) else { + return; + }; + let Some(environment) = &callable.env else { + return; + }; + let Ok(cells) = environment.cells.lock() else { + return; + }; + let roots = cells + .iter() + .enumerate() + .map(|(ordinal, cell)| { + let record = state + .entries + .get(*state.index.get(&(Arc::as_ptr(cell) as usize))?)?; + let absolute = + base.checked_add(usize::from(*prototype.capture_slots.get(ordinal)?))?; + let connected = self.instance.capture_cells.get(&absolute)?; + (record.prototype == prototype_id + && record.ordinal == ordinal + && record.issued.ptr_eq(&Arc::downgrade(callable)) + && record.cell.ptr_eq(&Arc::downgrade(cell)) + && Arc::ptr_eq(connected, cell) + && record.valid.load(Ordering::Acquire)) + .then(|| record.clone()) + }) + .collect(); + state.frames.insert(base, roots); + } + pub(super) fn release_capture_frame(&mut self, base: usize) { + if let Some(state) = self.host.get_module_state_mut::() { + state.frames.remove(&base); + } + } + /// Native code supplies the authenticated instruction offset, rather than + /// changing interpreter IP or relying on a stale local mirror. + pub(super) fn execute_host_call_with_capture_site( + &mut self, + index: u16, + argc: u8, + call_ip: usize, + ) -> super::VmResult { + let previous = self + .host + .get_module_state_mut::() + .map(|state| { + let previous = state.native_site; + state.native_site = call_ip.checked_add(4); + previous + }); + let outcome = self.execute_host_call(index, argc, call_ip); + if let Some(previous) = previous + && let Some(state) = self.host.get_module_state_mut::() + { + state.native_site = previous; + } + outcome + } + pub(super) fn invalidate_capture_cell(&mut self, cell: &crate::bytecode::SharedCaptureCell) { + let Some(state) = self.host.get_module_state::() else { + return; + }; + if let Some(record) = state + .index + .get(&(Arc::as_ptr(cell) as usize)) + .and_then(|i| state.entries.get(*i)) + { + record.valid.store(false, Ordering::Release); + } + } + pub(super) fn checked_capture_origin( + &self, + expected: &ResourceTypeKey, + ) -> Result<(ResourceHandle, ResourceCaptureRoot), CaptureQueryError> { + let state = self + .host + .get_module_state::() + .ok_or(CaptureQueryError::Unavailable)?; + if !state.valid_for(&self.program) { + return Err(CaptureQueryError::Unavailable); + } + let frame = self + .instance + .execution_frames + .last() + .ok_or(CaptureQueryError::Unavailable)?; + let id = frame + .prototype_id + .ok_or(CaptureQueryError::NoMatchingCapture)?; + let prototype = self + .program + .callable_prototypes + .get(id as usize) + .ok_or(CaptureQueryError::Unavailable)?; + let facts = state + .facts + .prototypes + .get(id as usize) + .ok_or(CaptureQueryError::Unavailable)?; + if facts.len() != prototype.capture_slots.len() { + return Err(CaptureQueryError::Unavailable); + } + if facts.iter().any(|fact| { + fact.mode == CaptureBindingMode::Move + && matches!( + fact.schema, + None | Some(TypeSchema::Unknown | TypeSchema::GenericParam(_)) + ) + }) { + return Err(CaptureQueryError::Unavailable); + } + let mut matching = facts.iter().enumerate().filter(|(_, fact)| { + fact.mode == CaptureBindingMode::Move + && fact.schema.as_ref() == Some(&TypeSchema::Resource(expected.clone())) + }); + let (ordinal, fact) = matching + .next() + .ok_or(CaptureQueryError::NoMatchingCapture)?; + if matching.next().is_some() { + return Err(CaptureQueryError::AmbiguousCapture); + } + let record = state + .frames + .get(&frame.local_base) + .and_then(|roots| roots.get(ordinal)) + .and_then(Option::as_ref) + .ok_or(CaptureQueryError::Unavailable)?; + let absolute = frame.local_base + usize::from(fact.target); + let cell = self + .instance + .capture_cells + .get(&absolute) + .ok_or(CaptureQueryError::Unavailable)?; + if record.prototype != id + || record.ordinal != ordinal + || !record.cell.ptr_eq(&Arc::downgrade(cell)) + || !record.valid.load(Ordering::Acquire) + { + return Err(CaptureQueryError::Unavailable); + } + let value = cell.lock().map_err(|_| CaptureQueryError::Unavailable)?; + if !matches!(&*value, Value::Int(raw) if *raw as u64 == record.handle.raw()) { + return Err(CaptureQueryError::Unavailable); + } + Ok(( + record.handle, + ResourceCaptureRoot { + epoch: Arc::downgrade(&state.epoch), + validity: record.clone(), + }, + )) + } + pub(super) fn observe_capture_root(&self, root: &ResourceCaptureRoot) -> CaptureRootState { + let Some(state) = self.host.get_module_state::() else { + return CaptureRootState::Unavailable; + }; + if !state.valid_for(&self.program) + || !root.epoch.ptr_eq(&Arc::downgrade(&state.epoch)) + || !root.validity.valid.load(Ordering::Acquire) + || self + .host + .execution_scope + .resources() + .validate_resource_type_key(root.validity.handle, &root.validity.key) + .is_err() + { + return CaptureRootState::Unavailable; + } + let Some(cell) = root.validity.cell.upgrade() else { + return CaptureRootState::Released; + }; + let live = cell.lock().is_ok_and( + |value| matches!(&*value, Value::Int(raw) if *raw as u64 == root.validity.handle.raw()), + ); + drop(cell); + if live { + CaptureRootState::Live + } else { + CaptureRootState::Unavailable + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + CompileSourceFileOptions, HostApiBuilder, HostFunctionSchema, HostParamPassing, + HostParamSchema, HostTypeSchema, ResourceTypeSchema, SourceFlavor, VmStatus, + }; + struct BoxPayload(i64); + impl HostResource for BoxPayload { + fn resource_type_key() -> Option { + Some(key()) + } + } + struct OtherConcrete; + impl HostResource for OtherConcrete { + fn resource_type_key() -> Option { + Some(key()) + } + } + fn key() -> ResourceTypeKey { + ResourceTypeKey::new("neutral.cell-box").unwrap() + } + #[derive(Default)] + struct ProbeState { + environment: Option>, + root: Option, + handle: Option, + calls: usize, + expected: Option, + write_same_bits: bool, + internal_same_bits: bool, + detach: bool, + held_alias: Option, + inspect_native_origin: bool, + expect_after_guest_write: bool, + native_semantic_write: bool, + } + + fn fixture(source: &str, certified: bool) -> Vm { + let mut b = HostApiBuilder::new(); + b.resource(ResourceTypeSchema::new(key(), "cell box")); + b.function(HostFunctionSchema::with_return( + "neutral::open", + vec![], + HostTypeSchema::Resource(key()), + )); + b.function(HostFunctionSchema::with_return( + "neutral::check", + vec![], + HostTypeSchema::Int, + )); + b.function(HostFunctionSchema::with_return( + "neutral::close", + vec![HostParamSchema::with_passing( + "value", + HostTypeSchema::Resource(key()), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + let catalog = Arc::new(b.build().unwrap()); + let options = CompileSourceFileOptions::new().with_host_api_catalog(catalog.clone()); + let issued = crate::compile_source_with_flavor_and_options_with_capture_certification( + source, + SourceFlavor::RustScript, + options, + ) + .unwrap(); + let mut vm = if certified { + issued.into_vm().unwrap() + } else { + issued.into_uncertified().into_vm().unwrap() + }; + vm.host_context().set_module_state(ProbeState::default()); + let mut registry = crate::HostFunctionRegistry::new(); + for schema in crate::catalog_import_schemas(&catalog, "neutral::open") { + registry + .register_exact_static("neutral::open", 0, schema, |vm, _| { + let token = vm.host_context().push_resource(BoxPayload(41)).unwrap(); + Ok(super::super::CallOutcome::Return( + super::super::CallReturn::one(Value::Int(token.handle().raw() as i64)), + )) + }) + .unwrap(); + } + for schema in crate::catalog_import_schemas(&catalog, "neutral::check") { + registry.register_exact_static("neutral::check", 0, schema, |vm, _| { + let expected = vm.host_context().module_state::().unwrap().expected.clone(); + if vm.host_context().module_state::().unwrap().inspect_native_origin { + let frame = vm.instance.execution_frames.last().unwrap(); + let state = vm.host.get_module_state::().unwrap(); + let records = state.frames.get(&frame.local_base); + let record = records.and_then(|roots| roots.iter().flatten().next()); + let cell_matches = record.is_some_and(|record| { + vm.instance.capture_cells.values().any(|cell| record.cell.ptr_eq(&Arc::downgrade(cell))) + }); + eprintln!("native origin diagnostic: stats={:?} frame_prototype={:?} frame_roots_present={} record_present={} record_valid={:?} actual_cell_match={} native_count={}", vm.capture_certification_stats(), frame.prototype_id, records.is_some(), record.is_some(), record.map(|r| r.valid.load(Ordering::Acquire)), cell_matches, vm.aot_exec_count()); + } + if let Some(expected) = expected { + assert!(matches!(vm.host_context().checked_move_resource_capture::(&key()), Err(error) if error == expected)); + } else { + let held = vm.host_context().module_state_mut::().unwrap().held_alias.take(); + drop(held); + let context = vm.host_context(); + let queried = context.checked_move_resource_capture::(&key()); + let checked = match queried { + Ok(checked) => checked, + Err(error) if context.module_state::().unwrap().inspect_native_origin => return Err(crate::VmError::HostError(format!("source-native checked origin unavailable: {error}"))), + Err(error) => panic!("unexpected checked origin failure: {error}"), + }; + assert!(matches!(context.checked_move_resource_capture::(&ResourceTypeKey::new("neutral.other-key").unwrap()), Err(CaptureQueryError::NoMatchingCapture))); + assert!(matches!(context.checked_move_resource_capture::(&key()), Err(CaptureQueryError::Resource(_)))); + assert_eq!(context.resource(checked.token()).unwrap().get().0, 41); + assert_eq!(checked.root().observe(&context), CaptureRootState::Live); + if let Some(environment) = &context.module_state::().unwrap().environment { + assert!(environment.upgrade().is_none(), "no-self frame must not retain its environment"); + } + let root = checked.root().clone(); + let handle = checked.token().handle(); + let replace = context.module_state::().unwrap().write_same_bits; + let internal = context.module_state::().unwrap().internal_same_bits; + let detach = context.module_state::().unwrap().detach; + if replace { + let frame = vm.instance.execution_frames.last().unwrap(); + let prototype = &vm.program.callable_prototypes[frame.prototype_id.unwrap() as usize]; + vm.set_local(prototype.capture_slots[0] as u8, Value::Int(handle.raw() as i64)).unwrap(); + assert_eq!(root.observe(&vm.host_context()), CaptureRootState::Unavailable); + } else if internal || detach || context.module_state::().unwrap().native_semantic_write { + let frame = vm.instance.execution_frames.last().unwrap(); + let slot = vm.program.callable_prototypes[frame.prototype_id.unwrap() as usize].capture_slots[0] as u8; + if internal { vm.store_local_with_drop_contract(slot, Value::Int(handle.raw() as i64)).unwrap(); } + if vm.host_context().module_state::().unwrap().native_semantic_write { + vm.note_native_semantic_local_write(slot).unwrap(); + vm.synchronize_native_local(slot, Value::Int(handle.raw() as i64)).unwrap(); + } + if detach { vm.detach_local_with_drop_contract(slot).unwrap(); } + assert_eq!(root.observe(&vm.host_context()), CaptureRootState::Unavailable); + assert!(vm.capture_certification_stats().unwrap().valid); + assert!(matches!(vm.host_context().checked_move_resource_capture::(&key()), Err(CaptureQueryError::Unavailable))); + } else { + let resources_before = vm.host_context().resource_count(); + let token = vm.host_context().push_resource(BoxPayload(9)).unwrap(); + { + let context = vm.host_context(); + let candidate = context.typed_resource_with_key::(token.handle(), &key()).unwrap(); + assert_eq!(context.resource(&candidate).unwrap().get().0, 9); + assert_ne!(candidate.handle(), handle, "a valid typed candidate is not proof of the captured owner"); + assert_eq!(context.resource_count(), resources_before + 1); + } + vm.host_context().close_resource::(token.handle(), super::super::resource::ResourceCloseReason::Requested).unwrap(); + assert_eq!(vm.host_context().resource_count(), resources_before); + assert_eq!(root.observe(&vm.host_context()), CaptureRootState::Live); + } + let mut context = vm.host_context(); + let state = context.module_state_mut::().unwrap(); + state.root = Some(root); state.handle = Some(handle); + if state.expect_after_guest_write { state.expected = Some(CaptureQueryError::Unavailable); } + } + vm.host_context().module_state_mut::().unwrap().calls += 1; + Ok(super::super::CallOutcome::Return(super::super::CallReturn::one(Value::Int(41)))) + }).unwrap(); + } + for schema in crate::catalog_import_schemas(&catalog, "neutral::close") { + registry + .register_exact_static("neutral::close", 1, schema, |vm, args| { + let Value::Int(raw) = args[0] else { + panic!("resource carrier") + }; + vm.host_context() + .close_resource::( + ResourceHandle::from_raw(raw as u64).unwrap(), + super::super::resource::ResourceCloseReason::Requested, + ) + .unwrap(); + Ok(super::super::CallOutcome::Return( + super::super::CallReturn::none(), + )) + }) + .unwrap(); + } + registry.bind_vm_cached(&mut vm).unwrap(); + vm + } + const NO_SELF: &str = "let owner = neutral::open(); || if true => { neutral::check(); 41 } else => { neutral::close(owner); 41 };"; + fn take_closure(vm: &mut Vm) -> Value { + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let value = vm.instance.stack.pop().unwrap(); + let Value::Callable(callable) = &value else { + panic!("source closure") + }; + assert!( + vm.program.callable_prototypes[callable.prototype_id as usize] + .self_slot + .is_none() + ); + vm.host_context() + .module_state_mut::() + .unwrap() + .environment = callable.env.as_ref().map(Arc::downgrade); + value + } + #[cfg(feature = "cranelift-jit")] + #[test] + fn no_self_source_native_readonly_query_preserves_issued_capture_origin() { + let mut vm = fixture(NO_SELF, true); + vm.host_context() + .module_state_mut::() + .unwrap() + .inspect_native_origin = true; + vm.compile_aot().unwrap(); + let closure = take_closure(&mut vm); + assert_eq!(vm.invoke_callable(closure, &[]).unwrap(), Value::Int(41)); + assert!(vm.aot_exec_count() > 1); + assert!(vm.capture_certification_stats().unwrap().valid); + let root = vm + .host_context() + .module_state::() + .unwrap() + .root + .clone() + .unwrap(); + assert_eq!(root.observe(&vm.host_context()), CaptureRootState::Released); + assert_eq!(vm.host_context().resource_count(), 1); + vm.reset_for_reuse().unwrap(); + assert_eq!(vm.host_context().resource_count(), 0); + } + #[cfg(feature = "cranelift-jit")] + #[test] + fn source_native_guest_call_readonly_query_preserves_issued_capture_origin() { + let source = "let owner = neutral::open(); let dispatch = || if true => { neutral::check(); 41 } else => { neutral::close(owner); 41 }; dispatch();"; + let mut vm = fixture(source, true); + vm.host_context() + .module_state_mut::() + .unwrap() + .inspect_native_origin = true; + vm.compile_aot().unwrap(); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert!(vm.aot_exec_count() > 1); + assert_eq!( + vm.host_context() + .module_state::() + .unwrap() + .calls, + 1 + ); + assert!(vm.capture_certification_stats().unwrap().valid); + vm.reset_for_reuse().unwrap(); + assert_eq!(vm.host_context().resource_count(), 0); + } + #[cfg(feature = "cranelift-jit")] + #[test] + fn source_native_semantic_same_bits_write_is_not_reauthorized_by_sync() { + let mut vm = fixture(NO_SELF, true); + vm.host_context() + .module_state_mut::() + .unwrap() + .native_semantic_write = true; + vm.compile_aot().unwrap(); + let closure = take_closure(&mut vm); + assert_eq!(vm.invoke_callable(closure, &[]).unwrap(), Value::Int(41)); + assert!(vm.aot_exec_count() > 1); + assert!(vm.capture_certification_stats().unwrap().valid); + assert_eq!(vm.host_context().resource_count(), 1); + vm.reset_for_reuse().unwrap(); + } + #[cfg(feature = "cranelift-jit")] + #[test] + fn source_native_guest_same_value_assignment_invalidates_the_old_origin() { + let source = "let mut owner = neutral::open(); let dispatch = || if true => { neutral::check(); owner = owner; neutral::check(); 41 } else => { neutral::check(); neutral::close(owner); 41 }; dispatch();"; + let mut vm = fixture(source, true); + vm.host_context() + .module_state_mut::() + .unwrap() + .expect_after_guest_write = true; + vm.compile_aot().unwrap(); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert!(vm.aot_exec_count() > 1); + assert_eq!( + vm.host_context() + .module_state::() + .unwrap() + .calls, + 2 + ); + assert_eq!(vm.host_context().resource_count(), 1); + vm.reset_for_reuse().unwrap(); + } + #[test] + fn no_self_actual_frame_cell_survives_expired_environment_then_releases() { + let mut vm = fixture(NO_SELF, true); + let closure = take_closure(&mut vm); + assert_eq!(vm.invoke_callable(closure, &[]).unwrap(), Value::Int(41)); + let root = vm + .host_context() + .module_state::() + .unwrap() + .root + .clone() + .unwrap(); + assert_eq!(root.observe(&vm.host_context()), CaptureRootState::Released); + assert_eq!(vm.host_context().resource_count(), 1); + assert!(vm.instance.capture_cells.is_empty()); + assert_eq!( + vm.host_context() + .module_state::() + .unwrap() + .calls, + 1 + ); + } + #[test] + fn public_same_bits_write_revokes_epoch_before_write_and_reset_does_not_restore_it() { + let mut vm = fixture(NO_SELF, true); + vm.host_context() + .module_state_mut::() + .unwrap() + .write_same_bits = true; + let closure = take_closure(&mut vm); + vm.invoke_callable(closure, &[]).unwrap(); + let root = vm + .host_context() + .module_state::() + .unwrap() + .root + .clone() + .unwrap(); + assert_eq!( + root.observe(&vm.host_context()), + CaptureRootState::Unavailable + ); + assert!(!vm.capture_certification_stats().unwrap().valid); + vm.reset_for_reuse().unwrap(); + assert_eq!( + root.observe(&vm.host_context()), + CaptureRootState::Unavailable + ); + } + #[test] + fn uncertified_compilation_has_legacy_execution_without_capture_authority() { + let mut vm = fixture(NO_SELF, false); + vm.host_context() + .module_state_mut::() + .unwrap() + .expected = Some(CaptureQueryError::Unavailable); + let closure = take_closure(&mut vm); + assert_eq!(vm.invoke_callable(closure, &[]).unwrap(), Value::Int(41)); + assert!(vm.capture_certification_stats().is_none()); + assert_eq!( + vm.host_context() + .module_state::() + .unwrap() + .calls, + 1 + ); + } + #[cfg(feature = "cranelift-jit")] + #[test] + fn loading_legacy_aot_artifact_revokes_source_origin_but_executes() { + let mut vm = fixture(NO_SELF, true); + let bytes = vm.encode_aot_artifact().unwrap(); + assert!(vm.capture_certification_stats().unwrap().valid); + vm.load_aot_artifact(&bytes).unwrap(); + assert!( + !vm.capture_certification_stats().unwrap().valid, + "legacy native bytes cannot retain compiler issuance authority" + ); + vm.host_context() + .module_state_mut::() + .unwrap() + .expected = Some(CaptureQueryError::Unavailable); + let closure = take_closure(&mut vm); + assert_eq!(vm.invoke_callable(closure, &[]).unwrap(), Value::Int(41)); + assert!(vm.aot_exec_count() > 0); + println!("legacy artifact native entries={}", vm.aot_exec_count()); + assert_eq!( + vm.host_context() + .module_state::() + .unwrap() + .calls, + 1 + ); + } + #[cfg(feature = "cranelift-jit")] + #[test] + fn source_certified_aot_native_execution_keeps_actual_capture_query() { + let mut vm = fixture(NO_SELF, true); + vm.compile_aot().unwrap(); + let closure = take_closure(&mut vm); + assert_eq!(vm.invoke_callable(closure, &[]).unwrap(), Value::Int(41)); + assert!(vm.aot_exec_count() > 0); + println!("{}", vm.dump_aot_info()); + } + #[cfg(feature = "cranelift-jit")] + #[test] + fn source_certified_trace_jit_loop_then_capture_query() { + let source = "let owner = neutral::open(); fn dispatch() -> int { let mut n = 0; while n < 500 { n = n + 1; } neutral::check(); if true => { 41 } else => { neutral::close(owner); 41 } } dispatch();"; + let mut vm = fixture(source, true); + let config = crate::JitConfig { + enabled: true, + hot_loop_threshold: 2, + ..Default::default() + }; + vm.set_jit_config(config); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert!(vm.jit_snapshot().metrics.native_trace_exec_count > 0); + println!("{:?}", vm.jit_snapshot().metrics); + } + #[test] + fn final_external_alias_dropped_during_host_call_leaves_frame_cell_live() { + let mut vm = fixture(NO_SELF, true); + let closure = take_closure(&mut vm); + vm.host_context() + .module_state_mut::() + .unwrap() + .held_alias = Some(closure.clone()); + vm.invoke_callable(closure, &[]).unwrap(); + let root = vm + .host_context() + .module_state::() + .unwrap() + .root + .clone() + .unwrap(); + assert_eq!(root.observe(&vm.host_context()), CaptureRootState::Released); + assert_eq!(vm.host_context().resource_count(), 1); + assert!(vm.instance.capture_cells.is_empty()); + } + #[test] + fn internal_same_bits_replacement_invalidates_only_the_cell_origin() { + let mut vm = fixture(NO_SELF, true); + vm.host_context() + .module_state_mut::() + .unwrap() + .internal_same_bits = true; + let closure = take_closure(&mut vm); + vm.invoke_callable(closure, &[]).unwrap(); + let root = vm + .host_context() + .module_state::() + .unwrap() + .root + .clone() + .unwrap(); + assert_eq!( + root.observe(&vm.host_context()), + CaptureRootState::Unavailable + ); + assert!(vm.capture_certification_stats().unwrap().valid); + assert_eq!(vm.host_context().resource_count(), 1); + } + #[test] + fn detached_cell_root_is_unavailable_without_automatic_resource_close() { + let mut vm = fixture(NO_SELF, true); + vm.host_context() + .module_state_mut::() + .unwrap() + .detach = true; + let closure = take_closure(&mut vm); + vm.invoke_callable(closure, &[]).unwrap(); + let root = vm + .host_context() + .module_state::() + .unwrap() + .root + .clone() + .unwrap(); + assert_eq!( + root.observe(&vm.host_context()), + CaptureRootState::Unavailable + ); + assert_eq!(vm.host_context().resource_count(), 1); + assert!(vm.instance.capture_cells.is_empty()); + } + #[test] + fn aliases_share_one_real_root_and_foreign_scope_cannot_observe_it() { + let mut vm = fixture(NO_SELF, true); + let closure = take_closure(&mut vm); + let alias = closure.clone(); + vm.host_context() + .module_state_mut::() + .unwrap() + .environment = None; + vm.invoke_callable(closure, &[]).unwrap(); + let root = vm + .host_context() + .module_state::() + .unwrap() + .root + .clone() + .unwrap(); + assert_eq!(root.observe(&vm.host_context()), CaptureRootState::Live); + assert_eq!(vm.capture_certification_stats().unwrap().registrations, 1); + let mut foreign = fixture(NO_SELF, true); + assert_eq!( + root.observe(&foreign.host_context()), + CaptureRootState::Unavailable + ); + drop(alias); + assert_eq!(root.observe(&vm.host_context()), CaptureRootState::Released); + } + #[test] + fn two_direct_move_resources_are_ambiguous_before_typed_borrow() { + let source = "let first = neutral::open(); let second = neutral::open(); || if true => { neutral::check(); 41 } else => { neutral::close(first); neutral::close(second); 41 };"; + let mut vm = fixture(source, true); + vm.host_context() + .module_state_mut::() + .unwrap() + .expected = Some(CaptureQueryError::AmbiguousCapture); + let closure = take_closure(&mut vm); + vm.invoke_callable(closure, &[]).unwrap(); + assert_eq!( + vm.host_context() + .module_state::() + .unwrap() + .calls, + 1 + ); + assert_eq!(vm.capture_certification_stats().unwrap().registrations, 2); + } + #[test] + fn registered_fake_callable_with_genuine_environment_has_no_issuance_origin() { + let mut vm = fixture(NO_SELF, true); + let closure = take_closure(&mut vm); + let Value::Callable(genuine) = &closure else { + unreachable!() + }; + let fake = Arc::new(CallableValue { + prototype_id: genuine.prototype_id, + kind: genuine.kind, + env: genuine.env.clone(), + }); + // The existing identity registry can admit a native/adopted callable; + // it must not mint compiler cell-issuance proof for that new identity. + super::super::callable_maintenance::register( + &mut vm.instance.owned_callables, + &mut vm.host.module_state_store, + Arc::downgrade(&fake), + ); + vm.host_context() + .module_state_mut::() + .unwrap() + .expected = Some(CaptureQueryError::Unavailable); + vm.invoke_callable(Value::Callable(fake), &[]).unwrap(); + assert_eq!( + vm.host_context() + .module_state::() + .unwrap() + .calls, + 1 + ); + assert_eq!(vm.capture_certification_stats().unwrap().registrations, 1); + } + #[test] + fn explicit_close_and_slot_reuse_do_not_restore_old_observation() { + let mut vm = fixture(NO_SELF, true); + let closure = take_closure(&mut vm); + vm.invoke_callable(closure, &[]).unwrap(); + let context = vm.host_context(); + let state = context.module_state::().unwrap(); + let root = state.root.clone().unwrap(); + let handle = state.handle.unwrap(); + + vm.host_context() + .close_resource::( + handle, + super::super::resource::ResourceCloseReason::Requested, + ) + .unwrap(); + assert_eq!( + root.observe(&vm.host_context()), + CaptureRootState::Unavailable + ); + let fresh = vm.host_context().push_resource(BoxPayload(41)).unwrap(); + assert_ne!(fresh.handle(), handle); + assert_eq!( + root.observe(&vm.host_context()), + CaptureRootState::Unavailable + ); + assert_eq!(vm.host_context().resource_count(), 1); + } + #[test] + fn legacy_vmbc_bytes_execute_without_recreating_capture_authority() { + let mut vm = fixture(NO_SELF, true); + let bytes = crate::vmbc::encode_program(vm.program()).unwrap(); + let decoded = crate::vmbc::decode_program(&bytes).unwrap(); + assert_eq!(crate::vmbc::encode_program(&decoded).unwrap(), bytes); + // Preserve the exact catalog handlers, but deliberately use a fresh + // ordinary instance; no private compiler pair crosses the wire. + let mut legacy = fixture(NO_SELF, false); + legacy.program = Arc::new(decoded); + legacy + .host_context() + .module_state_mut::() + .unwrap() + .expected = Some(CaptureQueryError::Unavailable); + let closure = take_closure(&mut legacy); + assert_eq!( + legacy.invoke_callable(closure, &[]).unwrap(), + Value::Int(41) + ); + assert!(legacy.capture_certification_stats().is_none()); + assert!( + vm.host_context() + .checked_move_resource_capture::(&key()) + .is_err() + ); + } + #[test] + fn fixed_live_cell_history_has_fixed_four_probe_registration_work() { + let source = "pub fn factory() { let owner = neutral::open(); || if true => { neutral::check(); 41 } else => { neutral::close(owner); 41 } }"; + let mut vm = fixture(source, true); + vm.run().unwrap(); + let factory = vm.resolve_exported_callable("factory").unwrap(); + let live = (0..3) + .map(|_| vm.invoke_callable(factory.clone(), &[]).unwrap()) + .collect::>(); + let mut max_entries = 0; + for _ in 0..2048 { + let closure = vm.invoke_callable(factory.clone(), &[]).unwrap(); + let Value::Callable(callable) = &closure else { + panic!("factory closure") + }; + vm.host_context() + .module_state_mut::() + .unwrap() + .environment = callable.env.as_ref().map(Arc::downgrade); + vm.invoke_callable(closure, &[]).unwrap(); + let context = vm.host_context(); + let state = context.module_state::().unwrap(); + let root = state.root.clone().unwrap(); + let handle = state.handle.unwrap(); + assert_eq!(root.observe(&context), CaptureRootState::Released); + + vm.host_context() + .close_resource::( + handle, + super::super::resource::ResourceCloseReason::Requested, + ) + .unwrap(); + let stats = vm.capture_certification_stats().unwrap(); + max_entries = max_entries.max(stats.entries); + assert!(stats.entries <= 12, "{stats:?}"); + assert!(stats.probes <= stats.registrations * 4, "{stats:?}"); + assert_eq!(vm.host_context().resource_count(), 3); + } + let stats = vm.capture_certification_stats().unwrap(); + assert_eq!(stats.registrations, 2051); + assert!(stats.capacity <= 16, "{stats:?}"); + println!("max_entries={max_entries} {stats:?}"); + drop(live); + } +} diff --git a/src/vm/host.rs b/src/vm/host.rs index 68f0386b..984ed576 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -156,7 +156,13 @@ impl<'vm> OwnedHostCall<'vm> { } registry.bind_vm_cached(&mut vm)?; configure(&mut vm)?; - vm.instance.owned_callables.extend(owned); + for callable in owned { + super::callable_maintenance::register( + &mut vm.instance.owned_callables, + &mut vm.host.module_state_store, + callable, + ); + } clear_owned_callable_vm_state(&mut vm); Ok(vm) } @@ -186,7 +192,13 @@ impl Vm { "owned callable vm is not reusable", ))); } - self.instance.owned_callables.extend(owned); + for callable in owned { + super::callable_maintenance::register( + &mut self.instance.owned_callables, + &mut self.host.module_state_store, + callable, + ); + } clear_owned_callable_vm_state(self); cancellation?; reset?; diff --git a/src/vm/host_context.rs b/src/vm/host_context.rs index f76a78e3..b138efb1 100644 --- a/src/vm/host_context.rs +++ b/src/vm/host_context.rs @@ -188,6 +188,26 @@ impl<'a> HostContext<'a> { Self { vm } } + /// Selects exactly one compiler-certified direct Move Resource capture in + /// the active script frame. Numeric equality alone never supplies origin. + pub fn checked_move_resource_capture( + &self, + expected_key: &ResourceTypeKey, + ) -> Result, super::CaptureQueryError> { + let (handle, root) = self.vm.checked_capture_origin(expected_key)?; + let token = self + .typed_resource_with_key::(handle, expected_key) + .map_err(|error| super::CaptureQueryError::Resource(Box::new(error)))?; + Ok(super::CheckedResourceCapture::new(token, root)) + } + + pub(super) fn observe_capture_root( + &self, + root: &super::ResourceCaptureRoot, + ) -> super::CaptureRootState { + self.vm.observe_capture_root(root) + } + /// Registers typed per-VM module state, replacing any earlier value of the /// same type. /// diff --git a/src/vm/instance.rs b/src/vm/instance.rs index ad52c461..a5dd193a 100644 --- a/src/vm/instance.rs +++ b/src/vm/instance.rs @@ -148,7 +148,11 @@ impl Instance { /// Rewinds run-scoped interpreter state for a fresh execution of the same /// program. Host bindings, backend configuration, and compiled artifacts /// (owned outside this struct) are preserved. - pub(crate) fn reset(&mut self, program: &Program) { + pub(crate) fn reset( + &mut self, + program: &Program, + on_registration: impl FnMut(&mut Vec>), + ) { self.invalidate_callback_registries(); self.ip = 0; self.drop_contract_events = 0; @@ -159,7 +163,7 @@ impl Instance { self.clear_locals_with_drop_contract(); self.owned_callables.clear(); self.locals.resize(program.local_count, Value::Null); - self.initialize_root_callable_bindings(program); + self.initialize_root_callable_bindings(program, on_registration); self.call_depth = 0; self.run_depth = 0; self.execution_frames.clear(); @@ -301,7 +305,11 @@ impl Instance { self.callback_registry_flags.push(Arc::downgrade(active)); } - pub(crate) fn initialize_root_callable_bindings(&mut self, program: &Program) { + pub(crate) fn initialize_root_callable_bindings( + &mut self, + program: &Program, + mut on_registration: impl FnMut(&mut Vec>), + ) { let bindings = program.root_callable_bindings.clone(); for binding in bindings { let Some(kind) = program @@ -320,6 +328,7 @@ impl Instance { env: None, }); self.owned_callables.push(Arc::downgrade(&callable)); + on_registration(&mut self.owned_callables); self.locals[binding.local_slot as usize] = Value::Callable(callable); } } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index c66d1ed1..15d06a4e 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -6,6 +6,12 @@ use std::task::{Context, Poll, Waker}; pub(crate) mod aot; pub mod async_host; +mod callable_maintenance; +mod capture_origin; +pub use capture_origin::{ + CaptureCertificationStats, CaptureQueryError, CaptureRootState, CheckedResourceCapture, + ResourceCaptureRoot, +}; mod capability; pub mod diagnostics; mod engine; @@ -36,6 +42,9 @@ mod tests; pub use self::aot::AotArtifactError; use self::async_host::preserve_stream_cleanup; pub use self::async_host::{CaptureAsyncHostContext, HostFuture, HostFutureOutput}; +pub use self::callable_maintenance::{ + CallableIdentityMaintenanceConfig, CallableIdentityMaintenanceStats, +}; pub use self::capability::{CapabilityProfile, CapabilityProfileBuilder}; use self::engine::Engine; pub use self::epoch::{EpochCheckpoint, EpochHandle}; @@ -1149,13 +1158,16 @@ impl Vm { pub fn new_shared_with_jit_config(program: Arc, jit_config: jit::JitConfig) -> Self { let engine = Engine::new(jit_config, &program); let mut instance = Instance::new(&program); - instance.initialize_root_callable_bindings(&program); + let mut host = HostRuntime::with_standard_composition(crate::standard_composition()); + instance.initialize_root_callable_bindings(&program, |entries| { + callable_maintenance::after_registration(entries, &mut host.module_state_store); + }); Self { program, engine, instance, run_ctx: RunContext::default(), - host: HostRuntime::with_standard_composition(crate::standard_composition()), + host, } } @@ -1268,6 +1280,7 @@ impl Vm { /// `poll_reset_for_reuse` to obtain the deterministic completion result /// before observing an empty scope or reusing the VM. pub fn reset_for_reuse(&mut self) -> VmResult<()> { + self.revoke_capture_certification(); if self.stream_completion_running() { return Err(VmError::InvalidFrameState( "callable stream completion is running", @@ -1296,7 +1309,10 @@ impl Vm { return Err(error); } self.run_ctx.reset_for_reuse(); - self.instance.reset(&self.program); + callable_maintenance::identities_cleared(&mut self.host.module_state_store); + self.instance.reset(&self.program, |entries| { + callable_maintenance::after_registration(entries, &mut self.host.module_state_store); + }); self.engine.reset_runtime_state(&self.program); Ok(()) } @@ -1777,9 +1793,12 @@ impl Vm { kind: prototype.kind, env, }); - self.instance - .owned_callables - .push(Arc::downgrade(&callable)); + callable_maintenance::register( + &mut self.instance.owned_callables, + &mut self.host.module_state_store, + Arc::downgrade(&callable), + ); + self.certify_capture_cells(&callable); Ok(Value::Callable(callable)) } @@ -1979,9 +1998,11 @@ impl Vm { kind, env: None, }); - self.instance - .owned_callables - .push(Arc::downgrade(&callable)); + callable_maintenance::register( + &mut self.instance.owned_callables, + &mut self.host.module_state_store, + Arc::downgrade(&callable), + ); self.instance.locals[local_base + relative] = Value::Callable(callable); } for (slot, value) in inherited_callables { @@ -2041,6 +2062,7 @@ impl Vm { } } } + self.certify_capture_frame(local_base, callable.as_ref(), prototype_id); if let Some(slot) = prototype.self_slot { let relative = slot as usize; if relative >= local_count { @@ -2099,6 +2121,7 @@ impl Vm { .execution_frames .pop() .ok_or(VmError::InvalidFrameState("missing active frame"))?; + self.release_capture_frame(frame.local_base); self.instance.active_local_base_cache = self .instance .execution_frames @@ -2626,6 +2649,38 @@ impl Vm { Ok(value as u32) } + // SSA synchronization is not a new guest write. Genuine native stores + // invalidate capture authority at their original semantic instruction. + pub(super) fn synchronize_native_local(&mut self, index: u8, value: Value) -> VmResult<()> { + let absolute = self.absolute_local_index(index)?; + if let Some(cell) = self.instance.capture_cells.get(&absolute).cloned() { + if Self::value_references_capture_cell(&value, &cell, &mut HashSet::new())? { + return Err(VmError::InvalidFrameState( + "callable capture ownership cycle is unsupported", + )); + } + let previous = { + let mut captured = cell + .lock() + .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))?; + std::mem::replace(&mut *captured, value.clone()) + }; + self.instance.locals[absolute] = value; + self.drop_value_with_contract(previous); + Ok(()) + } else { + self.store_local_absolute_with_drop_contract(absolute, index, value) + } + } + + pub(super) fn note_native_semantic_local_write(&mut self, index: u8) -> VmResult<()> { + let absolute = self.absolute_local_index(index)?; + if let Some(cell) = self.instance.capture_cells.get(&absolute).cloned() { + self.invalidate_capture_cell(&cell); + } + Ok(()) + } + #[inline(always)] pub(super) fn store_local_with_drop_contract( &mut self, @@ -2670,6 +2725,7 @@ impl Vm { "callable capture ownership cycle is unsupported", )); } + self.invalidate_capture_cell(&cell); let previous = { let mut captured = cell .lock() @@ -2743,6 +2799,9 @@ impl Vm { pub(crate) fn detach_local_with_drop_contract(&mut self, index: u8) -> VmResult<()> { let absolute = self.absolute_local_index(index)?; + if let Some(cell) = self.instance.capture_cells.get(&absolute).cloned() { + self.invalidate_capture_cell(&cell); + } self.instance.capture_cells.remove(&absolute); let slot = self .instance @@ -3483,6 +3542,8 @@ impl Vm { } pub fn set_local(&mut self, index: u8, value: Value) -> VmResult<()> { + self.absolute_local_index(index)?; + self.revoke_capture_certification(); self.store_local_with_drop_contract(index, value)?; let config = *self.engine.jit.config(); self.set_jit_config(config); @@ -3706,6 +3767,7 @@ impl Vm { if self.stream_completion_running() { return; } + self.revoke_capture_certification(); self.invalidate_callback_registries(); let _ = self.cancel_waiting_host_op_with_reason( crate::vm::operation::OperationCancelReason::VmDrop, @@ -3731,6 +3793,7 @@ impl Vm { self.instance.queued_callables.clear(); self.instance.completed_callable_results.clear(); self.instance.owned_callables.clear(); + callable_maintenance::identities_cleared(&mut self.host.module_state_store); self.instance.draining_queued_callables = false; self.clear_stack_with_drop_contract(); self.instance.capture_cells.clear(); @@ -3847,6 +3910,7 @@ impl Vm { let Some(frame) = self.instance.execution_frames.pop() else { break; }; + self.release_capture_frame(frame.local_base); let frame_end = match frame.local_base.checked_add(frame.local_count) { Some(end) => end, None => self.instance.locals.len(), diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index f5852d4a..6907db5a 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -707,7 +707,11 @@ pub(crate) extern "C" fn pd_vm_native_materialize_root_callable( kind: prototype.kind, env: None, }); - vm.instance.owned_callables.push(Arc::downgrade(&callable)); + crate::vm::callable_maintenance::register( + &mut vm.instance.owned_callables, + &mut vm.host.module_state_store, + Arc::downgrade(&callable), + ); unsafe { // The owned temp slot may already hold a previous iteration's // materialized callable (the trace reuses the slot on every loop @@ -1237,6 +1241,80 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_exit_state( }) } +pub(crate) extern "C" fn pd_vm_native_synchronize_active_exit_state( + vm: *mut Vm, + stack_src: *const Value, + stack_len: usize, + locals_src: *const Value, + locals_len: usize, + ip: usize, +) -> i32 { + // Preserve the existing dense-restore diagnostic family for scalar callers. + run_step(vm, "restore_active_exit_state", |vm| { + let stack_base = vm.active_operand_stack_base(); + let local_base = vm.active_local_base(); + let expected_locals_len = local_base + .checked_add(locals_len) + .ok_or_else(|| VmError::JitNative("native active local length overflow".to_string()))?; + if expected_locals_len != vm.instance.locals.len() { + return Err(VmError::JitNative(format!( + "native active exit restore locals length mismatch: expected {}, got {}", + vm.instance.locals.len(), + expected_locals_len + ))); + } + if stack_base > vm.instance.stack.len() { + return Err(VmError::JitNative(format!( + "native active stack base {stack_base} exceeds stack length {}", + vm.instance.stack.len() + ))); + } + if stack_len != 0 && stack_src.is_null() { + return Err(VmError::JitNative( + "native active exit restore received null stack buffer".to_string(), + )); + } + if locals_len != 0 && locals_src.is_null() { + return Err(VmError::JitNative( + "native active exit restore received null locals buffer".to_string(), + )); + } + + vm.instance.stack.truncate(stack_base); + vm.instance.stack.reserve(stack_len); + for index in 0..stack_len { + let value = unsafe { std::ptr::read(stack_src.add(index)) }; + vm.instance.stack.push(value); + } + + for index in 0..locals_len { + let local_index = u8::try_from(index).map_err(|_| { + VmError::JitNative("native active local index out of range".to_string()) + })?; + let value = unsafe { std::ptr::read(locals_src.add(index)) }; + vm.synchronize_native_local(local_index, value)?; + } + + vm.jump_to(ip)?; + Ok(STATUS_CONTINUE) + }) +} + +pub(crate) extern "C" fn pd_vm_native_note_local_write(vm: *mut Vm, index: usize) -> i32 { + run_step(vm, "note_semantic_local_write", |vm| { + let index = u8::try_from(index) + .map_err(|_| VmError::JitNative("native local write index out of range".into()))?; + vm.note_native_semantic_local_write(index)?; + Ok(STATUS_CONTINUE) + }) +} +pub(crate) fn synchronize_active_exit_state_entry_address() -> usize { + pd_vm_native_synchronize_active_exit_state as *const () as usize +} +pub(crate) fn note_local_write_entry_address() -> usize { + pd_vm_native_note_local_write as *const () as usize +} + pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( vm: *mut Vm, stack_src: *const Value, @@ -2123,7 +2201,7 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, .map_err(|_| VmError::JitNative("call argc out of range".to_string()))?; let call_ip = usize::try_from(c) .map_err(|_| VmError::JitNative("call ip out of range".to_string()))?; - match vm.execute_host_call(index, argc, call_ip)? { + match vm.execute_host_call_with_capture_site(index, argc, call_ip)? { HostCallExecOutcome::Returned => Ok(STATUS_CONTINUE), HostCallExecOutcome::Halted => Ok(STATUS_HALTED), HostCallExecOutcome::Yielded => Ok(STATUS_YIELDED), @@ -2139,7 +2217,7 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, })?; let call_ip = usize::try_from(c) .map_err(|_| VmError::JitNative("builtin call ip out of range".to_string()))?; - match vm.execute_host_call(index, argc, call_ip)? { + match vm.execute_host_call_with_capture_site(index, argc, call_ip)? { HostCallExecOutcome::Returned => Ok(STATUS_CONTINUE), HostCallExecOutcome::Halted => Ok(STATUS_HALTED), HostCallExecOutcome::Yielded => Ok(STATUS_YIELDED), diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index 3a8008a2..081f0222 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -23,14 +23,15 @@ pub(crate) use bridge::{ map_iter_take_key_entry_address, map_iter_take_value_entry_address, map_set_entry_address, materialize_root_callable_entry_address, non_yielding_host_call_entry_address, non_yielding_i64_host_call_entry_address, non_yielding_scalar_host_call_entry_address, - regex_match_entry_address, regex_replace_entry_address, replace_value_in_slot_entry_address, - restore_active_exit_state_entry_address, restore_active_sparse_exit_state_entry_address, - restore_exit_state_entry_address, restore_sparse_exit_state_entry_address, - restore_virtual_frame_entry_address, shared_array_from_buffer_entry_address, - shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, - store_bridge_error, string_contains_entry_address, string_lower_ascii_entry_address, - string_replace_literal_entry_address, string_split_literal_entry_address, take_bridge_error, - to_string_entry_address, type_of_entry_address, value_eq_entry_address, + note_local_write_entry_address, regex_match_entry_address, regex_replace_entry_address, + replace_value_in_slot_entry_address, restore_active_exit_state_entry_address, + restore_active_sparse_exit_state_entry_address, restore_exit_state_entry_address, + restore_sparse_exit_state_entry_address, restore_virtual_frame_entry_address, + shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, + shared_string_from_buffer_entry_address, store_bridge_error, string_contains_entry_address, + string_lower_ascii_entry_address, string_replace_literal_entry_address, + string_split_literal_entry_address, synchronize_active_exit_state_entry_address, + take_bridge_error, to_string_entry_address, type_of_entry_address, value_eq_entry_address, value_len_entry_address, write_heap_value_to_slot_entry_address, zero_bytes_entry_address, }; #[cfg(feature = "cranelift-jit")] diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 24bd25d9..05775ba2 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -1,3 +1,5 @@ +mod callable_identity; + use super::*; use crate::BuiltinFunction; use crate::bytecode::TypeMap; diff --git a/src/vm/tests/callable_identity.rs b/src/vm/tests/callable_identity.rs new file mode 100644 index 00000000..758420e5 --- /dev/null +++ b/src/vm/tests/callable_identity.rs @@ -0,0 +1,258 @@ +use super::*; +use crate::OwnedHostCall; + +#[test] +fn callable_identity_long_lived_factory_metadata_is_bounded() { + let compiled = crate::compile_source_for_repl( + r#" + pub fn make_counter() { + let mut count = 0; + fn next() { count = count + 1; count } + next + } + "#, + ) + .expect("factory source"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + assert_eq!(vm.run().expect("root execution"), VmStatus::Halted); + let factory = vm + .resolve_exported_callable("make_counter") + .expect("factory"); + let mut checkpoints = Vec::new(); + for round in 1usize..=4096 { + let value = vm + .invoke_callable(factory.clone(), &[]) + .expect("factory invocation"); + assert!(matches!(value, Value::Callable(_))); + drop(value); + if round.is_power_of_two() { + checkpoints.push(( + round, + vm.instance.owned_callables.len(), + vm.instance.owned_callables.capacity(), + )); + } + } + println!("weak identity checkpoints: {checkpoints:?}"); + let stats = vm + .callable_identity_maintenance_stats() + .expect("enabled policy"); + assert_eq!(stats.registrations, 8192); + assert_eq!(stats.probes, 3 * stats.registrations); + assert_eq!( + stats.entries as u64, + 1 + stats.registrations - stats.retired + ); + println!("weak identity factory stats: {stats:?}"); + assert!( + vm.instance.owned_callables.len() <= 32, + "historical identities: {checkpoints:?}" + ); + assert!( + vm.instance.owned_callables.capacity() <= 64, + "historical vector allocation: {checkpoints:?}" + ); +} + +#[test] +fn callable_identity_native_layout_control() { + let offsets = [ + std::mem::size_of::(), + std::mem::align_of::(), + std::mem::size_of::(), + std::mem::align_of::(), + std::mem::offset_of!(Vm, instance.stack), + std::mem::offset_of!(Vm, instance.locals), + std::mem::offset_of!(Vm, instance.ip), + std::mem::offset_of!(Vm, instance.owned_callables), + std::mem::offset_of!(Vm, engine.program_constants_ptr), + std::mem::offset_of!(Vm, run_ctx.fuel_remaining), + std::mem::offset_of!(Vm, run_ctx.fuel_ops_until_check), + std::mem::offset_of!(Vm, run_ctx.epoch_deadline), + std::mem::offset_of!(Vm, run_ctx.epoch_counter_ptr), + std::mem::offset_of!(Vm, engine.jit_native_region_edge_count), + std::mem::offset_of!(Vm, engine.jit_native_direct_link_count), + std::mem::offset_of!(Vm, engine.jit_native_active_direct_trace_id), + ]; + println!("weak identity native layout: {offsets:?}"); + assert!(offsets.iter().skip(1).all(|offset| *offset < offsets[0])); +} + +#[test] +fn callable_identity_root_batches_have_per_identity_budget() { + for roots in [8usize, 64] { + let source: String = (0..roots) + .map(|index| format!("pub fn root_{index}() -> int {{ {index} }}\n")) + .collect(); + let compiled = crate::compile_source_for_repl(&source).expect("root batch"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + vm.run().expect("root run"); + let root = vm.resolve_exported_callable("root_0").expect("root"); + for _ in 0..2048 { + assert_eq!( + vm.invoke_callable(root.clone(), &[]).expect("invoke"), + Value::Int(0) + ); + let stats = vm.callable_identity_maintenance_stats().unwrap(); + assert!(stats.entries <= 4 * roots + 32, "{stats:?}"); + assert!( + stats.capacity <= (4 * roots + 32).next_power_of_two(), + "{stats:?}" + ); + } + let stats = vm.callable_identity_maintenance_stats().unwrap(); + assert_eq!(stats.registrations, (2048 * roots) as u64); + assert_eq!(stats.probes, 3 * stats.registrations); + println!("weak identity root batch roots={roots}: {stats:?}"); + } +} + +#[test] +fn callable_identity_repeated_owned_graph_adoption_does_not_duplicate_live_records() { + let compiled = crate::compile_source_for_repl( + r#" + pub fn make_counter() { + let mut count = 0; + fn next() { count = count + 1; count } + next + } + "#, + ) + .expect("source"); + let mut source = Vm::new(compiled.program.with_local_count(compiled.locals)); + source.run().expect("run"); + let factory = source.resolve_exported_callable("make_counter").unwrap(); + let counter = source.invoke_callable(factory, &[]).unwrap(); + let graph = Value::array(vec![counter.clone(); 64]); + let registry = HostFunctionRegistry::empty(); + let mut call = OwnedHostCall::new(&mut source, Vec::new()); + let mut child = call + .spawn_owned_callable_vm(®istry, &graph, |vm| { + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + Ok(()) + }) + .expect("adoption"); + assert_eq!( + child + .callable_identity_maintenance_stats() + .unwrap() + .registrations, + 1 + ); + for round in 1..=4096 { + child + .recover_owned_callable(&graph) + .expect("legitimate readoption after reset"); + assert_eq!( + child + .invoke_callable(counter.clone(), &[]) + .expect("retained graph"), + Value::Int(round) + ); + let stats = child.callable_identity_maintenance_stats().unwrap(); + assert!(stats.entries <= 4, "{stats:?}"); + assert!(stats.capacity <= 8, "{stats:?}"); + let Value::Callable(pointer) = &counter else { + unreachable!() + }; + assert_eq!( + child + .instance + .owned_callables + .iter() + .filter(|record| record + .upgrade() + .is_some_and(|owned| Arc::ptr_eq(&owned, pointer))) + .count(), + 1 + ); + } + println!( + "weak identity owned graph: {:?}", + child.callable_identity_maintenance_stats() + ); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn callable_identity_native_materialization_prunes_history_without_invalidating_aliases() { + let compiled = crate::compile_source_for_repl("pub fn answer() -> int { 42 }").unwrap(); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + vm.run().unwrap(); + let prototype = vm.program.root_callable_bindings[0].prototype_id; + let mut slot = Value::Null; + let mut aliases = Vec::new(); + // Invoke the existing native ABI entry with its exact declared signature. + let materialize: extern "C" fn(*mut Vm, *mut Value, i64) -> i32 = + unsafe { std::mem::transmute(native::materialize_root_callable_entry_address()) }; + for round in 0..16384 { + assert_eq!( + materialize(&mut vm, &mut slot, i64::from(prototype)), + native::STATUS_CONTINUE + ); + if round < 64 { + aliases.push(slot.clone()); + } + let stats = vm.callable_identity_maintenance_stats().unwrap(); + assert!(stats.entries <= 4 * 66 + 32, "{stats:?}"); + assert!(stats.capacity <= 512, "{stats:?}"); + } + let stats = vm.callable_identity_maintenance_stats().unwrap(); + assert_eq!(stats.registrations, 16384); + assert_eq!(stats.probes, 3 * stats.registrations); + for alias in aliases { + assert_eq!(vm.invoke_callable(alias, &[]).unwrap(), Value::Int(42)); + } + println!("weak identity native materializations: {stats:?}"); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn callable_identity_real_jit_and_aot_preserve_bounded_registration() { + let source = r#" + fn answer() -> int { 42 } + let function = answer; + let mut index = 0; + let mut result = 0; + while index < 16384 { + result = function(); + index = index + 1; + } + result; + "#; + for aot in [false, true] { + let compiled = crate::compile_source_for_repl(source).unwrap(); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + if aot { + vm.compile_aot().expect("AOT"); + } else { + vm.set_jit_config(jit::JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + } + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + let executions = if aot { + vm.aot_exec_count() + } else { + vm.jit_native_exec_count() + }; + assert!(executions > 0, "must execute native code"); + let stats = vm.callable_identity_maintenance_stats().unwrap(); + assert!( + stats.registrations >= 16384, + "must mint fresh identities: {stats:?}" + ); + assert_eq!(stats.probes, 3 * stats.registrations); + assert!(stats.entries <= 32, "{stats:?}"); + assert!(stats.capacity <= 64, "{stats:?}"); + assert!(!vm.engine.aot_interpreter_boundary_hit); + println!("weak identity backend aot={aot} native_executions={executions}: {stats:?}"); + } +} diff --git a/tests/aot_artifact_process_tests.rs b/tests/aot_artifact_process_tests.rs new file mode 100644 index 00000000..6426507c --- /dev/null +++ b/tests/aot_artifact_process_tests.rs @@ -0,0 +1,288 @@ +#![cfg(all(feature = "cli", feature = "cranelift-jit", unix))] + +use std::fs; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!("aot-process-{}-{name}", std::process::id())); + fs::create_dir(&path).expect("create isolated fixture"); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +// A defective loader can jump to an unmapped writer-process helper. Execute +// only in a bounded child, never in this test runner, and prohibit core files. +fn run_cli(args: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_pd-vm-run")); + command + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + unsafe { + command.pre_exec(|| { + let limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if libc::setrlimit(libc::RLIMIT_CORE, &limit) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().expect("spawn Core CLI"); + let started = Instant::now(); + loop { + if child.try_wait().expect("poll Core CLI").is_some() { + break; + } + if started.elapsed() > Duration::from_secs(20) { + child.kill().expect("kill timed out Core CLI"); + let output = child.wait_with_output().expect("reap Core CLI"); + panic!("Core CLI timed out: {args:?}: {output:?}"); + } + std::thread::sleep(Duration::from_millis(5)); + } + let output = child.wait_with_output().expect("collect Core CLI"); + eprintln!( + "Core CLI {args:?}: status={}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn assert_native_result(output: &Output, dump: bool, value: i64) { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "reader failed: {output:?}"); + assert!(stdout.contains("vm halted"), "{stdout}"); + assert!( + stdout.contains(&format!("stack: [Int({value})]")), + "{stdout}" + ); + if dump { + assert!(stdout.contains("lowering=ssa"), "{stdout}"); + assert!(!stdout.contains("interpreter-boundary"), "{stdout}"); + let count = stdout + .lines() + .find_map(|line| line.trim().strip_prefix("aot executions: ")) + .expect("native execution count") + .parse::() + .expect("numeric count"); + assert!(count > 0, "no actual native returns: {stdout}"); + } +} + +fn roundtrip(name: &str, source: &str, result: i64) { + let fixture = Fixture::new(name); + let source_path = fixture.0.join("program.rss"); + let artifact_path = fixture.0.join("program.pat"); + fs::write(&source_path, source).expect("write source"); + let source_path = source_path.to_str().unwrap(); + let artifact_path = artifact_path.to_str().unwrap(); + assert_native_result( + &run_cli(&[ + "--aot-save", + artifact_path, + "--aot-dump", + "--fuel", + "100000", + source_path, + ]), + true, + result, + ); + let bytes = fs::read(artifact_path).expect("read baseline writer artifact"); + assert_eq!(&bytes[..10], b"PAT\0\x08\0\x08\0\0\0"); + // Each load has a new address space, including the matching source companion. + let mut failures = Vec::new(); + for companion in [false, true] { + for dump in [false, true] { + let mut args = vec!["--aot-load", artifact_path, "--fuel", "100000"]; + if dump { + args.push("--aot-dump"); + } + if companion { + args.push(source_path); + } + let output = run_cli(&args); + if !output.status.success() { + failures.push(format!( + "companion={companion} dump={dump}: {}", + output.status + )); + } else { + assert_native_result(&output, dump, result); + } + } + } + assert!( + failures.is_empty(), + "fresh-process artifact readers failed: {failures:?}" + ); + // Embedded bytecode suffices; standalone loading must not reopen source. + fs::remove_file(source_path).expect("remove original source"); + assert_native_result( + &run_cli(&[ + "--aot-load", + artifact_path, + "--aot-dump", + "--fuel", + "100000", + ]), + true, + result, + ); +} + +#[test] +fn fresh_process_number_factorial_artifact_is_native_with_and_without_source() { + roundtrip( + "number-factorial", + "fn factorial(n: number) -> number { if n < 2 => { 1 } else => { n * factorial(n - 1) } } factorial(6);", + 720, + ); +} + +#[test] +fn fresh_process_float_number_factorial_retains_float_result() { + let fixture = Fixture::new("float-number-factorial"); + let source = fixture.0.join("program.rss"); + let artifact = fixture.0.join("program.pat"); + fs::write(&source, "fn factorial(n: number) -> number { if n < 2 => { 1 } else => { n * factorial(n - 1) } } factorial(6.0);").unwrap(); + let source = source.to_str().unwrap(); + let artifact = artifact.to_str().unwrap(); + let assert_float_native = |output: Output| { + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("vm halted"), "{stdout}"); + assert!(stdout.contains("stack: [Float(720.0)]"), "{stdout}"); + assert!(stdout.contains("lowering=ssa"), "{stdout}"); + assert!(!stdout.contains("interpreter-boundary"), "{stdout}"); + let count = stdout + .lines() + .find_map(|line| line.trim().strip_prefix("aot executions: ")) + .expect("native execution count") + .parse::() + .unwrap(); + assert!(count > 0, "{stdout}"); + }; + assert_float_native(run_cli(&[ + "--aot-save", + artifact, + "--aot-dump", + "--fuel", + "100000", + source, + ])); + assert_float_native(run_cli(&[ + "--aot-load", + artifact, + "--aot-dump", + "--fuel", + "100000", + source, + ])); + assert_float_native(run_cli(&[ + "--aot-load", + artifact, + "--aot-dump", + "--fuel", + "100000", + ])); + fs::remove_file(source).unwrap(); + assert_float_native(run_cli(&[ + "--aot-load", + artifact, + "--aot-dump", + "--fuel", + "100000", + ])); +} + +#[test] +fn fresh_process_native_artifact_loads_both_entrypoints() { + roundtrip("neutral", "let value = 1 + 2; value;", 3); +} + +#[test] +fn fresh_process_ignores_persisted_instructions_in_both_modes() { + let fixture = Fixture::new("persisted-instructions"); + let source = fixture.0.join("program.rss"); + let artifact = fixture.0.join("program.pat"); + fs::write(&source, "let value = 1 + 2; value;").unwrap(); + let source = source.to_str().unwrap(); + let artifact = artifact.to_str().unwrap(); + assert_native_result( + &run_cli(&[ + "--aot-save", + artifact, + "--aot-dump", + "--fuel", + "100000", + source, + ]), + true, + 3, + ); + let mut bytes = fs::read(artifact).unwrap(); + fn read_u32(bytes: &[u8], offset: &mut usize) -> usize { + let value = u32::from_le_bytes(bytes[*offset..*offset + 4].try_into().unwrap()) as usize; + *offset += 4; + value + } + let mut offset = 11; + for _ in 0..3 { + let len = read_u32(&bytes, &mut offset); + offset += len; + } + offset += 12 + 8 + 4; + let len = read_u32(&bytes, &mut offset); + offset += len; + let resumes = read_u32(&bytes, &mut offset); + offset += resumes * 4; + let len = read_u32(&bytes, &mut offset); + assert_eq!(offset + len, bytes.len()); + bytes[offset..].fill(0xcc); // Child isolation contains a regressed raw loader. + for boundary in [false, true] { + bytes[8..10].copy_from_slice(&u16::from(boundary).to_le_bytes()); + fs::write(artifact, &bytes).unwrap(); + for companion in [false, true] { + let mut args = vec!["--aot-load", artifact, "--aot-dump", "--fuel", "100000"]; + if companion { + args.push(source); + } + let output = run_cli(&args); + if !boundary { + assert_native_result(&output, true, 3); + } else { + assert_native_result(&output, false, 3); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("interpreter-boundary"), "{stdout}"); + assert!(stdout.contains("aot executions: 1"), "{stdout}"); + } + } + } +} + +#[test] +fn fresh_process_native_artifact_calls_script_and_host_services() { + roundtrip( + "calls", + "fn bump(value: int) -> int { value + 1 }\nlet printed = print(\"native artifact host call\");\nlet value = bump(2); value;", + 3, + ); +} diff --git a/tests/aot_tagged_number_tests.rs b/tests/aot_tagged_number_tests.rs new file mode 100644 index 00000000..444a9f95 --- /dev/null +++ b/tests/aot_tagged_number_tests.rs @@ -0,0 +1,448 @@ +#![cfg(all(feature = "runtime", feature = "cranelift-jit"))] + +use vm::{BytecodeBuilder, JitConfig, Program, Value, Vm, VmError, VmStatus, compile_source}; + +fn assert_ssa(machine: &Vm) { + let dump = machine.dump_aot_info(); + assert!(dump.contains("lowering=ssa"), "{dump}"); + assert!(!dump.contains("interpreter-boundary"), "{dump}"); +} + +fn exported_vm(source: &str, native: bool) -> Vm { + let mut machine = source_vm(source); + if native { + machine.compile_aot().unwrap(); + assert_ssa(&machine); + } + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + machine +} + +fn callable_result(source: &str, name: &str, args: &[Value], native: bool) -> Value { + // Feed legitimate public Program constants through an ordinary root call. + // The callee's logical Number schema stays unchanged for every invocation. + let arguments = (0..args.len()) + .map(|index| (41 + index).to_string()) + .collect::>() + .join(", "); + let source = format!("{source}\nlet callable = {name}; callable({arguments});"); + let compiled = compile_source(&source).unwrap(); + let mut program = compiled.program.with_local_count(compiled.locals); + for constant in &mut program.constants { + if let Value::Int(value) = constant + && (41..41 + args.len() as i64).contains(value) + { + *constant = args[(*value - 41) as usize].clone(); + } + } + let mut machine = Vm::new_with_jit_config( + program, + JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(100_000); + if native { + machine.compile_aot().unwrap(); + assert_ssa(&machine); + } + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack().len(), 1); + assert_eq!(machine.call_depth(), 0); + if native { + assert!(machine.aot_exec_count() >= 3); + } + let result = machine.stack()[0].clone(); + eprintln!( + "{name} {args:?}: {result:?} native returns={}", + machine.aot_exec_count() + ); + result +} + +#[test] +fn materialized_number_comparisons_preserve_integer_precision_and_mixed_float_semantics() { + let source = "pub fn less(lhs: number, rhs: number) -> bool { lhs < rhs }\n\ + pub fn greater(lhs: number, rhs: number) -> bool { lhs > rhs }"; + let pairs = [ + ( + Value::Int(9_007_199_254_740_992), + Value::Int(9_007_199_254_740_993), + ), + (Value::Int(i64::MIN), Value::Int(i64::MIN + 1)), + (Value::Int(i64::MAX - 1), Value::Int(i64::MAX)), + (Value::Int(i64::MIN), Value::Int(i64::MAX)), + (Value::Int(1), Value::Float(1.5)), + (Value::Float(1.5), Value::Int(2)), + (Value::Float(-2.5), Value::Float(3.5)), + ( + Value::Int(9_007_199_254_740_993), + Value::Float(9_007_199_254_740_992.0), + ), + (Value::Float(f64::NAN), Value::Int(1)), + (Value::Int(1), Value::Float(f64::NAN)), + (Value::Float(f64::NEG_INFINITY), Value::Float(f64::INFINITY)), + (Value::Float(-0.0), Value::Int(0)), + (Value::Float(0.0), Value::Float(-0.0)), + ]; + for (lhs, rhs) in pairs { + for args in [[lhs.clone(), rhs.clone()], [rhs, lhs]] { + for name in ["less", "greater"] { + let expected = callable_result(source, name, &args, false); + let actual = callable_result(source, name, &args, true); + assert_eq!(actual, expected, "{name} {args:?}"); + } + } + } +} + +#[test] +fn dynamic_number_relations_cover_literal_operand_positions() { + for (body, args, expected) in [ + ("n < 2", "1", true), + ("2 > n", "1", true), + ("2 < n", "3", true), + ("n > 2", "3", true), + ("n < 2", "1.5", true), + ("2 > n", "1.5", true), + ("n < 2.0", "1", true), + ("2.0 > n", "1", true), + ("n > 2.0", "1", false), + ("2.0 < n", "1", false), + ] { + assert_native( + &format!("fn compare(n: number) -> bool {{ {body} }} compare({args});"), + &[Value::Bool(expected)], + ); + } +} + +#[test] +fn unrelated_frames_and_materialized_callables_do_not_specialize_number_arguments() { + assert_native( + "fn text(v: string) -> string { v }\n\ + fn integer(v: int) -> int { v }\n\ + fn less(a: number, b: number) -> bool { a < b }\n\ + let label = text(\"other frame\"); let count = integer(3);\n\ + let compare = less;\n\ + let first = compare(9007199254740992, 9007199254740993);\n\ + let second = compare(1.5, 2); first && second;", + &[Value::Bool(true)], + ); +} + +#[test] +fn number_factorial_preserves_float_and_fractional_results() { + let source = "pub fn factorial(n: number) -> number { if n < 2 => { 1 } else => { n * factorial(n - 1) } }"; + for (arg, expected) in [ + (Value::Int(6), Value::Int(720)), + (Value::Float(6.0), Value::Float(720.0)), + (Value::Float(3.5), Value::Float(8.75)), + (Value::Int(1), Value::Int(1)), + (Value::Float(-0.0), Value::Int(1)), + ] { + let args = [arg]; + assert_eq!(callable_result(source, "factorial", &args, false), expected); + let actual = callable_result(source, "factorial", &args, true); + assert_eq!(actual, expected); + } +} + +#[test] +fn dynamic_number_arithmetic_preserves_wrapping_and_result_tags() { + let source = "pub fn subtract(a: number, b: number) -> number { a - b }\n\ + pub fn multiply(a: number, b: number) -> number { a * b }"; + for args in [ + [Value::Int(0), Value::Int(0)], + [Value::Int(0), Value::Float(0.0)], + [Value::Float(-0.0), Value::Float(0.0)], + [Value::Int(i64::MIN), Value::Int(1)], + [Value::Int(i64::MAX), Value::Int(2)], + [Value::Int(9_007_199_254_740_993), Value::Int(1)], + [Value::Int(6), Value::Float(0.5)], + [Value::Float(6.0), Value::Int(2)], + [Value::Float(-0.0), Value::Int(2)], + [Value::Int(2), Value::Float(f64::INFINITY)], + [Value::Float(f64::NAN), Value::Int(2)], + ] { + for name in ["subtract", "multiply"] { + let expected = callable_result(source, name, &args, false); + let actual = callable_result(source, name, &args, true); + match (&actual, &expected) { + (Value::Float(actual), Value::Float(expected)) => { + if expected.is_nan() { + assert!(actual.is_nan()); + } else { + assert_eq!(actual.to_bits(), expected.to_bits()); + } + } + _ => assert_eq!(actual, expected, "{name} {args:?}"), + } + } + } +} + +fn dynamic_program(op: &str) -> Program { + let mut code = BytecodeBuilder::new(); + code.ldloc(0); + code.ldloc(1); + match op { + "less" => code.clt(), + "greater" => code.cgt(), + "subtract" => code.sub(), + "multiply" => code.mul(), + _ => unreachable!(), + } + code.ret(); + Program::new(vec![], code.finish()).with_local_count(2) +} + +#[test] +fn invalid_dynamic_tags_reject_safely_at_the_numeric_instruction() { + for op in ["less", "greater", "subtract", "multiply"] { + for invalid in [ + Value::Null, + Value::Bool(true), + Value::string("invalid"), + Value::array(vec![Value::Int(1)]), + ] { + for args in [ + [invalid.clone(), Value::Int(1)], + [Value::Float(1.5), invalid], + ] { + let mut reference = Vm::new(dynamic_program(op)); + let mut native = Vm::new(dynamic_program(op)); + for machine in [&mut reference, &mut native] { + machine.set_jit_config(JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + machine.set_local(0, args[0].clone()).unwrap(); + machine.set_local(1, args[1].clone()).unwrap(); + machine.set_fuel(100_000); + } + assert!(matches!( + reference.run(), + Err(VmError::TypeMismatch("number")) + )); + native.compile_aot().unwrap(); + assert_ssa(&native); + let error = native + .run() + .expect_err("invalid tags must not produce a numeric result"); + assert!(matches!(error, VmError::JitNative(_)), "{error:?}"); + assert_eq!(native.ip(), 4, "failure must identify numeric opcode"); + assert!(native.stack().is_empty()); + assert_eq!( + native.aot_exec_count(), + 1, + "error return count is not success" + ); + eprintln!( + "{op} {args:?}: {error:?} native error returns={}", + native.aot_exec_count() + ); + } + } + } +} + +struct NumericOperand { + index: u8, + value: Value, + calls: std::sync::Arc>>, +} + +impl vm::HostFunction for NumericOperand { + fn call(&mut self, _: &mut Vm, _: &[Value]) -> vm::VmResult { + self.calls.lock().unwrap().push(self.index); + Ok(vm::CallOutcome::Return(vec![self.value.clone()].into())) + } +} + +#[test] +fn dynamic_numeric_operands_execute_once_in_left_to_right_order() { + for (op, expected) in [ + ("less", Value::Bool(false)), + ("greater", Value::Bool(true)), + ("subtract", Value::Int(1)), + ("multiply", Value::Int(6)), + ] { + for native in [false, true] { + let mut code = BytecodeBuilder::new(); + code.call(0, 0); + code.call(1, 0); + match op { + "less" => code.clt(), + "greater" => code.cgt(), + "subtract" => code.sub(), + "multiply" => code.mul(), + _ => unreachable!(), + } + code.ret(); + let program = Program::with_imports_and_debug( + vec![], + code.finish(), + (0..2) + .map(|index| vm::HostImport { + name: format!("operand{index}"), + arity: 0, + return_type: vm::ValueType::Unknown, + }) + .collect(), + None, + ); + let mut machine = Vm::new_with_jit_config( + program, + JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + for (index, value) in [Value::Int(3), Value::Int(2)].into_iter().enumerate() { + machine.register_function(Box::new(NumericOperand { + index: index as u8, + value, + calls: calls.clone(), + })); + } + machine.set_fuel(100_000); + if native { + machine.compile_aot().unwrap(); + assert_ssa(&machine); + } + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), std::slice::from_ref(&expected)); + assert_eq!(*calls.lock().unwrap(), vec![0, 1]); + if native { + assert!(machine.aot_exec_count() >= 3); + } + } + } +} + +#[test] +fn dynamic_arithmetic_results_survive_loop_carried_number_slots() { + assert_native( + "fn descend(n: number) -> number { let mut current = n; while current > 1 { current = current - 1; } current } descend(6.0);", + &[Value::Float(1.0)], + ); +} + +#[test] +fn the_same_number_factorial_frame_accepts_multiple_runtime_representations() { + assert_native( + "fn factorial(n: number) -> number { if n < 2 => { 1 } else => { n * factorial(n - 1) } }\n\ + let integer = factorial(6); let floating = factorial(6.0); [integer, floating, factorial(3.5)];", + &[Value::array(vec![ + Value::Int(720), + Value::Float(720.0), + Value::Float(8.75), + ])], + ); +} + +#[test] +fn host_callable_invocation_retains_its_existing_interpreter_policy() { + let mut machine = exported_vm("pub fn below(n: number) -> bool { n < 2 }", true); + let callable = machine.resolve_exported_callable("below").unwrap(); + let before = machine.aot_exec_count(); + assert_eq!( + machine.invoke_callable(callable, &[Value::Int(1)]).unwrap(), + Value::Bool(true) + ); + assert_eq!( + machine.aot_exec_count(), + before, + "host invocation currently disables native execution" + ); + eprintln!( + "host invoke_callable boundary: native returns before={before} after={}", + machine.aot_exec_count() + ); +} + +#[test] +fn number_factorial_direct_float_source_is_native() { + for (argument, result) in [("6.0", 720.0), ("3.5", 8.75)] { + assert_native( + &format!( + "fn factorial(n: number) -> number {{ if n < 2 => {{ 1 }} else => {{ n * factorial(n - 1) }} }} factorial({argument});" + ), + &[Value::Float(result)], + ); + } +} + +#[test] +fn number_schema_rejects_invalid_callable_arguments_without_native_success() { + for native in [false, true] { + let mut machine = exported_vm("pub fn below(n: number) -> bool { n < 2 }", native); + let callable = machine.resolve_exported_callable("below").unwrap(); + let before = machine.aot_exec_count(); + let error = machine + .invoke_callable(callable, &[Value::Bool(true)]) + .unwrap_err(); + assert!(matches!( + error, + VmError::TypeMismatch("callable argument schema") + )); + eprintln!("Number callable admission native={native}: {error:?}"); + assert_eq!(machine.aot_exec_count(), before); + assert_eq!(machine.call_depth(), 0); + } +} + +fn source_vm(source: &str) -> Vm { + let compiled = compile_source(source).expect("compile neutral source"); + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.set_jit_config(JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + machine.set_fuel(100_000); + machine +} + +fn assert_native(source: &str, expected: &[Value]) { + let mut reference = source_vm(source); + assert_eq!(reference.run().unwrap(), VmStatus::Halted); + assert_eq!(reference.stack(), expected); + let mut machine = source_vm(source); + machine + .compile_aot() + .expect("compile dynamic Number natively"); + let dump = machine.dump_aot_info(); + assert!(dump.contains("lowering=ssa"), "{dump}"); + assert!(!dump.contains("interpreter-boundary"), "{dump}"); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), expected); + assert!(machine.aot_exec_count() > 0, "{dump}"); + eprintln!( + "native returns={} result={:?}\n{dump}", + machine.aot_exec_count(), + machine.stack() + ); +} + +#[test] +fn number_factorial_preserves_integer_result_natively() { + assert_native( + "fn factorial(n: number) -> number { if n < 2 => { 1 } else => { n * factorial(n - 1) } } factorial(6);", + &[Value::Int(720)], + ); +} + +#[test] +fn number_parameter_relational_comparison_is_native() { + assert_native( + "fn below(n: number) -> bool { n < 2 } below(1);", + &[Value::Bool(true)], + ); +} diff --git a/tests/builtins/io_scope_lifecycle_tests.rs b/tests/builtins/io_scope_lifecycle_tests.rs index 78ce8260..f2dd81aa 100644 --- a/tests/builtins/io_scope_lifecycle_tests.rs +++ b/tests/builtins/io_scope_lifecycle_tests.rs @@ -11,7 +11,6 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{Duration, Instant}; use vm::operation::OperationCancelReason; use vm::operation::OperationId; diff --git a/tests/callable_identity_maintenance.rs b/tests/callable_identity_maintenance.rs new file mode 100644 index 00000000..5de5c4a5 --- /dev/null +++ b/tests/callable_identity_maintenance.rs @@ -0,0 +1,325 @@ +#![cfg(feature = "runtime")] + +use std::sync::Arc; +use vm::{ + CallableIdentityMaintenanceConfig, CallableValue, ScriptCallback, Store, Value, Vm, VmError, + VmStatus, compile_source_for_repl, +}; + +fn factory_vm() -> Vm { + let compiled = compile_source_for_repl( + r#" + pub fn make_counter() { + let mut count = 0; + fn next() { count = count + 1; count } + next + } + "#, + ) + .expect("factory source"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!(vm.run().expect("root execution"), VmStatus::Halted); + vm +} + +#[test] +fn fixed_live_aliases_callbacks_and_returned_containers_survive_long_history() { + for live in [1usize, 8, 64] { + let mut vm = factory_vm(); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + let factory = vm + .resolve_exported_callable("make_counter") + .expect("factory"); + let aliases: Vec<_> = (0..live) + .map(|_| vm.invoke_callable(factory.clone(), &[]).expect("counter")) + .collect(); + let returned_container = Value::array(aliases.clone()); + let mut store = Store::from_vm(vm); + let callback: ScriptCallback<(), i64> = + store.script_callback(aliases[0].clone()).expect("callback"); + store + .enqueue_callback(callback.prepare(()).expect("prepare")) + .expect("queue"); + let mut checkpoints = Vec::new(); + for round in 1usize..=16384 { + let transient = store + .vm_mut() + .invoke_callable(factory.clone(), &[]) + .expect("churn"); + drop(transient); + if round.is_power_of_two() { + let stats = store + .vm() + .callable_identity_maintenance_stats() + .expect("stats"); + checkpoints.push((round, stats.entries, stats.capacity)); + assert_eq!(stats.probes, 3 * stats.registrations); + assert!(stats.entries <= 4 * live + 32, "live={live}: {stats:?}"); + assert!(stats.capacity <= (4 * live + 32).next_power_of_two()); + } + } + assert_eq!( + store.drain_callbacks().expect("queued live alias"), + vec![Value::Int(1)] + ); + assert_eq!(callback.call(&mut store, ()).expect("callback alias"), 2); + let Value::Array(container) = returned_container else { + unreachable!() + }; + for (index, alias) in container.iter().enumerate() { + assert_eq!( + store + .vm_mut() + .invoke_callable(alias.clone(), &[]) + .expect("container alias"), + Value::Int(if index == 0 { 3 } else { 1 }) + ); + } + println!("weak identity public live={live} checkpoints: {checkpoints:?}"); + drop(container); + drop(aliases); + drop(callback); + for _ in 0..1024 { + drop( + store + .vm_mut() + .invoke_callable(factory.clone(), &[]) + .expect("post-release churn"), + ); + } + let stats = store + .vm() + .callable_identity_maintenance_stats() + .expect("final stats"); + assert!( + stats.entries <= 8, + "released aliases must retire: {stats:?}" + ); + } +} + +#[test] +fn raw_clones_stay_valid_and_forged_foreign_stale_and_replacement_handles_are_rejected() { + let mut vm = factory_vm(); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + let factory = vm + .resolve_exported_callable("make_counter") + .expect("factory"); + let owned = vm.invoke_callable(factory.clone(), &[]).expect("counter"); + let raw_clone = owned.clone(); + let Value::Callable(arc) = &owned else { + unreachable!() + }; + let forged = Value::Callable(Arc::new(CallableValue { + prototype_id: arc.prototype_id, + kind: arc.kind, + env: arc.env.clone(), + })); + let mut other = factory_vm(); + other.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + let foreign = other + .resolve_exported_callable("make_counter") + .expect("foreign factory"); + for _ in 0..4096 { + drop(vm.invoke_callable(factory.clone(), &[]).expect("churn")); + } + for invalid in [forged, foreign] { + let ip = vm.ip(); + let stack = vm.stack().to_vec(); + assert!(matches!( + vm.invoke_callable(invalid, &[]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + )); + assert_eq!(vm.ip(), ip); + assert_eq!(vm.stack(), stack); + } + assert_eq!( + vm.invoke_callable(raw_clone.clone(), &[]) + .expect("raw clone remains live"), + Value::Int(1) + ); + vm.reset_for_reuse().expect("reset"); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + std::task::Poll::Ready(Ok(())) + )); + assert_eq!(vm.run().expect("rerun"), VmStatus::Halted); + assert!(vm.callable_identity_maintenance_stats().is_some()); + assert!(vm.invoke_callable(raw_clone.clone(), &[]).is_err()); + assert!(vm.invoke_callable(factory, &[]).is_err()); + let fresh = vm + .resolve_exported_callable("make_counter") + .expect("fresh factory"); + assert!(matches!( + vm.invoke_callable(fresh, &[]), + Ok(Value::Callable(_)) + )); + let mut store = Store::from_vm(vm); + let replacement = factory_vm(); + store.replace_vm(replacement); + assert!(store.vm().callable_identity_maintenance_stats().is_none()); + assert!(store.vm_mut().invoke_callable(raw_clone, &[]).is_err()); +} + +#[test] +fn policy_is_explicit_reconfigurable_and_scope_independent() { + assert!(CallableIdentityMaintenanceConfig::new(0).is_none()); + assert!(CallableIdentityMaintenanceConfig::new(1).is_none()); + assert!(CallableIdentityMaintenanceConfig::new(2).is_none()); + assert_eq!( + CallableIdentityMaintenanceConfig::new(3) + .unwrap() + .probes_per_registration(), + 3 + ); + let mut vm = factory_vm(); + assert!(vm.callable_identity_maintenance_stats().is_none()); + assert!(vm.host_context().is_module_state_empty()); + let scope_counts = ( + vm.host_context().resource_count(), + vm.host_context().operation_count(), + ); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(7)); + assert!(!vm.host_context().is_module_state_empty()); + assert_eq!( + ( + vm.host_context().resource_count(), + vm.host_context().operation_count() + ), + scope_counts + ); + let factory = vm + .resolve_exported_callable("make_counter") + .expect("factory"); + for _ in 0..32 { + drop(vm.invoke_callable(factory.clone(), &[]).expect("churn")); + } + let stats = vm.callable_identity_maintenance_stats().unwrap(); + assert_eq!(stats.registrations, 64); + assert_eq!(stats.probes, 7 * stats.registrations); + assert_eq!( + ( + vm.host_context().resource_count(), + vm.host_context().operation_count() + ), + scope_counts + ); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + assert_eq!( + vm.callable_identity_maintenance_stats() + .unwrap() + .registrations, + 0 + ); + vm.shutdown(); + assert_eq!(vm.callable_identity_maintenance_stats().unwrap().entries, 0); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(5)); + assert!(vm.callable_identity_maintenance_stats().is_some()); + vm.set_callable_identity_maintenance(None); + assert!(vm.callable_identity_maintenance_stats().is_none()); + assert!(vm.host_context().is_module_state_empty()); +} + +#[test] +fn enabling_retires_existing_backlog_incrementally_and_keeps_capacity_high_water() { + let mut vm = factory_vm(); + let factory = vm.resolve_exported_callable("make_counter").unwrap(); + for _ in 0..4096 { + drop(vm.invoke_callable(factory.clone(), &[]).unwrap()); + } + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + let initial = vm.callable_identity_maintenance_stats().unwrap(); + assert_eq!( + initial.entries, 8193, + "default registration remains append-only" + ); + assert_eq!(initial.probes, 0, "configuration must not scan"); + drop(vm.invoke_callable(factory.clone(), &[]).unwrap()); + let first = vm.callable_identity_maintenance_stats().unwrap(); + assert_eq!(first.registrations, 2); + assert_eq!(first.probes, 6); + assert!(first.retired <= 6); + for _ in 0..8192 { + drop(vm.invoke_callable(factory.clone(), &[]).unwrap()); + } + let final_stats = vm.callable_identity_maintenance_stats().unwrap(); + assert!(final_stats.entries <= 8, "{final_stats:?}"); + assert_eq!( + final_stats.capacity, initial.capacity, + "allocation high water is retained" + ); + println!("weak identity backlog initial={initial:?} final={final_stats:?}"); +} + +#[test] +fn active_capture_frame_survives_nested_factory_churn() { + let compiled = compile_source_for_repl( + r#" + pub fn make_counter() { + let mut count = 40; + fn next() { + count = count + 1; + let mut index = 0; + while index < 4096 { + let transient = make_counter(); + index = index + 1; + } + count = count + 1; + count + } + next + } + "#, + ) + .unwrap(); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + vm.run().unwrap(); + let factory = vm.resolve_exported_callable("make_counter").unwrap(); + let counter = vm.invoke_callable(factory, &[]).unwrap(); + assert_eq!( + vm.invoke_callable(counter.clone(), &[]).unwrap(), + Value::Int(42) + ); + assert_eq!(vm.invoke_callable(counter, &[]).unwrap(), Value::Int(44)); + let stats = vm.callable_identity_maintenance_stats().unwrap(); + assert!(stats.retired > 8192); + assert!(stats.entries <= 32, "{stats:?}"); + assert_eq!(stats.probes, 3 * stats.registrations); + println!("weak identity nested active capture: {stats:?}"); +} + +#[test] +fn active_host_reentry_remains_rejected_before_effects() { + struct Reentry; + impl vm::HostFunction for Reentry { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + let callable = vm.resolve_exported_callable("entry")?; + let before = vm.callable_identity_maintenance_stats().unwrap(); + assert!(matches!( + vm.invoke_callable(callable, &[]), + Err(VmError::InvalidFrameState( + "host invocation requires a halted VM" + )) + )); + assert_eq!(vm.callable_identity_maintenance_stats().unwrap(), before); + Ok(vm::CallOutcome::Return(vm::CallReturn::none())) + } + } + let compiled = compile_source_for_repl( + r#" + fn pulse(); + pub fn entry() -> int { pulse(); 42 } + "#, + ) + .unwrap(); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.register_function(Box::new(Reentry)); + vm.set_callable_identity_maintenance(CallableIdentityMaintenanceConfig::new(3)); + vm.run().unwrap(); + let callable = vm.resolve_exported_callable("entry").unwrap(); + assert_eq!(vm.invoke_callable(callable, &[]).unwrap(), Value::Int(42)); +} diff --git a/tests/capture_origin_certification.rs b/tests/capture_origin_certification.rs new file mode 100644 index 00000000..8181c325 --- /dev/null +++ b/tests/capture_origin_certification.rs @@ -0,0 +1,928 @@ +#![cfg(feature = "runtime")] + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use vm::resource::{HostResource, ResourceHandle}; +use vm::{ + CompileSourceFileOptions, HostApiBuilder, HostFunctionSchema, HostParamPassing, + HostParamSchema, HostTypeSchema, ResourceTypeKey, ResourceTypeSchema, SourceFlavor, TypeSchema, + Value, Vm, VmStatus, +}; + +struct NeutralBox(i64); +struct ExpectUnavailable; +struct CaptureExpectations(Vec); +impl HostResource for NeutralBox { + fn resource_type_key() -> Option { + Some(key()) + } +} +fn key() -> ResourceTypeKey { + ResourceTypeKey::new("neutral.box").unwrap() +} +fn catalog() -> Arc { + let mut b = HostApiBuilder::new(); + b.resource(ResourceTypeSchema::new(key(), "neutral box")); + b.function(HostFunctionSchema::with_return( + "neutral::open", + vec![], + HostTypeSchema::Resource(key()), + )); + b.function(HostFunctionSchema::with_return( + "neutral::probe", + vec![HostParamSchema::with_passing( + "candidate", + HostTypeSchema::Resource(key()), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + b.function(HostFunctionSchema::with_return( + "neutral::close", + vec![HostParamSchema::with_passing( + "owner", + HostTypeSchema::Resource(key()), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + b.function(HostFunctionSchema::with_return( + "neutral::bits", + vec![], + HostTypeSchema::Int, + )); + b.function(HostFunctionSchema::with_return( + "neutral::check", + vec![], + HostTypeSchema::Int, + )); + Arc::new(b.build().unwrap()) +} +fn source(perturbed: bool) -> String { + format!( + "let a = 1; let b = 2; let c = 3; let d = 4; let e = 5; let f = 6; let g = 7; let h = 8; let i = 9;\nlet owner = neutral::open();\nfn dispatch() -> int {{ neutral::probe(&owner); neutral::close(owner); 41 }}\ndispatch();\n{}", + if perturbed { + "fn text(value: string) -> string { let second = value; second }\nfn flag(value: bool) -> bool { let second = value; second }\ntext(\"x\"); flag(true);" + } else { + "" + } + ) +} +fn bind(machine: &mut Vm, catalog: &vm::HostApiCatalog, borrows: Arc) { + let mut registry = vm::HostFunctionRegistry::new(); + for schema in vm::catalog_import_schemas(catalog, "neutral::open") { + registry + .register_exact_static("neutral::open", 0, schema, |machine, _| { + let token = machine + .host_context() + .push_resource(NeutralBox(41)) + .unwrap(); + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int( + token.handle().raw() as i64, + )))) + }) + .unwrap(); + } + machine.host_context().set_module_state(borrows); + for schema in vm::catalog_import_schemas(catalog, "neutral::probe") { + registry + .register_exact_static( + "neutral::probe", + 1, + schema, + |machine: &mut Vm, args: &[Value]| { + let Value::Int(raw) = args[0] else { + panic!("carrier") + }; + let context = machine.host_context(); + let token = context + .typed_resource_with_key::( + ResourceHandle::from_raw(raw as u64).unwrap(), + &key(), + ) + .unwrap(); + assert_eq!(context.resource(&token).unwrap().get().0, 41); + let unavailable = context + .module_state::() + .map(|expected| { + let ordinal = context + .module_state::>() + .unwrap() + .load(Ordering::SeqCst); + !expected.0[ordinal] + }) + .unwrap_or_else(|| context.module_state::().is_some()); + if unavailable { + let query = context.checked_move_resource_capture::(&key()); + println!( + "expected unavailable: authorized={} error={:?}", + query.is_ok(), + query.as_ref().err() + ); + assert!(matches!(query, Err(vm::CaptureQueryError::Unavailable))); + } else { + let checked = context + .checked_move_resource_capture::(&key()) + .unwrap(); + assert_eq!(checked.token().handle(), token.handle()); + assert_eq!(checked.root().observe(&context), vm::CaptureRootState::Live); + } + context + .module_state::>() + .unwrap() + .fetch_add(1, Ordering::SeqCst); + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(41)))) + }, + ) + .unwrap(); + } + for schema in vm::catalog_import_schemas(catalog, "neutral::close") { + registry + .register_exact_static("neutral::close", 1, schema, |machine, args| { + let Value::Int(raw) = args[0] else { + panic!("carrier") + }; + machine + .host_context() + .close_resource::( + ResourceHandle::from_raw(raw as u64).unwrap(), + vm::resource::ResourceCloseReason::Requested, + ) + .unwrap(); + Ok(vm::CallOutcome::Return(vm::CallReturn::none())) + }) + .unwrap(); + } + registry.bind_vm_cached(machine).unwrap(); +} + +#[test] +fn rss_legal_resource_capture_aggregate_schema_perturbation() { + let catalog = catalog(); + for perturbed in [false, true] { + let compiled = vm::compile_source_with_flavor_and_options_with_capture_certification( + &source(perturbed), + SourceFlavor::RustScript, + CompileSourceFileOptions::new().with_host_api_catalog(catalog.clone()), + ) + .unwrap(); + let ordinary = vm::compile_source_with_flavor_and_options( + &source(perturbed), + SourceFlavor::RustScript, + CompileSourceFileOptions::new().with_host_api_catalog(catalog.clone()), + ) + .unwrap(); + let prototype = ordinary + .program + .callable_prototypes + .iter() + .find(|p| p.capture_modes.contains(&vm::CaptureBindingMode::Move)) + .unwrap(); + let ordinal = prototype + .capture_modes + .iter() + .position(|mode| *mode == vm::CaptureBindingMode::Move) + .unwrap(); + let slot = prototype.capture_source_slots[ordinal] as usize; + let aggregate = ordinary.program.type_map.as_ref().unwrap().local_schemas[slot].clone(); + println!( + "perturbed={perturbed} source={slot} target={} aggregate={aggregate:?}", + prototype.capture_slots[ordinal] + ); + let borrows = Arc::new(AtomicUsize::new(0)); + let mut machine = compiled.into_vm().unwrap(); + bind(&mut machine, &catalog, borrows.clone()); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(borrows.load(Ordering::SeqCst), 1); + if perturbed { + assert_eq!( + aggregate, + Some(TypeSchema::Bool), + "aggregate remains an unsuitable owner guard" + ); + } + } +} + +struct NumericControl { + handle: ResourceHandle, + calls: usize, +} + +struct MoveNumberFrontend; +impl vm::SourcePlugin for MoveNumberFrontend { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("neutral-move-number"), + extensions: &["move-number"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + source: &str, + context: &vm::SourceFrontendContext<'_>, + ) -> Result { + let mut ir = parse_neutral(source, context)?; + for implementation in ir.function_impls.values_mut() { + if let vm::Expr::Var(slot) = implementation.body_expr { + implementation.body_expr = vm::Expr::MoveVar(slot); + } + } + Ok(ir) + } +} +struct NeutralDialect; +impl vm::ParserDialect for NeutralDialect {} +fn parse_neutral( + source: &str, + context: &vm::SourceFrontendContext<'_>, +) -> Result { + let mut ir = vm::parse_source_with_dialect( + source, + &NeutralDialect, + vm::SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + import_scan_mode: true, + enforce_mutable_bindings: true, + ..Default::default() + }, + )?; + let placeholders = ir + .functions + .iter() + .filter(|f| { + ir.implicit_extern_names.contains(&f.name) + && matches!( + f.name.as_str(), + "open" | "probe" | "close" | "bits" | "check" + ) + }) + .cloned() + .collect::>(); + ir.functions + .retain(|f| !placeholders.iter().any(|p| p.index == f.index)); + ir.implicit_extern_names + .retain(|name| !placeholders.iter().any(|p| &p.name == name)); + let mut mapping = std::collections::HashMap::new(); + for old in placeholders { + mapping.insert( + old.index, + context.install_host_function(&mut ir, &format!("neutral::{}", old.name), old.arity)?, + ); + } + fn remap(expr: &mut vm::Expr, mapping: &std::collections::HashMap) { + if let vm::Expr::Call(index, _, arguments, ..) = expr { + if let Some(new) = mapping.get(index) { + *index = *new; + } + for arg in arguments { + remap(arg, mapping); + } + } + match expr { + vm::Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + remap(condition, mapping); + remap(then_expr, mapping); + remap(else_expr, mapping); + } + vm::Expr::Closure(closure) | vm::Expr::ClosureCall(closure, _) => { + remap(&mut closure.body, mapping); + } + vm::Expr::Block { stmts, expr } => { + for stmt in stmts { + if let vm::Stmt::Let { expr, .. } | vm::Stmt::Expr { expr, .. } = stmt { + remap(expr, mapping); + } + } + remap(expr, mapping); + } + _ => {} + } + } + for statement in &mut ir.stmts { + if let vm::Stmt::Let { expr, .. } | vm::Stmt::Expr { expr, .. } = statement { + remap(expr, &mapping); + } + } + for implementation in ir.function_impls.values_mut() { + for statement in &mut implementation.body_stmts { + if let vm::Stmt::Let { expr, .. } | vm::Stmt::Expr { expr, .. } = statement { + remap(expr, &mapping); + } + } + remap(&mut implementation.body_expr, &mapping); + } + Ok(ir) +} +static MOVE_NUMBER: MoveNumberFrontend = MoveNumberFrontend; + +struct BoxNotesFrontend; +static BOX_NOTES: BoxNotesFrontend = BoxNotesFrontend; +impl vm::SourcePlugin for BoxNotesFrontend { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("neutral-box-notes"), + extensions: &["box-notes"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + source: &str, + context: &vm::SourceFrontendContext<'_>, + ) -> Result { + if source.trim() != "box dispatch" { + return Err(vm::ParseError::new("expected box dispatch")); + } + parse_neutral( + "let owner = open(); fn dispatch() -> int { probe(&owner); close(owner); 41 } dispatch();", + context, + ) + } +} + +struct NonOwningFrontend; +static NON_OWNING: NonOwningFrontend = NonOwningFrontend; +impl vm::SourcePlugin for NonOwningFrontend { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("neutral-non-owning"), + extensions: &["non-owning"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + fn parse_source( + &self, + source: &str, + context: &vm::SourceFrontendContext<'_>, + ) -> Result { + parse_neutral(&source.replace("use self::dep;", " "), context) + } + fn parse_module_imports( + &self, + source: &str, + _path: &std::path::Path, + _context: &vm::SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + Ok(if source.starts_with("use self::dep;") { + vec![vm::ModuleImport::value( + "./dep.rss", + vm::ImportClause::Named(vec![vm::NamedImport::value( + if source.contains("forward()") { + "forward" + } else { + "dispatch" + }, + if source.contains("forward()") { + "forward" + } else { + "dispatch" + }, + )]), + 1, + )] + } else { + Vec::new() + }) + } +} + +#[test] +fn ownership_disabled_frontend_executes_but_cannot_certify_capture_origin() { + let catalog = catalog(); + let issued = vm::compile_source_with_flavor_and_options_with_capture_certification( + "let owner = open(); fn dispatch() -> int { probe(owner); close(owner); 41 } dispatch();", + SourceFlavor::Plugin(vm::SourcePlugin::descriptor(&NON_OWNING).id), + CompileSourceFileOptions::new() + .with_source_plugin(&NON_OWNING) + .with_host_api_catalog(catalog.clone()), + ) + .unwrap(); + let mut machine = issued.into_vm().unwrap(); + machine.host_context().set_module_state(ExpectUnavailable); + let borrows = Arc::new(AtomicUsize::new(0)); + bind(&mut machine, &catalog, borrows.clone()); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(borrows.load(Ordering::SeqCst), 1); + assert_eq!(machine.host_context().resource_count(), 0); + assert_eq!( + machine.capture_certification_stats().unwrap().registrations, + 0 + ); +} + +#[test] +fn mixed_policy_disabled_dependency_executes_without_capture_certification() { + let catalog = catalog(); + let source = "use self::dep; dep::dispatch();"; + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(&NON_OWNING) + .with_host_api_catalog(catalog.clone()) + .with_module_override_path("dep.rss", "dep.non-owning") + .with_module_override_source( + "dep.rss", + "let owner=open(); pub fn dispatch()->int {probe(owner);close(owner);41}", + ) + }; + let ordinary = + vm::compile_source_with_flavor_and_options(source, SourceFlavor::RustScript, options()) + .unwrap(); + for prototype in &ordinary.program.callable_prototypes { + println!( + "prototype source={:?} target={:?} modes={:?}", + prototype.capture_source_slots, prototype.capture_slots, prototype.capture_modes + ); + } + let mut ordinary_machine = ordinary.into_vm().unwrap(); + ordinary_machine + .host_context() + .set_module_state(ExpectUnavailable); + let ordinary_borrows = Arc::new(AtomicUsize::new(0)); + bind(&mut ordinary_machine, &catalog, ordinary_borrows.clone()); + assert_eq!(ordinary_machine.run().unwrap(), VmStatus::Halted); + assert_eq!(ordinary_machine.stack(), &[Value::Int(41)]); + assert_eq!(ordinary_borrows.load(Ordering::SeqCst), 1); + assert_eq!(ordinary_machine.host_context().resource_count(), 0); + println!("ordinary mixed execution: result=41 probes=1 resources=0"); + let issued = vm::compile_source_with_flavor_and_options_with_capture_certification( + source, + SourceFlavor::RustScript, + options(), + ) + .unwrap(); + let mut machine = issued.into_vm().unwrap(); + machine.host_context().set_module_state(ExpectUnavailable); + let borrows = Arc::new(AtomicUsize::new(0)); + bind(&mut machine, &catalog, borrows.clone()); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(borrows.load(Ordering::SeqCst), 1); + assert_eq!(machine.host_context().resource_count(), 0); + assert_eq!( + machine.capture_certification_stats().unwrap().registrations, + 0 + ); +} + +fn assert_mixed_producer( + dependency: &str, + dependency_enabled: bool, + entry_enabled: bool, + transitive: bool, +) { + assert_mixed_producer_return( + dependency, + dependency_enabled, + entry_enabled, + transitive, + false, + ); +} + +fn assert_mixed_producer_return( + dependency: &str, + dependency_enabled: bool, + entry_enabled: bool, + transitive: bool, + returns_callable: bool, +) { + let catalog = catalog(); + let entry = match (entry_enabled, transitive) { + (true, true) => "use self::dep; dep::forward();", + (true, false) => "use self::dep; dep::dispatch();", + (false, true) => "use self::dep; forward();", + (false, false) => "use self::dep; dispatch();", + }; + let flavor = if entry_enabled { + SourceFlavor::RustScript + } else { + SourceFlavor::Plugin(vm::SourcePlugin::descriptor(&NON_OWNING).id) + }; + let entry = if returns_callable { + if entry_enabled { + "use self::dep; let callback=dep::dispatch(); callback();" + } else { + "use self::dep; let callback=dispatch(); callback();" + } + } else { + entry + }; + let options = || { + let mut options = CompileSourceFileOptions::new() + .with_source_plugin(&NON_OWNING) + .with_host_api_catalog(catalog.clone()); + let producer = if transitive { "leaf.rss" } else { "dep.rss" }; + if !dependency_enabled { + options = options.with_module_override_path(producer, "producer.non-owning"); + } + options = options.with_module_override_source(producer, dependency); + if transitive { + options = options.with_module_override_source( + "dep.rss", + "use self::leaf; pub fn forward() -> int { leaf::dispatch() }", + ); + } + options + }; + let ordinary = vm::compile_source_with_flavor_and_options(entry, flavor, options()).unwrap(); + let issued = vm::compile_source_with_flavor_and_options_with_capture_certification( + entry, + flavor, + options(), + ) + .unwrap(); + let paired = vm::compile_source_with_flavor_and_options_with_capture_certification( + entry, + flavor, + options(), + ) + .unwrap() + .into_uncertified(); + assert_eq!( + vm::vmbc::encode_program(&ordinary.program).unwrap(), + vm::vmbc::encode_program(&paired.program).unwrap() + ); + for (mut machine, unavailable) in [ + (ordinary.into_vm().unwrap(), true), + (issued.into_vm().unwrap(), !dependency_enabled), + ] { + if unavailable { + machine.host_context().set_module_state(ExpectUnavailable); + } + let borrows = Arc::new(AtomicUsize::new(0)); + bind(&mut machine, &catalog, borrows.clone()); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(41)]); + assert_eq!(borrows.load(Ordering::SeqCst), 1); + assert_eq!(machine.host_context().resource_count(), 0); + if let Some(stats) = machine.capture_certification_stats() { + assert_eq!(stats.registrations > 0, dependency_enabled); + } + } +} + +#[test] +fn mixed_policy_root_anonymous_dependency_follows_producer() { + for entry_enabled in [true, false] { + assert_mixed_producer( + "let owner=open(); let callback=|| if true => {probe(owner);close(owner);41} else => {0}; let finished=callback(); pub fn dispatch()->int {41}", + false, + entry_enabled, + false, + ); + assert_mixed_producer( + "let owner=neutral::open(); let callback=|| if true => {neutral::probe(&owner);neutral::close(owner);41} else => {0}; let finished=callback(); pub fn dispatch()->int {41}", + true, + entry_enabled, + false, + ); + } +} + +#[test] +fn mixed_policy_nested_anonymous_dependency_follows_producer() { + for entry_enabled in [true, false] { + assert_mixed_producer( + "pub fn dispatch()->int {let outer=|| if true => {let owner=open();let inner=|| if true => {probe(owner);close(owner);41} else => {0};inner()} else => {0};outer()}", + false, + entry_enabled, + false, + ); + assert_mixed_producer( + "pub fn dispatch()->int {let outer=|| if true => {let owner=neutral::open();let inner=|| if true => {neutral::probe(&owner);neutral::close(owner);41} else => {0};inner()} else => {0};outer()}", + true, + entry_enabled, + false, + ); + } +} + +#[test] +fn mixed_policy_returned_closure_keeps_factory_producer() { + for entry_enabled in [true, false] { + assert_mixed_producer_return( + "pub fn dispatch()->fn()->int {let owner=open(); || if true => {probe(owner);close(owner);41} else => {0}}", + false, + entry_enabled, + false, + true, + ); + assert_mixed_producer_return( + "pub fn dispatch()->fn()->int {let owner=neutral::open(); || if true => {neutral::probe(&owner);neutral::close(owner);41} else => {0}}", + true, + entry_enabled, + false, + true, + ); + } +} + +#[test] +fn mixed_policy_named_enabled_dependency_survives_disabled_entry() { + for entry_enabled in [true, false] { + assert_mixed_producer( + "let owner=neutral::open(); pub fn dispatch()->int {neutral::probe(&owner);neutral::close(owner);41}", + true, + entry_enabled, + false, + ); + } +} + +#[test] +fn mixed_policy_transitive_dependency_follows_producer() { + for entry_enabled in [true, false] { + assert_mixed_producer( + "let owner=open(); pub fn dispatch()->int {probe(owner);close(owner);41}", + false, + entry_enabled, + true, + ); + assert_mixed_producer( + "let owner=neutral::open(); pub fn dispatch()->int {neutral::probe(&owner);neutral::close(owner);41}", + true, + entry_enabled, + true, + ); + } +} + +#[test] +fn mixed_policy_root_closures_keep_distinct_producers_after_compaction() { + let catalog = catalog(); + let entry = "use self::dep; let ignored=dep::dispatch(); let a=1;let b=2;let c=3;let d=4;let e=5;let f=6;let g=7;let h=8;let i=9; let owner=neutral::open();let callback=|| if true => {neutral::probe(&owner);neutral::close(owner);41} else => {0};callback();"; + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(&NON_OWNING) + .with_host_api_catalog(catalog.clone()) + .with_module_override_path("dep.rss", "dep.non-owning") + .with_module_override_source("dep.rss", "let owner=open();let callback=|| if true => {probe(owner);close(owner);41} else => {0};let finished=callback();pub fn dispatch()->int {41}") + }; + let ordinary = + vm::compile_source_with_flavor_and_options(entry, SourceFlavor::RustScript, options()) + .unwrap(); + let issued = vm::compile_source_with_flavor_and_options_with_capture_certification( + entry, + SourceFlavor::RustScript, + options(), + ) + .unwrap(); + let paired = vm::compile_source_with_flavor_and_options_with_capture_certification( + entry, + SourceFlavor::RustScript, + options(), + ) + .unwrap() + .into_uncertified(); + assert_eq!( + vm::vmbc::encode_program(&ordinary.program).unwrap(), + vm::vmbc::encode_program(&paired.program).unwrap() + ); + println!( + "compacted producer layouts: {:?}", + ordinary + .program + .callable_prototypes + .iter() + .map(|p| (&p.capture_source_slots, &p.capture_slots, &p.capture_modes)) + .collect::>() + ); + for (mut machine, expected) in [ + (ordinary.into_vm().unwrap(), vec![false, false]), + (issued.into_vm().unwrap(), vec![false, true]), + ] { + machine + .host_context() + .set_module_state(CaptureExpectations(expected)); + let borrows = Arc::new(AtomicUsize::new(0)); + bind(&mut machine, &catalog, borrows.clone()); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(41)]); + assert_eq!(borrows.load(Ordering::SeqCst), 2); + assert_eq!(machine.host_context().resource_count(), 0); + if let Some(stats) = machine.capture_certification_stats() { + assert_eq!(stats.registrations, 1); + } + } +} + +#[test] +fn mixed_policy_ordinary_program_layout_control() { + let catalog = catalog(); + for entry_enabled in [true, false] { + for dependency_enabled in [true, false] { + let entry = if entry_enabled { + "use self::dep; dep::dispatch();" + } else { + "use self::dep; dispatch();" + }; + let flavor = if entry_enabled { + SourceFlavor::RustScript + } else { + SourceFlavor::Plugin(vm::SourcePlugin::descriptor(&NON_OWNING).id) + }; + let dependency = if dependency_enabled { + "let seed=1; if seed==1 {let a=2;let b=3;let c=4;a+b+c;} let owner=neutral::open(); let callback=|| if true => {neutral::probe(&owner);neutral::close(owner);41} else => {0}; let finished=callback(); pub fn dispatch()->int {41}" + } else { + "let owner=open(); pub fn dispatch()->int {probe(owner);close(owner);41}" + }; + let options = || { + let options = CompileSourceFileOptions::new() + .with_source_plugin(&NON_OWNING) + .with_host_api_catalog(catalog.clone()) + .with_module_override_source("dep.rss", dependency); + if dependency_enabled { + options + } else { + options.with_module_override_path("dep.rss", "producer.non-owning") + } + }; + let ordinary = + vm::compile_source_with_flavor_and_options(entry, flavor, options()).unwrap(); + let paired = vm::compile_source_with_flavor_and_options_with_capture_certification( + entry, + flavor, + options(), + ) + .unwrap() + .into_uncertified(); + let wire = vm::vmbc::encode_program(&ordinary.program).unwrap(); + assert_eq!(wire, vm::vmbc::encode_program(&paired.program).unwrap()); + if let Some(output) = std::env::var_os("PROVENANCE_POLICY_PROGRAMS") { + std::fs::write( + std::path::Path::new(&output).join(format!( + "entry-{entry_enabled}-dependency-{dependency_enabled}.vmbc" + )), + &wire, + ) + .unwrap(); + } + let mut machine = ordinary.into_vm().unwrap(); + machine.host_context().set_module_state(ExpectUnavailable); + let borrows = Arc::new(AtomicUsize::new(0)); + bind(&mut machine, &catalog, borrows.clone()); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack().last(), Some(&Value::Int(41))); + assert_eq!(borrows.load(Ordering::SeqCst), 1); + assert_eq!(machine.host_context().resource_count(), 0); + } + } +} + +#[test] +fn two_independent_typed_frontends_admit_and_execute_resource_capture() { + let catalog = catalog(); + for (plugin, input) in [ + ( + &MOVE_NUMBER as &'static dyn vm::SourcePlugin, + "let owner = open(); fn dispatch() -> int { probe(&owner); close(owner); 41 } dispatch();", + ), + (&BOX_NOTES as &'static dyn vm::SourcePlugin, "box dispatch"), + ] { + let certified = vm::compile_source_with_flavor_and_options_with_capture_certification( + input, + SourceFlavor::Plugin(plugin.descriptor().id), + CompileSourceFileOptions::new() + .with_source_plugin(plugin) + .with_host_api_catalog(catalog.clone()), + ) + .unwrap(); + let mut machine = certified.into_vm().unwrap(); + let borrows = Arc::new(AtomicUsize::new(0)); + bind(&mut machine, &catalog, borrows.clone()); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(borrows.load(Ordering::SeqCst), 1); + assert_eq!( + machine.capture_certification_stats().unwrap().registrations, + 1 + ); + assert_eq!(machine.host_context().resource_count(), 0); + } +} + +#[test] +fn source_file_certification_matches_ordinary_options_and_old_three_field_literals() { + let catalog = catalog(); + let path = std::env::temp_dir().join(format!("capture-public-{}.rss", std::process::id())); + std::fs::write(&path, source(true)).unwrap(); + let options = || CompileSourceFileOptions::new().with_host_api_catalog(catalog.clone()); + let ordinary = vm::compile_source_file_with_options(&path, options()).unwrap(); + let vm::CompiledProgram { + program, + locals, + functions, + } = ordinary; + let literal = vm::CompiledProgram { + program, + locals, + functions, + }; + let certified = + vm::compile_source_file_with_capture_certification_and_options(&path, options()).unwrap(); + assert_eq!( + vm::vmbc::encode_program(&literal.program).unwrap(), + vm::vmbc::encode_program(&certified.into_uncertified().program).unwrap() + ); + let certified = + vm::compile_source_file_with_capture_certification_and_options(&path, options()).unwrap(); + let mut machine = certified.into_vm().unwrap(); + let borrows = Arc::new(AtomicUsize::new(0)); + bind(&mut machine, &catalog, borrows.clone()); + machine.run().unwrap(); + assert_eq!(borrows.load(Ordering::SeqCst), 1); + std::fs::remove_file(path).unwrap(); + let issued = vm::compile_source_with_capture_certification("40 + 2;").unwrap(); + let ordinary = vm::compile_source("40 + 2;").unwrap(); + assert_eq!( + vm::vmbc::encode_program(&issued.into_uncertified().program).unwrap(), + vm::vmbc::encode_program(&ordinary.program).unwrap() + ); +} + +#[test] +fn moved_int_equal_to_live_typed_handle_has_no_resource_origin() { + let catalog = catalog(); + let source = "let owner = bits(); fn dispatch() -> int { check(); owner } dispatch();"; + let flavor = SourceFlavor::Plugin(vm::FrontendId::new("neutral-move-number")); + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(&MOVE_NUMBER) + .with_host_api_catalog(catalog.clone()) + }; + let ordinary = vm::compile_source_with_flavor_and_options(source, flavor, options()).unwrap(); + assert!( + ordinary + .program + .callable_prototypes + .iter() + .any(|p| p.capture_modes == [vm::CaptureBindingMode::Move]) + ); + let certified = vm::compile_source_with_flavor_and_options_with_capture_certification( + source, + flavor, + options(), + ) + .unwrap(); + let mut machine = certified.into_vm().unwrap(); + let mut registry = vm::HostFunctionRegistry::new(); + for schema in vm::catalog_import_schemas(&catalog, "neutral::bits") { + registry + .register_exact_static("neutral::bits", 0, schema, |machine, _| { + let token = machine + .host_context() + .push_resource(NeutralBox(41)) + .unwrap(); + machine.host_context().set_module_state(NumericControl { + handle: token.handle(), + calls: 0, + }); + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int( + token.handle().raw() as i64, + )))) + }) + .unwrap(); + } + for schema in vm::catalog_import_schemas(&catalog, "neutral::check") { + registry + .register_exact_static("neutral::check", 0, schema, |machine, _| { + let mut context = machine.host_context(); + assert!(matches!( + context.checked_move_resource_capture::(&key()), + Err(vm::CaptureQueryError::NoMatchingCapture) + )); + let handle = context.module_state::().unwrap().handle; + let candidate = context + .typed_resource_with_key::(handle, &key()) + .unwrap(); + assert_eq!(context.resource(&candidate).unwrap().get().0, 41); + context.module_state_mut::().unwrap().calls += 1; + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(0)))) + }) + .unwrap(); + } + registry.bind_vm_cached(&mut machine).unwrap(); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!( + machine + .host_context() + .module_state::() + .unwrap() + .calls, + 1 + ); + assert_eq!( + machine.capture_certification_stats().unwrap().registrations, + 0 + ); +} diff --git a/tests/cli_embedding_api.rs b/tests/cli_embedding_api.rs new file mode 100644 index 00000000..d3d56ab0 --- /dev/null +++ b/tests/cli_embedding_api.rs @@ -0,0 +1,975 @@ +#![cfg(feature = "cli")] + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use vm::cli::{CliHostServices, CliRuntime, run_with_args}; +use vm::{ + CallOutcome, CallReturn, CompileSourceFileOptions, HostApiBuilder, HostFunctionRegistry, + HostFunctionSchema, HostParamSchema, HostTypeSchema, Value, VmError, +}; + +struct Scale(Arc>>); +impl vm::HostFunction for Scale { + fn call(&mut self, vm: &mut vm::Vm, args: &[Value]) -> vm::VmResult { + let factor = *vm.host_context().module_state::().unwrap(); + let Value::Int(value) = args[0] else { + return Err(VmError::TypeMismatch("int")); + }; + self.0.lock().unwrap().push(value * factor); + Ok(CallOutcome::Return(CallReturn::one(Value::Int( + value * factor, + )))) + } +} + +fn scalar_services( + seen: Arc>>, + inits: Arc, +) -> CliHostServices<'static> { + let mut builder = HostApiBuilder::new(); + builder.function(HostFunctionSchema::with_return( + "measure::scale", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog = Arc::new(builder.build().unwrap()); + let registration_catalog = Arc::clone(&catalog); + CliHostServices::new(catalog, move || { + let mut registry = HostFunctionRegistry::new(); + let output = Arc::clone(&seen); + registry + .register_catalog( + vm::catalog_import_schemas(®istration_catalog, "measure::scale").remove(0), + move || Box::new(Scale(Arc::clone(&output))), + ) + .map_err(|err| VmError::HostError(err.to_string()))?; + Ok(registry) + }) + .with_vm_init(move |vm| { + inits.fetch_add(1, Ordering::SeqCst); + vm.host_context().set_module_state(3_i64); + Ok(()) + }) +} + +fn runtime() -> CliRuntime { + // External caller of the original three-field API and process entrypoint. + let _: fn(CliRuntime) -> Result<(), Box> = vm::cli::main; + CliRuntime { + binary_name: "embedding-probe", + default_source: "unused.rss", + compile_options: CompileSourceFileOptions::default, + } +} + +struct Fixture(std::path::PathBuf); +impl Fixture { + fn new(name: &str, source: &str) -> Self { + let path = std::env::temp_dir().join(format!("cli-{}-{}.rss", name, std::process::id())); + std::fs::write(&path, source).unwrap(); + Self(path) + } + fn arg(&self) -> String { + self.0.display().to_string() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +#[test] +fn public_cli_executes_typed_scalar_with_per_vm_initialization() { + let seen = Arc::new(Mutex::new(Vec::new())); + let services = scalar_services(Arc::clone(&seen), Arc::new(AtomicUsize::new(0))); + let source = Fixture::new("scalar", "measure::scale(14);"); + let compiled = vm::compile_source_for_repl_with_state_and_options( + "measure::scale(14);", + &[], + services + .compile_options(CompileSourceFileOptions::default()) + .unwrap(), + ) + .unwrap(); + assert_eq!(compiled.compiled.program.imports[0].name, "measure::scale"); + run_with_args(&runtime(), &[source.arg()], &services).unwrap(); + assert_eq!(*seen.lock().unwrap(), vec![42]); +} + +#[test] +fn public_cli_injects_hosts_into_vmbc_record_debug_and_native_paths() { + let seen = Arc::new(Mutex::new(Vec::new())); + let inits = Arc::new(AtomicUsize::new(0)); + let services = scalar_services(Arc::clone(&seen), Arc::clone(&inits)); + let source = Fixture::new("paths", "measure::scale(14);"); + let wire = source.0.with_extension("vmbc"); + let recording = source.0.with_extension("pdr"); + let aot = source.0.with_extension("pat"); + run_with_args( + &runtime(), + &[ + "--emit-vmbc".into(), + wire.display().to_string(), + source.arg(), + ], + &services, + ) + .unwrap(); + run_with_args(&runtime(), &[wire.display().to_string()], &services).unwrap(); + run_with_args( + &runtime(), + &[ + "--record".into(), + recording.display().to_string(), + source.arg(), + ], + &services, + ) + .unwrap(); + let recorded = vm::VmRecording::load_from_file(&recording).unwrap(); + assert!(!recorded.frames.is_empty()); + run_with_args( + &runtime(), + &["--debug".into(), "--no-stop-on-entry".into(), source.arg()], + &services, + ) + .unwrap(); + #[cfg(feature = "cranelift-jit")] + { + run_with_args( + &runtime(), + &["--jit-hot-loop".into(), "1".into(), source.arg()], + &services, + ) + .unwrap(); + run_with_args( + &runtime(), + &["--aot-save".into(), aot.display().to_string(), source.arg()], + &services, + ) + .unwrap(); + run_with_args( + &runtime(), + &["--aot-load".into(), aot.display().to_string()], + &services, + ) + .unwrap(); + run_with_args( + &runtime(), + &["--aot-load".into(), aot.display().to_string(), source.arg()], + &services, + ) + .unwrap(); + } + let expected = if cfg!(feature = "cranelift-jit") { + 7 + } else { + 3 + }; + assert_eq!(*seen.lock().unwrap(), vec![42; expected]); + assert_eq!( + inits.load(Ordering::SeqCst), + expected + 1, + "emit validates and initializes its VM without guest execution" + ); + for path in [wire, recording, aot] { + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn reloaded_artifact_cannot_use_a_registry_from_another_services_catalog() { + let seen = Arc::new(Mutex::new(Vec::new())); + let original = scalar_services(Arc::clone(&seen), Arc::new(AtomicUsize::new(0))); + let options = original + .compile_options(CompileSourceFileOptions::default()) + .unwrap(); + let schema = + vm::catalog_import_schemas(options.host_api_catalog().unwrap(), "measure::scale").remove(0); + let compiled = vm::compile_source_with_flavor_and_options( + "measure::scale(14);", + vm::SourceFlavor::RustScript, + options, + ) + .unwrap(); + let wire = Fixture::new("mismatch", ""); + let path = wire.0.with_extension("vmbc"); + std::fs::write(&path, vm::encode_program(&compiled.program).unwrap()).unwrap(); + let empty = Arc::new(HostApiBuilder::new().build().unwrap()); + let output = Arc::clone(&seen); + let wrong = CliHostServices::new(empty, move || { + let mut registry = HostFunctionRegistry::new(); + let output = Arc::clone(&output); + registry + .register_catalog(schema.clone(), move || Box::new(Scale(Arc::clone(&output)))) + .map_err(|e| VmError::HostError(e.to_string()))?; + Ok(registry) + }) + .with_vm_init(|vm| { + vm.host_context().set_module_state(3_i64); + Ok(()) + }); + let result = run_with_args(&runtime(), &[path.display().to_string()], &wrong); + std::fs::remove_file(path).unwrap(); + assert!( + result.is_err(), + "a matching registry cannot substitute another services catalog" + ); + assert!( + seen.lock().unwrap().is_empty(), + "rejection must precede guest effects" + ); +} + +#[test] +fn legacy_three_field_cli_can_emit_an_artifact_for_an_unbound_embedding() { + let runtime = CliRuntime { + compile_options: || { + scalar_services( + Arc::new(Mutex::new(Vec::new())), + Arc::new(AtomicUsize::new(0)), + ) + .compile_options(CompileSourceFileOptions::default()) + .unwrap() + }, + ..runtime() + }; + let source = Fixture::new("legacy-emit", "measure::scale(14);"); + let wire = source.0.with_extension("vmbc"); + let result = run_with_args( + &runtime, + &[ + "--emit-vmbc".into(), + wire.display().to_string(), + source.arg(), + ], + &CliHostServices::default(), + ); + result.unwrap(); + let decoded = vm::decode_program(&std::fs::read(&wire).unwrap()).unwrap(); + assert_eq!(decoded.imports[0].name, "measure::scale"); + std::fs::remove_file(wire).unwrap(); +} + +#[test] +fn repl_module_override_preserves_native_entry_locals() { + let options = CompileSourceFileOptions::default() + .with_module_override_source("calibration.rss", "pub fn value() -> int { 1 }"); + let first = + vm::compile_source_for_repl_with_state_and_options("let x = 41;", &[], options.clone()) + .unwrap(); + let mut first_vm = vm::Vm::new(first.compiled.program); + first_vm.run().unwrap(); + let slot = first_vm.debug_info().unwrap().local_index("x").unwrap(); + let value = first_vm.locals()[slot as usize].clone(); + let state = vm::ReplLocalState { + binding: vm::ReplLocalBinding { + name: "x".into(), + mutable: false, + schema: Some(vm::TypeSchema::Int), + optional: false, + }, + moved: false, + }; + let second = vm::compile_source_for_repl_with_state_and_options( + "use calibration; x + calibration::value();", + &[state], + options, + ) + .expect("selected modules must use the generic checked pipeline with entry locals"); + let mut second_vm = vm::Vm::new(second.compiled.program); + let slot = second_vm.debug_info().unwrap().local_index("x").unwrap(); + second_vm.set_local(slot, value).unwrap(); + second_vm.run().unwrap(); + assert_eq!(second_vm.stack().last(), Some(&Value::Int(42))); +} + +#[test] +fn services_preserve_catalog_conflicts_missing_bindings_and_restricted_capabilities() { + let seen = Arc::new(Mutex::new(Vec::new())); + let inits = Arc::new(AtomicUsize::new(0)); + let original = scalar_services(Arc::clone(&seen), Arc::clone(&inits)); + let options = original + .compile_options(CompileSourceFileOptions::default()) + .unwrap(); + let catalog = options.host_api_catalog().unwrap().clone(); + let source = Fixture::new("denial", "measure::scale(14);"); + let absent = CliHostServices::new(Arc::clone(&catalog), || Ok(HostFunctionRegistry::new())); + assert!( + run_with_args(&runtime(), &[source.arg()], &absent) + .unwrap_err() + .to_string() + .contains("measure::scale") + ); + let schema = vm::catalog_import_schemas(&catalog, "measure::scale").remove(0); + let denied = CliHostServices::new(catalog, move || { + let mut registry = HostFunctionRegistry::restricted(); + registry + .register_catalog(schema.clone(), || { + Box::new(Scale(Arc::new(Mutex::new(Vec::new())))) + }) + .map_err(|e| VmError::HostError(e.to_string()))?; + Ok(registry) + }); + assert!( + run_with_args(&runtime(), &[source.arg()], &denied) + .unwrap_err() + .to_string() + .contains("capability") + ); + let conflicting = CompileSourceFileOptions::default() + .with_host_api_catalog(Arc::new(HostApiBuilder::new().build().unwrap())); + assert!(original.compile_options(conflicting).is_err()); + assert!(seen.lock().unwrap().is_empty()); + assert_eq!(inits.load(Ordering::SeqCst), 0); + // The existing unbound VM fails even with correctly compiled typed imports. + let compiled = vm::compile_source_with_flavor_and_options( + "measure::scale(14);", + vm::SourceFlavor::RustScript, + options, + ) + .unwrap(); + let mut unbound = vm::Vm::new(compiled.program); + assert!(unbound.run().is_err()); +} + +struct SelectedModuleFrontend; +static SELECTED_MODULE_FRONTEND: SelectedModuleFrontend = SelectedModuleFrontend; +static SELECTED_HOOKS: AtomicUsize = AtomicUsize::new(0); +static SELECTED_TEST_LOCK: Mutex<()> = Mutex::new(()); +impl vm::SourcePlugin for SelectedModuleFrontend { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("selected-module-probe"), + extensions: &["selected"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + source: &str, + context: &vm::SourceFrontendContext<'_>, + ) -> Result { + struct Grammar; + impl vm::ParserDialect for Grammar {} + static GRAMMAR: Grammar = Grammar; + assert_eq!(context.source_id, 1); + assert_eq!(context.phase, vm::ParsePhase::Module); + assert!( + context + .original_path + .unwrap() + .ends_with("calibration.selected") + ); + assert!( + !context + .host_api_catalog + .unwrap() + .functions_named("measure::scale") + .is_empty() + ); + vm::parse_source_with_dialect( + source, + &GRAMMAR, + vm::SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: false, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: false, + }, + ) + } + fn parse_module_imports( + &self, + source: &str, + _: &std::path::Path, + context: &vm::SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + assert_eq!(context.source_id, 1); + assert_eq!(context.phase, vm::ParsePhase::ImportDiscovery); + assert!(context.host_api_catalog.is_some()); + if source.contains("semantic-dependency") { + return Ok(vec![ + vm::ModuleImport::value("ghost.rss", vm::ImportClause::AllPublic, 1).type_only(), + ]); + } + Ok(Vec::new()) + } + fn validate_typed_ir(&self, typed: &vm::FrontendTypedIr<'_>) -> Result<(), vm::CompileError> { + assert_eq!(typed.source_id, Some(1)); + assert!( + typed + .source_map + .unwrap() + .file(1) + .unwrap() + .name + .ends_with("calibration.selected") + ); + assert!(typed.host_api_catalog.is_some()); + let slot = typed + .ir + .local_bindings + .iter() + .find(|(name, _)| name == "x") + .unwrap() + .1; + assert_eq!( + typed.local_schemas[slot as usize], + Some(vm::TypeSchema::Int) + ); + SELECTED_HOOKS.fetch_add(1, Ordering::SeqCst); + if typed.ir.function_impls.values().any(|function| matches!(&function.body_expr, vm::Expr::Int(-1)) || matches!(&function.body_expr, vm::Expr::Neg(inner) if matches!(inner.as_ref(), vm::Expr::Int(1)))) { + return Err(vm::CompileError::HostCallResolve { line: None, source_name: None, span: None, detail: "selected module admission rejected input".into() }); + } + Ok(()) + } +} + +#[test] +fn native_repl_selected_module_hook_receives_catalog_source_and_entry_types() { + let _guard = SELECTED_TEST_LOCK.lock().unwrap(); + SELECTED_HOOKS.store(0, Ordering::SeqCst); + let services = scalar_services( + Arc::new(Mutex::new(Vec::new())), + Arc::new(AtomicUsize::new(0)), + ); + let options = services + .compile_options( + CompileSourceFileOptions::default() + .with_source_plugin(&SELECTED_MODULE_FRONTEND) + .with_module_override_path("calibration.rss", "calibration.selected") + .with_module_override_source("calibration.rss", "pub fn value() -> int { 1; }"), + ) + .unwrap(); + let state = vm::ReplLocalState { + binding: vm::ReplLocalBinding { + name: "x".into(), + mutable: false, + schema: Some(vm::TypeSchema::Int), + optional: false, + }, + moved: false, + }; + let compiled = vm::compile_source_for_repl_with_state_and_options( + "use calibration; x + calibration::value();", + &[state], + options, + ) + .unwrap(); + assert_eq!(SELECTED_HOOKS.load(Ordering::SeqCst), 1); + let mut machine = vm::Vm::new(compiled.compiled.program); + let slot = machine.debug_info().unwrap().local_index("x").unwrap(); + machine.set_local(slot, Value::Int(41)).unwrap(); + machine.run().unwrap(); + assert_eq!(machine.stack().last(), Some(&Value::Int(42))); +} + +#[test] +fn native_repl_runtime_entry_slots_exclude_semantic_only_module_locals() { + let _guard = SELECTED_TEST_LOCK.lock().unwrap(); + SELECTED_HOOKS.store(0, Ordering::SeqCst); + let services = scalar_services( + Arc::new(Mutex::new(Vec::new())), + Arc::new(AtomicUsize::new(0)), + ); + let options = services + .compile_options( + CompileSourceFileOptions::default() + .with_source_plugin(&SELECTED_MODULE_FRONTEND) + .with_module_override_path("calibration.rss", "calibration.selected") + .with_module_override_source( + "calibration.rss", + "// semantic-dependency\npub fn value() -> int { 1; }", + ) + .with_module_override_source( + "ghost.rss", + "measure::scale(666); let semantic_local = 40; pub fn invisible() -> int { 3 }", + ), + ) + .unwrap(); + let state = vm::ReplLocalState { + binding: vm::ReplLocalBinding { + name: "x".into(), + mutable: false, + schema: Some(vm::TypeSchema::Int), + optional: false, + }, + moved: false, + }; + let compiled = vm::compile_source_for_repl_with_state_and_options( + "use calibration; x + calibration::value();", + &[state], + options, + ) + .unwrap(); + assert_eq!(SELECTED_HOOKS.load(Ordering::SeqCst), 1); + assert!( + compiled + .compiled + .program + .debug + .as_ref() + .unwrap() + .locals + .iter() + .all(|local| !local.name.contains("semantic_local")) + ); + assert!( + compiled.compiled.program.imports.is_empty(), + "type-only host effects must not be runtime reachable" + ); + let mut machine = vm::Vm::new(compiled.compiled.program); + let slot = machine.debug_info().unwrap().local_index("x").unwrap(); + machine.set_local(slot, Value::Int(41)).unwrap(); + machine.run().unwrap(); + assert_eq!(machine.stack().last(), Some(&Value::Int(42))); +} + +#[test] +fn native_repl_selected_module_admission_precedes_initialization_and_guest_effects() { + use std::io::Write; + use std::process::{Command, Stdio}; + for (module, snippet, diagnostic, inits) in [ + ( + "pub fn value() -> int { 1; }", + "use calibration; measure::scale(x + calibration::value());", + "=> 126", + 2, + ), + ( + "pub fn value() -> int { -1; }", + "use calibration; measure::scale(x + calibration::value());", + "selected module admission rejected input", + 1, + ), + ( + "pub fn value() -> int { 1; }", + "use calibration; let bad: bool = x + calibration::value(); measure::scale(x);", + "error:", + 1, + ), + ] { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "embedding_repl_child", + "--nocapture", + ]) + .env("EMBEDDING_SELECTED_MODULE", module) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(format!("let x = 41;\n{snippet}\n.quit\n").as_bytes()) + .unwrap(); + let result = child.wait_with_output().unwrap(); + let out = String::from_utf8(result.stdout).unwrap(); + let err = String::from_utf8(result.stderr).unwrap(); + assert!(result.status.success(), "{out}\n{err}"); + assert!( + out.contains(diagnostic) || err.contains(diagnostic), + "{out}\n{err}" + ); + assert!( + out.contains(&format!("embedding-init-count={inits}")), + "{out}\n{err}" + ); + if inits == 1 { + assert!(!out.contains("=> 126") && !out.contains("=> 120"), "{out}"); + } + } +} + +struct CalibrationFrontend; +static CALIBRATION_FRONTEND: CalibrationFrontend = CalibrationFrontend; +impl vm::SourcePlugin for CalibrationFrontend { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("calibration-probe"), + extensions: &["calib"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + source: &str, + context: &vm::SourceFrontendContext<'_>, + ) -> Result { + struct Grammar; + impl vm::ParserDialect for Grammar {} + static GRAMMAR: Grammar = Grammar; + let mut ir = vm::parse_source_with_dialect( + "", + &GRAMMAR, + vm::SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: false, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: false, + }, + )?; + let value = source + .trim() + .parse::() + .map_err(|e| vm::ParseError::new(e.to_string()))?; + let index = context.install_host_function(&mut ir, "measure::scale", 1)?; + ir.stmts.push(vm::Stmt::Expr { + expr: vm::Expr::Call(index, Vec::new(), vec![vm::Expr::Int(value)], None, None), + line: 1, + }); + Ok(ir) + } + fn parse_module_imports( + &self, + _: &str, + _: &std::path::Path, + _: &vm::SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + Ok(Vec::new()) + } + fn validate_typed_ir(&self, typed: &vm::FrontendTypedIr<'_>) -> Result<(), vm::CompileError> { + assert!( + typed.host_api_catalog.is_some(), + "effective catalog must reach the compiler hook" + ); + if typed.ir.stmts.iter().any(|stmt| matches!(stmt, vm::Stmt::Expr { expr: vm::Expr::Call(_, _, args, ..), .. } if matches!(args.first(), Some(vm::Expr::Int(-1))))) { + return Err(vm::CompileError::HostCallResolve { line: None, source_name: None, span: None, detail: "calibration hook rejected input".into() }); + } + Ok(()) + } +} + +#[test] +fn public_cli_preserves_language_neutral_frontend_options_and_typed_hooks() { + let seen = Arc::new(Mutex::new(Vec::new())); + let inits = Arc::new(AtomicUsize::new(0)); + let services = scalar_services(Arc::clone(&seen), Arc::clone(&inits)); + let runtime = CliRuntime { + compile_options: || { + CompileSourceFileOptions::default().with_source_plugin(&CALIBRATION_FRONTEND) + }, + ..runtime() + }; + let mut source = Fixture::new("plugin", "14"); + let extension = source.0.with_extension("calib"); + std::fs::rename(&source.0, &extension).unwrap(); + source.0 = extension; + run_with_args(&runtime, &[source.arg()], &services).unwrap(); + std::fs::write(&source.0, "-1").unwrap(); + assert!( + run_with_args(&runtime, &[source.arg()], &services) + .unwrap_err() + .to_string() + .contains("calibration hook rejected input") + ); + assert_eq!(*seen.lock().unwrap(), vec![42]); + assert_eq!(inits.load(Ordering::SeqCst), 1); + vm::compile_source_for_repl_with_state_and_options("1;", &[], (runtime.compile_options)()) + .expect("an unselected file frontend must not reject native snippets"); +} + +struct UnusedFrontend; +static UNUSED_FRONTEND: UnusedFrontend = UnusedFrontend; +static UNUSED_HOOKS: AtomicUsize = AtomicUsize::new(0); +impl vm::SourcePlugin for UnusedFrontend { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("unused-file-probe"), + extensions: &["unused"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + _: &str, + _: &vm::SourceFrontendContext<'_>, + ) -> Result { + UNUSED_HOOKS.fetch_add(1, Ordering::SeqCst); + Err(vm::ParseError::new("unselected frontend invoked")) + } + fn validate_typed_ir(&self, _: &vm::FrontendTypedIr<'_>) -> Result<(), vm::CompileError> { + UNUSED_HOOKS.fetch_add(1, Ordering::SeqCst); + panic!("unselected frontend hook invoked") + } +} + +#[test] +fn native_repl_accepts_unused_file_registration_with_default_and_configured_services() { + use std::io::Write; + use std::process::{Command, Stdio}; + for default_services in [false, true] { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--ignored", + "--exact", + "embedding_repl_child", + "--nocapture", + ]) + .env("EMBEDDING_UNUSED_PLUGIN", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if default_services { + command.env("EMBEDDING_DEFAULT_SERVICES", "1"); + } + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"let x = 41;\nx + 1\n.quit\n") + .unwrap(); + let result = child.wait_with_output().unwrap(); + let out = String::from_utf8(result.stdout).unwrap(); + let err = String::from_utf8(result.stderr).unwrap(); + assert!(result.status.success(), "{out}\n{err}"); + assert!( + out.contains("=> 42"), + "default={default_services}: {out}\n{err}" + ); + assert!( + !err.contains("not module overrides or source plugins"), + "{err}" + ); + assert!(out.contains("unused-plugin-hooks=0"), "{out}"); + if !default_services { + assert!(out.contains("embedding-init-count=2"), "{out}"); + } + } +} + +#[test] +fn final_layout_cli_preserves_unused_entry_and_first_snippet_with_both_services() { + use std::io::Write; + use std::process::{Command, Stdio}; + let module = "let a = 101; let b = 102; let c = 103; let d = 104; let e = 105; let f = 106; let g = 107; let h = 108; let i = 109; pub fn value() -> int { 1 }"; + let cases = [ + ( + "let x = 41;\nuse calibration; calibration::value();\nx\n.quit\n", + vec![41], + ), + ( + "let a = 11; let b = 12; let c = 13; let d = 14; let e = 15; let f = 16; let g = 17; let h = 18; let i = 19;\na\nb\nc\nd\ne\nf\ng\nh\ni\n.quit\n", + (11..=19).collect(), + ), + ]; + for default_services in [false, true] { + for (input, expected) in &cases { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--ignored", + "--exact", + "embedding_repl_child", + "--nocapture", + ]) + .env("EMBEDDING_NINE_LOCAL_MODULE", module) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if default_services { + command.env("EMBEDDING_DEFAULT_SERVICES", "1"); + } + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + let result = child.wait_with_output().unwrap(); + let out = String::from_utf8(result.stdout).unwrap(); + let err = String::from_utf8(result.stderr).unwrap(); + assert!(result.status.success(), "{out}\n{err}"); + assert!(err.trim().is_empty(), "{out}\n{err}"); + let actual = out + .lines() + .filter_map(|line| { + line.split_once("=> ") + .and_then(|(_, value)| value.trim().parse::().ok()) + }) + .collect::>(); + assert!( + actual.ends_with(expected), + "default={default_services}: {actual:?}\n{out}" + ); + } + } +} + +// Subprocess harness only; never counted as a business test. +#[test] +#[ignore] +fn embedding_repl_child() { + let inits = Arc::new(AtomicUsize::new(0)); + let services = scalar_services(Arc::new(Mutex::new(Vec::new())), Arc::clone(&inits)); + let args = std::env::var("EMBEDDING_CLI_ARGS") + .map(|raw| serde_json::from_str::>(&raw).unwrap()) + .unwrap_or_else(|_| vec!["repl".into()]); + let mut runtime = runtime(); + if std::env::var_os("EMBEDDING_UNUSED_PLUGIN").is_some() { + runtime.compile_options = + || CompileSourceFileOptions::default().with_source_plugin(&UNUSED_FRONTEND); + } + if std::env::var_os("EMBEDDING_SELECTED_MODULE").is_some() { + runtime.compile_options = || { + CompileSourceFileOptions::default() + .with_source_plugin(&SELECTED_MODULE_FRONTEND) + .with_module_override_path("calibration.rss", "calibration.selected") + .with_module_override_source( + "calibration.rss", + std::env::var("EMBEDDING_SELECTED_MODULE").unwrap(), + ) + }; + } + if std::env::var_os("EMBEDDING_NINE_LOCAL_MODULE").is_some() { + runtime.compile_options = || { + CompileSourceFileOptions::default().with_module_override_source( + "calibration.rss", + std::env::var("EMBEDDING_NINE_LOCAL_MODULE").unwrap(), + ) + }; + } + if std::env::var_os("EMBEDDING_DEFAULT_SERVICES").is_some() { + run_with_args(&runtime, &args, &CliHostServices::default()).unwrap(); + } else { + run_with_args(&runtime, &args, &services).unwrap(); + } + println!( + "unused-plugin-hooks={}", + UNUSED_HOOKS.load(Ordering::SeqCst) + ); + println!("embedding-init-count={}", inits.load(Ordering::SeqCst)); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn standalone_aot_initializer_observes_the_same_runtime_flags_as_source() { + let seen = Arc::new(Mutex::new(Vec::new())); + let services = + scalar_services(Arc::clone(&seen), Arc::new(AtomicUsize::new(0))).with_vm_init(|vm| { + if vm.get_fuel() != Some(99) { + return Err(VmError::HostError( + "initializer did not receive runtime flags".into(), + )); + } + vm.host_context().set_module_state(3_i64); + Ok(()) + }); + let source = Fixture::new("aot-flags", "measure::scale(14);"); + let aot = source.0.with_extension("pat"); + run_with_args( + &runtime(), + &[ + "--fuel".into(), + "99".into(), + "--aot-save".into(), + aot.display().to_string(), + source.arg(), + ], + &services, + ) + .unwrap(); + let result = run_with_args( + &runtime(), + &[ + "--fuel".into(), + "99".into(), + "--aot-load".into(), + aot.display().to_string(), + ], + &services, + ); + std::fs::remove_file(aot).unwrap(); + result.unwrap(); + assert_eq!(*seen.lock().unwrap(), vec![42, 42]); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn public_cli_runs_native_jit_work_in_the_initialized_vm() { + let source = Fixture::new( + "native-jit", + "let mut i = 0; let mut total = 0; while i < 1000 { total = total + 1; i = i + 1; } measure::scale(total);", + ); + let args = vec![ + "--jit-hot-loop".to_string(), + "1".to_string(), + "--jit-dump".to_string(), + "--jit-dump-no-code".to_string(), + source.arg(), + ]; + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "embedding_repl_child", + "--nocapture", + ]) + .env("EMBEDDING_CLI_ARGS", serde_json::to_string(&args).unwrap()) + .output() + .unwrap(); + let out = String::from_utf8(result.stdout).unwrap(); + assert!( + result.status.success(), + "{out}\n{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(out.contains("stack: [Int(3000)]"), "{out}"); + let compiled = out + .lines() + .find_map(|line| line.trim().strip_prefix("native traces: ")) + .unwrap() + .parse::() + .unwrap(); + assert!(compiled > 0, "{out}"); + assert!(out.contains("embedding-init-count=1"), "{out}"); +} + +#[test] +fn public_cli_repl_uses_catalog_and_initializer_across_snippets() { + use std::io::Write; + use std::process::{Command, Stdio}; + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "embedding_repl_child", + "--nocapture", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"let x = measure::scale(14);\nx + 1\nmeasure::scale(x)\n.quit\n") + .unwrap(); + let result = child.wait_with_output().unwrap(); + let out = String::from_utf8(result.stdout).unwrap(); + assert!( + result.status.success(), + "{out}\n{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(out.contains("=> 43"), "{out}"); + assert!(out.contains("=> 126"), "{out}"); + assert!(out.contains("embedding-init-count=3"), "{out}"); +} diff --git a/tests/cli_embedding_resources.rs b/tests/cli_embedding_resources.rs new file mode 100644 index 00000000..75d223e8 --- /dev/null +++ b/tests/cli_embedding_resources.rs @@ -0,0 +1,303 @@ +#![cfg(feature = "cli")] + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use vm::cli::{CliHostServices, CliRuntime, run_with_args}; +use vm::resource::{HostResource, ResourceHandle}; +use vm::{ + CallOutcome, HostApiBuilder, HostFunctionRegistry, HostFunctionSchema, HostParamPassing, + HostParamSchema, HostTypeSchema, ResourceTypeKey, ResourceTypeSchema, Value, Vm, VmError, +}; + +#[derive(Clone, Default)] +struct Audit { + values: Arc>>, + resources_dropped: Arc, + states_dropped: Arc, + factories: Arc, +} +struct Owner { + audit: Audit, + next: i64, +} +impl Drop for Owner { + fn drop(&mut self) { + self.audit.states_dropped.fetch_add(1, Ordering::SeqCst); + } +} +struct Counter { + value: i64, + audit: Audit, +} +impl HostResource for Counter { + fn resource_type_key() -> Option { + Some(key()) + } +} +impl Drop for Counter { + fn drop(&mut self) { + self.audit.resources_dropped.fetch_add(1, Ordering::SeqCst); + } +} +fn key() -> ResourceTypeKey { + ResourceTypeKey::new("embedding.counter").unwrap() +} +fn error(e: impl std::fmt::Display) -> VmError { + VmError::HostError(e.to_string()) +} +fn handle(args: &[Value]) -> vm::VmResult { + let Value::Int(raw) = args[0] else { + return Err(VmError::TypeMismatch("resource")); + }; + ResourceHandle::from_raw(raw as u64).map_err(error) +} +fn make(vm: &mut Vm, _: &[Value]) -> vm::VmResult { + let (value, audit) = { + let mut context = vm.host_context(); + let owner = context.module_state_mut::().unwrap(); + owner.next += 1; + (owner.next, owner.audit.clone()) + }; + let token = vm + .host_context() + .push_resource(Counter { value, audit }) + .map_err(error)?; + Ok(CallOutcome::Return(vm::return_one( + token.handle().raw() as i64 + ))) +} +fn increment(vm: &mut Vm, args: &[Value]) -> vm::VmResult { + vm.host_context() + .borrow_resource_mut::(handle(args)?) + .map_err(error)? + .value += 1; + Ok(CallOutcome::Return(vm::return_one(()))) +} +fn read(vm: &mut Vm, args: &[Value]) -> vm::VmResult { + let context = vm.host_context(); + let counter = context + .borrow_resource::(handle(args)?) + .map_err(error)?; + counter.audit.values.lock().unwrap().push(counter.value); + Ok(CallOutcome::Return(vm::return_one(counter.value))) +} +fn abort(_: &mut Vm, _: &[Value]) -> vm::VmResult { + Err(error("counter operation failed")) +} + +fn services(audit: Audit, fail_init: bool, reset_probe: bool) -> CliHostServices<'static> { + let resource = HostTypeSchema::Resource(key()); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(key(), "An embedding-owned counter")); + builder.function(HostFunctionSchema::with_return( + "counter::make", + vec![], + resource.clone(), + )); + builder.function(HostFunctionSchema::with_return( + "counter::increment", + vec![HostParamSchema::with_passing( + "counter", + resource.clone(), + HostParamPassing::BorrowMut, + )], + HostTypeSchema::Null, + )); + builder.function(HostFunctionSchema::with_return( + "counter::read", + vec![HostParamSchema::with_passing( + "counter", + resource, + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "counter::abort", + vec![], + HostTypeSchema::Null, + )); + let catalog = Arc::new(builder.build().unwrap()); + let register_catalog = Arc::clone(&catalog); + let factory_audit = audit.clone(); + CliHostServices::new(catalog, move || { + factory_audit.factories.fetch_add(1, Ordering::SeqCst); + let mut registry = HostFunctionRegistry::new(); + for (name, function) in [ + ("counter::make", make as vm::StaticHostFunction), + ("counter::increment", increment), + ("counter::read", read), + ("counter::abort", abort), + ] { + registry + .register_catalog_static( + vm::catalog_import_schemas(®ister_catalog, name).remove(0), + function, + ) + .map_err(error)?; + } + Ok(registry) + }) + .with_vm_init(move |vm| { + vm.host_context().set_module_state(Owner { + audit: audit.clone(), + next: 10, + }); + if reset_probe || fail_init { + vm.host_context() + .push_resource(Counter { + value: 99, + audit: audit.clone(), + }) + .map_err(error)?; + } + if fail_init { + return Err(error("initialization failed after allocation")); + } + if reset_probe { + vm.reset_for_reuse()?; + assert_eq!(vm.host_context().resource_count(), 0); + } + Ok(()) + }) +} + +struct Source(std::path::PathBuf); +impl Source { + fn new(name: &str, source: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "embedding-resource-{name}-{}.rss", + std::process::id() + )); + std::fs::write(&path, source).unwrap(); + Self(path) + } + fn run(&self, services: &CliHostServices<'_>) -> Result<(), Box> { + run_with_args( + &CliRuntime::default(), + &[self.0.display().to_string()], + services, + ) + } +} +impl Drop for Source { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +#[test] +fn independent_vm_factories_execute_typed_resource_operations_and_drop_once() { + let audit = Audit::default(); + let configured = services(audit.clone(), false, false); + let source = Source::new( + "isolation", + "let mut counter = counter::make(); counter::increment(&mut counter); counter::read(&counter);", + ); + source.run(&configured).unwrap(); + source.run(&configured).unwrap(); + assert_eq!(*audit.values.lock().unwrap(), vec![12, 12]); + assert_eq!(audit.factories.load(Ordering::SeqCst), 2); + assert_eq!(audit.resources_dropped.load(Ordering::SeqCst), 2); + assert_eq!(audit.states_dropped.load(Ordering::SeqCst), 2); +} + +#[test] +fn failed_initialization_cleans_resources_without_guest_effects() { + let audit = Audit::default(); + let source = Source::new( + "failed-init", + "let counter = counter::make(); counter::read(&counter);", + ); + let error = source + .run(&services(audit.clone(), true, false)) + .unwrap_err(); + assert!( + error + .to_string() + .contains("initialization failed after allocation") + ); + assert!(audit.values.lock().unwrap().is_empty()); + assert_eq!(audit.resources_dropped.load(Ordering::SeqCst), 1); + assert_eq!(audit.states_dropped.load(Ordering::SeqCst), 1); +} + +#[test] +fn guest_host_error_prevents_later_effects_and_cleans_resources() { + let audit = Audit::default(); + let source = Source::new( + "guest-error", + "let counter = counter::make(); counter::abort(); counter::read(&counter);", + ); + let error = source + .run(&services(audit.clone(), false, false)) + .unwrap_err(); + assert!(error.to_string().contains("counter operation failed")); + assert!(audit.values.lock().unwrap().is_empty()); + assert_eq!(audit.resources_dropped.load(Ordering::SeqCst), 1); + assert_eq!(audit.states_dropped.load(Ordering::SeqCst), 1); +} + +#[test] +fn initialization_can_reset_resources_without_sharing_owner_state() { + let audit = Audit::default(); + let source = Source::new( + "reset", + "let counter = counter::make(); counter::read(&counter);", + ); + source.run(&services(audit.clone(), false, true)).unwrap(); + assert_eq!(*audit.values.lock().unwrap(), vec![11]); + assert_eq!(audit.resources_dropped.load(Ordering::SeqCst), 2); + assert_eq!(audit.states_dropped.load(Ordering::SeqCst), 1); +} + +#[test] +fn repl_catalog_does_not_bypass_resource_copy_or_strict_typing_rejections() { + let configured = services(Audit::default(), false, false); + let options = configured + .compile_options(vm::CompileSourceFileOptions::default()) + .unwrap(); + let copied = vm::compile_source_for_repl_with_state_and_options( + "let counter = counter::make(); let alias = counter.copy();", + &[], + options.clone(), + ); + let copied = copied + .err() + .expect("resource duplication remains forbidden"); + assert!( + matches!(&copied, vm::SourceError::Parse(parse) if parse.code.as_deref() == Some("E_OWNERSHIP_COPY_RESOURCE")), + "{copied}" + ); + let wrong = vm::compile_source_for_repl_with_state_and_options( + "fn invalid() -> int { \"wrong\" } invalid();", + &[], + options.clone(), + ) + .err() + .expect("independent strict rejection"); + assert!( + matches!( + &wrong, + vm::SourceError::Compile(vm::CompileError::StrictTypingRequired { .. }) + ), + "{wrong}" + ); + let moved = vm::ReplLocalState { + binding: vm::ReplLocalBinding { + name: "message".into(), + mutable: false, + schema: Some(vm::TypeSchema::String), + optional: false, + }, + moved: true, + }; + let unavailable = + vm::compile_source_for_repl_with_state_and_options("message;", &[moved], options) + .err() + .expect("independent moved-local rejection"); + assert!( + matches!(&unavailable, vm::SourceError::Parse(parse) if parse.code.as_deref() == Some("E_LOCAL_MOVED")), + "{unavailable}" + ); +} diff --git a/tests/compiler/frontend_plugin_catalog_tests.rs b/tests/compiler/frontend_plugin_catalog_tests.rs new file mode 100644 index 00000000..ab2cb2ae --- /dev/null +++ b/tests/compiler/frontend_plugin_catalog_tests.rs @@ -0,0 +1,912 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use vm::compiler::{ + CompileSourceFileOptions, Expr, FrontendId, FrontendIr, FrontendTypingPolicy, ParseError, + ParserDialect, SharedParserOptions, SourceFlavor, SourcePosition, Stmt, + analyze_source_from_string_with_options, collect_inferred_local_type_hints_with_options, + compile_source_with_flavor_and_options, parse_source_with_dialect, +}; +use vm::host_api::{ + HostApiBuilder, HostApiCatalog, HostFunctionSchema, HostParamPassing, HostParamSchema, + HostStructField, HostStructSchema, HostTypeSchema, ResourceTypeKey, ResourceTypeSchema, +}; +use vm::{ + CallOutcome, CallReturn, HostExtension, HostFunctionRegistry, SourceFrontendContext, + SourceFrontendDescriptor, SourcePlugin, Value, Vm, VmResult, +}; + +static HOST_CALLS: AtomicUsize = AtomicUsize::new(0); +static CAPTURE: Mutex> = Mutex::new(None); + +#[derive(Clone, Debug)] +struct BridgeCapture { + candidates: Vec, + profile_origin: vm::compiler::StructDeclOrigin, + address_origin: vm::compiler::StructDeclOrigin, + returned_type: vm::TypeSchema, +} + +struct NativeGrammar; +impl ParserDialect for NativeGrammar { + fn allow_arrow_closure(&self) -> bool { + true + } +} +static NATIVE_GRAMMAR: NativeGrammar = NativeGrammar; + +struct CatalogFrontend; +static CATALOG_FRONTEND: CatalogFrontend = CatalogFrontend; + +impl SourcePlugin for CatalogFrontend { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("synthetic-catalog-bridge"), + extensions: &["plug"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = parse_source_with_dialect( + source, + &NATIVE_GRAMMAR, + SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: true, + }, + )?; + let returned_type = context.install_host_type(&mut ir, "Profile")?; + let alias = "load_profile"; + let declaration = ir + .functions + .iter() + .find(|function| function.name == alias) + .cloned() + .ok_or_else(|| ParseError::new("synthetic source did not parse load_profile"))?; + ir.functions + .retain(|function| function.index != declaration.index); + ir.implicit_extern_names.retain(|name| name != alias); + let function_index = + context.install_host_function(&mut ir, "profile::load", declaration.arity)?; + for stmt in &mut ir.stmts { + if let Stmt::Let { + expr: Expr::Call(index, ..), + .. + } = stmt + && *index == declaration.index + { + *index = function_index; + } + } + let metadata = ir + .host_api_metadata + .as_ref() + .ok_or_else(|| ParseError::new("catalog helper did not bind host metadata"))?; + let candidates = metadata + .candidates(function_index) + .ok_or_else(|| ParseError::new("catalog helper did not retain candidates"))? + .to_vec(); + let capture = BridgeCapture { + candidates, + profile_origin: ir + .struct_schemas + .get("Profile") + .ok_or_else(|| ParseError::new("Profile schema was not installed"))? + .origin, + address_origin: ir + .struct_schemas + .get("Address") + .ok_or_else(|| ParseError::new("nested Address schema was not installed"))? + .origin, + returned_type, + }; + *CAPTURE.lock().expect("capture lock") = Some(capture); + Ok(ir) + } +} + +fn profile_catalog() -> Arc { + let address = HostStructSchema::new( + "Address", + vec![HostStructField::new("city", HostTypeSchema::String)], + ); + let profile = HostStructSchema::new( + "Profile", + vec![ + HostStructField::new("name", HostTypeSchema::String), + HostStructField::new("address", address.as_type()), + ], + ); + let mut builder = HostApiBuilder::new(); + builder.named_struct(address); + builder.named_struct(profile.clone()); + builder.function(HostFunctionSchema::with_return( + "profile::load", + vec![HostParamSchema::value("name", HostTypeSchema::String)], + profile.as_type(), + )); + Arc::new(builder.build().expect("profile catalog")) +} + +struct OverloadFrontend; +static OVERLOAD_FRONTEND: OverloadFrontend = OverloadFrontend; + +impl SourcePlugin for OverloadFrontend { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("synthetic-catalog-overloads"), + extensions: &["plug"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = parse_source_with_dialect( + source, + &NATIVE_GRAMMAR, + SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: true, + }, + )?; + let implicit_names = ir.implicit_extern_names.clone(); + let placeholders: Vec<_> = ir + .functions + .iter() + .filter(|function| implicit_names.contains(&function.name)) + .cloned() + .collect(); + let placeholder_indices: std::collections::HashSet<_> = + placeholders.iter().map(|function| function.index).collect(); + ir.functions + .retain(|function| !placeholder_indices.contains(&function.index)); + ir.implicit_extern_names.clear(); + + let mut remap = HashMap::new(); + for placeholder in placeholders { + let catalog_name = match placeholder.name.as_str() { + "select_int" | "select_string" | "select_pair" | "select_callback" + | "borrow_handle" => "matrix::select", + "open_handle" => "matrix::open", + "only_int" => "matrix::only_int", + _ => { + return Err(ParseError::new(format!( + "unexpected synthetic host alias '{}'", + placeholder.name + ))); + } + }; + let index = context.install_host_function(&mut ir, catalog_name, placeholder.arity)?; + remap.insert(placeholder.index, index); + } + for statement in &mut ir.stmts { + let expression = match statement { + Stmt::Let { expr, .. } | Stmt::Expr { expr, .. } => expr, + _ => continue, + }; + if let Expr::Call(index, ..) = expression + && let Some(bound_index) = remap.get(index) + { + *index = *bound_index; + } + } + + Ok(ir) + } +} + +fn overload_catalog() -> Arc { + let resource = ResourceTypeKey::new("synthetic.handle").expect("resource key"); + let callback = HostTypeSchema::Callable { + params: vec![HostTypeSchema::String], + result: Box::new(HostTypeSchema::Bool), + }; + let resource_type = HostTypeSchema::Resource(resource.clone()); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new( + resource.clone(), + "synthetic handle", + )); + builder.function(HostFunctionSchema::with_return( + "matrix::select", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "matrix::select", + vec![HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::String, + )); + builder.function(HostFunctionSchema::with_return( + "matrix::select", + vec![HostParamSchema::with_passing( + "value", + resource_type.clone(), + HostParamPassing::Borrow, + )], + HostTypeSchema::String, + )); + builder.function(HostFunctionSchema::with_return( + "matrix::select", + vec![HostParamSchema::with_passing( + "value", + resource_type.clone(), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + builder.function(HostFunctionSchema::with_return( + "matrix::select", + vec![HostParamSchema::value("callback", callback)], + HostTypeSchema::Bool, + )); + builder.function(HostFunctionSchema::with_return( + "matrix::select", + vec![ + HostParamSchema::value("left", HostTypeSchema::Int), + HostParamSchema::value("right", HostTypeSchema::String), + ], + HostTypeSchema::String, + )); + builder.function(HostFunctionSchema::with_return( + "matrix::open", + Vec::new(), + resource_type, + )); + builder.function(HostFunctionSchema::with_return( + "matrix::only_int", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + Arc::new(builder.build().expect("overload catalog")) +} + +struct ProfileExtension { + catalog: Arc, +} + +impl HostExtension for ProfileExtension { + fn catalog(&self) -> Option<&HostApiCatalog> { + Some(self.catalog.as_ref()) + } + + fn register(&self, registry: &mut HostFunctionRegistry) -> VmResult<()> { + for schema in vm::catalog_import_schemas(&self.catalog, "profile::load") { + registry.register_exact_static("profile::load", 1, schema, profile_load)?; + } + Ok(()) + } +} + +fn profile_load(_vm: &mut Vm, _args: &[Value]) -> VmResult { + HOST_CALLS.fetch_add(1, Ordering::SeqCst); + let address = Value::map(vec![(Value::string("city"), Value::string("Paris"))]); + let profile = Value::map(vec![ + (Value::string("name"), Value::string("Ada")), + (Value::string("address"), address), + ]); + Ok(CallOutcome::Return(CallReturn::one(profile))) +} + +#[test] +fn synthetic_plugin_binds_alias_to_catalog_identity_and_runs_named_result() { + HOST_CALLS.store(0, Ordering::SeqCst); + *CAPTURE.lock().expect("capture lock") = None; + let catalog = profile_catalog(); + let source = "let profile = load_profile(\"Ada\");\nlet city = profile.address.city;\ncity;\n"; + let flavor = SourceFlavor::Plugin(FrontendId::new("synthetic-catalog-bridge")); + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(&CATALOG_FRONTEND) + .with_host_api_catalog(Arc::clone(&catalog)) + }; + + let compiled = compile_source_with_flavor_and_options(source, flavor, options()) + .expect("catalog-backed plugin source should compile"); + let capture = CAPTURE + .lock() + .expect("capture lock") + .clone() + .expect("frontend should capture installed metadata"); + assert_eq!( + capture.candidates, + catalog + .functions_named("profile::load") + .into_iter() + .cloned() + .collect::>() + ); + assert_eq!( + capture.profile_origin, + vm::compiler::StructDeclOrigin::Catalog + ); + assert_eq!( + capture.address_origin, + vm::compiler::StructDeclOrigin::Catalog + ); + assert_eq!( + capture.returned_type, + vm::TypeSchema::Named("Profile".to_string(), Vec::new()) + ); + assert_eq!(compiled.program.imports.len(), 1); + assert_eq!(compiled.program.imports[0].name, "profile::load"); + assert_eq!(compiled.program.imports[0].arity, 1); + let expected_host_schema = vm::catalog_import_schemas(&catalog, "profile::load"); + assert_eq!( + compiled.program.host_import_schemas()[0].as_ref(), + expected_host_schema.first() + ); + let local_schemas = &compiled + .program + .type_map + .as_ref() + .expect("strict compile retains inferred schemas") + .local_schemas; + assert!(local_schemas.iter().any(|schema| { + schema == &Some(vm::TypeSchema::Named("Profile".to_string(), Vec::new())) + })); + assert!( + local_schemas + .iter() + .any(|schema| schema == &Some(vm::TypeSchema::String)) + ); + let hints = collect_inferred_local_type_hints_with_options(source, flavor, options()) + .expect("plugin type hints use the caller catalog"); + assert_eq!( + hints + .iter() + .find(|hint| hint.name == "profile") + .unwrap() + .inferred_type, + "Profile" + ); + assert_eq!( + hints + .iter() + .find(|hint| hint.name == "city") + .unwrap() + .inferred_type, + "string" + ); + + let mut vm = Vm::try_new(compiled.program).expect("compiled program creates VM"); + let extension = ProfileExtension { catalog }; + let mut registry = HostFunctionRegistry::empty(); + vm::register_host_extension(&mut registry, &extension).expect("register exact catalog host"); + registry + .bind_vm_cached(&mut vm) + .expect("bind exact compiled import"); + assert_eq!( + vm.run().expect("host call and named field access run"), + vm::VmStatus::Halted + ); + assert_eq!(HOST_CALLS.load(Ordering::SeqCst), 1); + assert!(vm.stack().contains(&Value::string("Paris"))); +} + +struct NameOnlyFrontend; +static NAME_ONLY_FRONTEND: NameOnlyFrontend = NameOnlyFrontend; + +impl SourcePlugin for NameOnlyFrontend { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("synthetic-name-only"), + extensions: &["plug"], + typing_policy: FrontendTypingPolicy::Dynamic, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = parse_source_with_dialect( + source, + &NATIVE_GRAMMAR, + SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: true, + }, + )?; + let declaration = ir + .functions + .iter_mut() + .find(|function| function.name == "load_profile") + .ok_or_else(|| ParseError::new("synthetic source did not parse load_profile"))?; + declaration.name = "profile::load".to_string(); + Ok(ir) + } +} + +#[test] +fn catalog_installation_preserves_existing_source_function_identities() { + for source in [ + "print(41);", + "collide(41);", + "pub fn collide(value: int) -> int { value + 1 } collide(41);", + ] { + let mut ir = parse_source_with_dialect( + source, + &NATIVE_GRAMMAR, + SharedParserOptions { + allow_implicit_externs: true, + ..SharedParserOptions::default() + }, + ) + .expect("collision source parses"); + let before = ir.functions[0].clone(); + let mut builder = HostApiBuilder::new(); + builder.function(HostFunctionSchema::with_return( + &before.name, + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("collision catalog"); + let context = SourceFrontendContext { + source_id: 0, + original_path: None, + phase: vm::compiler::ParsePhase::Standalone, + host_api_catalog: Some(&catalog), + imports: &[], + }; + let installed = context + .install_host_function(&mut ir, &before.name, 1) + .expect("catalog installation allocates a safe slot"); + assert_ne!( + installed, before.index, + "existing source declaration must keep its identity: {source}" + ); + let preserved = ir + .functions + .iter() + .find(|f| f.index == before.index) + .unwrap(); + assert_eq!(format!("{preserved:?}"), format!("{before:?}")); + assert_eq!( + context + .install_host_function(&mut ir, &before.name, 1) + .unwrap(), + installed, + "already exact-bound host identity is reusable" + ); + } +} + +#[test] +fn rejects_name_only_import_for_catalog_owned_function() { + let catalog = profile_catalog(); + let flavor = SourceFlavor::Plugin(FrontendId::new("synthetic-name-only")); + let options = CompileSourceFileOptions::new() + .with_source_plugin(&NAME_ONLY_FRONTEND) + .with_host_api_catalog(catalog); + let error = + match compile_source_with_flavor_and_options("load_profile(\"Ada\");\n", flavor, options) { + Ok(_) => panic!("catalog-owned imports require exact candidate metadata"), + Err(error) => error.to_string(), + }; + assert!( + error.contains("catalog-bound host function") || error.contains("candidate metadata"), + "expected fail-closed catalog binding diagnostic, got {error}" + ); +} + +#[test] +fn binds_complete_arity_families_and_resolves_typed_resource_and_callable_calls() { + let catalog = overload_catalog(); + let source = concat!( + "let int_result = select_int(7);\n", + "let string_result = select_string(\"hello\");\n", + "let callback_result = select_callback((value) => value == \"hello\");\n", + ); + let flavor = SourceFlavor::Plugin(FrontendId::new("synthetic-catalog-overloads")); + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(&OVERLOAD_FRONTEND) + .with_host_api_catalog(Arc::clone(&catalog)) + }; + let compiled = compile_source_with_flavor_and_options(source, flavor, options()) + .expect("all compatible overload calls compile"); + let mut select_import_arities: Vec<_> = compiled + .program + .imports + .iter() + .filter(|import| import.name == "matrix::select") + .map(|import| import.arity) + .collect(); + select_import_arities.sort_unstable(); + assert_eq!(select_import_arities, vec![1, 1, 1]); + + let model = analyze_source_from_string_with_options("overload.plug", source, options()) + .expect("analysis resolves the same catalog candidates"); + let signature_at = |alias: &str| { + let offset = source.find(alias).expect("alias source offset") + 2; + model + .callable_signature_at(SourcePosition::new(0, offset)) + .expect("callable signature at alias") + }; + let int_signature = signature_at("select_int"); + assert_eq!(int_signature.params[0].ty, HostTypeSchema::Int); + assert_eq!(int_signature.return_type, HostTypeSchema::Int); + assert_eq!(int_signature.params[0].passing, HostParamPassing::Value); + let string_signature = signature_at("select_string"); + assert_eq!(string_signature.params[0].ty, HostTypeSchema::String); + assert_eq!(string_signature.return_type, HostTypeSchema::String); + assert_eq!(string_signature.params[0].passing, HostParamPassing::Value); + let callback_signature = signature_at("select_callback"); + assert!(matches!( + callback_signature.params[0].ty, + HostTypeSchema::Callable { .. } + )); + assert_eq!(callback_signature.return_type, HostTypeSchema::Bool); +} + +#[test] +fn separates_arity_indexes_while_resolving_calls_from_the_complete_same_arity_family() { + let catalog = overload_catalog(); + let source = concat!( + "let int_result = select_int(7);\n", + "let string_result = select_string(\"hello\");\n", + "let pair_result = select_pair(4, \"right\");\n", + "let handle = open_handle();\n", + "borrow_handle(&handle);\n", + ); + let context = SourceFrontendContext { + source_id: 0, + original_path: None, + phase: vm::compiler::ParsePhase::Standalone, + host_api_catalog: Some(catalog.as_ref()), + imports: &[], + }; + let ir = OVERLOAD_FRONTEND + .parse_source(source, &context) + .expect("synthetic RSS source parses and binds catalog aliases"); + let call_indices: Vec<_> = ir + .stmts + .iter() + .filter_map(|statement| match statement { + Stmt::Let { + expr: Expr::Call(index, ..), + .. + } + | Stmt::Expr { + expr: Expr::Call(index, ..), + .. + } => Some(*index), + _ => None, + }) + .collect(); + assert_eq!(call_indices.len(), 5); + assert_eq!(call_indices[0], call_indices[1]); + assert_ne!(call_indices[0], call_indices[2]); + assert_ne!(call_indices[0], call_indices[3]); + assert_eq!(call_indices[0], call_indices[4]); + let metadata = ir + .host_api_metadata + .as_ref() + .expect("helper binds candidates to caller catalog"); + assert_eq!(metadata.fingerprint(), catalog.fingerprint()); + let expected_one: Vec<_> = catalog + .functions_named("matrix::select") + .into_iter() + .filter(|schema| schema.params.len() == 1) + .cloned() + .collect(); + let expected_two: Vec<_> = catalog + .functions_named("matrix::select") + .into_iter() + .filter(|schema| schema.params.len() == 2) + .cloned() + .collect(); + assert_eq!( + metadata.candidates(call_indices[0]), + Some(expected_one.as_slice()) + ); + assert_eq!( + metadata.candidates(call_indices[2]), + Some(expected_two.as_slice()) + ); + assert!( + expected_one + .iter() + .any(|candidate| { candidate.params[0].passing == HostParamPassing::Borrow }) + ); + assert!( + expected_one + .iter() + .any(|candidate| { candidate.params[0].passing == HostParamPassing::TakeOwned }) + ); + assert!( + expected_one + .iter() + .any(|candidate| { matches!(candidate.params[0].ty, HostTypeSchema::Callable { .. }) }) + ); + + let resolver = vm::compiler::HostCallResolver::new(catalog.as_ref()); + let selected_int = resolver + .resolve("matrix::select", &[vm::TypeSchema::Int]) + .expect("integer source call resolves"); + assert_eq!(selected_int.params[0].schema, vm::TypeSchema::Int); + assert_eq!(selected_int.return_type, vm::TypeSchema::Int); + assert_eq!(selected_int.passing, vec![HostParamPassing::Value]); + let selected_pair = resolver + .resolve( + "matrix::select", + &[vm::TypeSchema::Int, vm::TypeSchema::String], + ) + .expect("two-argument source call resolves independently"); + assert_eq!(selected_pair.params.len(), 2); + assert_eq!(selected_pair.params[0].schema, vm::TypeSchema::Int); + assert_eq!(selected_pair.params[1].schema, vm::TypeSchema::String); + assert_eq!(selected_pair.return_type, vm::TypeSchema::String); + assert_eq!(selected_pair.passing, vec![HostParamPassing::Value; 2]); + let resource_ambiguity = resolver + .resolve( + "matrix::select", + &[vm::TypeSchema::Resource( + ResourceTypeKey::new("synthetic.handle").unwrap(), + )], + ) + .expect_err("pass-only resource candidates remain ambiguous without explicit intent"); + assert!( + resource_ambiguity + .to_string() + .contains("ambiguous host function") + ); + assert!(resource_ambiguity.to_string().contains("equally-viable")); +} + +#[test] +fn rejects_unsupported_arity_and_incompatible_argument_during_source_compilation() { + let catalog = overload_catalog(); + let flavor = SourceFlavor::Plugin(FrontendId::new("synthetic-catalog-overloads")); + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(&OVERLOAD_FRONTEND) + .with_host_api_catalog(Arc::clone(&catalog)) + }; + for (source, expected_fragment) in [ + ( + "let result = select_int(1, 2, 3);\n", + "no overload with 3 argument", + ), + ( + "let result = only_int(\"not an int\");\n", + "no host function", + ), + ] { + let error = match compile_source_with_flavor_and_options(source, flavor, options()) { + Ok(_) => panic!("incompatible source must fail compilation: {source}"), + Err(error) => error.to_string(), + }; + assert!( + error.contains(expected_fragment), + "expected {expected_fragment:?} diagnostic, got {error}" + ); + } +} + +fn parse_native_source( + source: &str, + context: &SourceFrontendContext<'_>, +) -> Result { + parse_source_with_dialect( + source, + &NATIVE_GRAMMAR, + SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: true, + }, + ) +} + +struct TypeOnlyFrontend; +static TYPE_ONLY_FRONTEND: TypeOnlyFrontend = TypeOnlyFrontend; + +impl SourcePlugin for TypeOnlyFrontend { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("synthetic-catalog-type-only"), + extensions: &["plug"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = parse_native_source(source, context)?; + context.install_host_type(&mut ir, "Profile")?; + Ok(ir) + } +} + +struct GuestCollisionFrontend; +static GUEST_COLLISION_FRONTEND: GuestCollisionFrontend = GuestCollisionFrontend; + +impl SourcePlugin for GuestCollisionFrontend { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("synthetic-guest-catalog-collision"), + extensions: &["plug"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = parse_native_source(source, context)?; + context.install_host_type(&mut ir, "Profile")?; + Ok(ir) + } +} + +struct StoredFrontend; +static STORED_FRONTEND: StoredFrontend = StoredFrontend; +static STORED_IR: Mutex> = Mutex::new(None); + +impl SourcePlugin for StoredFrontend { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("synthetic-catalog-reuse"), + extensions: &["plug"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + if source.contains("load_profile") { + let ir = CATALOG_FRONTEND.parse_source(source, context)?; + let index = ir + .host_api_metadata + .as_ref() + .and_then(|metadata| metadata.function_indices().next()) + .ok_or_else(|| ParseError::new("stored source has no catalog function index"))?; + *STORED_IR.lock().expect("stored IR lock") = Some((ir.clone(), index)); + return Ok(ir); + } + + let (mut ir, original_index) = STORED_IR + .lock() + .expect("stored IR lock") + .clone() + .ok_or_else(|| ParseError::new("source reuse requested before catalog source"))?; + context.install_host_type(&mut ir, "Profile")?; + let index = context.install_host_function(&mut ir, "profile::load", 1)?; + if index != original_index { + return Err(ParseError::new( + "compatible catalog reuse changed the flat index", + )); + } + Ok(ir) + } +} + +fn foreign_profile_catalog() -> Arc { + let profile = HostStructSchema::new( + "Profile", + vec![HostStructField::new("name", HostTypeSchema::Int)], + ); + let mut builder = HostApiBuilder::new(); + builder.named_struct(profile.clone()); + builder.function(HostFunctionSchema::with_return( + "profile::load", + vec![HostParamSchema::value("name", HostTypeSchema::String)], + profile.as_type(), + )); + Arc::new(builder.build().expect("foreign profile catalog")) +} + +#[test] +fn type_only_catalog_installation_adds_no_executable_host_imports() { + let catalog = profile_catalog(); + let flavor = SourceFlavor::Plugin(FrontendId::new("synthetic-catalog-type-only")); + let options = CompileSourceFileOptions::new() + .with_source_plugin(&TYPE_ONLY_FRONTEND) + .with_host_api_catalog(catalog); + let compiled = compile_source_with_flavor_and_options("7;\n", flavor, options) + .expect("type-only source compiles"); + assert!(compiled.program.imports.is_empty()); + assert!(compiled.program.named_struct_decls().is_empty()); +} + +#[test] +fn guest_named_type_collision_fails_in_source_compilation() { + let flavor = SourceFlavor::Plugin(FrontendId::new("synthetic-guest-catalog-collision")); + let options = CompileSourceFileOptions::new() + .with_source_plugin(&GUEST_COLLISION_FRONTEND) + .with_host_api_catalog(profile_catalog()); + let error = match compile_source_with_flavor_and_options( + "struct Profile { label: string }\n7;\n", + flavor, + options, + ) { + Ok(_) => panic!("catalog type must not replace the guest declaration"), + Err(error) => error.to_string(), + }; + assert!( + error.contains("guest"), + "unexpected collision error: {error}" + ); + assert!( + error.contains("Profile"), + "unexpected collision error: {error}" + ); +} + +#[test] +fn same_catalog_ir_reuse_succeeds_and_foreign_catalog_reuse_fails() { + *STORED_IR.lock().expect("stored IR lock") = None; + let catalog = profile_catalog(); + let flavor = SourceFlavor::Plugin(FrontendId::new("synthetic-catalog-reuse")); + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(&STORED_FRONTEND) + .with_host_api_catalog(Arc::clone(&catalog)) + }; + compile_source_with_flavor_and_options( + "let profile = load_profile(\"Ada\");\nlet city = profile.address.city;\ncity;\n", + flavor, + options(), + ) + .expect("initial source binds catalog metadata"); + let reused = compile_source_with_flavor_and_options("7;\n", flavor, options()) + .expect("same-catalog helper state is reusable"); + assert_eq!(reused.program.imports[0].name, "profile::load"); + + *STORED_IR.lock().expect("stored IR lock") = None; + compile_source_with_flavor_and_options( + "let profile = load_profile(\"Ada\");\nlet city = profile.address.city;\ncity;\n", + flavor, + options(), + ) + .expect("foreign-catalog fixture is prepared with its original catalog"); + let foreign_options = CompileSourceFileOptions::new() + .with_source_plugin(&STORED_FRONTEND) + .with_host_api_catalog(foreign_profile_catalog()); + let error = match compile_source_with_flavor_and_options("7;\n", flavor, foreign_options) { + Ok(_) => panic!("IR metadata from another catalog must be rejected"), + Err(error) => error.to_string(), + }; + assert!( + error.contains("different") || error.contains("identity"), + "unexpected foreign-catalog diagnostic: {error}" + ); +} diff --git a/tests/compiler/frontend_plugin_fixtures/diagnostic_provenance.rs b/tests/compiler/frontend_plugin_fixtures/diagnostic_provenance.rs new file mode 100644 index 00000000..10ea1108 --- /dev/null +++ b/tests/compiler/frontend_plugin_fixtures/diagnostic_provenance.rs @@ -0,0 +1,166 @@ +use super::*; + +struct DiagnosticPlugin { + mode: usize, +} +static DIAGNOSTIC_PLUGINS: [DiagnosticPlugin; 6] = [ + DiagnosticPlugin { mode: 0 }, + DiagnosticPlugin { mode: 1 }, + DiagnosticPlugin { mode: 2 }, + DiagnosticPlugin { mode: 3 }, + DiagnosticPlugin { mode: 4 }, + DiagnosticPlugin { mode: 5 }, +]; +impl SourcePlugin for DiagnosticPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("neutral-diagnostic-producer"), + extensions: &["diagnostic"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_module_imports( + &self, + source: &str, + path: &Path, + context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + graph_format::GRAPH_FORMAT.parse_module_imports(source, path, context) + } + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + graph_format::GRAPH_FORMAT + .parse_source(source, context) + .map_err(|mut error| { + // Retain the genuine syntax diagnostic; vary only the producer's supplied provenance. + let exact = source.find(';').unwrap(); + error.span = match self.mode { + 0 => Some(vm::Span { + source_id: context.source_id, + lo: exact, + hi: exact + 1, + }), + 1 => None, + 2 => Some(vm::Span { + source_id: context.source_id + 1, + lo: exact, + hi: exact + 1, + }), + 3 => Some(vm::Span { + source_id: context.source_id, + lo: exact + 1, + hi: exact, + }), + 4 => Some(vm::Span { + source_id: context.source_id, + lo: exact, + hi: source.len() + 1, + }), + _ => Some(vm::Span { + source_id: context.source_id, + lo: source.find('中').unwrap() + 1, + hi: exact, + }), + }; + error + }) + } +} +#[test] +fn genuine_parser_diagnostics_preserve_valid_spans_and_fallback_for_all_invalid_byte_ranges() { + let root = temp_root("diagnostic-fallback"); + let entry = root.join("main.graph"); + let dependency = root.join("lib.diagnostic"); + let malformed = "// 中文\r\n\r\nlet value = ;\r\n"; + for plugin in &DIAGNOSTIC_PLUGINS { + for nested in [false, true] { + let entry = if nested { + entry.clone() + } else { + root.join("main.diagnostic") + }; + let source = if nested { + "bind namespace _ lib ./lib.diagnostic\n42;\n" + } else { + malformed + }; + write_source(&entry, source); + write_source(&dependency, malformed); + let configured = || { + CompileSourceFileOptions::new() + .with_source_plugin(plugin) + .with_source_plugin(&graph_format::GRAPH_FORMAT) + }; + for operation in 0..3 { + let error = match operation { + 0 => compile_source_file_with_options(&entry, configured()) + .err() + .unwrap(), + 1 => vm::compiler::analyze_source_file_with_options(&entry, configured()) + .err() + .unwrap(), + _ => vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new(if nested { + "neutral-graph-format" + } else { + "neutral-diagnostic-producer" + })), + configured(), + ) + .err() + .unwrap(), + }; + let vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + sources, + } = error + else { + panic!("real parser error with sources required") + }; + assert!(parse.message.contains("expected"), "{}", parse.message); + let span = parse.span.unwrap(); + let owner = u32::from(nested); + assert_eq!(span.source_id, owner); + if plugin.mode == 0 { + assert_eq!(span.lo, malformed.find(';').unwrap()); + assert_eq!(sources.span_text(span), Some(";")); + } else { + assert_eq!(span, sources.line_span(owner, 3).unwrap()); + assert_eq!(sources.span_text(span).unwrap().trim_end(), "let value = ;"); + } + } + } + } + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn parameter_hints_keep_optional_schema_and_exact_chinese_crlf_declaration_bytes() { + let root = temp_root("parameter-provenance"); + let entry = root.join("main.graph"); + let source = + "// 中文\r\nfn maybe(parameter: int?) -> int? { parameter }\r\nlet callback = maybe;\r\n"; + write_source(&entry, source); + let hints = vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + CompileSourceFileOptions::new().with_source_plugin(&graph_format::GRAPH_FORMAT), + ) + .unwrap(); + let parameter = hints.iter().find(|hint| hint.name == "parameter").unwrap(); + assert_eq!( + parameter.schema, + Some(vm::TypeSchema::Optional(Box::new(vm::TypeSchema::Int))) + ); + let span = parameter.span.unwrap(); + assert_eq!(span.source_id, 0); + assert_eq!(span.lo, source.find("parameter").unwrap()); + assert_eq!(&source[span.lo..span.hi], "parameter"); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/compiler/frontend_plugin_fixtures/graph_acceptance.rs b/tests/compiler/frontend_plugin_fixtures/graph_acceptance.rs new file mode 100644 index 00000000..ffa8ce9c --- /dev/null +++ b/tests/compiler/frontend_plugin_fixtures/graph_acceptance.rs @@ -0,0 +1,116 @@ +use super::*; + +fn options() -> CompileSourceFileOptions { + CompileSourceFileOptions::new() + .with_source_plugin(&graph_format::GRAPH_FORMAT) + .with_source_plugin(&FORMAT_ONE) +} +fn reject_in_all_entries(entry: &Path, source: &str, expected: &str) { + write_source(entry, source); + let errors = [ + compile_source_file_with_options(entry, options()) + .err() + .unwrap(), + vm::compiler::analyze_source_file_with_options(entry, options()) + .err() + .unwrap(), + vm::collect_inferred_local_type_hints_at_path_with_options( + entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + options(), + ) + .err() + .unwrap(), + ]; + for error in &errors { + assert!(error.to_string().contains(expected), "{error}"); + } + assert_eq!(errors[0].to_string(), errors[1].to_string()); + assert_eq!(errors[0].to_string(), errors[2].to_string()); +} +#[test] +fn private_missing_types_and_cross_frontend_cycles_reject_in_all_checked_entries() { + let root = temp_root("graph-negative-matrix"); + let entry = root.join("main.graph"); + write_source( + &root.join("lib.rss"), + "struct Secret { value: int }\npub fn answer() -> int { 42 }\n", + ); + reject_in_all_entries(&entry, "bind type Secret Item ./lib.rss\n42;\n", "Secret"); + write_source(&root.join("lib.graph"), "struct Visible { value: int }\n"); + reject_in_all_entries( + &entry, + "bind type Missing Item ./lib.graph\n42;\n", + "Missing", + ); + write_source( + &root.join("lib.fmt-one"), + "export fn answer(value: int) -> int = value", + ); + reject_in_all_entries( + &entry, + "bind type answer Item ./lib.fmt-one\n42;\n", + "answer", + ); + // A plugin -> native -> plugin cycle is diagnosed before either initializer can run. + write_source( + &root.join("loop.rss"), + "use self::main as main;\npub fn answer() -> int { 42 }\n", + ); + write_source(&entry, "bind value answer answer ./loop.rss\nanswer();\n"); + let configured = || options().with_module_override_path("./main.rss", &entry); + let error = compile_source_file_with_options(&entry, configured()) + .err() + .unwrap(); + assert!(error.to_string().contains("cycle"), "{error}"); + assert!(vm::compiler::analyze_source_file_with_options(&entry, configured()).is_err()); + assert!( + vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + &std::fs::read_to_string(&entry).unwrap(), + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + configured() + ) + .is_err() + ); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn source_and_path_overrides_keep_cross_frontend_identity_and_runtime_value() { + let root = temp_root("graph-overrides"); + let entry = root.join("main.graph"); + let real = root.join("real.fmt-one"); + write_source(&real, "export fn plus(value: int) -> int = value + 1"); + let source = "bind value plus plus ./virtual.fmt-one\nlet result = plus(41);\nresult;\n"; + write_source(&entry, source); + for by_path in [false, true] { + let configured = || { + if by_path { + options().with_module_override_path("./virtual.fmt-one", &real) + } else { + options().with_module_override_source( + "./virtual.fmt-one", + "export fn plus(value: int) -> int = value + 1", + ) + } + }; + let compiled = compile_source_file_with_options(&entry, configured()).unwrap(); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + vm::compiler::analyze_source_file_with_options(&entry, configured()).unwrap(); + let hints = vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + configured(), + ) + .unwrap(); + let result = hints.iter().find(|hint| hint.name == "result").unwrap(); + assert_eq!(result.schema, Some(vm::TypeSchema::Int)); + let span = result.span.unwrap(); + assert_eq!(&source[span.lo..span.hi], "result"); + } + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/compiler/frontend_plugin_fixtures/graph_format.rs b/tests/compiler/frontend_plugin_fixtures/graph_format.rs new file mode 100644 index 00000000..f3aabbfe --- /dev/null +++ b/tests/compiler/frontend_plugin_fixtures/graph_format.rs @@ -0,0 +1,364 @@ +use super::*; + +pub(super) struct GraphFormat; +pub(super) static GRAPH_FORMAT: GraphFormat = GraphFormat; +impl SourcePlugin for GraphFormat { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("neutral-graph-format"), + extensions: &["graph"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_module_imports( + &self, + source: &str, + _path: &Path, + context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + let mut imports = Vec::new(); + let mut offset = 0; + for (line, text) in source.split_inclusive('\n').enumerate() { + if text.starts_with("bind ") { + let parts: Vec<_> = text.split_whitespace().collect(); + if parts.len() != 5 { + let lo = offset + text.find("bind").unwrap(); + return Err(vm::SourcePathError::Source(vm::SourceError::Parse( + ParseError { + line: line + 1, + message: + "graph binding requires kind, imported name, local name and module" + .to_string(), + span: Some(vm::Span { + source_id: context.source_id, + lo, + hi: lo + 4, + }), + code: None, + }, + ))); + } + let clause = match parts[1] { + "type" => ImportClause::Named(vec![NamedImport::type_only(parts[2], parts[3])]), + "value" => ImportClause::Named(vec![NamedImport::value(parts[2], parts[3])]), + "namespace" | "type-namespace" => ImportClause::Namespace(parts[3].to_string()), + _ => panic!("invalid graph binding"), + }; + let mut import = ModuleImport::value(parts[4], clause, line + 1); + if parts[1] == "type-namespace" { + import = import.type_only(); + } + import.span = Some(vm::Span { + source_id: context.source_id, + lo: offset, + hi: offset + text.trim_end().len(), + }); + imports.push(import); + } + offset += text.len(); + } + Ok(imports) + } + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let lowered = source + .split_inclusive('\n') + .map(|line| { + if line.starts_with("bind ") || line.starts_with("alias ") { + line.chars() + .map(|c| if c == '\r' || c == '\n' { c } else { ' ' }) + .collect::() + } else { + line.to_string() + } + }) + .collect::(); + parse_source_with_dialect( + &lowered, + &DIALECT, + SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + import_scan_mode: true, + ..SharedParserOptions::default() + }, + ) + } + fn module_type_exports( + &self, + source: &str, + _context: &SourceFrontendContext<'_>, + ir: &FrontendIr, + ) -> Result, ParseError> { + let mut exports = ir + .struct_schemas + .values() + .filter(|decl| decl.origin == vm::compiler::StructDeclOrigin::Guest) + .map(|decl| vm::compiler::FrontendTypeExport { + source_name: decl.name.clone(), + schema: vm::TypeSchema::Named(decl.name.clone(), vec![]), + declaration: Some(decl.clone()), + }) + .collect::>(); + for line in source.lines().filter(|line| line.starts_with("alias ")) { + let parts: Vec<_> = line.split_whitespace().collect(); + let decl = ir.struct_schemas.get(parts[2]).expect("alias declaration"); + exports.push(vm::compiler::FrontendTypeExport { + source_name: parts[1].to_string(), + schema: vm::TypeSchema::Named(decl.name.clone(), vec![]), + declaration: Some(decl.clone()), + }); + } + Ok(exports) + } +} + +#[test] +fn public_consumer_can_query_independent_declaration_namespaces() { + use vm::DeclarationNamespace; + let mut graph = vm::ModuleGraph::new(); + let module = graph.add_node( + PathBuf::from("neutral.module"), + "neutral.module".to_string(), + vec![], + ); + let value = graph + .add_declaration_in_namespace(module, DeclarationNamespace::Value, "Item", true) + .unwrap(); + let typ = graph + .add_declaration_in_namespace(module, DeclarationNamespace::Type, "Item", true) + .unwrap(); + assert_ne!(value, typ); + assert_eq!( + graph.declaration_symbol_in_namespace(module, DeclarationNamespace::Value, "Item"), + Some(value) + ); + assert_eq!( + graph.declaration_symbol_in_namespace(module, DeclarationNamespace::Type, "Item"), + Some(typ) + ); +} + +#[test] +fn import_diagnostics_keep_exact_utf8_byte_span_in_root_and_dependency() { + let root = temp_root("import-span"); + let malformed = "// 中文\r\n\r\nbind type\r\n"; + for dependency in [false, true] { + let entry = root.join("main.graph"); + if dependency { + write_source(&root.join("lib.graph"), malformed); + write_source(&entry, "bind namespace _ lib ./lib.graph\n42;\n"); + } else { + write_source(&entry, malformed); + } + let error = compile_source_file_with_options( + &entry, + CompileSourceFileOptions::new().with_source_plugin(&GRAPH_FORMAT), + ) + .err() + .unwrap(); + let vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + sources, + } = error + else { + panic!("expected source-owned import diagnostic"); + }; + let span = parse.span.unwrap(); + assert_eq!(span.source_id, u32::from(dependency)); + assert_eq!(span.lo, malformed.find("bind").unwrap()); + assert_eq!(span.hi, span.lo + 4); + assert_eq!( + &sources.source(span.source_id).unwrap()[span.lo..span.hi], + "bind" + ); + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn type_and_value_imports_with_same_local_spelling_resolve_independently() { + let root = temp_root("namespaces"); + write_source(&root.join("type.fmt-type"), "export type Box = int"); + write_source( + &root.join("value.fmt-one"), + "export fn plus(value: int) -> int = value + 1", + ); + let entry = root.join("main.graph"); + write_source( + &entry, + "bind type Box Item ./type.fmt-type\nbind value plus Item ./value.fmt-one\nItem(41);", + ); + let compiled = compile_source_file_with_options( + &entry, + CompileSourceFileOptions::new() + .with_source_plugin(&GRAPH_FORMAT) + .with_source_plugin(&FORMAT_ONE) + .with_source_plugin(&FORMAT_TYPE_EXPORT), + ) + .expect("independent type/value names must not be ambiguous calls"); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn type_namespace_does_not_authorize_value_call() { + let root = temp_root("type-namespace"); + write_source( + &root.join("lib.graph"), + "struct Box { value: int } pub fn hidden() -> int { 99 }", + ); + let entry = root.join("main.graph"); + write_source( + &entry, + "bind type-namespace _ types ./lib.graph\ntypes::hidden();", + ); + let error = compile_source_file_with_options( + &entry, + CompileSourceFileOptions::new().with_source_plugin(&GRAPH_FORMAT), + ) + .err() + .expect("type namespace cannot provide executable functions"); + assert!(error.to_string().contains("hidden"), "{error}"); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn independent_generic_nominal_modules_keep_identity_and_arity() { + let root = temp_root("generic-nominal"); + for directory in ["left", "right"] { + std::fs::create_dir_all(root.join(directory)).unwrap(); + } + write_source( + &root.join("left/lib.graph"), + "struct Box { value: T }\nalias Alias Box\n", + ); + write_source( + &root.join("right/lib.graph"), + "struct Box { value: T }\n", + ); + let entry = root.join("main.graph"); + let source = "bind type Alias Left ./left/lib.graph\nbind type Box Right ./right/lib.graph\nlet left: Left = { value: 42 };\nlet right: Right = { value: \"other\" };\nleft.value;\n"; + write_source(&entry, source); + let configured = || CompileSourceFileOptions::new().with_source_plugin(&GRAPH_FORMAT); + let compiled = compile_source_file_with_options(&entry, configured()).unwrap(); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + let hints = vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + configured(), + ) + .unwrap(); + let left = hints.iter().find(|hint| hint.name == "left").unwrap(); + let right = hints.iter().find(|hint| hint.name == "right").unwrap(); + let ( + Some(vm::TypeSchema::Named(left_name, left_args)), + Some(vm::TypeSchema::Named(right_name, right_args)), + ) = (&left.schema, &right.schema) + else { + panic!("nominal schemas required"); + }; + assert_ne!(left_name, right_name); + assert_eq!(left_args, &[vm::TypeSchema::Int]); + assert_eq!(right_args, &[vm::TypeSchema::String]); + assert_eq!(left.inferred_type, "Left"); + assert_eq!(right.inferred_type, "Right"); + write_source( + &entry, + "bind type Alias Left ./left/lib.graph\nlet value: Left = { value: 42 };\n", + ); + assert!( + compile_source_file_with_options(&entry, configured()).is_err(), + "generic arity is checked after alias canonicalization" + ); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn nominal_export_alias_uses_underlying_declaration_identity() { + let root = temp_root("nominal-alias"); + write_source( + &root.join("lib.graph"), + "struct Box { value: int }\nalias Alias Box\n", + ); + let entry = root.join("main.graph"); + write_source( + &entry, + "bind type Alias Item ./lib.graph\nlet value: Item = { value: 42 };\nvalue.value;", + ); + let compiled = compile_source_file_with_options( + &entry, + CompileSourceFileOptions::new().with_source_plugin(&GRAPH_FORMAT), + ) + .expect("export spelling must not mint a different nominal type"); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn nominal_hint_keeps_source_alias_label_and_declaration_span() { + let root = temp_root("nominal-hint"); + write_source( + &root.join("lib.graph"), + "struct Box { value: int }\nalias Alias Box\n", + ); + let entry = root.join("main.graph"); + let source = "// 中文\r\nbind type Alias Item ./lib.graph\r\nlet value: Item = { value: 42 };\r\nvalue.value;\r\n"; + write_source(&entry, source); + let hints = vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + CompileSourceFileOptions::new().with_source_plugin(&GRAPH_FORMAT), + ) + .unwrap(); + let hint = hints + .iter() + .find(|hint| hint.name == "value") + .expect("root value hint"); + assert_eq!( + hint.inferred_type, "Item", + "logical label must retain the source alias" + ); + let span = hint.span.expect("logical declaration span"); + assert_eq!(span.source_id, 0); + assert_eq!(&source[span.lo..span.hi], "value"); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn conflicting_type_alias_imports_report_owned_ambiguity() { + let root = temp_root("duplicate-types"); + for name in ["one", "two"] { + write_source( + &root.join(format!("{name}.fmt-type")), + "export type Box = int", + ); + } + let entry = root.join("main.graph"); + write_source( + &entry, + "bind type Box Item ./one.fmt-type\nbind type Box Item ./two.fmt-type\n42;", + ); + let error = compile_source_file_with_options( + &entry, + CompileSourceFileOptions::new() + .with_source_plugin(&GRAPH_FORMAT) + .with_source_plugin(&FORMAT_TYPE_EXPORT), + ) + .err() + .expect("different type symbol imports with one local name are ambiguous"); + assert!(error.to_string().contains("Item"), "{error}"); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/compiler/frontend_plugin_fixtures/linked_admission.rs b/tests/compiler/frontend_plugin_fixtures/linked_admission.rs new file mode 100644 index 00000000..8f39f02c --- /dev/null +++ b/tests/compiler/frontend_plugin_fixtures/linked_admission.rs @@ -0,0 +1,214 @@ +use super::*; +use std::sync::{Arc, Mutex}; +static HOOKS: Mutex> = Mutex::new(Vec::new()); +struct LinkedProducer; +static PRODUCER: LinkedProducer = LinkedProducer; +impl SourcePlugin for LinkedProducer { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("neutral-linked-policy"), + extensions: &["linked"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_module_imports( + &self, + source: &str, + path: &Path, + context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + graph_format::GRAPH_FORMAT.parse_module_imports(source, path, context) + } + fn module_type_exports( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ir: &FrontendIr, + ) -> Result, ParseError> { + graph_format::GRAPH_FORMAT.module_type_exports(source, context, ir) + } + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = graph_format::GRAPH_FORMAT.parse_source(source, context)?; + if let Some(old) = ir + .functions + .iter() + .find(|decl| decl.name == "probe") + .cloned() + { + ir.functions.retain(|decl| decl.index != old.index); + ir.implicit_extern_names.retain(|name| name != "probe"); + let installed = context.install_host_function(&mut ir, "neutral::probe", 1)?; + for stmt in &mut ir.stmts { + resource_policy::remap_stmt( + stmt, + &std::collections::HashMap::from([(old.index, installed)]), + ); + } + } + Ok(ir) + } + fn validate_typed_ir(&self, typed: &vm::FrontendTypedIr<'_>) -> Result<(), vm::CompileError> { + let owner = typed.source_id.unwrap(); + HOOKS.lock().unwrap().push(owner); + for decl in typed + .ir + .parsed_semantic_index + .as_ref() + .unwrap() + .local_decls + .iter() + .filter(|decl| decl.ident_span.source_id == owner) + { + let initializer = typed + .ir + .stmts + .iter() + .find_map(|stmt| match stmt { + vm::Stmt::Let { index, expr, .. } if *index == decl.slot => Some(expr), + _ => None, + }) + .unwrap(); + let schema = typed.schema_of_expr(initializer); + if schema != Some(vm::TypeSchema::Int) + || typed.schema_of_expr(&vm::Expr::Var(decl.slot)) != schema + { + return Err(vm::CompileError::StrictTypingRequired { + line: Some(1), + source_name: None, + detail: format!("integer initializer policy rejected {schema:?}"), + span: Some(decl.ident_span), + }); + } + if decl.name == "checked" { + assert!(matches!(initializer, vm::Expr::Call(_, _, _, Some(_), _))); + } + } + Ok(()) + } +} +#[test] +fn semantic_only_imported_guest_and_host_facts_are_linked_before_owned_hook() { + let root = temp_root("linked-admission"); + let entry = root.join("main.graph"); + let library = root.join("lib.linked"); + let source = + "bind type Box Item ./lib.linked\nlet value: Item = { value: 42 };\nvalue.value;\n"; + write_source(&entry, source); + write_source( + &root.join("value.fmt-one"), + "export fn plus(x: int) -> int = x + 1", + ); + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "neutral::probe", + vec![vm::HostParamSchema::value("value", vm::HostTypeSchema::Int)], + vm::HostTypeSchema::Int, + )); + let catalog = Arc::new(builder.build().unwrap()); + let configured = || { + CompileSourceFileOptions::new() + .with_source_plugin(&graph_format::GRAPH_FORMAT) + .with_source_plugin(&PRODUCER) + .with_source_plugin(&FORMAT_ONE) + .with_host_api_catalog(catalog.clone()) + }; + let library_source = "bind value plus plus ./value.fmt-one\nstruct Box { value: int }\nlet n = plus(41);\nlet checked = probe(41);\npub fn done() -> int { 42 }\n"; + write_source(&library, library_source); + for operation in 0..3 { + HOOKS.lock().unwrap().clear(); + match operation { + 0 => { + let compiled = compile_source_file_with_options(&entry, configured()).unwrap(); + assert!(compiled.functions.is_empty()); + assert!(compiled.program.callable_prototypes.is_empty()); + assert!( + !compiled + .program + .imports + .iter() + .any(|import| import.name == "neutral::probe") + ); + let baseline = vm::compile_source_with_flavor_and_options( + "let value = { value: 42 };\nvalue.value;\n", + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + configured(), + ) + .unwrap(); + assert_eq!(compiled.program.local_count, baseline.program.local_count); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + } + 1 => { + let model = + vm::compiler::analyze_source_file_with_options(&entry, configured()).unwrap(); + let n = model + .ir() + .parsed_semantic_index + .as_ref() + .unwrap() + .local_decls + .iter() + .find(|decl| decl.name == "n") + .unwrap(); + assert_eq!( + model.ir().semantic_index.as_ref().unwrap().slot_schemas[usize::from(n.slot)], + Some(vm::TypeSchema::Int) + ); + } + _ => { + let hints = vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + configured(), + ) + .unwrap(); + assert_eq!( + hints.iter().find(|hint| hint.name == "n").unwrap().schema, + Some(vm::TypeSchema::Int) + ); + } + } + assert_eq!(*HOOKS.lock().unwrap(), vec![1]); + } + for invalid in [ + library_source.replace("plus(41)", "plus(\"wrong\")"), + library_source.replace("let n = plus(41);", "plus(\"wrong\");"), + library_source.replace("let n = plus(41);", "let n = true;"), + ] { + write_source(&library, &invalid); + for operation in 0..3 { + HOOKS.lock().unwrap().clear(); + let error = match operation { + 0 => compile_source_file_with_options(&entry, configured()).map(|_| ()), + 1 => { + vm::compiler::analyze_source_file_with_options(&entry, configured()).map(|_| ()) + } + _ => vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + configured(), + ) + .map(|_| ()), + } + .expect_err("semantic-only guest argument or real hook policy must reject"); + if invalid.contains("let n = true") { + assert!( + error.to_string().contains("integer initializer policy"), + "{error}" + ); + assert_eq!(*HOOKS.lock().unwrap(), vec![1]); + } else { + assert!(error.to_string().contains("argument"), "{error}"); + } + } + } + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/compiler/frontend_plugin_fixtures/producer_policy.rs b/tests/compiler/frontend_plugin_fixtures/producer_policy.rs new file mode 100644 index 00000000..e6af2e77 --- /dev/null +++ b/tests/compiler/frontend_plugin_fixtures/producer_policy.rs @@ -0,0 +1,158 @@ +use super::*; +use std::sync::Mutex; + +static VALIDATIONS: Mutex> = Mutex::new(Vec::new()); +static SERIAL: Mutex<()> = Mutex::new(()); +struct ProducerPolicy { + id: &'static str, + extension: &'static [&'static str], + strict: bool, +} +static STRICT: ProducerPolicy = ProducerPolicy { + id: "neutral-policy-strict", + extension: &["checked"], + strict: true, +}; +static DYNAMIC: ProducerPolicy = ProducerPolicy { + id: "neutral-policy-dynamic", + extension: &["flow"], + strict: false, +}; +impl SourcePlugin for ProducerPolicy { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new(self.id), + extensions: self.extension, + typing_policy: if self.strict { + FrontendTypingPolicy::Strict + } else { + FrontendTypingPolicy::Dynamic + }, + supports_ownership_checking: self.strict, + } + } + fn parse_module_imports( + &self, + source: &str, + path: &Path, + context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + graph_format::GRAPH_FORMAT.parse_module_imports(source, path, context) + } + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + graph_format::GRAPH_FORMAT.parse_source(source, context) + } + fn module_type_exports( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ir: &FrontendIr, + ) -> Result, ParseError> { + graph_format::GRAPH_FORMAT.module_type_exports(source, context, ir) + } + fn validate_typed_ir(&self, typed: &vm::FrontendTypedIr<'_>) -> Result<(), vm::CompileError> { + let index = typed.ir.parsed_semantic_index.as_ref().unwrap(); + let owner = typed.source_id.expect("source-owned validation identity"); + assert!( + index + .scopes + .iter() + .any(|scope| scope.range.source_id == owner) + ); + VALIDATIONS + .lock() + .unwrap() + .push((self.id.to_string(), owner)); + Ok(()) + } +} +fn options() -> CompileSourceFileOptions { + CompileSourceFileOptions::new() + .with_source_plugin(&STRICT) + .with_source_plugin(&DYNAMIC) +} +#[test] +fn strict_dependency_cannot_be_erased_by_dynamic_root_and_dynamic_dependency_remains_dynamic() { + let _lock = SERIAL.lock().unwrap(); + let root = temp_root("producer-policy"); + let entry = root.join("main.flow"); + write_source( + &root.join("lib.checked"), + "pub fn bad(value: unknown) -> int { 42 }\n", + ); + let source = "bind value bad bad ./lib.checked\nbad(1);\n"; + write_source(&entry, source); + assert!( + compile_source_file_with_options(&entry, options()).is_err(), + "dynamic root cannot erase strict dependency annotations" + ); + assert!(vm::compiler::analyze_source_file_with_options(&entry, options()).is_err()); + assert!( + vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new(DYNAMIC.id)), + options() + ) + .is_err() + ); + let entry = root.join("main.checked"); + write_source( + &root.join("lib.flow"), + "pub fn dynamic(value: unknown) -> int { 42 }\n", + ); + let source = "bind value dynamic dynamic ./lib.flow\ndynamic(1);\n"; + write_source(&entry, source); + let compiled = compile_source_file_with_options(&entry, options()) + .expect("strict root must not impose its policy on dynamic producer"); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn each_dependency_validator_runs_once_for_compile_analyze_hint_including_native_root() { + let _lock = SERIAL.lock().unwrap(); + let root = temp_root("producer-hooks"); + write_source( + &root.join("lib.checked"), + "pub fn plus(value: int) -> int { value + 1 }\n", + ); + let entry = root.join("main.rss"); + let source = "use self::lib as lib;\nlib::plus(41);\n"; + let configured = || options().with_module_override_path("./lib.rss", root.join("lib.checked")); + write_source(&entry, source); + for operation in 0..3 { + VALIDATIONS.lock().unwrap().clear(); + match operation { + 0 => { + let compiled = compile_source_file_with_options(&entry, configured()).unwrap(); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + } + 1 => { + vm::compiler::analyze_source_file_with_options(&entry, configured()).unwrap(); + } + _ => { + vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::RustScript, + configured(), + ) + .unwrap(); + } + } + assert_eq!( + *VALIDATIONS.lock().unwrap(), + vec![(STRICT.id.to_string(), 1)], + "operation {operation}" + ); + } + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/compiler/frontend_plugin_fixtures/rejection_policy.rs b/tests/compiler/frontend_plugin_fixtures/rejection_policy.rs new file mode 100644 index 00000000..5e555159 --- /dev/null +++ b/tests/compiler/frontend_plugin_fixtures/rejection_policy.rs @@ -0,0 +1,354 @@ +use super::*; +static HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); +static MIXED_SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +struct RejectionPolicy { + id: &'static str, + extensions: &'static [&'static str], + required: vm::TypeSchema, +} +static ROOT_POLICY: RejectionPolicy = RejectionPolicy { + id: "neutral-predicate-policy", + extensions: &["predicate"], + required: vm::TypeSchema::Bool, +}; +static DEPENDENCY_POLICY: RejectionPolicy = RejectionPolicy { + id: "neutral-label-policy", + extensions: &["label"], + required: vm::TypeSchema::String, +}; +impl SourcePlugin for RejectionPolicy { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new(self.id), + extensions: self.extensions, + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_module_imports( + &self, + source: &str, + path: &Path, + context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + graph_format::GRAPH_FORMAT.parse_module_imports(source, path, context) + } + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = graph_format::GRAPH_FORMAT.parse_source(source, context)?; + if let Some(old) = ir + .functions + .iter() + .find(|decl| decl.name == "probe") + .cloned() + { + ir.functions.retain(|decl| decl.index != old.index); + ir.implicit_extern_names.retain(|name| name != "probe"); + let installed = context.install_host_function(&mut ir, "neutral::probe", 1)?; + let indexes = std::collections::HashMap::from([(old.index, installed)]); + for stmt in &mut ir.stmts { + resource_policy::remap_stmt(stmt, &indexes); + } + } + Ok(ir) + } + fn validate_typed_ir(&self, typed: &vm::FrontendTypedIr<'_>) -> Result<(), vm::CompileError> { + let owner = typed.source_id.unwrap(); + if typed.host_api_catalog.is_some() { + let checked = typed + .ir + .parsed_semantic_index + .as_ref() + .unwrap() + .local_decls + .iter() + .find(|decl| decl.name == "checked" && decl.ident_span.source_id == owner) + .unwrap(); + let initializer = typed + .ir + .stmts + .iter() + .find_map(|stmt| match stmt { + vm::Stmt::Let { index, expr, .. } if *index == checked.slot => Some(expr), + _ => None, + }) + .unwrap(); + assert!(matches!(initializer, vm::Expr::Call(_, _, _, Some(_), _))); + assert_eq!(typed.schema_of_expr(initializer), Some(vm::TypeSchema::Int)); + assert_eq!( + typed.schema_of_expr(&vm::Expr::Var(checked.slot)), + Some(vm::TypeSchema::Int) + ); + HOOKS.lock().unwrap().push(owner); + } + let declaration = typed + .ir + .parsed_semantic_index + .as_ref() + .unwrap() + .local_decls + .iter() + .find(|decl| decl.name == "result" && decl.ident_span.source_id == owner) + .expect("validator must query its own result declaration"); + let schema = typed.schema_of_expr(&vm::Expr::Var(declaration.slot)); + if schema == Some(self.required.clone()) { + return Ok(()); + } + Err(vm::CompileError::StrictTypingRequired { + line: Some(2), + source_name: None, + detail: format!( + "{} requires {:?}, observed {:?}", + self.id, self.required, schema + ), + span: Some(declaration.ident_span), + }) + } +} + +#[test] +fn distinct_root_and_dependency_semantic_rejections_preserve_owner_across_checked_entries() { + let root = temp_root("semantic-rejections"); + let entry = root.join("main.predicate"); + let library = root.join("lib.label"); + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(&ROOT_POLICY) + .with_source_plugin(&DEPENDENCY_POLICY) + }; + let valid_root = "bind value answer answer ./lib.label\nlet result = true;\nanswer();\n"; + let valid_dependency = + "// 中文\r\nlet result = \"label\";\r\npub fn answer() -> int { 42 }\r\n"; + for invalid_owner in [None, Some(0), Some(1)] { + let source = if invalid_owner == Some(0) { + valid_root.replace("true", "1") + } else { + valid_root.to_string() + }; + let dependency = if invalid_owner == Some(1) { + valid_dependency.replace("\"label\"", "1") + } else { + valid_dependency.to_string() + }; + write_source(&entry, &source); + write_source(&library, &dependency); + for operation in 0..3 { + let result = match operation { + 0 => compile_source_file_with_options(&entry, options()).map(|compiled| { + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack().last(), Some(&Value::Int(42))); + }), + 1 => vm::compiler::analyze_source_file_with_options(&entry, options()).map(|_| ()), + _ => vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + &source, + vm::SourceFlavor::Plugin(FrontendId::new(ROOT_POLICY.id)), + options(), + ) + .map(|_| ()), + }; + if let Some(owner) = invalid_owner { + let error = + result.expect_err("semantic policy rejects the actual inferred integer"); + let expected_policy = if owner == 0 { + ROOT_POLICY.id + } else { + DEPENDENCY_POLICY.id + }; + assert!(error.to_string().contains(expected_policy), "{error}"); + assert!(error.to_string().contains("Some(Int)"), "{error}"); + let vm::SourcePathError::SourceWithMap { + error: + vm::SourceError::Compile(vm::CompileError::StrictTypingRequired { + span: Some(span), + .. + }), + sources, + } = error + else { + panic!("source-owned validation error required") + }; + assert_eq!(span.source_id, owner); + assert_eq!(sources.span_text(span), Some("result")); + } else { + result.unwrap(); + } + } + } + // A native root still invokes the dependency's semantic checker. + let native = root.join("main.rss"); + write_source(&native, "use self::lib as lib;\nlib::answer();\n"); + let error = compile_source_file_with_options( + &native, + options().with_module_override_path("./lib.rss", &library), + ) + .err() + .unwrap(); + assert!(error.to_string().contains(DEPENDENCY_POLICY.id), "{error}"); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn unresolved_host_result_recovery_keeps_independent_parameter_admission() { + let _lock = MIXED_SERIAL.lock().unwrap(); + let root = temp_root("host-result-admission"); + let entry = root.join("main.predicate"); + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "neutral::probe", + vec![vm::HostParamSchema::value("value", vm::HostTypeSchema::Int)], + vm::HostTypeSchema::Int, + )); + let catalog = std::sync::Arc::new(builder.build().unwrap()); + let configured = || { + CompileSourceFileOptions::new() + .with_source_plugin(&ROOT_POLICY) + .with_host_api_catalog(catalog.clone()) + }; + let source = "let result = true;\nlet checked = probe(41);\nlet failed = probe(\"wrong\");\n"; + write_source(&entry, source); + HOOKS.lock().unwrap().clear(); + let model = vm::compiler::analyze_source_file_with_options(&entry, configured()) + .expect("host-only failure retains diagnostic model despite unresolved result local"); + assert_eq!(model.diagnostics().len(), 1); + let span = model.diagnostics()[0].span.unwrap(); + assert_eq!(model.sources().span_text(span), Some("probe")); + assert_eq!(*HOOKS.lock().unwrap(), vec![0]); + assert!(compile_source_file_with_options(&entry, configured()).is_err()); + assert!( + vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new(ROOT_POLICY.id)), + configured(), + ) + .is_err() + ); + let source = format!("{source}fn unresolved(value) -> int {{ 42 }}\n"); + write_source(&entry, &source); + let error = vm::compiler::analyze_source_file_with_options(&entry, configured()) + .err() + .expect("independent untyped parameter must remain rejected"); + let vm::SourcePathError::SourceWithMap { + error: + vm::SourceError::Compile(vm::CompileError::StrictTypingRequired { + span: Some(span), + detail, + .. + }), + sources, + } = error + else { + panic!("independent strict parameter diagnostic required") + }; + assert!(detail.contains("parameter"), "{detail}"); + assert_eq!(sources.span_text(span), Some("value")); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn host_recovery_retains_mixed_owner_hooks_and_independent_strict_errors() { + let _lock = MIXED_SERIAL.lock().unwrap(); + let root = temp_root("mixed-admission"); + let entry = root.join("main.predicate"); + let library = root.join("lib.label"); + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "neutral::probe", + vec![vm::HostParamSchema::value("value", vm::HostTypeSchema::Int)], + vm::HostTypeSchema::Int, + )); + let catalog = std::sync::Arc::new(builder.build().unwrap()); + let configured = || { + CompileSourceFileOptions::new() + .with_source_plugin(&ROOT_POLICY) + .with_source_plugin(&DEPENDENCY_POLICY) + .with_host_api_catalog(catalog.clone()) + }; + for rejected in [0, 1, 2] { + let root_result = if rejected == 1 { "true" } else { "1" }; + let dependency_result = if rejected == 0 { "\"label\"" } else { "1" }; + let source = format!( + "bind value answer answer ./lib.label\nlet result = {root_result};\nlet checked = probe(41);\nprobe(\"wrong\");\nanswer();\n" + ); + write_source(&entry, &source); + write_source( + &library, + &format!( + "let result = {dependency_result};\nlet checked = probe(41);\npub fn answer() -> int {{ 42 }}\n" + ), + ); + for operation in 0..3 { + HOOKS.lock().unwrap().clear(); + let error = match operation { + 0 => compile_source_file_with_options(&entry, configured()).map(|_| ()), + 1 => { + vm::compiler::analyze_source_file_with_options(&entry, configured()).map(|_| ()) + } + _ => vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + &source, + vm::SourceFlavor::Plugin(FrontendId::new(ROOT_POLICY.id)), + configured(), + ) + .map(|_| ()), + } + .expect_err("real semantic rejection must survive wrong installed host call"); + let hooks = HOOKS.lock().unwrap().clone(); + assert!(!hooks.is_empty()); + assert_eq!( + hooks + .iter() + .copied() + .collect::>() + .len(), + hooks.len() + ); + let owner = if rejected == 2 { + *hooks.last().unwrap() + } else { + rejected + }; + assert_eq!(hooks.last(), Some(&owner)); + let vm::SourcePathError::SourceWithMap { + error: + vm::SourceError::Compile(vm::CompileError::StrictTypingRequired { + span: Some(span), + .. + }), + sources, + } = error + else { + panic!("owner policy error required") + }; + assert_eq!(span.source_id, owner); + assert_eq!(sources.span_text(span), Some("result")); + } + } + let source = "bind value answer answer ./lib.label\nlet result = true;\nlet checked = probe(41);\nlet wrong: int = \"bad\";\nprobe(\"wrong\");\nanswer();\n"; + write_source(&entry, source); + write_source( + &library, + "let result = \"label\";\nlet checked = probe(41);\npub fn answer() -> int { 42 }\n", + ); + let error = vm::compiler::analyze_source_file_with_options(&entry, configured()) + .err() + .unwrap(); + assert!(!matches!( + &error, + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Compile(vm::CompileError::HostCallResolve { .. }), + .. + } + )); + assert!( + error.to_string().contains("schema") || error.to_string().contains("type"), + "{error}" + ); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/compiler/frontend_plugin_fixtures/resource_policy.rs b/tests/compiler/frontend_plugin_fixtures/resource_policy.rs new file mode 100644 index 00000000..deb5b131 --- /dev/null +++ b/tests/compiler/frontend_plugin_fixtures/resource_policy.rs @@ -0,0 +1,518 @@ +use super::*; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use vm::arg; +use vm::resource::{ + CloseProgress, HostResource, ResourceCloseReason, ResourceMut, ResourceOwned, ResourceRef, +}; +use vm::{ + HostApiBuilder, HostFunctionSchema, HostParamPassing, HostParamSchema, HostTypeSchema, + ResourceTypeKey, ResourceTypeSchema, +}; +static CALLS: AtomicUsize = AtomicUsize::new(0); +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); +struct Handle(i64); +impl HostResource for Handle { + fn resource_type_key() -> Option { + Some(ResourceTypeKey::new("neutral.handle").unwrap()) + } + fn begin_close( + &mut self, + _: ResourceCloseReason, + ) -> vm::resource::ResourceResult { + Ok(CloseProgress::Ready) + } +} +impl vm::HostResourceType for Handle { + const KEY: &'static str = "neutral.handle"; + const DESCRIPTION: &'static str = "neutral handle"; +} +mod adapters { + use super::*; + /// Execute a typed neutral resource operation. + #[pd_host_function::pd_host_function(name = "neutral::read")] + pub fn read(handle: ResourceRef<'_, Handle>) -> VmResult { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(handle.get().0) + } + /// Execute a typed neutral resource operation. + #[pd_host_function::pd_host_function(name = "neutral::change")] + pub fn change(mut handle: ResourceMut<'_, Handle>) -> VmResult { + CALLS.fetch_add(1, Ordering::SeqCst); + handle.get().0 += 1; + Ok(handle.get().0) + } + /// Execute a typed neutral resource operation. + #[pd_host_function::pd_host_function(name = "neutral::close")] + pub fn close(handle: ResourceOwned) -> VmResult { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(handle.into_inner().0) + } +} +struct ResourceProducer; +struct ResourceDialect; +impl ParserDialect for ResourceDialect { + fn allow_let_mut_binding(&self) -> bool { + true + } + fn allow_arrow_closure(&self) -> bool { + true + } +} +static RESOURCE_DIALECT: ResourceDialect = ResourceDialect; +static PRODUCER: ResourceProducer = ResourceProducer; +impl SourcePlugin for ResourceProducer { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("neutral-resource-producer"), + extensions: &["owned"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_module_imports( + &self, + source: &str, + path: &Path, + context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + graph_format::GRAPH_FORMAT.parse_module_imports(source, path, context) + } + fn module_type_exports( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ir: &FrontendIr, + ) -> Result, ParseError> { + graph_format::GRAPH_FORMAT.module_type_exports(source, context, ir) + } + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = parse_source_with_dialect( + source, + &RESOURCE_DIALECT, + SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + import_scan_mode: true, + enforce_mutable_bindings: true, + ..SharedParserOptions::default() + }, + )?; + let placeholders = ir + .functions + .iter() + .filter(|decl| ir.implicit_extern_names.contains(&decl.name)) + .cloned() + .collect::>(); + ir.functions + .retain(|decl| !placeholders.iter().any(|old| old.index == decl.index)); + ir.implicit_extern_names.clear(); + let mut indexes = std::collections::HashMap::new(); + for declaration in placeholders { + let name = match declaration.name.as_str() { + "open_handle" => "neutral::open", + "read_handle" => "neutral::read", + "change_handle" => "neutral::change", + "close_handle" => "neutral::close", + _ => return Err(ParseError::new("unexpected resource alias")), + }; + let installed = context.install_host_function(&mut ir, name, declaration.arity)?; + indexes.insert(declaration.index, installed); + } + for stmt in &mut ir.stmts { + remap_stmt(stmt, &indexes); + } + for implementation in ir.function_impls.values_mut() { + for stmt in &mut implementation.body_stmts { + remap_stmt(stmt, &indexes); + } + remap_expr(&mut implementation.body_expr, &indexes); + } + Ok(ir) + } +} +pub(super) fn remap_stmt(stmt: &mut vm::Stmt, indexes: &std::collections::HashMap) { + match stmt { + vm::Stmt::Let { expr, .. } | vm::Stmt::Expr { expr, .. } => remap_expr(expr, indexes), + vm::Stmt::ClosureLet { closure, .. } => remap_expr(&mut closure.body, indexes), + vm::Stmt::FuncDecl { .. } | vm::Stmt::Noop { .. } => {} + _ => panic!("unsupported resource fixture statement"), + } +} +fn remap_expr(expr: &mut vm::Expr, indexes: &std::collections::HashMap) { + match expr { + vm::Expr::Call(index, _, args, ..) => { + if let Some(installed) = indexes.get(index) { + *index = *installed; + } + for arg in args { + remap_expr(arg, indexes); + } + } + vm::Expr::Closure(closure) => remap_expr(&mut closure.body, indexes), + vm::Expr::Block { stmts, expr } => { + for stmt in stmts { + remap_stmt(stmt, indexes); + } + remap_expr(expr, indexes); + } + vm::Expr::Borrow(expr) | vm::Expr::BorrowMut(expr) => remap_expr(expr, indexes), + vm::Expr::LocalCall(_, _, args, _) => { + for arg in args { + remap_expr(arg, indexes); + } + } + vm::Expr::Int(_) + | vm::Expr::Bool(_) + | vm::Expr::String(_) + | vm::Expr::Var(_) + | vm::Expr::Null => {} + _ => panic!("unsupported resource fixture expression"), + } +} +fn catalog() -> Arc { + let key = ResourceTypeKey::new("neutral.handle").unwrap(); + let mut b = HostApiBuilder::new(); + b.resource(ResourceTypeSchema::new(key.clone(), "neutral handle")); + b.function(HostFunctionSchema::with_return( + "neutral::open", + vec![], + HostTypeSchema::Resource(key.clone()), + )); + for (name, passing) in [ + ("neutral::read", HostParamPassing::Borrow), + ("neutral::change", HostParamPassing::BorrowMut), + ("neutral::close", HostParamPassing::TakeOwned), + ] { + b.function(HostFunctionSchema::with_return( + name, + vec![HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(key.clone()), + passing, + )], + HostTypeSchema::Int, + )); + } + Arc::new(b.build().unwrap()) +} +fn options(catalog: &Arc) -> CompileSourceFileOptions { + CompileSourceFileOptions::new() + .with_source_plugin(&PRODUCER) + .with_host_api_catalog(catalog.clone()) +} +fn run(program: vm::Program, catalog: &vm::HostApiCatalog) -> Vm { + let mut registry = vm::HostFunctionRegistry::new(); + for schema in vm::catalog_import_schemas(catalog, "neutral::open") { + fn open(machine: &mut Vm, _: &[Value]) -> vm::VmResult { + CALLS.fetch_add(1, Ordering::SeqCst); + let token = machine.host_context().push_resource(Handle(41)).unwrap(); + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int( + token.handle().raw() as i64, + )))) + } + registry + .register_exact_static("neutral::open", 0, schema, open) + .unwrap(); + } + fn read(machine: &mut Vm, args: &[Value]) -> VmResult { + adapters::read(machine, args) + .map(|result| vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(result)))) + } + fn change(machine: &mut Vm, args: &[Value]) -> VmResult { + adapters::change(machine, args) + .map(|result| vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(result)))) + } + fn close(machine: &mut Vm, args: &[Value]) -> VmResult { + adapters::close(machine, args) + .map(|result| vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(result)))) + } + for (name, adapter) in [ + ( + "neutral::read", + read as fn(&mut Vm, &[Value]) -> VmResult, + ), + ("neutral::change", change), + ("neutral::close", close), + ] { + for schema in vm::catalog_import_schemas(catalog, name) { + registry + .register_exact_static(name, 1, schema, adapter) + .unwrap(); + } + } + let mut machine = Vm::new(program); + registry.bind_vm_cached(&mut machine).unwrap(); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + machine +} +#[test] +fn enabled_resource_policy_repeated_borrow_mutation_close_and_rejection_before_host_execution() { + let _lock = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + CALLS.store(0, Ordering::SeqCst); + let catalog = catalog(); + let source = "let mut handle = open_handle();\nlet first = read_handle(handle);\nlet second = read_handle(handle);\nlet changed = change_handle(&mut handle);\nclose_handle(handle);\n"; + let compiled = vm::compile_source_with_flavor_and_options( + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-resource-producer")), + options(&catalog), + ) + .unwrap(); + let mut machine = run(compiled.program, &catalog); + assert_eq!(machine.stack(), &[Value::Int(42)]); + assert_eq!(CALLS.load(Ordering::SeqCst), 5); + assert_eq!(machine.host_context().resource_count(), 0); + for invalid in [ + "let handle = open_handle(); close_handle(handle); read_handle(handle);", + "let handle = open_handle(); let escape = || read_handle(handle); escape();", + ] { + CALLS.store(0, Ordering::SeqCst); + let error = vm::compile_source_with_flavor_and_options( + invalid, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-resource-producer")), + options(&catalog), + ) + .err() + .expect("resource misuse must fail before execution"); + assert!( + error.to_string().contains("moved") || error.to_string().contains("borrow"), + "{error}" + ); + let analysis = + vm::analyze_source_from_string_with_options("main.owned", invalid, options(&catalog)) + .err() + .expect("analysis rejects resource misuse"); + let hints = vm::collect_inferred_local_type_hints_with_options( + invalid, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-resource-producer")), + options(&catalog), + ) + .expect_err("hints reject resource misuse"); + assert_eq!(error.to_string(), analysis.to_string()); + assert_eq!(error.to_string(), hints.to_string()); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); + } +} +#[test] +fn host_resolution_recovery_preserves_independent_resource_rejection() { + let _lock = SERIAL.lock().unwrap_or_else(|p| p.into_inner()); + CALLS.store(0, Ordering::SeqCst); + let catalog = catalog(); + let source = "let handle = open_handle();\nclose_handle(handle);\nread_handle(handle);\nread_handle(42);\n"; + let error = + vm::analyze_source_from_string_with_options("main.owned", source, options(&catalog)) + .err() + .expect("host error must not hide resource rejection"); + assert!(error.to_string().contains("moved"), "{error}"); + let vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + sources, + } = error + else { + panic!("ownership diagnostic required") + }; + let span = parse.span.expect("ownership source span"); + assert_eq!(span.source_id, 0); + assert_eq!(sources.span_text(span), Some("read_handle(handle);")); + for error in [ + vm::compile_source_with_flavor_and_options( + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-resource-producer")), + options(&catalog), + ) + .err() + .unwrap() + .to_string(), + vm::collect_inferred_local_type_hints_with_options( + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-resource-producer")), + options(&catalog), + ) + .err() + .unwrap() + .to_string(), + ] { + assert!(error.contains("moved"), "{error}"); + } + assert_eq!(CALLS.load(Ordering::SeqCst), 0); + let host_only = "read_handle(42);\n"; + let model = + vm::analyze_source_from_string_with_options("main.owned", host_only, options(&catalog)) + .unwrap(); + assert!(!model.diagnostics().is_empty()); + assert!( + vm::compile_source_with_flavor_and_options( + host_only, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-resource-producer")), + options(&catalog) + ) + .is_err() + ); + assert!( + vm::collect_inferred_local_type_hints_with_options( + host_only, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-resource-producer")), + options(&catalog) + ) + .is_err() + ); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); +} + +#[test] +fn semantic_only_resource_producer_retains_type_provenance_without_runtime_authority() { + let _lock = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = temp_root("semantic-resource"); + let catalog = catalog(); + let library = "struct Box { value: int }\nlet handle = open_handle();\nread_handle(handle);\nclose_handle(handle);\npub fn done() -> int { 42 }\n"; + write_source(&root.join("lib.owned"), library); + let entry = root.join("main.graph"); + let configured = || options(&catalog).with_source_plugin(&graph_format::GRAPH_FORMAT); + let source = "bind type Box Item ./lib.owned\nlet value: Item = { value: 42 };\nvalue.value;\n"; + write_source(&entry, source); + CALLS.store(0, Ordering::SeqCst); + let compiled = compile_source_file_with_options(&entry, configured()).unwrap(); + assert!(compiled.functions.is_empty()); + assert!( + !compiled + .program + .imports + .iter() + .any(|import| import.name.starts_with("neutral::")) + ); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); + let model = vm::compiler::analyze_source_file_with_options(&entry, configured()).unwrap(); + let provenance = &model + .ir() + .parsed_semantic_index + .as_ref() + .unwrap() + .type_provenance; + let original = provenance + .iter() + .find(|site| site.source_id == 1 && site.source_name == "Box") + .expect("type-only producer declaration provenance"); + assert_eq!( + &library[original.declaration_span.unwrap().lo..original.declaration_span.unwrap().hi], + "Box" + ); + let source = "bind type Box Item ./lib.owned\nbind value done done ./lib.owned\nlet value: Item = { value: 42 };\ndone();\n"; + write_source(&entry, source); + CALLS.store(0, Ordering::SeqCst); + let compiled = compile_source_file_with_options(&entry, configured()).unwrap(); + let machine = run(compiled.program, &catalog); + assert_eq!(machine.stack().last(), Some(&Value::Int(42))); + assert_eq!( + CALLS.load(Ordering::SeqCst), + 3, + "value promotion initializes once" + ); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn semantic_only_resource_producer_rejects_owned_misuse() { + let _lock = SERIAL.lock().unwrap_or_else(|p| p.into_inner()); + let root = temp_root("semantic-owned-rejection"); + let catalog = catalog(); + let entry = root.join("main.graph"); + let source = "bind type Box Item ./lib.owned\nlet value: Item = { value: 42 };\nvalue.value;\n"; + write_source(&entry, source); + write_source( + &root.join("lib.owned"), + "struct Box { value: int }\nlet handle = open_handle();\nclose_handle(handle);\nread_handle(handle);\n", + ); + let configured = || options(&catalog).with_source_plugin(&graph_format::GRAPH_FORMAT); + CALLS.store(0, Ordering::SeqCst); + for operation in 0..3 { + let error = match operation { + 0 => compile_source_file_with_options(&entry, configured()).map(|_| ()), + 1 => vm::compiler::analyze_source_file_with_options(&entry, configured()).map(|_| ()), + _ => vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + source, + vm::SourceFlavor::Plugin(FrontendId::new("neutral-graph-format")), + configured(), + ) + .map(|_| ()), + } + .expect_err("Type edge must retain ownership admission"); + assert!(error.to_string().contains("moved"), "{error}"); + let vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + sources, + } = error + else { + panic!("owner diagnostic required") + }; + let span = parse.span.unwrap(); + assert_eq!(span.source_id, 1); + assert_eq!(sources.span_text(span), Some("read_handle(handle);")); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn enabled_dependency_resource_policy_survives_ownership_disabled_root() { + let _lock = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + CALLS.store(0, Ordering::SeqCst); + let root = temp_root("owned-dependency"); + let catalog = catalog(); + let library = "let handle = open_handle();\nread_handle(handle);\nread_handle(handle);\nclose_handle(handle);\npub fn done() -> int { 42 }\n"; + write_source(&root.join("lib.owned"), library); + let entry = root.join("main.graph"); + write_source(&entry, "bind value done done ./lib.owned\ndone();\n"); + // TypedFormat's descriptor disables ownership, while its dependency enables it. + let entry = root.join("main.fmt-two"); + write_source(&entry, "use \"./lib.owned\" as done;\ncall done()"); + let configured = || options(&catalog).with_source_plugin(&FORMAT_TWO); + let compiled = compile_source_file_with_options(&entry, configured()) + .expect("enabled dependency borrowing must survive disabled root"); + let machine = run(compiled.program, &catalog); + assert_eq!(machine.stack().last(), Some(&Value::Int(42))); + assert_eq!(CALLS.load(Ordering::SeqCst), 4); + write_source( + &root.join("lib.owned"), + "let handle = open_handle(); close_handle(handle); read_handle(handle); pub fn done() -> int { 42 }", + ); + CALLS.store(0, Ordering::SeqCst); + let compiled_error = compile_source_file_with_options(&entry, configured()) + .err() + .unwrap(); + let analyze_error = vm::compiler::analyze_source_file_with_options(&entry, configured()) + .err() + .unwrap(); + let source = std::fs::read_to_string(&entry).unwrap(); + let hint_error = vm::collect_inferred_local_type_hints_at_path_with_options( + &entry, + &source, + vm::SourceFlavor::Plugin(FrontendId::new(FORMAT_TWO.id)), + configured(), + ) + .err() + .unwrap(); + assert_eq!(compiled_error.to_string(), analyze_error.to_string()); + assert_eq!(compiled_error.to_string(), hint_error.to_string()); + assert!( + compiled_error.to_string().contains("moved"), + "{compiled_error}" + ); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/compiler/frontend_plugin_fixtures/schema_matrix.rs b/tests/compiler/frontend_plugin_fixtures/schema_matrix.rs new file mode 100644 index 00000000..502ae58c --- /dev/null +++ b/tests/compiler/frontend_plugin_fixtures/schema_matrix.rs @@ -0,0 +1,157 @@ +use super::*; +use std::collections::HashMap; + +struct MatrixPlugin; +static MATRIX: MatrixPlugin = MatrixPlugin; +fn callable() -> TypeSchema { + TypeSchema::Callable { + params: vec![TypeSchema::Int], + result: Box::new(TypeSchema::Int), + } +} +fn expected() -> HashMap<&'static str, TypeSchema> { + let optional = TypeSchema::Optional(Box::new(TypeSchema::Int)); + HashMap::from([ + ("mapper", callable()), + ("specialized", callable()), + ( + "nested", + TypeSchema::Map(Box::new(TypeSchema::Array(Box::new(optional.clone())))), + ), + ("tuple", TypeSchema::ArrayTuple(vec![optional, callable()])), + ( + "bundle", + TypeSchema::Named("Bundle".to_string(), vec![TypeSchema::Int]), + ), + ( + "object", + TypeSchema::Object(HashMap::from([("callback".to_string(), callable())])), + ), + ]) +} +impl SourcePlugin for MatrixPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("neutral-schema-matrix"), + extensions: &["matrix"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + parse_source_with_dialect( + source, + &SYNTHETIC_DIALECT, + SharedParserOptions { + source_id: context.source_id, + import_scan_mode: true, + ..SharedParserOptions::default() + }, + ) + } + fn validate_typed_ir(&self, typed: &FrontendTypedIr<'_>) -> Result<(), CompileError> { + for (name, schema) in expected() { + let (_, slot) = typed + .ir + .local_bindings + .iter() + .find(|(label, _)| label == name) + .unwrap(); + let actual = typed.schema_of_expr(&vm::Expr::Var(*slot)).unwrap(); + assert_eq!( + typed.ir.source_schema(&actual, 0), + schema, + "typed query {name}" + ); + assert_eq!(typed.local_schemas[usize::from(*slot)], Some(actual)); + if name == "mapper" || name == "specialized" { + let initializer = typed + .ir + .stmts + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { index, expr, .. } if index == slot => Some(expr), + _ => None, + }) + .unwrap(); + assert_eq!( + typed.schema_of_expr(initializer), + Some(schema), + "initializer query {name}" + ); + } + } + Ok(()) + } +} +#[test] +fn contextual_generic_and_recursive_schemas_agree_across_query_hint_semantic_prototype_and_runtime() +{ + let source = "// 中文\r\nstruct Bundle { items: [T?], callback: fn(T) -> T }\r\nfn identity(value: T) -> T { value }\r\nlet mapper: fn(int) -> int = |parameter| parameter + 1;\r\nlet specialized = identity::;\r\nlet nested: map<[int?]> = { \"one\": [null, 41] };\r\nlet tuple: [int?, fn(int) -> int] = [null, specialized];\r\nlet bundle: Bundle = { items: [null, 41], callback: specialized };\r\nlet object = { callback: specialized };\r\nmapper(specialized(41));\r\n"; + let options = || CompileSourceFileOptions::new().with_source_plugin(&MATRIX); + let flavor = SourceFlavor::Plugin(FrontendId::new("neutral-schema-matrix")); + let compiled = compile_source_with_flavor_and_options(source, flavor, options()).unwrap(); + let schemas: Vec<_> = compiled + .program + .callable_prototypes + .iter() + .filter_map(|prototype| prototype.schema.as_ref()) + .collect(); + assert!( + schemas + .iter() + .filter(|schema| ***schema == callable()) + .count() + >= 2, + "contextual closure and specialized generic prototypes carry executable signatures: {schemas:?}" + ); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + let model = analyze_source_from_string_with_options("main.matrix", source, options()).unwrap(); + let hints = collect_inferred_local_type_hints_with_options(source, flavor, options()).unwrap(); + for (name, schema) in expected() { + let hint = named_hint(&hints, name); + let actual = hint.schema.as_ref().unwrap(); + assert_eq!(model.ir().source_schema(actual, 0), schema, "hint {name}"); + let span = hint.span.unwrap(); + assert_eq!(&source[span.lo..span.hi], name); + let semantic = model + .inferred_schema_at(vm::SourcePosition::new(0, span.lo)) + .unwrap(); + assert_eq!(semantic, *actual, "semantic hover {name}"); + } + let parameter = named_hint(&hints, "parameter"); + assert_eq!(parameter.schema, Some(TypeSchema::Int)); + let span = parameter.span.unwrap(); + assert_eq!(&source[span.lo..span.hi], "parameter"); + let invalid = source.replace("specialized(41)", "specialized(\"wrong\")"); + let errors = [ + compile_source_with_flavor_and_options(&invalid, flavor, options()) + .err() + .unwrap(), + analyze_source_from_string_with_options("main.matrix", &invalid, options()) + .err() + .unwrap(), + collect_inferred_local_type_hints_with_options(&invalid, flavor, options()) + .err() + .unwrap(), + ]; + for error in &errors { + let diagnostic = error.to_string(); + assert!( + diagnostic.contains("function 'identity' argument 'value' type mismatch"), + "{error}" + ); + assert!( + diagnostic.contains("schema type 'int'") && diagnostic.contains("string"), + "{error}" + ); + } + assert_eq!(errors[0].to_string(), errors[1].to_string()); + assert_eq!(errors[0].to_string(), errors[2].to_string()); +} diff --git a/tests/compiler/frontend_plugin_module_tests.rs b/tests/compiler/frontend_plugin_module_tests.rs new file mode 100644 index 00000000..861d7f27 --- /dev/null +++ b/tests/compiler/frontend_plugin_module_tests.rs @@ -0,0 +1,513 @@ +#[path = "frontend_plugin_fixtures/diagnostic_provenance.rs"] +mod diagnostic_provenance; +#[path = "frontend_plugin_fixtures/graph_acceptance.rs"] +mod graph_acceptance; +#[path = "frontend_plugin_fixtures/graph_format.rs"] +mod graph_format; +#[path = "frontend_plugin_fixtures/linked_admission.rs"] +mod linked_admission; +#[path = "frontend_plugin_fixtures/producer_policy.rs"] +mod producer_policy; +#[path = "frontend_plugin_fixtures/rejection_policy.rs"] +mod rejection_policy; +#[path = "frontend_plugin_fixtures/resource_policy.rs"] +mod resource_policy; + +use std::path::{Path, PathBuf}; +use vm::VmResult; + +use vm::{ + CompileSourceFileOptions, FrontendId, FrontendIr, FrontendTypingPolicy, ImportClause, + ModuleImport, NamedImport, ParseError, ParserDialect, SharedParserOptions, + SourceFrontendContext, SourceFrontendDescriptor, SourcePlugin, Value, Vm, VmStatus, + compile_source_file_with_options, parse_source_with_dialect, +}; + +struct SyntheticDialect; +impl ParserDialect for SyntheticDialect {} +static DIALECT: SyntheticDialect = SyntheticDialect; + +struct TypedFormat { + id: &'static str, + extensions: &'static [&'static str], + format: Format, +} + +#[derive(Clone, Copy)] +enum Format { + ExportFunction, + ExportFunctionWithImports, + ExportTypeAlias, + ImportTypeAlias, + CallFunction, +} + +impl SourcePlugin for TypedFormat { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new(self.id), + extensions: self.extensions, + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_module_imports( + &self, + source: &str, + _path: &Path, + _context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + if matches!( + self.format, + Format::ExportFunction | Format::ExportTypeAlias + ) { + return Ok(Vec::new()); + } + source + .lines() + .filter(|line| line.starts_with("use ")) + .map(|line| { + let spec = line + .strip_prefix("use \"") + .and_then(|line| line.split_once('\"')) + .map(|(spec, _)| spec.to_string()) + .ok_or_else(|| vm::SourcePathError::UnsupportedExtension(line.to_string()))?; + if matches!(self.format, Format::ImportTypeAlias) { + let binding = line + .split_once('"') + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(_, rest)| rest.trim().trim_start_matches("type ")) + .unwrap_or_default() + .trim_end_matches(';'); + let (imported, local) = + binding.split_once(" as ").unwrap_or((binding, binding)); + Ok(ModuleImport::value( + spec, + ImportClause::Named(vec![NamedImport::type_only(imported, local)]), + 1, + )) + } else { + let imported = line + .split_once(" as ") + .map(|(_, imported)| imported.trim().trim_end_matches(';')) + .unwrap_or("plus"); + Ok(ModuleImport::value( + spec, + ImportClause::Named(vec![NamedImport::value(imported, imported)]), + 1, + )) + } + }) + .collect() + } + + fn strip_import_directives(&self, source: &str) -> String { + match self.format { + Format::ExportFunction => source.to_string(), + Format::ExportFunctionWithImports | Format::ImportTypeAlias => source + .lines() + .filter(|line| !line.starts_with("use ")) + .collect::>() + .join("\n"), + Format::ExportTypeAlias => source.to_string(), + Format::CallFunction => source + .lines() + .filter(|line| !line.starts_with("use ")) + .collect::>() + .join("\n"), + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let rss = match self.format { + Format::ExportTypeAlias => { + if source.trim() != "export type Box = int" { + return Err(ParseError::new("expected exported Box alias")); + } + // This type producer also owns executable code. A type-only edge + // must discover its alias without executing this initializer or + // allocating its otherwise callable guest function. + "pub fn hidden() -> int { 99 } hidden();".to_string() + } + Format::ImportTypeAlias => { + let imported_type = context.imports.iter().find_map(|import| match import { + vm::FrontendImportedDeclaration::Type { + local, + symbol, + schema: vm::TypeSchema::Int, + declaration: None, + } if local == "Item" && symbol.module.0 > 0 => Some(symbol), + _ => None, + }); + if imported_type.is_none() { + return Err(ParseError::new( + "type-only import did not provide the transparent Item alias", + )); + } + let call = source + .lines() + .find(|line| !line.starts_with("use ")) + .and_then(|line| line.trim().strip_prefix("call ")) + .ok_or_else(|| ParseError::new("expected synthetic call statement"))?; + format!("{call};") + } + Format::ExportFunction | Format::ExportFunctionWithImports => { + if matches!(self.format, Format::ExportFunctionWithImports) { + let imported_plus = context.imports.iter().find_map(|import| match import { + vm::FrontendImportedDeclaration::Function { + local, declaration, .. + } if local == "plus" => Some(declaration), + _ => None, + }); + let has_integer_signature = imported_plus.is_some_and(|declaration| { + declaration.arg_schemas == [Some(vm::TypeSchema::Int)] + && declaration.return_schema == Some(vm::TypeSchema::Int) + }); + if !has_integer_signature { + return Err(ParseError::new( + "nested import did not provide plus(value: int) -> int", + )); + } + } + let source = source + .lines() + .filter(|line| !line.starts_with("use ")) + .collect::>() + .join("\n"); + let declaration = source.strip_prefix("export ").ok_or_else(|| { + ParseError::new("expected synthetic exported-function declaration") + })?; + let (signature, expr) = declaration.split_once(" = ").ok_or_else(|| { + ParseError::new("expected '=' in synthetic exported-function declaration") + })?; + let mut rss = signature.replace("fn ", "pub fn "); + rss.push_str(" { "); + rss.push_str(expr); + rss.push_str(" }"); + rss + } + Format::CallFunction => { + let call = source + .trim() + .strip_prefix("call ") + .ok_or_else(|| ParseError::new("expected synthetic call statement"))?; + format!("{call};") + } + }; + parse_source_with_dialect( + &rss, + &DIALECT, + SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: false, + }, + ) + } + + fn module_type_exports( + &self, + source: &str, + _context: &SourceFrontendContext<'_>, + _ir: &FrontendIr, + ) -> Result, ParseError> { + if matches!(self.format, Format::ExportTypeAlias) + && source.trim() == "export type Box = int" + { + return Ok(vec![vm::compiler::FrontendTypeExport { + source_name: "Box".to_string(), + schema: vm::TypeSchema::Int, + declaration: None, + }]); + } + Ok(Vec::new()) + } +} + +static FORMAT_ONE: TypedFormat = TypedFormat { + id: "synthetic-format-one", + extensions: &["fmt-one"], + format: Format::ExportFunction, +}; +static FORMAT_TWO: TypedFormat = TypedFormat { + id: "synthetic-format-two", + extensions: &["fmt-two"], + format: Format::CallFunction, +}; +static FORMAT_TYPE_EXPORT: TypedFormat = TypedFormat { + id: "synthetic-format-type-export", + extensions: &["fmt-type"], + format: Format::ExportTypeAlias, +}; +static FORMAT_TYPE_IMPORT: TypedFormat = TypedFormat { + id: "synthetic-format-type-import", + extensions: &["fmt-type-user"], + format: Format::ImportTypeAlias, +}; +static FORMAT_WITH_IMPORTS: TypedFormat = TypedFormat { + id: "synthetic-format-with-imports", + extensions: &["fmt-imports"], + format: Format::ExportFunctionWithImports, +}; + +fn temp_root(label: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "pd-vm-plugin-module-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("fixture directory should be created"); + root +} + +fn write_source(path: &Path, source: &str) { + std::fs::write(path, source).expect("fixture source should be written"); +} + +#[test] +fn native_catalog_call_keeps_exact_host_identity_through_modules() { + assert_catalog_call_through_modules(false); +} + +#[test] +fn catalog_bound_dependency_accepts_host_free_plugin_root() { + assert_catalog_call_through_modules(true); +} + +fn assert_catalog_call_through_modules(plugin_root: bool) { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + use vm::host_api::{HostApiBuilder, HostFunctionSchema, HostParamSchema, HostTypeSchema}; + + let root = temp_root("native-catalog-identity"); + let library = root.join("lib.rss"); + let entry = root.join(if plugin_root { + "main.fmt-two" + } else { + "main.rss" + }); + write_source( + &library, + "use probe;\npub fn plus(value: int) -> int { probe::add(value) }", + ); + write_source( + &entry, + if plugin_root { + "use \"./lib.rss\";\ncall plus(41)" + } else { + "use self::lib as lib;\nlib::plus(41);" + }, + ); + let mut builder = HostApiBuilder::new(); + builder.function(HostFunctionSchema::with_return( + "probe::add", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog = Arc::new(builder.build().expect("catalog should be valid")); + let expected = vm::catalog_import_schemas(&catalog, "probe::add")[0].clone(); + let compiled = compile_source_file_with_options( + &entry, + CompileSourceFileOptions::new() + .with_source_plugin(&FORMAT_TWO) + .with_host_api_catalog(Arc::clone(&catalog)), + ) + .expect("native catalog call should survive module resolution"); + let imports: Vec<_> = compiled + .program + .imports + .iter() + .enumerate() + .filter(|(_, import)| import.name == "probe::add") + .collect(); + assert_eq!(imports.len(), 1, "exact host import must be emitted once"); + assert_eq!( + compiled.program.host_import_schemas()[imports[0].0].as_ref(), + Some(&expected) + ); + + struct AddHost(Arc); + impl vm::HostFunction for AddHost { + fn call(&mut self, _vm: &mut Vm, args: &[Value]) -> vm::VmResult { + self.0.fetch_add(1, Ordering::SeqCst); + assert_eq!(args, &[Value::Int(41)]); + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(42)))) + } + } + let calls = Arc::new(AtomicUsize::new(0)); + let mut registry = vm::HostFunctionRegistry::new(); + let counter = Arc::clone(&calls); + registry + .register_catalog(expected, move || -> Box { + Box::new(AddHost(Arc::clone(&counter))) + }) + .expect("exact host registration should succeed"); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("exact catalog should bind"); + assert_eq!( + vm.run().expect("native module host call should execute"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert_eq!(calls.load(Ordering::SeqCst), 1); + std::fs::remove_dir_all(&root).expect("fixture directory should be removed"); +} + +#[test] +fn plugin_source_imports_and_runs_typed_plugin_dependency() { + let root = temp_root("plugin-to-plugin"); + let library = root.join("lib.fmt-one"); + let entry = root.join("main.fmt-two"); + write_source(&library, r#"export fn plus(value: int) -> int = value + 1"#); + write_source( + &entry, + r#"use "./lib.fmt-one"; +call plus(41)"#, + ); + + let options = CompileSourceFileOptions::new() + .with_source_plugin(&FORMAT_ONE) + .with_source_plugin(&FORMAT_TWO); + let compiled = compile_source_file_with_options(&entry, options) + .expect("registered plugin dependency should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!( + vm.run().expect("linked program should execute"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + std::fs::remove_dir_all(&root).expect("fixture directory should be removed"); +} + +#[test] +fn plugin_source_imports_and_runs_native_rustscript_dependency() { + let root = temp_root("plugin-to-rss"); + let library = root.join("lib.rss"); + let entry = root.join("main.fmt-two"); + write_source(&library, r#"pub fn plus(value: int) -> int { value + 1 }"#); + write_source( + &entry, + r#"use "./lib.rss"; +call plus(41)"#, + ); + + let options = CompileSourceFileOptions::new().with_source_plugin(&FORMAT_TWO); + let compiled = compile_source_file_with_options(&entry, options) + .expect("plugin importer should call a native RSS dependency"); + let mut vm = Vm::new(compiled.program); + assert_eq!( + vm.run().expect("linked program should execute"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + std::fs::remove_dir_all(&root).expect("fixture directory should be removed"); +} + +#[test] +fn plugin_imported_signature_rejects_incompatible_argument() { + let root = temp_root("plugin-argument-schema"); + let library = root.join("lib.fmt-one"); + let entry = root.join("main.fmt-two"); + write_source(&library, r#"export fn plus(value: int) -> int = value + 1"#); + write_source( + &entry, + r#"use "./lib.fmt-one"; +call plus("wrong")"#, + ); + + let options = CompileSourceFileOptions::new() + .with_source_plugin(&FORMAT_ONE) + .with_source_plugin(&FORMAT_TWO); + let error = match compile_source_file_with_options(&entry, options) { + Ok(_) => panic!("imported integer parameter schema must reject a string argument"), + Err(error) => error, + }; + assert!( + error.to_string().contains("string"), + "unexpected error: {error}" + ); + + std::fs::remove_dir_all(&root).expect("fixture directory should be removed"); +} + +#[test] +fn nested_plugin_import_resolves_relative_to_its_own_source() { + let root = temp_root("nested-relative-import"); + let nested = root.join("nested"); + std::fs::create_dir_all(&nested).expect("nested directory should be created"); + let entry = root.join("main.fmt-two"); + let relay = nested.join("relay.fmt-imports"); + let dependency = nested.join("leaf.fmt-one"); + let decoy = root.join("leaf.fmt-one"); + write_source( + &dependency, + r#"export fn plus(value: int) -> int = value + 1"#, + ); + write_source(&decoy, r#"export fn plus(value: string) -> string = value"#); + write_source( + &relay, + "use \"./leaf.fmt-one\" as plus;\nexport fn relay(value: int) -> int = plus(value)", + ); + write_source( + &entry, + "use \"./nested/relay.fmt-imports\" as relay;\ncall relay(41)", + ); + + let options = CompileSourceFileOptions::new() + .with_source_plugin(&FORMAT_ONE) + .with_source_plugin(&FORMAT_TWO) + .with_source_plugin(&FORMAT_WITH_IMPORTS); + let compiled = compile_source_file_with_options(&entry, options) + .expect("nested relative import must resolve against the importing source"); + let mut vm = Vm::new(compiled.program); + assert_eq!( + vm.run().expect("linked program should execute"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + std::fs::remove_dir_all(&root).expect("fixture directory should be removed"); +} + +#[test] +fn type_only_import_preserves_transparent_alias_without_runtime_imports() { + let root = temp_root("type-only-alias"); + let type_module = root.join("box.fmt-type"); + let entry = root.join("main.fmt-type-user"); + write_source(&type_module, "export type Box = int"); + write_source(&entry, "use \"./box.fmt-type\" type Box as Item;\ncall 42"); + + let options = CompileSourceFileOptions::new() + .with_source_plugin(&FORMAT_TYPE_EXPORT) + .with_source_plugin(&FORMAT_TYPE_IMPORT); + let compiled = compile_source_file_with_options(&entry, options) + .expect("type-only alias should be visible during source parsing"); + assert!( + compiled.functions.is_empty(), + "a type-only import creates no function imports" + ); + let mut vm = Vm::new(compiled.program); + assert_eq!( + vm.run().expect("linked program should execute"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + std::fs::remove_dir_all(&root).expect("fixture directory should be removed"); +} diff --git a/tests/compiler/frontend_plugin_policy_tests.rs b/tests/compiler/frontend_plugin_policy_tests.rs new file mode 100644 index 00000000..6af59a8d --- /dev/null +++ b/tests/compiler/frontend_plugin_policy_tests.rs @@ -0,0 +1,1101 @@ +#[path = "frontend_plugin_fixtures/schema_matrix.rs"] +mod schema_matrix; + +use std::path::Path; +use std::sync::{Arc, LazyLock, Mutex}; + +use vm::{ + CallOutcome, CallReturn, CompileError, CompileSourceFileOptions, FrontendId, FrontendIr, + FrontendTypedIr, FrontendTypingPolicy, HostApiBuilder, HostApiCatalog, HostFunctionRegistry, + HostFunctionSchema, HostParamSchema, HostStructField, HostStructSchema, HostTypeSchema, + ParseError, ParserDialect, SharedParserOptions, SourceFlavor, SourceFrontendContext, + SourceFrontendDescriptor, SourcePlugin, Stmt, TypeSchema, Value, Vm, VmStatus, + analyze_source_from_string_with_options, collect_inferred_local_type_hints_with_options, + compile_source_with_flavor_and_options, parse_source_with_dialect, +}; + +struct SyntheticDialect; + +fn point_struct() -> HostStructSchema { + HostStructSchema::new( + "Point", + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("y", HostTypeSchema::Int), + ], + ) +} + +fn point_catalog() -> Arc { + let point = point_struct(); + let mut builder = HostApiBuilder::new(); + builder.named_struct(point.clone()); + builder.function(HostFunctionSchema::with_return( + "geo::origin", + vec![], + point.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "geo::take_point", + vec![HostParamSchema::value("point", point.as_type())], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "geo::through", + vec![ + HostParamSchema::value( + "callback", + HostTypeSchema::Callable { + params: vec![point.as_type()], + result: Box::new(point.as_type()), + }, + ), + HostParamSchema::value("point", point.as_type()), + ], + point.as_type(), + )); + Arc::new(builder.build().expect("Point catalog must build")) +} + +static TEST_POINT_CATALOG: LazyLock> = LazyLock::new(point_catalog); + +fn point_origin_host(_vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + let point = Value::map(vec![ + (Value::string("x"), Value::Int(3)), + (Value::string("y"), Value::Int(4)), + ]); + Ok(CallOutcome::Return(CallReturn::One(point))) +} + +fn point_through_host(_vm: &mut Vm, args: &[Value]) -> vm::VmResult { + let point = args + .get(1) + .cloned() + .ok_or(vm::VmError::TypeMismatch("Point"))?; + Ok(CallOutcome::Return(CallReturn::One(point))) +} + +fn point_sum_host(_vm: &mut Vm, args: &[Value]) -> vm::VmResult { + let Some(Value::Map(point)) = args.first() else { + return Err(vm::VmError::TypeMismatch("Point")); + }; + let Some(Value::Int(x)) = point.get(&Value::string("x")) else { + return Err(vm::VmError::TypeMismatch("Point.x")); + }; + let Some(Value::Int(y)) = point.get(&Value::string("y")) else { + return Err(vm::VmError::TypeMismatch("Point.y")); + }; + Ok(CallOutcome::Return(CallReturn::One(Value::Int(x + y)))) +} + +fn register_point_host( + registry: &mut HostFunctionRegistry, + catalog: &HostApiCatalog, + name: &str, + function: fn(&mut Vm, &[Value]) -> vm::VmResult, +) { + let schema = vm::catalog_import_schemas_into(registry, catalog, name) + .expect("named struct schemas should install") + .into_iter() + .next() + .expect("registered catalog schema"); + registry + .register_exact_static(name, schema.params.len() as u8, schema, function) + .expect("matching host adapter should register"); +} + +fn bind_point_hosts(registry: &mut HostFunctionRegistry, catalog: &HostApiCatalog) { + register_point_host(registry, catalog, "geo::origin", point_origin_host); + register_point_host(registry, catalog, "geo::through", point_through_host); + register_point_host(registry, catalog, "geo::take_point", point_sum_host); +} + +fn named_hint<'a>( + hints: &'a [vm::InferredLocalTypeHint], + name: &str, +) -> &'a vm::InferredLocalTypeHint { + hints + .iter() + .find(|hint| hint.name == name) + .unwrap_or_else(|| panic!("missing hint for {name}")) +} + +fn run_point_source(source: &str, catalog: Arc) -> Vec { + let options = CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)); + let compiled = + compile_source_with_flavor_and_options(source, SourceFlavor::RustScript, options) + .expect("typed host source should compile"); + let mut vm = Vm::try_new(compiled.program).expect("test VM construction should succeed"); + let mut registry = HostFunctionRegistry::empty(); + bind_point_hosts(&mut registry, catalog.as_ref()); + registry + .bind_vm_cached(&mut vm) + .expect("matching catalog adapters should bind"); + loop { + match vm.run().expect("typed host source should run") { + VmStatus::Halted => break, + VmStatus::Yielded => continue, + VmStatus::Waiting(_) => panic!("point host functions should not wait"), + } + } + vm.stack().to_vec() +} + +impl ParserDialect for SyntheticDialect {} + +static SYNTHETIC_DIALECT: SyntheticDialect = SyntheticDialect; +static VALIDATION_TEST_LOCK: Mutex<()> = Mutex::new(()); +type SchemaObservation = (Option, Option, Option); +type SchemaObservations = Mutex>; +static VALIDATION_OBSERVATIONS: SchemaObservations = Mutex::new(Vec::new()); +static NAMED_VALIDATION_OBSERVATIONS: SchemaObservations = Mutex::new(Vec::new()); +static VALIDATION_FAILURES: Mutex> = Mutex::new(Vec::new()); + +struct SyntheticSourcePlugin { + id: &'static str, + extensions: &'static [&'static str], + typing_policy: FrontendTypingPolicy, + requires_boolean_result: bool, + observations: &'static SchemaObservations, +} + +impl SyntheticSourcePlugin { + fn flavor(&self) -> SourceFlavor { + SourceFlavor::Plugin(FrontendId::new(self.id)) + } + + fn options(&'static self) -> CompileSourceFileOptions { + let options = CompileSourceFileOptions::new().with_source_plugin(self); + if self.requires_boolean_result { + options.with_host_api_catalog(Arc::clone(&TEST_POINT_CATALOG)) + } else { + options + } + } + + fn analyze(&'static self, source: &str) -> Result { + analyze_source_from_string_with_options( + format!("synthetic.{}", self.extensions[0]), + source, + self.options(), + ) + } +} + +fn local_initializer_schema(typed_ir: &FrontendTypedIr<'_>, name: &str) -> Option { + let (_, slot) = typed_ir + .ir + .local_bindings + .iter() + .find(|(local_name, _)| local_name == name)?; + typed_ir.ir.stmts.iter().find_map(|stmt| match stmt { + Stmt::Let { index, expr, .. } if index == slot => typed_ir.schema_of_expr(expr), + _ => None, + }) +} + +impl SourcePlugin for SyntheticSourcePlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new(self.id), + extensions: self.extensions, + typing_policy: self.typing_policy, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + parse_source_with_dialect( + source, + &SYNTHETIC_DIALECT, + SharedParserOptions { + source_id: context.source_id, + ..SharedParserOptions::default() + }, + ) + } + + fn validate_typed_ir(&self, typed_ir: &FrontendTypedIr<'_>) -> Result<(), CompileError> { + if !self.requires_boolean_result { + return Ok(()); + } + let Some((_, result_slot)) = typed_ir + .ir + .local_bindings + .iter() + .find(|(name, _)| name == "result") + else { + return Err(CompileError::StrictTypingRequired { + line: Some(1), + source_name: None, + detail: "synthetic predicate result binding is missing".to_string(), + span: None, + }); + }; + let local_schema = typed_ir + .local_schemas + .get(usize::from(*result_slot)) + .cloned() + .flatten(); + let expression_schema = typed_ir.ir.stmts.iter().find_map(|stmt| match stmt { + Stmt::Let { index, expr, .. } if index == result_slot => typed_ir.schema_of_expr(expr), + _ => None, + }); + let function_schema = typed_ir + .ir + .functions + .iter() + .find(|decl| decl.name == "predicate") + .and_then(|decl| typed_ir.schema_of_function(decl.index)); + let direct_expression_schema = local_initializer_schema(typed_ir, "direct"); + let wrapper_expression_schema = local_initializer_schema(typed_ir, "through"); + let wrapper_function_schema = typed_ir + .ir + .functions + .iter() + .find(|decl| decl.name == "wrap") + .and_then(|decl| typed_ir.schema_of_function(decl.index)); + let caller_catalog_matches = typed_ir + .host_api_catalog + .map(|catalog| catalog.fingerprint() == TEST_POINT_CATALOG.fingerprint()) + .unwrap_or(false); + NAMED_VALIDATION_OBSERVATIONS.lock().unwrap().push(( + direct_expression_schema + .as_ref() + .map(|schema| typed_ir.ir.source_schema(schema, 0)), + wrapper_expression_schema + .as_ref() + .map(|schema| typed_ir.ir.source_schema(schema, 0)), + wrapper_function_schema + .as_ref() + .map(|schema| typed_ir.ir.source_schema(schema, 0)), + )); + self.observations.lock().unwrap().push(( + local_schema.clone(), + expression_schema.clone(), + function_schema.clone(), + )); + let provenance = typed_ir + .ir + .parsed_semantic_index + .as_ref() + .unwrap() + .type_provenance + .iter() + .find(|entry| { + entry.source_name == "LocalPoint" + && entry.source_id == 0 + && entry.declaration_span.is_some() + }) + .expect("canonical guest type retains its original declaration"); + let declaration_span = provenance.declaration_span.unwrap(); + assert_eq!( + typed_ir.source_map.unwrap().span_text(declaration_span), + Some("LocalPoint") + ); + let expected_point = TypeSchema::Named(provenance.canonical_name.clone(), vec![]); + assert_eq!( + typed_ir.ir.source_schema(&expected_point, 0), + expected_point_schema() + ); + let declaration = &typed_ir.ir.struct_schemas[&provenance.canonical_name]; + assert_eq!( + declaration.body_schema, + TypeSchema::Object(std::collections::HashMap::from([ + ("x".to_string(), TypeSchema::Int), + ("y".to_string(), TypeSchema::Int) + ])) + ); + let expected_wrapper = TypeSchema::Callable { + params: vec![expected_point.clone()], + result: Box::new(expected_point.clone()), + }; + let expected_function = TypeSchema::Callable { + params: vec![TypeSchema::Int], + result: Box::new(TypeSchema::Bool), + }; + if expression_schema == Some(TypeSchema::Bool) + && function_schema == Some(expected_function) + && direct_expression_schema == Some(expected_point.clone()) + && wrapper_expression_schema == Some(expected_point) + && wrapper_function_schema == Some(expected_wrapper) + && caller_catalog_matches + { + return Ok(()); + } + Err(CompileError::StrictTypingRequired { + line: Some(1), + source_name: None, + detail: format!( + "synthetic checked schemas must preserve bool and LocalPoint identities: local={local_schema:?}, expression={expression_schema:?}, function={function_schema:?}, direct={direct_expression_schema:?}, wrapper={wrapper_expression_schema:?}, wrapper_function={wrapper_function_schema:?}, caller_catalog_matches={caller_catalog_matches}" + ), + span: None, + }) + } + + fn parse_module_imports( + &self, + _source: &str, + _path: &Path, + _context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + Ok(Vec::new()) + } +} + +static STRICT_UNKNOWN_PLUGIN: SyntheticSourcePlugin = SyntheticSourcePlugin { + id: "policy-strict", + extensions: &["plug-strict"], + typing_policy: FrontendTypingPolicy::Strict, + requires_boolean_result: false, + observations: &VALIDATION_OBSERVATIONS, +}; +static DYNAMIC_UNKNOWN_PLUGIN: SyntheticSourcePlugin = SyntheticSourcePlugin { + id: "policy-dynamic", + extensions: &["plug-dynamic"], + typing_policy: FrontendTypingPolicy::Dynamic, + requires_boolean_result: false, + observations: &VALIDATION_OBSERVATIONS, +}; +static VALIDATION_PLUGIN: SyntheticSourcePlugin = SyntheticSourcePlugin { + id: "policy-validator", + extensions: &["plug-check"], + typing_policy: FrontendTypingPolicy::Strict, + requires_boolean_result: true, + observations: &VALIDATION_OBSERVATIONS, +}; + +fn source_path_error_inner(error: vm::SourcePathError) -> String { + match error { + vm::SourcePathError::Source(error) | vm::SourcePathError::SourceWithMap { error, .. } => { + error.to_string() + } + other => other.to_string(), + } +} + +#[test] +fn strict_unknown_schema_is_rejected_across_checked_entries_but_dynamic_source_runs() { + let source = "fn identity(value: unknown) { 7 }\nlet item = 7;\nitem;\n"; + let strict_options = STRICT_UNKNOWN_PLUGIN.options(); + let strict_flavor = STRICT_UNKNOWN_PLUGIN.flavor(); + let compile_error = + match compile_source_with_flavor_and_options(source, strict_flavor, strict_options.clone()) + { + Ok(_) => panic!("strict plugin compilation must reject the unresolved schema"), + Err(error) => source_path_error_inner(error), + }; + let analyze_error = match STRICT_UNKNOWN_PLUGIN.analyze(source) { + Ok(_) => panic!("strict plugin analysis must reject the unresolved schema"), + Err(error) => source_path_error_inner(error), + }; + let strict_failure = "strict frontends require concrete compile-time types; 'unknown' annotations are not allowed"; + assert!(compile_error.contains(strict_failure)); + assert!(analyze_error.contains(strict_failure)); + let hints_error = + collect_inferred_local_type_hints_with_options(source, strict_flavor, strict_options) + .expect_err("strict plugin hints must reject the unresolved schema"); + assert!(source_path_error_inner(hints_error).contains(strict_failure)); + + let dynamic_options = DYNAMIC_UNKNOWN_PLUGIN.options(); + let dynamic_flavor = DYNAMIC_UNKNOWN_PLUGIN.flavor(); + let compiled = + compile_source_with_flavor_and_options(source, dynamic_flavor, dynamic_options.clone()) + .expect("dynamic policy should preserve its parsed-source behavior"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(7)]); + assert!(DYNAMIC_UNKNOWN_PLUGIN.analyze(source).is_ok()); + let hints = + collect_inferred_local_type_hints_with_options(source, dynamic_flavor, dynamic_options) + .expect("dynamic hints should remain available"); + let item = named_hint(&hints, "item"); + assert_eq!(item.schema, Some(TypeSchema::Int)); + let item_span = item + .span + .expect("dynamic source local retains parser provenance"); + assert_eq!(&source[item_span.lo..item_span.hi], "item"); +} + +fn reset_validation_observations() { + VALIDATION_OBSERVATIONS.lock().unwrap().clear(); + NAMED_VALIDATION_OBSERVATIONS.lock().unwrap().clear(); +} + +fn expected_point_schema() -> TypeSchema { + TypeSchema::Named("LocalPoint".to_string(), vec![]) +} + +fn expected_wrapper_schema() -> TypeSchema { + TypeSchema::Callable { + params: vec![expected_point_schema()], + result: Box::new(expected_point_schema()), + } +} + +fn expected_predicate_schema() -> TypeSchema { + TypeSchema::Callable { + params: vec![TypeSchema::Int], + result: Box::new(TypeSchema::Bool), + } +} + +fn assert_validated_once(source: &str, entry: &'static str) { + let observations = VALIDATION_OBSERVATIONS.lock().unwrap().clone(); + assert_eq!( + observations, + vec![( + Some(TypeSchema::Bool), + Some(TypeSchema::Bool), + Some(expected_predicate_schema()), + )], + "{entry} must pass actual local, expression, and callable schemas to validation" + ); + assert_eq!( + NAMED_VALIDATION_OBSERVATIONS.lock().unwrap().clone(), + vec![( + Some(expected_point_schema()), + Some(expected_point_schema()), + Some(expected_wrapper_schema()), + )], + "{entry} must pass named local and wrapper identities to the typed queries" + ); + reset_validation_observations(); + let invalid = source.replace("predicate(1)", "1 + 2"); + let validation_error = match entry { + "compile" => source_path_error_inner( + match compile_source_with_flavor_and_options( + &invalid, + VALIDATION_PLUGIN.flavor(), + VALIDATION_PLUGIN.options(), + ) { + Ok(_) => panic!("the finalized validation hook must reject the integer result"), + Err(err) => err, + }, + ), + "analyze" => source_path_error_inner(match VALIDATION_PLUGIN.analyze(&invalid) { + Ok(_) => panic!("analysis must propagate finalized validation failure"), + Err(err) => err, + }), + "hints" => source_path_error_inner( + match collect_inferred_local_type_hints_with_options( + &invalid, + VALIDATION_PLUGIN.flavor(), + VALIDATION_PLUGIN.options(), + ) { + Ok(_) => panic!("hints must propagate finalized validation failure"), + Err(err) => err, + }, + ), + _ => unreachable!(), + }; + assert!( + validation_error + .contains("synthetic checked schemas must preserve bool and LocalPoint identities") + ); + VALIDATION_FAILURES + .lock() + .unwrap() + .push((entry, validation_error)); + assert_eq!( + VALIDATION_OBSERVATIONS.lock().unwrap().clone(), + vec![( + None, + Some(TypeSchema::Int), + Some(expected_predicate_schema()), + )], + "{entry} validation must observe the inferred integer expression and complete function type" + ); + assert_eq!( + NAMED_VALIDATION_OBSERVATIONS.lock().unwrap().clone(), + vec![( + Some(expected_point_schema()), + Some(expected_point_schema()), + Some(expected_wrapper_schema()), + )], + "{entry} failure must preserve host and wrapper schemas" + ); +} + +struct LogicalSchemaPlugin; +static LOGICAL_SCHEMA_PLUGIN: LogicalSchemaPlugin = LogicalSchemaPlugin; +impl SourcePlugin for LogicalSchemaPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("neutral-logical-schemas"), + extensions: &["logical"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + parse_source_with_dialect( + source, + &SYNTHETIC_DIALECT, + SharedParserOptions { + source_id: context.source_id, + import_scan_mode: true, + ..SharedParserOptions::default() + }, + ) + } + fn validate_typed_ir(&self, typed: &FrontendTypedIr<'_>) -> Result<(), CompileError> { + let optional = TypeSchema::Optional(Box::new(TypeSchema::Int)); + for (name, slot) in &typed.ir.local_bindings { + let expected = match name.as_str() { + "optional" => optional.clone(), + "f" => TypeSchema::Callable { + params: vec![], + result: Box::new(TypeSchema::Int), + }, + "g" => TypeSchema::Callable { + params: vec![optional.clone()], + result: Box::new(optional.clone()), + }, + _ => continue, + }; + assert_eq!( + typed.local_schemas[usize::from(*slot)], + Some(expected.clone()), + "checked local {name}" + ); + if let Some(expr) = typed.ir.stmts.iter().find_map(|statement| match statement { + Stmt::Let { index, expr, .. } if index == slot => Some(expr), + _ => None, + }) { + assert_eq!( + typed.schema_of_expr(&vm::compiler::Expr::Var(*slot)), + Some(expected.clone()), + "variable query {name}" + ); + if name != "optional" { + assert_eq!( + typed.schema_of_expr(expr), + Some(expected), + "initializer query {name}" + ); + } + } + } + if let Some(function) = typed + .ir + .functions + .iter() + .find(|function| function.name == "maybe") + { + assert_eq!( + typed.schema_of_function(function.index), + Some(TypeSchema::Callable { + params: vec![optional.clone()], + result: Box::new(optional) + }) + ); + } + Ok(()) + } +} + +#[test] +fn inferred_function_reference_schema_agrees_with_runtime_hint_and_semantic_query() { + let source = "fn answer() { 42 }\nlet f = answer;\nf();\n"; + let options = || CompileSourceFileOptions::new().with_source_plugin(&LOGICAL_SCHEMA_PLUGIN); + let flavor = SourceFlavor::Plugin(FrontendId::new("neutral-logical-schemas")); + let expected = TypeSchema::Callable { + params: vec![], + result: Box::new(TypeSchema::Int), + }; + let compiled = compile_source_with_flavor_and_options(source, flavor, options()).unwrap(); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + let model = analyze_source_from_string_with_options("main.logical", source, options()).unwrap(); + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new( + 0, + source.find("let f").unwrap() + 4 + )), + Some(expected.clone()) + ); + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new( + 0, + source.find("= answer").unwrap() + 2 + )), + Some(expected.clone()) + ); + let hints = collect_inferred_local_type_hints_with_options(source, flavor, options()).unwrap(); + assert_eq!(named_hint(&hints, "f").schema, Some(expected)); +} + +#[test] +fn logical_optional_schema_is_complete_in_hints_and_validation_queries() { + let source = + "fn maybe(value: int?) -> int? { value }\nlet optional: int? = null;\nlet g = maybe;\n"; + let options = || CompileSourceFileOptions::new().with_source_plugin(&LOGICAL_SCHEMA_PLUGIN); + let flavor = SourceFlavor::Plugin(FrontendId::new("neutral-logical-schemas")); + compile_source_with_flavor_and_options(source, flavor, options()).unwrap(); + analyze_source_from_string_with_options("main.logical", source, options()).unwrap(); + let hints = collect_inferred_local_type_hints_with_options(source, flavor, options()).unwrap(); + assert_eq!( + named_hint(&hints, "optional").schema, + Some(TypeSchema::Optional(Box::new(TypeSchema::Int))) + ); + assert_eq!( + named_hint(&hints, "g").schema, + Some(TypeSchema::Callable { + params: vec![TypeSchema::Optional(Box::new(TypeSchema::Int))], + result: Box::new(TypeSchema::Optional(Box::new(TypeSchema::Int))) + }) + ); +} + +#[test] +fn inferred_optional_callable_results_agree_across_native_and_neutral_queries() { + let optional = TypeSchema::Optional(Box::new(TypeSchema::Int)); + let expected = TypeSchema::Callable { + params: vec![optional.clone()], + result: Box::new(optional), + }; + for (flavor, path) in [ + (SourceFlavor::RustScript, "main.rss"), + ( + SourceFlavor::Plugin(FrontendId::new("neutral-logical-schemas")), + "main.logical", + ), + ] { + let options = || CompileSourceFileOptions::new().with_source_plugin(&LOGICAL_SCHEMA_PLUGIN); + for source in [ + "fn maybe(value: int?) { value }\nlet g = maybe;\ng(null);\n", + "let g: fn(int?) -> int? = |value| value;\ng(null);\n", + ] { + let hints = + collect_inferred_local_type_hints_with_options(source, flavor, options()).unwrap(); + assert_eq!(named_hint(&hints, "g").schema, Some(expected.clone())); + let span = named_hint(&hints, "g").span.unwrap(); + assert_eq!(&source[span.lo..span.hi], "g"); + let model = analyze_source_from_string_with_options(path, source, options()).unwrap(); + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new(0, span.lo)), + Some(expected.clone()) + ); + let expression = model + .ir() + .stmts + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { expr, .. } => Some(expr), + _ => None, + }) + .unwrap(); + let typed = FrontendTypedIr { + source_id: Some(0), + ir: model.ir(), + local_schemas: &model.ir().semantic_index.as_ref().unwrap().slot_schemas, + host_api_catalog: None, + source_map: None, + }; + assert_eq!(typed.schema_of_expr(expression), Some(expected.clone())); + if let Some(decl) = model + .ir() + .functions + .iter() + .find(|decl| decl.name == "maybe") + { + assert_eq!(typed.schema_of_function(decl.index), Some(expected.clone())); + assert!( + decl.return_schema.is_none(), + "authored declaration stays unannotated" + ); + } + let compiled = + compile_source_with_flavor_and_options(source, flavor, options()).unwrap(); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Null]); + } + } +} + +#[test] +fn finalized_dynamic_callable_parameters_do_not_narrow_from_call_observations() { + let source = "fn eq(lhs, rhs) { lhs == rhs; }\nfn is_empty(value) { eq(value, \"\"); }\npub fn non_empty(value) { eq(is_empty(value), false); }\nlet comparator = eq;\nnon_empty(\"x\");\n"; + let model = analyze_source_from_string_with_options( + "dynamic.rss", + source, + CompileSourceFileOptions::new(), + ) + .unwrap(); + let typed = FrontendTypedIr { + source_id: Some(0), + ir: model.ir(), + local_schemas: &model.ir().semantic_index.as_ref().unwrap().slot_schemas, + host_api_catalog: None, + source_map: None, + }; + let declaration = model + .ir() + .functions + .iter() + .find(|decl| decl.name == "eq") + .unwrap(); + let reference = vm::Expr::FunctionRef(declaration.index, vec![]); + let expected = typed.schema_of_expr(&reference).unwrap(); + let TypeSchema::Callable { params, .. } = &expected else { + panic!("function reference must have a callable schema") + }; + assert_eq!(params, &vec![TypeSchema::Unknown; 2]); + assert_eq!( + typed.schema_of_function(declaration.index), + Some(expected.clone()) + ); + let hints = collect_inferred_local_type_hints_with_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::new(), + ) + .unwrap(); + assert_eq!( + named_hint(&hints, "comparator").schema, + Some(expected.clone()) + ); + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::new(), + ) + .unwrap(); + assert!( + compiled + .program + .callable_prototypes + .iter() + .any(|prototype| prototype.schema.as_ref() == Some(&expected)) + ); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Bool(true)]); +} + +#[test] +fn finalized_named_and_contextual_callable_consumers_keep_authored_generic_declarations() { + for (flavor, path) in [ + (SourceFlavor::RustScript, "main.rss"), + ( + SourceFlavor::Plugin(FrontendId::new("neutral-logical-schemas")), + "main.logical", + ), + ] { + let options = || CompileSourceFileOptions::new().with_source_plugin(&LOGICAL_SCHEMA_PLUGIN); + for (source, name, result, local) in [ + ( + "fn answer() { 42 }\nlet f = answer;\nanswer();\nf();\n", + "answer", + TypeSchema::Int, + "f", + ), + ( + "fn maybe(value: int?) { value }\nlet g = maybe;\nmaybe(null);\ng(null);\n", + "maybe", + TypeSchema::Optional(Box::new(TypeSchema::Int)), + "g", + ), + ] { + let model = analyze_source_from_string_with_options(path, source, options()).unwrap(); + let index = model.ir().parsed_semantic_index.as_ref().unwrap(); + let declaration = index + .func_decls + .iter() + .find(|decl| decl.name == name) + .unwrap(); + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new(0, declaration.ident_span.lo)), + Some(result.clone()) + ); + for site in index + .call_sites + .iter() + .filter(|site| &source[site.callee_span.lo..site.callee_span.hi] == name) + { + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new(0, site.callee_span.lo)), + Some(result.clone()) + ); + assert_eq!( + model.ir().semantic_index.as_ref().unwrap().resolved_calls[&site.id] + .return_type, + result + ); + } + let hints = + collect_inferred_local_type_hints_with_options(source, flavor, options()).unwrap(); + let expected = named_hint(&hints, local).schema.clone().unwrap(); + let compiled = + compile_source_with_flavor_and_options(source, flavor, options()).unwrap(); + assert!( + compiled + .program + .callable_prototypes + .iter() + .any(|prototype| prototype.schema.as_ref() == Some(&expected)), + "actual executable named prototype {expected:?}: {:?}", + compiled.program.callable_prototypes + ); + assert!( + model + .ir() + .functions + .iter() + .find(|decl| decl.name == name) + .unwrap() + .return_schema + .is_none() + ); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!( + machine.stack().last(), + Some(if name == "answer" { + &Value::Int(42) + } else { + &Value::Null + }) + ); + } + let source = "fn identity(value: T) -> T { value }\nlet output = identity::(41);\nlet other = identity::(\"authored\");\noutput;\n"; + let model = analyze_source_from_string_with_options(path, source, options()).unwrap(); + let index = model.ir().parsed_semantic_index.as_ref().unwrap(); + let decl = model + .ir() + .functions + .iter() + .find(|decl| decl.name == "identity") + .unwrap(); + assert_eq!( + decl.return_schema, + Some(TypeSchema::GenericParam("T".to_string())) + ); + assert_eq!( + decl.arg_schemas, + [Some(TypeSchema::GenericParam("T".to_string()))] + ); + for (needle, expected) in [ + ("identity::", TypeSchema::Int), + ("identity::", TypeSchema::String), + ] { + let offset = source.find(needle).unwrap(); + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new(0, offset)), + Some(expected) + ); + } + let declaration = index + .func_decls + .iter() + .find(|decl| decl.name == "identity") + .unwrap(); + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new(0, declaration.ident_span.lo)), + Some(TypeSchema::GenericParam("T".to_string())) + ); + let hints = + collect_inferred_local_type_hints_with_options(source, flavor, options()).unwrap(); + assert_eq!(named_hint(&hints, "output").schema, Some(TypeSchema::Int)); + assert_eq!(named_hint(&hints, "other").schema, Some(TypeSchema::String)); + let compiled = compile_source_with_flavor_and_options(source, flavor, options()).unwrap(); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(41)]); + let source = "let g: fn(int?) -> int? = |value| value;\ng(null);\n"; + let expected = TypeSchema::Callable { + params: vec![TypeSchema::Optional(Box::new(TypeSchema::Int))], + result: Box::new(TypeSchema::Optional(Box::new(TypeSchema::Int))), + }; + let compiled = compile_source_with_flavor_and_options(source, flavor, options()).unwrap(); + assert!( + compiled + .program + .callable_prototypes + .iter() + .any(|prototype| prototype.schema.as_ref() == Some(&expected)) + ); + } +} + +#[test] +fn finalized_validation_hook_checks_real_source_for_compile_analyze_and_hints() { + let _guard = VALIDATION_TEST_LOCK.lock().unwrap(); + VALIDATION_FAILURES.lock().unwrap().clear(); + let source = "struct LocalPoint { x: int, y: int }\nfn wrap(point: LocalPoint) -> LocalPoint { point }\nfn predicate(x: int) -> bool { x > 0 }\nlet direct = wrap({ x: 1, y: 2 });\nlet through = wrap(direct);\nlet callback = wrap;\nlet called = callback(through);\nlet result = predicate(1);\n"; + + reset_validation_observations(); + compile_source_with_flavor_and_options( + source, + VALIDATION_PLUGIN.flavor(), + VALIDATION_PLUGIN.options(), + ) + .expect("valid source should compile"); + assert_validated_once(source, "compile"); + + reset_validation_observations(); + VALIDATION_PLUGIN + .analyze(source) + .expect("valid source should analyze"); + assert_validated_once(source, "analyze"); + + reset_validation_observations(); + collect_inferred_local_type_hints_with_options( + source, + VALIDATION_PLUGIN.flavor(), + VALIDATION_PLUGIN.options(), + ) + .expect("valid source should produce hints"); + assert_validated_once(source, "hints"); + + let failures = VALIDATION_FAILURES.lock().unwrap(); + let failure = |entry| { + failures + .iter() + .find(|(name, _)| *name == entry) + .map(|(_, error)| error) + .unwrap_or_else(|| panic!("missing validation error for {entry}")) + }; + assert_eq!(failure("compile"), failure("analyze")); + assert_eq!(failure("analyze"), failure("hints")); +} + +#[test] +fn logical_hints_retain_nested_shadow_declarations_and_spans() { + let source = "let value = 1;\nif true { let value = 1; value; }\nvalue;\n"; + let hints = collect_inferred_local_type_hints_with_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::default(), + ) + .expect("same-typed nested shadow should produce logical hints"); + let outer = hints + .iter() + .find(|hint| hint.name == "value" && hint.declared_line == Some(1)) + .expect("outer lexical declaration keeps its hint"); + let inner = hints + .iter() + .find(|hint| hint.name == "value" && hint.declared_line == Some(2)) + .expect("nested lexical declaration keeps its hint"); + assert_eq!(outer.schema, Some(TypeSchema::Int)); + assert_eq!(inner.schema, Some(TypeSchema::Int)); + let outer_span = outer.span.expect("outer declaration keeps its source span"); + let inner_span = inner.span.expect("inner declaration keeps its source span"); + assert_ne!(outer_span.lo, inner_span.lo); + assert_eq!(&source[outer_span.lo..outer_span.hi], "value"); + assert_eq!(&source[inner_span.lo..inner_span.hi], "value"); + assert_ne!(outer.declared_line, inner.declared_line); +} + +#[test] +fn native_host_named_schema_flows_through_wrappers_collections_and_callback_calls() { + let source = r#" +use geo; +fn wrap(point: Point) -> Point { point } +fn relay(pass: fn(Point) -> Point, value: Point) { pass(value) } +let direct = geo::origin(); +let alias = direct; +let wrapped = wrap(alias); +let relayed = relay(wrap, wrapped); +let points = [relayed]; +let object = { point: points[0] }; +let callback = wrap; +let applied = geo::through(callback, object.point); +let contextual = geo::through(|host_parameter| host_parameter, applied); +let score = geo::take_point(contextual); +score; +"#; + let catalog = point_catalog(); + assert_eq!( + run_point_source(source, Arc::clone(&catalog)), + vec![Value::Int(7)], + "registered host functions should return and consume the real Point map" + ); + + let options = CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)); + analyze_source_from_string_with_options("point.rss", source, options.clone()) + .expect("native host source should analyze"); + let hints = + collect_inferred_local_type_hints_with_options(source, SourceFlavor::RustScript, options) + .expect("native host source should produce structured hints"); + let point = TypeSchema::Named("Point".to_string(), vec![]); + for name in [ + "direct", + "alias", + "wrapped", + "relayed", + "applied", + "contextual", + "host_parameter", + ] { + assert_eq!( + named_hint(&hints, name).schema, + Some(point.clone()), + "{name}" + ); + } + match named_hint(&hints, "points").schema.as_ref() { + Some(TypeSchema::Array(inner)) => assert_eq!(inner.as_ref(), &point), + Some(TypeSchema::ArrayTuple(items)) => assert_eq!(items, std::slice::from_ref(&point)), + other => panic!("array schema should retain Point, got {other:?}"), + } + match named_hint(&hints, "object").schema.as_ref() { + Some(TypeSchema::Object(fields)) => assert_eq!(fields.get("point"), Some(&point)), + other => panic!("object schema should retain named Point field, got {other:?}"), + } + let callback = named_hint(&hints, "callback"); + assert_eq!( + callback.schema, + Some(TypeSchema::Callable { + params: vec![point.clone()], + result: Box::new(point.clone()), + }) + ); + assert_eq!(callback.inferred_type, "fn(Point) -> Point"); + assert_eq!(named_hint(&hints, "point").schema, Some(point)); + + let wrong_source = "use geo;\ngeo::take_point(7);\n"; + let compile_error = match compile_source_with_flavor_and_options( + wrong_source, + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)), + ) { + Ok(_) => panic!("wrong host argument must fail compilation"), + Err(error) => error.to_string(), + }; + let rejected_model = analyze_source_from_string_with_options( + "wrong.rss", + wrong_source, + CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)), + ) + .expect("semantic API retains its recoverable host-resolution diagnostic contract"); + let diagnostics = rejected_model.diagnostics(); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].code.as_deref(), Some("E001")); + assert!(compile_error.contains(&diagnostics[0].message)); + let span = diagnostics[0].span.unwrap(); + assert_eq!( + rejected_model.sources().span_text(span), + Some("geo::take_point") + ); + let hints_error = match collect_inferred_local_type_hints_with_options( + wrong_source, + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) { + Ok(_) => panic!("wrong host argument must fail hint preparation"), + Err(error) => error.to_string(), + }; + assert_eq!(compile_error, hints_error); +} diff --git a/tests/compiler/frontend_plugin_tests.rs b/tests/compiler/frontend_plugin_tests.rs index 4531cf49..57ba3499 100644 --- a/tests/compiler/frontend_plugin_tests.rs +++ b/tests/compiler/frontend_plugin_tests.rs @@ -1,41 +1,83 @@ -#[path = "../common/mod.rs"] -mod common; -use common::*; -use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; use vm::{ - CompileSourceFileOptions, FrontendImportSyntax, FrontendIr, ParseError, SourceFlavor, - SourcePlugin, compile_source_with_flavor_and_options, + CompileSourceFileOptions, Expr, FrontendId, FrontendIr, FrontendTypingPolicy, ParseError, + ParsePhase, SourceFlavor, SourceFrontendContext, SourceFrontendDescriptor, SourcePlugin, Stmt, + Value, Vm, VmStatus, compile_source_file_with_options, compile_source_with_flavor_and_options, }; -struct ConstantPlugin; +#[derive(Clone, Debug, PartialEq, Eq)] +struct SeenContext { + call: &'static str, + source_id: u32, + original_path: Option, + phase: ParsePhase, + catalog_ptr: Option, + import_count: usize, +} -impl SourcePlugin for ConstantPlugin { - fn flavor(&self) -> SourceFlavor { - SourceFlavor::JavaScript - } +static PLUG_A_CONTEXTS: Mutex> = Mutex::new(Vec::new()); +static PLUG_B_CONTEXTS: Mutex> = Mutex::new(Vec::new()); +static PLUG_C_CONTEXTS: Mutex> = Mutex::new(Vec::new()); +static PLUG_D_CONTEXTS: Mutex> = Mutex::new(Vec::new()); - fn extensions(&self) -> &'static [&'static str] { - &["js"] - } +struct SyntheticPlugin { + id: &'static str, + extensions: &'static [&'static str], + typing_policy: FrontendTypingPolicy, + value: i64, + reject_unknown: bool, + contexts: &'static Mutex>, +} - fn import_syntax(&self) -> FrontendImportSyntax { - FrontendImportSyntax::JavaScript +impl SyntheticPlugin { + fn record(&self, call: &'static str, context: &SourceFrontendContext<'_>) { + self.contexts.lock().unwrap().push(SeenContext { + call, + source_id: context.source_id, + original_path: context.original_path.map(Path::to_path_buf), + phase: context.phase, + catalog_ptr: context + .host_api_catalog + .map(|catalog| catalog as *const _ as usize), + import_count: context.imports.len(), + }); } - fn parse_source(&self, _source: &str) -> Result { - Ok(FrontendIr { + fn ir(&self) -> FrontendIr { + let struct_schemas = if self.reject_unknown { + std::iter::once(( + "SyntheticRecord".to_string(), + vm::compiler::ir::StructDecl { + name: "SyntheticRecord".to_string(), + type_params: Vec::new(), + body_schema: vm::TypeSchema::Object( + std::iter::once(("value".to_string(), vm::TypeSchema::Unknown)).collect(), + ), + origin: vm::compiler::StructDeclOrigin::Guest, + }, + )) + .collect() + } else { + Default::default() + }; + FrontendIr { stmts: vec![Stmt::Expr { - expr: Expr::Int(7), + expr: Expr::Int(self.value), line: 1, }], locals: 0, local_bindings: Vec::new(), - struct_schemas: HashMap::new(), + struct_schemas, unknown_type_spans: Vec::new(), functions: Vec::new(), - function_impls: HashMap::new(), + function_impls: Default::default(), stmt_sources: Vec::new(), - function_sources: HashMap::new(), + function_sources: Default::default(), use_declarations: Vec::new(), implicit_extern_names: Vec::new(), host_api_metadata: None, @@ -43,21 +85,232 @@ impl SourcePlugin for ConstantPlugin { parsed_semantic_index: None, catalog_visibility: None, lexer_tokens: Vec::new(), - }) + } } } -static CONSTANT_PLUGIN: ConstantPlugin = ConstantPlugin; +impl SourcePlugin for SyntheticPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new(self.id), + extensions: self.extensions, + typing_policy: self.typing_policy, + supports_ownership_checking: false, + } + } -#[test] -fn registered_compat_frontend_plugin_compiles_source() { - let options = CompileSourceFileOptions::new().with_source_plugin(&CONSTANT_PLUGIN); - let compiled = - compile_source_with_flavor_and_options("ignored();", SourceFlavor::JavaScript, options) - .expect("registered plugin should compile JavaScript flavor"); + fn parse_source( + &self, + _source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + self.record("parse", context); + Ok(self.ir()) + } + + fn parse_module_imports( + &self, + _source: &str, + _path: &Path, + context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + self.record("imports", context); + Ok(Vec::new()) + } +} +static PLUG_A: SyntheticPlugin = SyntheticPlugin { + id: "plugin-a", + extensions: &["plug-a"], + typing_policy: FrontendTypingPolicy::Strict, + value: 11, + reject_unknown: false, + contexts: &PLUG_A_CONTEXTS, +}; +static PLUG_B: SyntheticPlugin = SyntheticPlugin { + id: "plugin-b", + extensions: &["plug-b"], + typing_policy: FrontendTypingPolicy::Dynamic, + value: 22, + reject_unknown: false, + contexts: &PLUG_B_CONTEXTS, +}; +static PLUG_C: SyntheticPlugin = SyntheticPlugin { + id: "plugin-c", + extensions: &["plug-c"], + typing_policy: FrontendTypingPolicy::Strict, + value: 33, + reject_unknown: true, + contexts: &PLUG_C_CONTEXTS, +}; +static PLUG_D: SyntheticPlugin = SyntheticPlugin { + id: "plugin-d", + extensions: &["plug-d"], + typing_policy: FrontendTypingPolicy::Dynamic, + value: 44, + reject_unknown: true, + contexts: &PLUG_D_CONTEXTS, +}; + +struct PluginSourceFixture { + path: PathBuf, +} + +impl Deref for PluginSourceFixture { + type Target = Path; + + fn deref(&self) -> &Self::Target { + &self.path + } +} + +impl AsRef for PluginSourceFixture { + fn as_ref(&self) -> &Path { + &self.path + } +} + +impl Drop for PluginSourceFixture { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +static NEXT_FIXTURE_ID: AtomicU64 = AtomicU64::new(0); + +fn plugin_source_path(extension: &str) -> PluginSourceFixture { + for _ in 0..32 { + let id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "pd-vm-frontend-plugin-{}-{id}.{extension}", + std::process::id() + )); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut file) => { + let fixture = PluginSourceFixture { path }; + let write_result = file.write_all(b"synthetic frontend input\n"); + drop(file); + if let Err(err) = write_result { + drop(fixture); + panic!("contract source should write: {err}"); + } + return fixture; + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => panic!("contract source should create: {err}"), + } + } + panic!("could not allocate a unique frontend fixture path"); +} + +fn run_plugin(path: &Path, options: CompileSourceFileOptions, expected: i64) { + let compiled = compile_source_file_with_options(path, options) + .expect("registered plugin should compile its extension"); let mut vm = Vm::new(compiled.program); let status = vm.run().expect("compiled plugin program should run"); assert_eq!(status, VmStatus::Halted); - assert_eq!(vm.stack(), &[Value::Int(7)]); + assert_eq!(vm.stack(), &[Value::Int(expected)]); +} + +#[test] +fn registered_plugins_dispatch_by_identity_and_extension_and_run() { + let path_a = plugin_source_path("plug-a"); + let path_b = plugin_source_path("plug-b"); + run_plugin( + &path_a, + CompileSourceFileOptions::new().with_source_plugin(&PLUG_A), + 11, + ); + run_plugin( + &path_b, + CompileSourceFileOptions::new().with_source_plugin(&PLUG_B), + 22, + ); + + let unregistered_extension = + compile_source_file_with_options(&path_a, CompileSourceFileOptions::default()) + .err() + .expect("an unregistered extension must be rejected"); + assert!(unregistered_extension.to_string().contains("plug-a")); + + let unregistered_identity = compile_source_with_flavor_and_options( + "synthetic frontend input", + SourceFlavor::Plugin(FrontendId::new("plugin-a")), + CompileSourceFileOptions::new().with_source_plugin(&PLUG_B), + ) + .err() + .expect("an unregistered plugin identity must be rejected"); + assert!(unregistered_identity.to_string().contains("plugin-a")); +} + +#[test] +fn plugin_context_preserves_source_phase_path_and_caller_catalog() { + PLUG_A_CONTEXTS.lock().unwrap().clear(); + let catalog = std::sync::Arc::new(vm::HostApiCatalog::builder().build().unwrap()); + let catalog_ptr = std::sync::Arc::as_ptr(&catalog) as usize; + let catalog_options = CompileSourceFileOptions::new() + .with_host_api_catalog(std::sync::Arc::clone(&catalog)) + .with_source_plugin(&PLUG_A); + + let source_path = plugin_source_path("plug-a"); + let file_options = catalog_options.clone(); + run_plugin(&source_path, file_options, 11); + compile_source_with_flavor_and_options( + "synthetic frontend input", + SourceFlavor::Plugin(FrontendId::new("plugin-a")), + catalog_options, + ) + .expect("standalone plugin source should compile"); + + let seen = PLUG_A_CONTEXTS.lock().unwrap().clone(); + assert!(seen.iter().any(|context| { + context.call == "imports" + && context.source_id == 0 + && context.original_path.as_deref() == Some(source_path.path.as_path()) + && context.phase == ParsePhase::ImportDiscovery + && context.catalog_ptr == Some(catalog_ptr) + && context.import_count == 0 + })); + assert!(seen.iter().any(|context| { + context.call == "parse" + && context.source_id == 0 + && context.original_path.as_deref() == Some(source_path.path.as_path()) + && context.phase == ParsePhase::Module + && context.catalog_ptr == Some(catalog_ptr) + && context.import_count == 0 + })); + assert!(seen.iter().any(|context| { + context.call == "parse" + && context.source_id == 0 + && context.original_path.is_none() + && context.phase == ParsePhase::Standalone + && context.catalog_ptr == Some(catalog_ptr) + && context.import_count == 0 + })); +} + +#[test] +fn descriptor_typing_policy_rejects_unknown_schema_only_for_strict_plugins() { + let strict = compile_source_with_flavor_and_options( + "synthetic frontend input", + SourceFlavor::Plugin(FrontendId::new("plugin-c")), + CompileSourceFileOptions::new().with_source_plugin(&PLUG_C), + ) + .err() + .expect("strict plugin must reject unresolved typed IR"); + assert!( + strict + .to_string() + .contains("strict typing requires concrete schemas") + ); + + let dynamic = compile_source_with_flavor_and_options( + "synthetic frontend input", + SourceFlavor::Plugin(FrontendId::new("plugin-d")), + CompileSourceFileOptions::new().with_source_plugin(&PLUG_D), + ) + .expect("dynamic plugin keeps its dynamic behavior"); + let mut vm = Vm::new(dynamic.program); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(44)]); } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index d8ce5df8..0e1a75c1 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -15,6 +15,16 @@ mod diagnostics_tests; #[path = "compiler/frontend_plugin_tests.rs"] mod frontend_plugin_tests; +#[cfg(feature = "runtime")] +#[path = "compiler/frontend_plugin_catalog_tests.rs"] +mod frontend_plugin_catalog_tests; +#[cfg(feature = "runtime")] +#[path = "compiler/frontend_plugin_module_tests.rs"] +mod frontend_plugin_module_tests; +#[cfg(feature = "runtime")] +#[path = "compiler/frontend_plugin_policy_tests.rs"] +mod frontend_plugin_policy_tests; + #[cfg(feature = "runtime")] #[path = "compiler/module_import_tests.rs"] mod module_import_tests; diff --git a/tests/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index 5ccf989e..1f7587ed 100644 --- a/tests/host_binding_generation_tests.rs +++ b/tests/host_binding_generation_tests.rs @@ -8,9 +8,11 @@ use build_script::{ }; use syn::parse_quote; use vm::{ - BuiltinFunction, CapabilityProfile, HostExecution, HostFunctionRegistry, JitConfig, - JitTraceTerminal, Value, Vm, VmStatus, compile_source, default_host_callables, + BuiltinFunction, CapabilityProfile, HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, + Vm, VmStatus, compile_source, }; +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +use vm::{HostExecution, default_host_callables}; fn native_jit_supported() -> bool { (cfg!(target_arch = "x86_64") diff --git a/tests/http_sse_resource_tests.rs b/tests/http_sse_resource_tests.rs index de2b0b59..3991dc16 100644 --- a/tests/http_sse_resource_tests.rs +++ b/tests/http_sse_resource_tests.rs @@ -247,11 +247,11 @@ async fn resource_sse_post_redirect_counts_each_transmitted_body() { let count = stream.read(&mut chunk).unwrap(); assert!(count > 0, "request ended before body"); request.extend_from_slice(&chunk[..count]); - if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { - if request.len() >= end + 4 + 7 { - assert_eq!(&request[end + 4..end + 11], b"payload"); - break; - } + if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + && request.len() >= end + 4 + 7 + { + assert_eq!(&request[end + 4..end + 11], b"payload"); + break; } } assert!(request.starts_with(if hop == 0 { diff --git a/tests/optional_projection_ownership_tests.rs b/tests/optional_projection_ownership_tests.rs new file mode 100644 index 00000000..36c23b3d --- /dev/null +++ b/tests/optional_projection_ownership_tests.rs @@ -0,0 +1,691 @@ +//! Field ownership is independent of the source frontend. + +use vm::{ + CompileSourceFileOptions, FrontendId, FrontendIr, FrontendTypingPolicy, ParseError, + ParserDialect, SharedParserOptions, SourceFlavor, SourceFrontendContext, + SourceFrontendDescriptor, SourcePlugin, Value, Vm, VmStatus, + analyze_source_from_string_with_options, collect_inferred_local_type_hints_with_options, + compile_source_with_flavor_and_options, parse_source_with_dialect, +}; + +struct ProjectionDialect; +impl ParserDialect for ProjectionDialect { + fn allow_let_mut_binding(&self) -> bool { + true + } +} + +struct ProjectionPlugin(&'static str, &'static [&'static str]); +impl SourcePlugin for ProjectionPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new(self.0), + extensions: self.1, + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + + fn parse_module_imports( + &self, + source: &str, + _path: &std::path::Path, + _context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + // This fixture grammar has one explicit namespace module directive. + Ok(if source.lines().any(|line| line == "use dep;") { + vec![vm::ModuleImport::value( + format!("./dep.{}", self.1[0]), + vm::ImportClause::Namespace("dep".to_string()), + 1, + )] + } else { + vec![] + }) + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + let mut ir = parse_source_with_dialect( + source, + &ProjectionDialect, + SharedParserOptions { + source_id: context.source_id, + import_scan_mode: true, + allow_implicit_externs: true, + ..SharedParserOptions::default() + }, + )?; + let mut host_indices = std::collections::HashMap::new(); + if let Some(catalog) = context.host_api_catalog { + let declarations = ir.functions.clone(); + for declaration in declarations { + if catalog.function(&declaration.name).is_some() { + ir.functions + .retain(|function| function.index != declaration.index); + ir.implicit_extern_names + .retain(|name| name != &declaration.name); + let new_index = context.install_host_function( + &mut ir, + &declaration.name, + declaration.arity, + )?; + host_indices.insert(declaration.index, new_index); + } + } + } + for stmt in &mut ir.stmts { + project_stmt(stmt, &host_indices); + } + for function in ir.function_impls.values_mut() { + for stmt in &mut function.body_stmts { + project_stmt(stmt, &host_indices); + } + project_expr(&mut function.body_expr, &host_indices); + } + Ok(ir) + } +} + +fn project_stmt(stmt: &mut vm::Stmt, hosts: &std::collections::HashMap) { + match stmt { + vm::Stmt::Let { + expr: vm::Expr::Var(slot), + .. + } => { + // The synthetic producer spells direct root transfer explicitly. + let root = *slot; + if let vm::Stmt::Let { expr, .. } = stmt { + *expr = vm::Expr::MoveVar(root); + } + } + vm::Stmt::Let { expr, .. } + | vm::Stmt::Assign { expr, .. } + | vm::Stmt::Expr { expr, .. } => project_expr(expr, hosts), + _ => {} + } +} + +fn project_expr(expr: &mut vm::Expr, hosts: &std::collections::HashMap) { + use vm::Expr; + match expr { + Expr::OptionalGet { + field_projection, + container, + key, + .. + } => { + *field_projection = true; + project_expr(container, hosts); + project_expr(key, hosts); + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + project_expr(value, hosts); + project_expr(fallback, hosts); + } + Expr::ToOwned(inner) | Expr::Borrow(inner) | Expr::BorrowMut(inner) => { + project_expr(inner, hosts) + } + Expr::Call(index, _, args, _, _) => { + if let Some(new_index) = hosts.get(index) { + *index = *new_index; + } + for arg in args { + project_expr(arg, hosts); + } + } + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + project_expr(condition, hosts); + project_expr(then_expr, hosts); + project_expr(else_expr, hosts); + } + Expr::Block { stmts, expr } => { + for stmt in stmts { + project_stmt(stmt, hosts); + } + project_expr(expr, hosts); + } + Expr::ModuleCall(_, _, args, _) => { + for arg in args { + project_expr(arg, hosts); + } + } + _ => {} + } +} + +static A: ProjectionPlugin = ProjectionPlugin("opaque-projection-a", &["projection-a"]); +static B: ProjectionPlugin = ProjectionPlugin("opaque-projection-b", &["projection-b"]); + +const SOURCE: &str = "struct Entry { name: string, note: string? }\nlet entry: Entry = { name: \"first\" };\nlet name = entry.name;\nlet note = entry?.note;\nnote;\n"; + +fn assert_run(source: &str, expected: Value, plugin: &'static ProjectionPlugin) { + let options = || CompileSourceFileOptions::new().with_source_plugin(plugin); + let flavor = SourceFlavor::Plugin(FrontendId::new(plugin.0)); + let compiled = compile_source_with_flavor_and_options(source, flavor, options()) + .unwrap_or_else(|error| panic!("strict sibling projection must compile: {error}")); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[expected]); + let model = + analyze_source_from_string_with_options(format!("main.{}", plugin.1[0]), source, options()) + .unwrap(); + assert!(model.diagnostics().is_empty()); + let hints = collect_inferred_local_type_hints_with_options(source, flavor, options()).unwrap(); + let note = hints.iter().find(|hint| hint.name == "note").unwrap(); + assert!(note.schema.is_some()); + let span = note.span.unwrap(); + assert_eq!(&source[span.lo..span.hi], "note"); + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new(0, span.lo)), + note.schema + ); + let parsed = model.ir().parsed_semantic_index.as_ref().unwrap(); + let site = parsed + .call_sites + .iter() + .find(|site| source.get(site.expr_span.lo..site.expr_span.hi) == Some("entry?.note")) + .unwrap(); + assert_eq!(&source[site.expr_span.lo..site.expr_span.hi], "entry?.note"); + assert!( + model + .ir() + .semantic_index + .as_ref() + .unwrap() + .resolved_calls + .contains_key(&site.id) + ); +} + +#[test] +fn opaque_a_available_optional_sibling_after_normal_projection() { + assert_run(SOURCE, Value::Null, &A); +} + +#[test] +fn opaque_b_available_optional_sibling_after_normal_projection() { + assert_run(SOURCE, Value::Null, &B); +} + +#[test] +fn optional_projection_null_and_present_results() { + for plugin in [&A, &B] { + for (field, expected) in [("null", Value::Null), ("\"ready\"", Value::string("ready"))] { + assert_run( + &SOURCE.replace( + "{ name: \"first\" }", + &format!("{{ name: \"first\", note: {field} }}"), + ), + expected, + plugin, + ); + } + } +} + +#[test] +fn nested_block_optional_projection_preserves_copyable_child() { + let source = "struct Entry { note: int? }\nlet mut entry: Entry = {};\nlet note: int? = (if true => { entry = { note: 42 }; let first = entry?.note; entry?.note } else => { entry?.note }).copy();\nnote.unwrap_or(0);"; + for plugin in [&A, &B] { + assert_run(source, Value::Int(42), plugin); + } +} + +#[test] +fn named_generic_projection_keeps_callable_and_semantic_facts() { + let source = "struct Entry { name: string, note: T? }\nfn preserve(value: T) -> T { value }\nlet entry: Entry = { name: \"first\" };\nlet preserved = preserve::>(entry);\nlet name = entry.name;\nlet note = entry?.note;\nnote;\n"; + for plugin in [&A, &B] { + assert_run(source, Value::Null, plugin); + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::Plugin(FrontendId::new(plugin.0)), + CompileSourceFileOptions::new().with_source_plugin(plugin), + ) + .unwrap(); + let entry = vm::TypeSchema::Named("Entry".to_string(), vec![vm::TypeSchema::String]); + let expected = vm::TypeSchema::Callable { + params: vec![entry.clone()], + result: Box::new(entry), + }; + let model = analyze_source_from_string_with_options( + format!("main.{}", plugin.1[0]), + source, + CompileSourceFileOptions::new().with_source_plugin(plugin), + ) + .unwrap(); + assert!( + compiled + .program + .callable_prototypes + .iter() + .filter_map(|prototype| prototype.schema.as_ref()) + .any(|schema| model.ir().source_schema(schema, 0) == expected), + "{:?}", + compiled.program.callable_prototypes + ); + } +} + +fn assert_rejected(source: &str, detail: &str) { + for plugin in [&A, &B] { + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(plugin) + .with_host_api_catalog(std::sync::Arc::new(resource_catalog())) + }; + let flavor = SourceFlavor::Plugin(FrontendId::new(plugin.0)); + let compile = compile_source_with_flavor_and_options(source, flavor, options()) + .err() + .expect("checked compile must reject"); + let analysis = analyze_source_from_string_with_options( + format!("main.{}", plugin.1[0]), + source, + options(), + ) + .err() + .expect("checked analysis must reject"); + let hints = collect_inferred_local_type_hints_with_options(source, flavor, options()) + .expect_err("checked hints must reject"); + for error in [compile, analysis, hints] { + assert!( + error.to_string().contains(detail), + "expected {detail}: {error}" + ); + } + } +} + +#[test] +fn whole_record_after_noncopy_field_remains_rejected() { + assert_rejected( + &SOURCE.replace("\nnote;\n", "\nentry;\n"), + "partially moved", + ); +} + +#[test] +fn repeated_noncopy_optional_field_remains_rejected() { + assert_rejected( + &SOURCE.replace("\nnote;\n", "\nentry?.note;\n"), + "was moved earlier", + ); +} + +#[test] +fn optional_projection_cannot_read_a_normally_moved_field() { + assert_rejected( + &SOURCE.replace("entry?.note", "entry?.name"), + "was moved earlier", + ); +} + +#[test] +fn already_moved_root_remains_rejected() { + let source = "use neutral;\nstruct Entry { name: string, note: resource? }\nlet entry: Entry = { name: \"first\", note: neutral::make() };\nlet moved = entry;\nentry?.note;"; + assert_rejected(source, "moved"); +} + +#[test] +fn literal_copyable_field_can_be_projected_repeatedly() { + let source = "struct Entry { name: string, note: int? }\nlet entry: Entry = { name: \"first\", note: 42 };\nlet name = entry.name;\nlet first = entry?.note;\nlet note = entry?.note;\nnote;\n"; + for plugin in [&A, &B] { + assert_run(source, Value::Int(42), plugin); + } +} + +#[test] +fn explicit_copy_optional_field_keeps_the_original_available() { + let source = SOURCE.replace( + "let note = entry?.note;", + "let first = (entry?.note).copy();\nlet note = entry?.note;", + ); + for plugin in [&A, &B] { + assert_run(&source, Value::Null, plugin); + } +} + +#[test] +fn projection_requires_literal_key_and_direct_unborrowed_local() { + for access in [ + "entry?.[key]", + "(&entry)?.note", + "(&mut entry)?.note", + "make()?.note", + ] { + let source = format!( + "fn make() -> Entry {{ {{ name: \"first\" }} }}\nlet key: string = \"note\";\n{}", + SOURCE + .replace("let entry:", "let mut entry:") + .replace("entry?.note", access) + ); + assert_rejected(&source, "requires a direct local and a literal key"); + } +} + +#[test] +fn optional_value_receiver_requires_unwrap_before_field_projection() { + let source = SOURCE + .replace("entry: Entry = { name: \"first\" }", "entry: Entry? = null") + .replace("let name = entry.name;", ""); + assert_rejected(&source, "unwrapped before field projection receiver"); +} + +#[test] +fn missing_required_and_wrong_present_fields_remain_rejected() { + assert_rejected( + &SOURCE.replace("{ name: \"first\" }", "{ note: null }"), + "field 'name' is required", + ); + assert_rejected( + &SOURCE.replace("{ name: \"first\" }", "{ name: \"first\", note: 7 }"), + "field 'note' is declared", + ); +} + +#[test] +fn independent_strict_unknown_stays_rejected() { + assert_rejected( + &format!("fn invalid(value: unknown) {{ 1 }}\n{SOURCE}"), + "'unknown' annotations are not allowed", + ); +} + +fn resource_catalog() -> vm::HostApiCatalog { + use vm::{ + HostApiBuilder, HostFunctionSchema, HostParamPassing, HostParamSchema, HostTypeSchema, + ResourceTypeKey, ResourceTypeSchema, + }; + let key = ResourceTypeKey::new("neutral.projection").unwrap(); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(key.clone(), "projection resource")); + builder.function(HostFunctionSchema::with_return( + "neutral::make", + vec![], + HostTypeSchema::Resource(key.clone()), + )); + builder.function(HostFunctionSchema::with_return( + "neutral::close", + vec![HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(key), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + builder.build().unwrap() +} + +#[test] +fn optional_resource_field_cannot_be_reprojected_or_copied() { + let source = "use neutral;\nstruct Entry { name: string, note: resource? }\nlet entry: Entry = { name: \"first\", note: neutral::make() };\nlet name = entry.name;\nlet note = entry?.note;\n"; + assert_rejected(&format!("{source}entry?.note;"), "was moved earlier"); + assert_rejected( + &source.replace("entry?.note", "(entry?.note).copy()"), + "resource", + ); + assert_rejected(&format!("{source}entry;"), "partially moved"); +} + +#[test] +fn resource_record_borrow_and_mutable_borrow_cannot_escape() { + let source = "use neutral;\nstruct Entry { name: string, note: resource? }\nlet mut entry: Entry = { name: \"first\", note: neutral::make() };\nlet note = entry?.note;\n"; + for receiver in ["(&entry)", "(&mut entry)"] { + assert_rejected( + &source.replace("entry?.note", &format!("{receiver}?.note")), + "requires a direct local and a literal key", + ); + } +} + +#[test] +fn native_value_lookup_still_permits_repeated_required_field_reads() { + let source = "struct Entry { name: string }\nlet entry: Entry = { name: \"first\" };\nentry?.name;\nentry?.name;"; + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::new(), + ) + .unwrap(); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!( + machine.stack(), + &[Value::string("first"), Value::string("first")] + ); +} + +struct EventCall { + events: std::sync::Arc>>, + name: &'static str, + result: Value, +} +impl vm::HostFunction for EventCall { + fn call(&mut self, _machine: &mut Vm, _args: &[Value]) -> vm::VmResult { + self.events.lock().unwrap().push(self.name); + Ok(vm::CallOutcome::Return(vm::CallReturn::one( + self.result.clone(), + ))) + } +} + +#[test] +fn native_temporary_dynamic_key_value_lookup_evaluates_each_once() { + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "neutral::tick", + vec![], + vm::HostTypeSchema::Null, + )); + builder.function(vm::HostFunctionSchema::with_return( + "neutral::key", + vec![], + vm::HostTypeSchema::String, + )); + let source = "use neutral;\nlet entry: map = { note: 42 };\n(if true => { let ignored = neutral::tick(); entry } else => { entry })?.[neutral::key()];"; + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::new() + .with_host_api_catalog(std::sync::Arc::new(builder.build().unwrap())), + ) + .unwrap(); + let events = std::sync::Arc::new(std::sync::Mutex::new(vec![])); + let mut machine = Vm::new(compiled.program); + machine.bind_function( + "neutral::tick", + Box::new(EventCall { + events: events.clone(), + name: "receiver", + result: Value::Null, + }), + ); + machine.bind_function( + "neutral::key", + Box::new(EventCall { + events: events.clone(), + name: "key", + result: Value::string("note"), + }), + ); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + assert_eq!(*events.lock().unwrap(), ["receiver", "key"]); +} + +struct ProjectionResource(std::sync::Arc); +impl vm::resource::HostResource for ProjectionResource { + fn resource_type_key() -> Option { + Some(vm::ResourceTypeKey::new("neutral.projection").unwrap()) + } + fn begin_close( + &mut self, + _reason: vm::resource::ResourceCloseReason, + ) -> vm::resource::ResourceResult { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(vm::resource::CloseProgress::Ready) + } +} +struct MakeResource { + creates: std::sync::Arc, + closes: std::sync::Arc, +} +impl vm::HostFunction for MakeResource { + fn call(&mut self, machine: &mut Vm, _args: &[Value]) -> vm::VmResult { + self.creates + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let token = machine + .host_context() + .push_resource(ProjectionResource(self.closes.clone())) + .map_err(|error| vm::VmError::HostError(error.to_string()))?; + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int( + token.handle().raw() as i64, + )))) + } +} +struct CloseResource; +impl vm::HostFunction for CloseResource { + fn call(&mut self, machine: &mut Vm, args: &[Value]) -> vm::VmResult { + let Value::Int(raw) = args[0] else { + return Err(vm::VmError::TypeMismatch("resource handle")); + }; + let handle = vm::resource::ResourceHandle::from_raw(raw as u64) + .map_err(|error| vm::VmError::HostError(error.to_string()))?; + machine + .execution_scope() + .close_resource::( + handle, + vm::resource::ResourceCloseReason::Requested, + ) + .map_err(|error| vm::VmError::HostError(error.to_string()))?; + Ok(vm::CallOutcome::Return(vm::CallReturn::none())) + } +} + +#[test] +fn optional_resource_projection_transfers_one_handle_without_record_clone() { + let source = "use neutral;\nstruct Entry { name: string, note: resource? }\nlet entry: Entry = { name: \"first\", note: neutral::make() };\nlet name = entry.name;\nlet note = entry?.note;\nlet handle = note.unwrap_or(neutral::make());\nneutral::close(handle);\n42;"; + for plugin in [&A, &B] { + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::Plugin(FrontendId::new(plugin.0)), + CompileSourceFileOptions::new() + .with_source_plugin(plugin) + .with_host_api_catalog(std::sync::Arc::new(resource_catalog())), + ) + .unwrap(); + let creates = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let closes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut machine = Vm::new(compiled.program); + machine.bind_function( + "neutral::make", + Box::new(MakeResource { + creates: creates.clone(), + closes: closes.clone(), + }), + ); + machine.bind_function("neutral::close", Box::new(CloseResource)); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + assert_eq!(creates.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(closes.load(std::sync::atomic::Ordering::SeqCst), 1); + } +} + +#[test] +fn integer_literal_projection_keeps_other_indexes_available() { + for plugin in [&A, &B] { + let source = "let entry: [string] = [\"first\", \"second\"];\nlet first = entry[0];\nlet note = entry?.[1];\nnote;"; + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::Plugin(FrontendId::new(plugin.0)), + CompileSourceFileOptions::new().with_source_plugin(plugin), + ) + .unwrap(); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::string("second")]); + assert_rejected( + &source.replace("entry?.[1]", "entry?.[0]"), + "was moved earlier", + ); + } +} + +#[test] +fn imported_projection_preserves_intent_through_linking_and_slot_compaction() { + for plugin in [&A, &B] { + let source = "use dep;\nlet note = dep::read();\nnote;"; + let dependency = "struct Entry { name: string, note: T? }\npub fn read() -> string? {\nlet a: int = 1; let b: int = 2; let c: int = 3; let d: int = 4; let e: int = 5; let f: int = 6; let g: int = 7; let h: int = 8;\nlet entry: Entry = { name: \"first\", note: \"ready\" };\nlet name = entry.name;\nlet note = entry?.note;\nnote\n}"; + let options = || { + CompileSourceFileOptions::new() + .with_source_plugin(plugin) + .with_module_override_source(format!("dep.{}", plugin.1[0]), dependency) + }; + let flavor = SourceFlavor::Plugin(FrontendId::new(plugin.0)); + let compiled = vm::compiler::compile_source_at_path_with_flavor_and_options( + format!("main.{}", plugin.1[0]), + source, + flavor, + options(), + ) + .unwrap(); + assert!( + compiled + .program + .callable_prototypes + .iter() + .any(|prototype| prototype.schema + == Some(vm::TypeSchema::Callable { + params: vec![], + result: Box::new(vm::TypeSchema::Optional(Box::new( + vm::TypeSchema::String + ))) + })) + ); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::string("ready")]); + let model = analyze_source_from_string_with_options( + format!("main.{}", plugin.1[0]), + source, + options(), + ) + .unwrap(); + assert!(model.diagnostics().is_empty()); + let hints = + collect_inferred_local_type_hints_with_options(source, flavor, options()).unwrap(); + assert!( + hints + .iter() + .any(|hint| hint.name == "entry" && hint.span.unwrap().source_id != 0) + ); + } +} + +#[test] +fn native_borrowed_resource_receiver_still_rejects_escape_in_value_mode() { + for receiver in ["(&entry)", "(&mut entry)"] { + let source = format!( + "use neutral;\nstruct Entry {{ note: resource? }}\nlet mut entry: Entry = {{ note: neutral::make() }};\n{receiver}?.note;" + ); + let error = compile_source_with_flavor_and_options( + &source, + SourceFlavor::RustScript, + CompileSourceFileOptions::new() + .with_host_api_catalog(std::sync::Arc::new(resource_catalog())), + ) + .err() + .expect("borrow escape must reject"); + assert!(error.to_string().contains("borrow"), "{error}"); + } +} diff --git a/tests/optional_record_schema_tests.rs b/tests/optional_record_schema_tests.rs new file mode 100644 index 00000000..836cbde1 --- /dev/null +++ b/tests/optional_record_schema_tests.rs @@ -0,0 +1,251 @@ +//! Declared record omission is a schema contract shared by native and plugin sources. + +use vm::{ + CompileSourceFileOptions, FrontendId, FrontendIr, FrontendTypingPolicy, ParseError, + ParserDialect, SharedParserOptions, SourceFlavor, SourceFrontendContext, + SourceFrontendDescriptor, SourcePlugin, TypeSchema, Value, Vm, VmStatus, + analyze_source_from_string_with_options, collect_inferred_local_type_hints_with_options, + compile_source_with_flavor_and_options, parse_source_with_dialect, +}; + +struct RecordDialect; +impl ParserDialect for RecordDialect {} + +struct RecordPlugin { + id: &'static str, + extensions: &'static [&'static str], +} +impl SourcePlugin for RecordPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new(self.id), + extensions: self.extensions, + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + parse_source_with_dialect( + source, + &RecordDialect, + SharedParserOptions { + source_id: context.source_id, + import_scan_mode: true, + ..SharedParserOptions::default() + }, + ) + } +} + +static RECORD_A: RecordPlugin = RecordPlugin { + id: "opaque-record-a", + extensions: &["record-a"], +}; +static RECORD_B: RecordPlugin = RecordPlugin { + id: "opaque-record-b", + extensions: &["record-b"], +}; + +fn options(plugin: Option<&'static RecordPlugin>) -> CompileSourceFileOptions { + let options = CompileSourceFileOptions::new(); + match plugin { + Some(plugin) => options.with_source_plugin(plugin), + None => options, + } +} + +fn flavor(plugin: Option<&'static RecordPlugin>) -> SourceFlavor { + match plugin { + Some(plugin) => SourceFlavor::Plugin(FrontendId::new(plugin.id)), + None => SourceFlavor::RustScript, + } +} + +fn source_path(plugin: Option<&'static RecordPlugin>) -> String { + format!( + "main.{}", + plugin.map_or("rss", |plugin| plugin.extensions[0]) + ) +} + +const OMITTED: &str = + "struct Entry { count: int, note: string? }\nlet entry: Entry = { count: 42 };\nentry.count;\n"; + +fn assert_checked_run(source: &str, plugin: Option<&'static RecordPlugin>) { + let compiled = compile_source_with_flavor_and_options(source, flavor(plugin), options(plugin)) + .unwrap_or_else(|error| panic!("checked compilation: {error}")); + let mut machine = Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack(), &[Value::Int(42)]); + + let model = + analyze_source_from_string_with_options(source_path(plugin), source, options(plugin)) + .unwrap(); + assert!(model.diagnostics().is_empty(), "{:?}", model.diagnostics()); + let hints = + collect_inferred_local_type_hints_with_options(source, flavor(plugin), options(plugin)) + .unwrap(); + let hint = hints.iter().find(|hint| hint.name == "entry").unwrap(); + assert_eq!( + model.ir().source_schema(hint.schema.as_ref().unwrap(), 0), + TypeSchema::Named("Entry".to_string(), vec![]) + ); + let span = hint.span.unwrap(); + assert_eq!(&source[span.lo..span.hi], "entry"); + assert_eq!( + model.inferred_schema_at(vm::SourcePosition::new(0, span.lo)), + hint.schema + ); +} + +#[test] +fn native_omitted_optional_record_field_compiles_analyzes_hints_and_runs() { + assert_checked_run(OMITTED, None); +} + +#[test] +fn opaque_a_omitted_optional_record_field_compiles_analyzes_hints_and_runs() { + assert_checked_run(OMITTED, Some(&RECORD_A)); +} + +#[test] +fn opaque_b_omitted_optional_record_field_compiles_analyzes_hints_and_runs() { + assert_checked_run(OMITTED, Some(&RECORD_B)); +} + +fn all_frontends() -> [Option<&'static RecordPlugin>; 3] { + [None, Some(&RECORD_A), Some(&RECORD_B)] +} + +#[test] +fn present_valid_optional_record_field_runs_across_frontends() { + let source = OMITTED.replace("{ count: 42 }", "{ count: 42, note: \"ready\" }"); + for plugin in all_frontends() { + assert_checked_run(&source, plugin); + } +} + +#[test] +fn present_null_optional_record_field_runs_across_frontends() { + let source = OMITTED.replace("{ count: 42 }", "{ count: 42, note: null }"); + for plugin in all_frontends() { + assert_checked_run(&source, plugin); + } +} + +#[test] +fn nested_named_generic_optional_fields_run_across_frontends() { + let source = "struct Parcel { value: T, extra: T? }\nstruct Entry { parcel: Parcel, note: string? }\nlet entry: Entry = { parcel: { value: 42 } };\nentry.parcel.value;\n"; + for plugin in all_frontends() { + assert_checked_run(source, plugin); + } +} + +#[test] +fn optional_record_omission_in_function_return_and_argument_runs_across_frontends() { + let source = "struct Entry { count: int, note: string? }\nfn make() -> Entry { { count: 42 } }\nfn read(value: Entry) -> int { value.count }\nlet entry: Entry = make();\nread({ count: entry.count });\n"; + for plugin in all_frontends() { + assert_checked_run(source, plugin); + } +} + +fn assert_rejected(source: &str, detail: &str, plugin: Option<&'static RecordPlugin>) { + let compile = compile_source_with_flavor_and_options(source, flavor(plugin), options(plugin)) + .err() + .expect("checked compilation must reject the schema"); + let analyze = + analyze_source_from_string_with_options(source_path(plugin), source, options(plugin)) + .err() + .expect("analysis must reject the schema"); + let hints = + collect_inferred_local_type_hints_with_options(source, flavor(plugin), options(plugin)) + .expect_err("hints must reject the schema"); + for error in [compile, analyze, hints] { + assert!(error.to_string().contains(detail), "{error}"); + } +} + +#[test] +fn omitted_required_record_field_remains_rejected_across_frontends() { + let source = OMITTED.replace("{ count: 42 }", "{ note: null }"); + for plugin in all_frontends() { + assert_rejected( + &source, + "field 'count' is required by the declared schema but is missing", + plugin, + ); + } +} + +#[test] +fn wrong_present_optional_field_remains_rejected_across_frontends() { + let source = OMITTED.replace("{ count: 42 }", "{ count: 42, note: 7 }"); + for plugin in all_frontends() { + assert_rejected( + &source, + "field 'note' is declared as schema type 'string' but was assigned int", + plugin, + ); + } +} + +#[test] +fn nested_required_generic_field_remains_rejected_across_frontends() { + let source = "struct Parcel { value: T, extra: T? }\nstruct Entry { parcel: Parcel, note: string? }\nlet entry: Entry = { parcel: { extra: 7 } };\nentry.parcel.value;\n"; + for plugin in all_frontends() { + assert_rejected( + source, + "field 'parcel.value' is required by the declared schema but is missing", + plugin, + ); + } +} + +#[test] +fn nested_wrong_present_generic_optional_field_remains_rejected_across_frontends() { + let source = "struct Parcel { value: T, extra: T? }\nstruct Entry { parcel: Parcel, note: string? }\nlet entry: Entry = { parcel: { value: 42, extra: \"wrong\" } };\nentry.parcel.value;\n"; + for plugin in all_frontends() { + assert_rejected( + source, + "field 'parcel.extra' is declared as schema type 'int' but was assigned string", + plugin, + ); + } +} + +#[test] +fn omitted_required_resource_field_remains_rejected_across_frontends() { + let source = "struct Entry { handle: resource, note: string? }\nlet entry: Entry = { note: null };\n42;\n"; + for plugin in all_frontends() { + assert_rejected( + source, + "field 'handle' is required by the declared schema but is missing", + plugin, + ); + } +} + +#[test] +fn independent_strict_unknown_rejection_survives_optional_omission() { + let source = format!("fn identity(value: unknown) {{ 7 }}\n{OMITTED}"); + for plugin in [&RECORD_A, &RECORD_B] { + assert_rejected( + &source, + "'unknown' annotations are not allowed", + Some(plugin), + ); + } +} + +#[test] +fn optional_field_does_not_make_callable_argument_optional() { + let source = "struct Entry { count: int, note: string? }\nfn read(value: Entry) -> int { value.count }\nread();\n"; + for plugin in all_frontends() { + assert_rejected(source, "argument", plugin); + } +} diff --git a/tests/repl_public_api.rs b/tests/repl_public_api.rs index 41aa2cc3..0e69f619 100644 --- a/tests/repl_public_api.rs +++ b/tests/repl_public_api.rs @@ -1,6 +1,603 @@ use vm::compiler::TypeSchema; use vm::{ReplLocalBinding, ReplLocalState, compile_source_for_repl_with_state}; +#[test] +fn root_result_join_optional_float_uses_expression_schema() { + let compiled = vm::compile_source_for_repl_with_state_and_options( + "let maybe: float? = 2.5; let y = if true => { maybe } else => { null };", + &[], + vm::CompileSourceFileOptions::default(), + ) + .unwrap(); + let binding = compiled.bindings.iter().find(|b| b.name == "y").unwrap(); + println!("root result join: y={binding:?}"); + #[cfg(feature = "runtime")] + { + let mut machine = vm::Vm::new(compiled.compiled.program.clone()); + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + let slot = machine.debug_info().unwrap().local_index("y").unwrap(); + assert_eq!(machine.locals()[slot as usize], vm::Value::Float(2.5)); + println!( + "root result join: runtime y={:?}", + machine.locals()[slot as usize] + ); + } + assert_eq!(binding.schema, Some(TypeSchema::Float)); + assert!(binding.optional); + let states = compiled + .bindings + .into_iter() + .map(|binding| ReplLocalState { + binding, + moved: false, + }) + .collect::>(); + let next = vm::compile_source_for_repl_with_state_and_options( + "let following = y; following;", + &states, + vm::CompileSourceFileOptions::default(), + ) + .unwrap(); + let following = next + .bindings + .iter() + .find(|b| b.name == "following") + .unwrap(); + assert_eq!(following.schema, Some(TypeSchema::Float)); + assert!(following.optional); + #[cfg(feature = "runtime")] + assert_eq!( + execute_persistent_snippets( + &[ + "let maybe: float? = 2.5; let y = if true => { maybe } else => { null };", + "let following = y; following;", + ], + vm::CompileSourceFileOptions::default(), + ), + vec![vm::Value::Float(2.5)] + ); +} + +#[test] +fn root_result_join_optional_named_retains_checked_host_identity() { + let named = vm::HostStructSchema::new( + "ResultJoinRoot", + vec![vm::HostStructField::new("value", vm::HostTypeSchema::Int)], + ); + let optional_named = vm::HostTypeSchema::Optional(Box::new(named.as_type())); + let mut builder = vm::HostApiBuilder::new(); + builder.named_struct(named.clone()); + builder.function(vm::HostFunctionSchema::with_return( + "resultjoin::origin", + vec![], + optional_named, + )); + let catalog = std::sync::Arc::new(builder.build().unwrap()); + let options = vm::CompileSourceFileOptions::default() + .with_host_api_catalog(std::sync::Arc::clone(&catalog)); + #[cfg(feature = "runtime")] + let registry = { + fn origin(_: &mut vm::Vm, _: &[vm::Value]) -> vm::VmResult { + Ok(vm::CallOutcome::Return(vm::return_one(vm::Value::map( + vec![(vm::Value::string("value"), vm::Value::Int(42))], + )))) + } + let mut registry = vm::HostFunctionRegistry::new(); + registry + .register_catalog_static( + vm::catalog_import_schemas(&catalog, "resultjoin::origin").remove(0), + origin, + ) + .unwrap(); + registry + }; + for (expression, flag, expected) in [ + ("if flag => { maybe } else => { null }", true, 42), + ("if flag => { null } else => { maybe }", false, 42), + ("if flag => { maybe } else => { null }", false, 0), + ("if flag => { null } else => { maybe }", true, 0), + ("match choice { 1 => maybe, _ => null }", true, 42), + ("match choice { 1 => null, _ => maybe }", false, 42), + ("match choice { 1 => maybe, _ => null }", false, 0), + ("match choice { 1 => null, _ => maybe }", true, 0), + ] { + let source = format!( + "let flag = {flag}; let choice = if flag => {{ 1 }} else => {{ 2 }}; let maybe: ResultJoinRoot? = resultjoin::origin(); let y = {expression};" + ); + let first = + vm::compile_source_for_repl_with_state_and_options(&source, &[], options.clone()) + .unwrap(); + let y = first.bindings.iter().find(|b| b.name == "y").unwrap(); + println!("root result join: {expression}, y={y:?}"); + assert_eq!( + y.schema, + Some(TypeSchema::Named("ResultJoinRoot".into(), vec![])) + ); + assert!(y.optional); + let states = first + .bindings + .iter() + .cloned() + .map(|binding| ReplLocalState { + binding, + moved: false, + }) + .collect::>(); + let second = vm::compile_source_for_repl_with_state_and_options( + "let following = y; following == null;", + &states, + options.clone(), + ) + .unwrap(); + let following = second + .bindings + .iter() + .find(|b| b.name == "following") + .unwrap(); + assert_eq!(following.schema, y.schema); + assert!(following.optional); + #[cfg(feature = "runtime")] + { + let mut machine = vm::Vm::new(first.compiled.program); + registry.bind_vm_cached(&mut machine).unwrap(); + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + let mut next = vm::Vm::new(second.compiled.program); + registry.bind_vm_cached(&mut next).unwrap(); + for binding in &first.bindings { + let previous_slot = machine + .debug_info() + .unwrap() + .local_index(&binding.name) + .unwrap(); + let slot = next + .debug_info() + .unwrap() + .local_index(&binding.name) + .unwrap(); + next.set_local(slot, machine.locals()[previous_slot as usize].clone()) + .unwrap(); + } + let y_slot = machine.debug_info().unwrap().local_index("y").unwrap(); + if expected == 0 { + assert_eq!(machine.locals()[y_slot as usize], vm::Value::Null); + } else { + let vm::Value::Map(fields) = &machine.locals()[y_slot as usize] else { + panic!("host result carrier") + }; + assert_eq!( + fields.get(&vm::Value::string("value")), + Some(&vm::Value::Int(expected)) + ); + } + assert_eq!(next.run().unwrap(), vm::VmStatus::Halted); + assert_eq!(next.stack(), &[vm::Value::Bool(expected == 0)]); + } + #[cfg(not(feature = "runtime"))] + let _ = expected; + } +} + +#[test] +fn root_result_join_match_uses_existing_expression_alternatives() { + for expression in [ + "match 1 { 1 => maybe, _ => null }", + "match 2 { 1 => null, _ => maybe }", + ] { + let source = format!("let maybe: float? = 2.5; let y = {expression};"); + let compiled = vm::compile_source_for_repl_with_state_and_options( + &source, + &[], + vm::CompileSourceFileOptions::default(), + ) + .unwrap(); + let y = compiled.bindings.iter().find(|b| b.name == "y").unwrap(); + println!("root result join: {expression}, y={y:?}"); + assert_eq!(y.schema, Some(TypeSchema::Float)); + assert!(y.optional); + #[cfg(feature = "runtime")] + assert_eq!( + execute_persistent_snippets( + &[&source, "let following = y; following;"], + vm::CompileSourceFileOptions::default() + ), + vec![vm::Value::Float(2.5)], + ); + } +} + +#[test] +fn root_result_join_conservative_guards_preserve_optional_flags() { + for (source, schema, optional) in [ + ( + "let y = if true => { null } else => { null };", + Some(TypeSchema::Null), + false, + ), + ( + "let y = match 1 { 1 => null, _ => null };", + Some(TypeSchema::Null), + false, + ), + ] { + let compiled = vm::compile_source_for_repl_with_state_and_options( + source, + &[], + vm::CompileSourceFileOptions::default(), + ) + .unwrap(); + let y = compiled.bindings.iter().find(|b| b.name == "y").unwrap(); + println!("root result join: conservative source={source}, y={y:?}"); + assert_eq!(y.schema, schema, "{source}"); + assert_eq!(y.optional, optional, "{source}"); + } + for expression in [ + "if flag => { left } else => { right }", + "match choice { 1 => left, _ => right }", + ] { + let source = format!( + "struct Left {{ value: int }} struct Right {{ value: int }} let flag = true; let choice = 1; let left: Left? = {{ value: 1 }}; let right: Right? = {{ value: 2 }}; let y = {expression};" + ); + let error = vm::compile_source_for_repl_with_state_and_options( + &source, + &[], + vm::CompileSourceFileOptions::default(), + ) + .err() + .expect("strict typing still rejects divergent nominal results"); + assert!( + matches!( + error, + vm::SourceError::Compile(vm::CompileError::StrictTypingRequired { .. }) + ), + "{error:?}" + ); + } +} + +#[test] +fn root_result_join_nullable_result_keeps_binding_effects_separate() { + for expression in [ + "if flag => { x = 2.5; maybe } else => { x = 2.5; null }", + "match choice { 1 => if flag => { x = 2.5; maybe } else => { x = 2.5; maybe }, _ => if flag => { x = 2.5; null } else => { x = 2.5; null } }", + ] { + let source = format!( + "let flag = true; let choice = 1; let mut x = 1; let maybe: float? = 3.5; let y = {expression};" + ); + let compiled = vm::compile_source_for_repl_with_state_and_options( + &source, + &[], + vm::CompileSourceFileOptions::default(), + ) + .unwrap(); + let x = compiled.bindings.iter().find(|b| b.name == "x").unwrap(); + let y = compiled.bindings.iter().find(|b| b.name == "y").unwrap(); + assert_eq!(x.schema, Some(TypeSchema::Float)); + assert!(!x.optional); + assert_eq!(y.schema, Some(TypeSchema::Float)); + assert!(y.optional); + #[cfg(feature = "runtime")] + assert_eq!( + execute_persistent_snippets( + &[&source, "let following: float = x; following; y;"], + vm::CompileSourceFileOptions::default() + ), + vec![vm::Value::Float(2.5), vm::Value::Float(3.5)], + ); + } +} + +fn sequential_callee_source() -> String { + format!( + "fn f() -> int {{ {} t256 }} f();", + (0..257) + .map(|i| format!("let t{i} = {i};")) + .collect::>() + .join(" ") + ) +} + +#[test] +fn persistence_allocator_compacts_257_sequential_callee_locals() { + let source = sequential_callee_source(); + assert_eq!(source.len(), 3919); + let compiled = vm::compile_source_for_repl_with_state(&source, &[]) + .expect("REPL must compact callee locals independently of persistent roots"); + assert!(compiled.bindings.is_empty()); + assert_eq!( + compiled.compiled.program.callable_prototypes[0].frame_local_count, + 1 + ); + #[cfg(feature = "runtime")] + { + let mut machine = vm::Vm::new(compiled.compiled.program.clone()); + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + assert_eq!(machine.stack(), &[vm::Value::Int(256)]); + } + #[cfg(all(feature = "cranelift-jit", target_arch = "x86_64"))] + { + let mut machine = vm::Vm::new(compiled.compiled.program); + machine.compile_aot().unwrap(); + assert!(machine.has_aot_program()); + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + assert_eq!(machine.stack(), &[vm::Value::Int(256)]); + assert!(machine.aot_exec_count() > 0); + println!( + "capacity parity: interpreter and native AOT return Int(256), native executions={}", + machine.aot_exec_count() + ); + } +} + +#[test] +fn persistence_allocator_ordinary_257_callee_control() { + let compiled = + vm::compile_source_with_flavor(&sequential_callee_source(), vm::SourceFlavor::RustScript) + .unwrap(); + assert_eq!(compiled.program.callable_prototypes[0].frame_local_count, 1); + #[cfg(feature = "runtime")] + { + let mut machine = vm::Vm::new(compiled.program); + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + assert_eq!(machine.stack(), &[vm::Value::Int(256)]); + } +} + +const NINE_MODULE_LOCALS: &str = "let a = 101; let b = 102; let c = 103; let d = 104; let e = 105; let f = 106; let g = 107; let h = 108; let i = 109; pub fn value() -> int { 1 }"; + +#[cfg(feature = "runtime")] +fn execute_persistent_snippets( + snippets: &[&str], + options: vm::CompileSourceFileOptions, +) -> Vec { + let mut states = Vec::::new(); + let mut values = std::collections::BTreeMap::::new(); + let mut results = Vec::new(); + for source in snippets { + let compiled = + vm::compile_source_for_repl_with_state_and_options(source, &states, options.clone()) + .unwrap(); + let mut machine = vm::Vm::new(compiled.compiled.program); + for (name, value) in &values { + let slot = machine.debug_info().unwrap().local_index(name).unwrap(); + machine.set_local(slot, value.clone()).unwrap(); + } + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + results.extend_from_slice(machine.stack()); + states.clear(); + values.clear(); + for binding in compiled.bindings { + let slot = machine + .debug_info() + .unwrap() + .local_index(&binding.name) + .unwrap() as usize; + values.insert(binding.name.clone(), machine.locals()[slot].clone()); + states.push(ReplLocalState { + binding, + moved: false, + }); + } + } + results +} + +#[cfg(feature = "runtime")] +#[test] +fn persistence_allocator_combines_roots_module_recursion_and_captures() { + let large = format!( + "use calibration; fn recurse(n: int) -> int {{ if n == 0 => {{ 0 }} else => {{ 1 + recurse(n - 1) }} }} fn large() -> int {{ let delta = base; let add = |value| value + delta; {} add(t256) + recurse(3) }} let fresh = 77; large();", + (0..257) + .map(|i| format!("let t{i} = {i};")) + .collect::>() + .join(" ") + ); + let results = execute_persistent_snippets( + &[ + "let mut base = 41; let a = 11; let b = 12; let c = 13; let d = 14; let e = 15; let f = 16; let g = 17; let h = 18; let i = 19;", + &large, + "base = base + 1; base; a; b; c; d; e; f; g; h; i; fresh;", + ], + vm::CompileSourceFileOptions::default() + .with_module_override_source("calibration.rss", NINE_MODULE_LOCALS), + ); + let expected = std::iter::once(vm::Value::Int(256 + 41 + 3)) + .chain(std::iter::once(vm::Value::Int(42))) + .chain((11..=19).map(vm::Value::Int)) + .chain(std::iter::once(vm::Value::Int(77))) + .collect::>(); + assert_eq!(results, expected); +} + +#[test] +fn persistence_allocator_rejects_257_simultaneously_live_roots_and_callee() { + let definitions = (0..257) + .map(|i| format!("let t{i} = {i};")) + .collect::>() + .join(" "); + let uses = (0..257) + .map(|i| format!("t{i}")) + .collect::>() + .join(" + "); + for source in [ + definitions.clone(), + format!("fn f() -> int {{ {definitions} {uses} }} f();"), + ] { + let error = vm::compile_source_for_repl_with_state(&source, &[]) + .err() + .expect("simultaneous liveness must still enforce the 256-slot limit"); + assert!( + matches!(&error, vm::SourceError::Parse(error) if error.message == "too many simultaneously live locals (requires more than 256 slots)"), + "{error:?}" + ); + } + let ordinary = vm::compile_source_with_flavor( + &format!("fn f() -> int {{ {definitions} {uses} }} f();"), + vm::SourceFlavor::RustScript, + ) + .err() + .expect("ordinary callee liveness limit is unchanged"); + assert!( + matches!(&ordinary, vm::SourceError::Parse(error) if error.message == "too many simultaneously live locals (requires more than 256 slots)"), + "{ordinary:?}" + ); +} + +#[cfg(feature = "runtime")] +#[test] +fn final_layout_preserves_unused_entry_across_nine_local_module() { + let options = vm::CompileSourceFileOptions::default() + .with_module_override_source("calibration.rss", NINE_MODULE_LOCALS); + let results = execute_persistent_snippets( + &[ + "let x = 41;", + "use calibration; calibration::value();", + "x;", + ], + options, + ); + assert_eq!(results.last(), Some(&vm::Value::Int(41))); +} + +#[cfg(feature = "runtime")] +#[test] +fn final_layout_preserves_all_nine_first_snippet_locals() { + let results = execute_persistent_snippets( + &[ + "let a = 11; let b = 12; let c = 13; let d = 14; let e = 15; let f = 16; let g = 17; let h = 18; let i = 19;", + "a;", + "b;", + "c;", + "d;", + "e;", + "f;", + "g;", + "h;", + "i;", + ], + vm::CompileSourceFileOptions::default(), + ); + assert_eq!(results, (11..=19).map(vm::Value::Int).collect::>()); +} + +#[test] +fn final_layout_ordinary_source_and_file_keep_compaction() { + let source = "let a = 11; let b = 12; let c = 13; let d = 14; let e = 15; let f = 16; let g = 17; let h = 18; let i = 19; i;"; + let ordinary = vm::compile_source_with_flavor(source, vm::SourceFlavor::RustScript).unwrap(); + let file = vm::compile_source_at_path_with_flavor_and_options( + "layout.rss", + source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default(), + ) + .unwrap(); + assert!(ordinary.locals < 9, "{}", ordinary.locals); + assert!(file.locals < 9, "{}", file.locals); + let repl = vm::compile_source_for_repl_with_state(source, &[]).unwrap(); + assert!(repl.compiled.locals >= 9, "{}", repl.compiled.locals); + #[cfg(feature = "runtime")] + for program in [ordinary.program, file.program] { + let mut machine = vm::Vm::new(program); + machine.run().unwrap(); + assert_eq!(machine.stack().last(), Some(&vm::Value::Int(19))); + } +} + +#[test] +fn final_layout_named_optional_entry_seeds_follow_final_debug_slots() { + let named = vm::HostStructSchema::new( + "RootNamedOptional", + vec![vm::HostStructField::new("value", vm::HostTypeSchema::Int)], + ); + let mut builder = vm::HostApiBuilder::new(); + builder.named_struct(named.clone()); + builder.function(vm::HostFunctionSchema::with_return( + "layout::root", + vec![], + named.as_type(), + )); + let options = vm::CompileSourceFileOptions::default() + .with_host_api_catalog(std::sync::Arc::new(builder.build().unwrap())) + .with_module_override_source("calibration.rss", NINE_MODULE_LOCALS); + let first = vm::compile_source_for_repl_with_state_and_options( + "let root = layout::root();", + &[], + options.clone(), + ) + .unwrap(); + let slot = first + .compiled + .program + .debug + .as_ref() + .unwrap() + .local_index("root") + .unwrap() as usize; + let schema = first + .compiled + .program + .type_map + .as_ref() + .unwrap() + .local_schemas[slot] + .clone() + .unwrap(); + assert_eq!( + schema, + TypeSchema::Named("RootNamedOptional".into(), vec![]) + ); + let states = [false, true].map(|optional| ReplLocalState { + binding: ReplLocalBinding { + name: if optional { "maybe" } else { "root" }.into(), + mutable: false, + schema: Some(schema.clone()), + optional, + }, + moved: false, + }); + let large = format!("use calibration; {}", sequential_callee_source()); + for source in [ + "use calibration; root.value; maybe == null; calibration::value();", + "use calibration; calibration::value();", + large.as_str(), + "use calibration; fn recurse(n: int) -> int { if n == 0 => { 0 } else => { 1 + recurse(n - 1) } } fn capture() -> int { let value = root.value; let add = |n| n + value; add(recurse(3)) } capture();", + ] { + let compiled = + vm::compile_source_for_repl_with_state_and_options(source, &states, options.clone()) + .unwrap(); + let program = compiled.compiled.program; + for state in &states { + let slot = program + .debug + .as_ref() + .unwrap() + .local_index(&state.binding.name) + .unwrap() as usize; + let binding = compiled + .bindings + .iter() + .find(|binding| binding.name == state.binding.name) + .unwrap(); + assert_eq!( + binding.schema, + Some(schema.clone()), + "{} in {source}, final debug slot {slot}", + state.binding.name + ); + assert_eq!( + binding.optional, state.binding.optional, + "{} in {source}, final debug slot {slot}", + state.binding.name + ); + // No-callee-conflict controls retain the previous backend map. + if !source.contains("fn ") { + let map = program.type_map.as_ref().unwrap(); + assert_eq!(map.local_schemas[slot], Some(schema.clone())); + assert_eq!(map.optional_slots[slot], state.binding.optional); + } + } + } +} + #[test] fn public_repl_state_api_preserves_moved_local_semantics() { let binding = ReplLocalBinding { @@ -26,3 +623,177 @@ fn public_repl_state_api_preserves_moved_local_semantics() { "moved local must be rejected" ); } + +#[test] +fn native_repl_module_admission_rejects_wrong_types_resource_copy_and_moved_entry() { + let options = vm::CompileSourceFileOptions::default() + .with_module_override_source("calibration.rss", NINE_MODULE_LOCALS); + let state = |name: &str, schema, moved| ReplLocalState { + binding: ReplLocalBinding { + name: name.into(), + mutable: false, + schema: Some(schema), + optional: false, + }, + moved, + }; + let wrong = vm::compile_source_for_repl_with_state_and_options( + "use calibration; let result: bool = x + calibration::value();", + &[state("x", TypeSchema::Int, false)], + options.clone(), + ) + .err() + .expect("admission must reject before producing a Program"); + assert!(matches!(wrong, vm::SourceError::Compile(_)), "{wrong:?}"); + let resource = vm::ResourceTypeKey::new("repl.admission-resource").unwrap(); + let copy = vm::compile_source_for_repl_with_state_and_options( + "use calibration; let duplicate = owned.copy(); calibration::value();", + &[state("owned", TypeSchema::Resource(resource), false)], + options.clone(), + ) + .err() + .expect("admission must reject before producing a Program"); + assert!( + matches!(©, vm::SourceError::Parse(error) if error.code.as_deref() == Some("E_OWNERSHIP_COPY_RESOURCE")), + "{copy:?}" + ); + let moved = vm::compile_source_for_repl_with_state_and_options( + "use calibration; message; calibration::value();", + &[state("message", TypeSchema::String, true)], + options, + ) + .err() + .expect("admission must reject before producing a Program"); + assert!( + matches!(&moved, vm::SourceError::Parse(error) if error.code.as_deref() == Some("E_LOCAL_MOVED")), + "{moved:?}" + ); + // Compilation has no VM/initializer authority. No failed admission result + // exposes a Program that an embedding can initialize or execute. +} + +#[cfg(feature = "runtime")] +#[test] +fn repl_options_preserve_named_host_identity_and_runtime_values_between_snippets() { + use std::sync::Arc; + use vm::{ + CallOutcome, CompileSourceFileOptions, HostApiBuilder, HostFunctionRegistry, + HostFunctionSchema, HostParamSchema, HostStructField, HostStructSchema, HostTypeSchema, + Value, Vm, VmStatus, + }; + let point = HostStructSchema::new( + "Calibration", + vec![HostStructField::new("value", HostTypeSchema::Int)], + ); + let mut builder = HostApiBuilder::new(); + builder.named_struct(point.clone()); + builder.function(HostFunctionSchema::with_return( + "calibration::origin", + vec![], + point.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "calibration::read", + vec![HostParamSchema::value("point", point.as_type())], + HostTypeSchema::Int, + )); + let catalog = Arc::new(builder.build().unwrap()); + let options = CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)); + fn origin(_: &mut Vm, _: &[Value]) -> vm::VmResult { + Ok(CallOutcome::Return(vm::return_one(Value::map(vec![( + Value::string("value"), + Value::Int(42), + )])))) + } + fn read(_: &mut Vm, args: &[Value]) -> vm::VmResult { + let Value::Map(fields) = &args[0] else { + return Err(vm::VmError::TypeMismatch("Calibration")); + }; + Ok(CallOutcome::Return(vm::return_one( + fields.get(&Value::string("value")).unwrap().clone(), + ))) + } + let mut registry = HostFunctionRegistry::new(); + for (name, handler) in [ + ("calibration::origin", origin as vm::StaticHostFunction), + ("calibration::read", read), + ] { + registry + .register_catalog_static( + vm::catalog_import_schemas(&catalog, name).remove(0), + handler, + ) + .unwrap(); + } + let first = vm::compile_source_for_repl_with_state_and_options( + "let point = calibration::origin();", + &[], + options.clone(), + ) + .unwrap(); + let mut first_vm = Vm::new(first.compiled.program); + registry.bind_vm_cached(&mut first_vm).unwrap(); + assert_eq!(first_vm.run().unwrap(), VmStatus::Halted); + let slot = first_vm.debug_info().unwrap().local_index("point").unwrap(); + let schema = first_vm.program().type_map.as_ref().unwrap().local_schemas[slot as usize] + .clone() + .unwrap(); + assert!(matches!(&schema, TypeSchema::Named(name, _) if name == "Calibration")); + let state = ReplLocalState { + binding: ReplLocalBinding { + name: "point".into(), + mutable: false, + schema: Some(schema), + optional: false, + }, + moved: false, + }; + let second = vm::compile_source_for_repl_with_state_and_options( + "calibration::read(point);", + &[state], + options.clone(), + ) + .unwrap(); + let exact = second + .compiled + .program + .host_import_schemas() + .iter() + .flatten() + .find(|s| s.name == "calibration::read") + .unwrap(); + assert_eq!( + *exact, + vm::catalog_import_schemas(&catalog, "calibration::read").remove(0) + ); + let mut second_vm = Vm::new(second.compiled.program); + registry.bind_vm_cached(&mut second_vm).unwrap(); + let next_slot = second_vm + .debug_info() + .unwrap() + .local_index("point") + .unwrap(); + second_vm + .set_local(next_slot, first_vm.locals()[slot as usize].clone()) + .unwrap(); + assert_eq!(second_vm.run().unwrap(), VmStatus::Halted); + assert_eq!(second_vm.stack().last(), Some(&Value::Int(42))); + let model = vm::analyze_source_from_string_with_options( + "calibration.rss", + "let point = calibration::origin(); point.value;", + options.clone(), + ) + .unwrap(); + assert_eq!(model.catalog().as_ref(), catalog.as_ref()); + let hints = vm::collect_inferred_local_type_hints_with_options( + "let point = calibration::origin(); point.value;", + vm::SourceFlavor::RustScript, + options, + ) + .unwrap(); + assert!( + hints.iter().any( + |h| matches!(&h.schema, Some(TypeSchema::Named(name, _)) if name == "Calibration") + ) + ); +} diff --git a/tests/semantic_model_provenance_tests.rs b/tests/semantic_model_provenance_tests.rs index 58d1a74f..1651b8c9 100644 --- a/tests/semantic_model_provenance_tests.rs +++ b/tests/semantic_model_provenance_tests.rs @@ -19,8 +19,11 @@ use std::path::PathBuf; use std::sync::Arc; use vm::compiler::{ - CompileSourceFileOptions, SemanticModel, SourcePosition, TypeSchema, analyze_source, - analyze_source_file_with_options, + CompileError, CompileSourceFileOptions, SemanticModel, SourceError, SourceFlavor, + SourcePosition, TypeSchema, analyze_source, analyze_source_file_with_options, + analyze_source_from_string_with_options, + collect_inferred_local_type_hints_at_path_with_options, + compile_source_at_path_with_flavor_and_options, }; use vm::host_api::{ HostApiBuilder, HostApiCatalog, HostFunctionSchema, HostParamSchema, HostTypeSchema, @@ -679,3 +682,240 @@ fn binary_operand_mismatch_diagnostic_carries_exact_statement_span() { ); assert!(span.hi > span.lo, "statement span has positive length"); } + +#[test] +fn native_diagnostic_models_do_not_authorize_compile_or_type_hints() { + let cases = [ + ( + "fn f(a: int, b: bool) -> int { a + b }\nlet r = f(1, true);\n", + "E004", + ), + ( + "let mut x = 1;\nif true { x = \"a\"; } else { x = 2; }\n", + "E005", + ), + ( + "fn tag(s: string) -> int { 1 }\nlet a = 42;\nlet b = tag(a);\n", + "E003", + ), + ]; + let dir = temp_module_root("native_diagnostic_admission"); + let path = dir.join("main.rss"); + for (source, code) in cases { + let options = CompileSourceFileOptions::new().with_host_api_catalog(provenance_catalog()); + let model = analyze_source_from_string_with_options(&path, source, options.clone()) + .expect("native analysis retains a diagnostic model"); + let diagnostic = model + .diagnostics() + .into_iter() + .find(|diagnostic| diagnostic.code.as_deref() == Some(code)) + .unwrap_or_else(|| panic!("missing {code}: {:?}", model.diagnostics())); + let compiled = match compile_source_at_path_with_flavor_and_options( + &path, + source, + SourceFlavor::RustScript, + options.clone(), + ) { + Ok(_) => panic!("diagnosed source cannot compile"), + Err(error) => error, + }; + let hints = collect_inferred_local_type_hints_at_path_with_options( + &path, + source, + SourceFlavor::RustScript, + options, + ) + .expect_err("diagnosed source cannot produce admitted hints"); + for error in [compiled, hints] { + let vm::compiler::SourcePathError::SourceWithMap { + error: SourceError::Compile(error), + .. + } = error + else { + panic!("expected typed error with source map: {error:?}"); + }; + let span = match (&error, code) { + (CompileError::BinaryOperandTypeMismatch { span, .. }, "E004") + | (CompileError::IfElseBranchTypeMismatch { span, .. }, "E005") + | (CompileError::CallableArgumentTypeMismatch { span, .. }, "E003") => *span, + _ => panic!("unexpected typed error: {error:?}"), + }; + assert_eq!(span, diagnostic.span); + assert_eq!(error.diagnostic_message(), diagnostic.message); + assert!(span.is_some()); + } + } + std::fs::remove_dir_all(dir).expect("remove diagnostic fixture"); +} + +#[test] +fn native_typing_diagnostics_preserve_independent_hard_rejections() { + let prefix = "fn tag(s: string) -> int { 1 }\nlet a = 42;\nlet b = tag(a);\n"; + let cases = [ + ( + "fn unresolved(value) -> int { 1 }\n", + "concrete compile-time type", + ), + ("fn annotated(value: unknown) -> int { 1 }\n", "unknown"), + ( + "struct User { name: string }\nlet user: User = { name: \"Ada\" };\nuser.age;\n", + "field 'age' is not declared", + ), + ( + "use prov;\nlet c = prov::make(\"db\");\nlet duplicate = c.copy();\n", + "resource", + ), + ]; + let dir = temp_module_root("native_independent_admission"); + let path = dir.join("main.rss"); + for (suffix, expected) in cases { + let source = format!("{prefix}{suffix}"); + let options = CompileSourceFileOptions::new().with_host_api_catalog(provenance_catalog()); + let error = match analyze_source_from_string_with_options(&path, &source, options) { + Ok(_) => panic!("independent failure cannot become a diagnostic model: {suffix}"), + Err(error) => error, + }; + assert!(error.to_string().contains(expected), "{error}"); + } + // The string entry uses the same admission, without a permissive fallback. + assert!(analyze_source(&format!("{prefix}fn unresolved(value) -> int {{ 1 }}\n")).is_err()); + assert!( + analyze_source(&format!( + "{prefix}fn annotated(value: unknown) -> int {{ 1 }}\n" + )) + .is_err() + ); + std::fs::remove_dir_all(dir).expect("remove independent fixture"); +} + +#[test] +fn linked_native_diagnostic_model_keeps_imported_signature_and_local_hover() { + let root = "use a::util;\nlet a = 42;\nlet b = tag(a);\n"; + let model = analyze_with_modules( + root, + &[("a/util.rss", "pub fn tag(s: string) -> int { 1 }\n")], + ); + let argument = offset_of(root, "tag(a)") + 4; + assert_eq!( + model.inferred_schema_at(SourcePosition::new(0, argument)), + Some(TypeSchema::Int) + ); + let callee = offset_of(root, "tag(a)"); + let definition = model + .definition_at(SourcePosition::new(0, callee)) + .expect("linked declaration"); + assert_eq!(definition.span.source_id, 1); + assert_slice(&model, definition.span, "tag"); + let diagnostic = model + .diagnostics() + .into_iter() + .find(|diagnostic| diagnostic.code.as_deref() == Some("E003")) + .expect("linked signature rejects integer argument"); + let span = diagnostic.span.expect("exact linked diagnostic span"); + assert_eq!(span.source_id, 0); + assert_slice(&model, span, "tag(a)"); +} + +struct DiagnosticAdmissionPlugin; +static DIAGNOSTIC_PLUGIN: DiagnosticAdmissionPlugin = DiagnosticAdmissionPlugin; +static DIAGNOSTIC_HOOKS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +impl vm::SourcePlugin for DiagnosticAdmissionPlugin { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("neutral-diagnostic-admission"), + extensions: &["checked"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: true, + } + } + fn parse_source( + &self, + source: &str, + context: &vm::SourceFrontendContext<'_>, + ) -> Result { + struct Dialect; + impl vm::compiler::ParserDialect for Dialect {} + vm::parse_source_with_dialect( + source, + &Dialect, + vm::SharedParserOptions { + source_id: context.source_id, + ..Default::default() + }, + ) + } + fn validate_typed_ir(&self, typed: &vm::FrontendTypedIr<'_>) -> Result<(), CompileError> { + DIAGNOSTIC_HOOKS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let owner = typed.source_id.expect("source-owned hook"); + let declaration = typed + .ir + .parsed_semantic_index + .as_ref() + .unwrap() + .func_decls + .iter() + .find(|declaration| declaration.ident_span.source_id == owner) + .expect("own linked function"); + assert_eq!( + typed.schema_of_function(declaration.function_index), + Some(TypeSchema::Callable { + params: vec![], + result: Box::new(TypeSchema::Int), + }) + ); + Err(CompileError::StrictTypingRequired { + line: Some(1), + source_name: None, + detail: "independent diagnostic admission hook".to_string(), + span: Some(declaration.ident_span), + }) + } +} + +#[test] +fn native_diagnostic_recovery_does_not_bypass_linked_plugin_hook_or_policy() { + let dir = temp_module_root("native_plugin_admission"); + let path = dir.join("main.rss"); + let dependency = dir.join("helper.checked"); + std::fs::write(&dependency, "pub fn helper() -> int { 1 }\n").unwrap(); + let source = + "use a::util;\nfn tag(s: string) -> int { 1 }\nlet a = 42;\nlet b = tag(a);\nhelper();\n"; + let options = CompileSourceFileOptions::new() + .with_host_api_catalog(provenance_catalog()) + .with_source_plugin(&DIAGNOSTIC_PLUGIN) + .with_module_override_path("a/util.rss", &dependency); + let error = match analyze_source_from_string_with_options(&path, source, options.clone()) { + Ok(_) => panic!("diagnostic model cannot bypass linked plugin hook"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("independent diagnostic admission hook"), + "{error}" + ); + assert_eq!( + DIAGNOSTIC_HOOKS.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + let error = match analyze_source_from_string_with_options( + &dependency, + "fn f(a: int, b: bool) -> int { a + b }\nlet r = f(1, true);\n", + options, + ) { + Ok(_) => panic!("plugin consistency rejection is not recoverable native analysis"), + Err(error) => error, + }; + assert!(matches!( + error, + vm::SourcePathError::SourceWithMap { + error: SourceError::Compile(CompileError::BinaryOperandTypeMismatch { .. }), + .. + } + )); + assert_eq!( + DIAGNOSTIC_HOOKS.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + std::fs::remove_dir_all(dir).expect("remove plugin fixture"); +}