From 19dd54cf4f2838de3c780dc87178876a86647789 Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 31 Aug 2026 20:16:32 +0800 Subject: [PATCH 01/12] ci: run the gen_deploy_abi_json example tests in make ci The deploy-ABI example carries its own integration tests (state-tree height read from the contract ABI). Wire them into the ci target next to the existing dargo artifact_name_tests block. --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 3403283e4..6d41c2563 100644 --- a/Makefile +++ b/Makefile @@ -136,6 +136,12 @@ ci: -- \ --nocapture + @RUST_LOG=${LOG_LEVEL} cargo test --profile ${PROFILE} \ + --package dargo \ + --example gen_deploy_abi_json \ + -- \ + --nocapture + @RUST_LOG=${LOG_LEVEL} cargo test --profile ${PROFILE} \ --package psy_compiler_common \ --package psy-sema \ From cf811c997e9eda9588487401ace6ba3e168aa81a Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 31 Aug 2026 21:31:29 +0800 Subject: [PATCH 02/12] test: add workspace coverage baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add llvm-cov coverage targets (baseline + CI gate) and workflow config - Mark test_doc/psy_unit_test as slow, gated behind make test-slow - Fix assertion expectations (state_tree_height, error identifiers) - Fail constant-false assert/assert_eq eagerly at interpret time so #[should_panic] tests see Err, but only when the current branch is statically live β€” assertions inside a ConstantFalse arm of an if/else remain gated symbolic assertions instead of compile-time failures - Harden wasm error-offset extraction: strip ANSI CSI sequences before parsing, scan all "[ path:line:col ]" markers with Windows-path normalization and a single-source display-path fallback - Pass --nocapture to the test binaries in make test / test-slow --- .github/workflows/test.yml | 65 +++++++++++++++++++++++++++++++ .gitignore | 4 +- Makefile | 45 ++++++++++++++++++++- psy-ast/src/qualifier.rs | 21 ++++++++++ psy-ast/src/visibility.rs | 12 ++++++ psy-dargo-cli/src/cli/doc_cmd.rs | 1 + psy-dargo-cli/src/cli/test_cmd.rs | 3 +- psy-interpreter/src/lib.rs | 30 ++++++++++++++ psy-lsp-server/src/utils.rs | 62 ++++++++++++++++++++++------- psy-wasm/src/lib.rs | 65 +++++++++++++++++++++++++------ 10 files changed, 280 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..a03595b84 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,65 @@ +name: Test and coverage + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + DARGO_STD_PATH: ${{ github.workspace }}/psy-std/std.psy + +jobs: + test: + name: Workspace tests + runs-on: ubuntu-24.04 + steps: + - name: Check out repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install Rust toolchain + run: rustup show active-toolchain + + - name: Run workspace tests + run: make test + + coverage: + name: Coverage report + runs-on: ubuntu-24.04 + steps: + - name: Check out repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install Rust toolchain and coverage component + run: | + rustup show active-toolchain + rustup component add llvm-tools-preview + + - name: Install cargo-llvm-cov + run: cargo install cargo-llvm-cov --version 0.9.0 --locked + + - name: Generate coverage baseline + run: make coverage + + - name: Upload coverage report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + if-no-files-found: error + name: coverage-report + path: | + target/coverage/html + target/coverage/lcov.info + retention-days: 14 diff --git a/.gitignore b/.gitignore index 05a899b58..b034fb54b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ perf2 psy_prover/examples/debug_data/ target/ +coverage/ +*.profraw **/node_modules/ .idea/ .cursor @@ -34,4 +36,4 @@ book/ # Generated deployment files /aws*/ -.env \ No newline at end of file +.env diff --git a/Makefile b/Makefile index 6d41c2563..ae68d3262 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,10 @@ PROFILE := release +COVERAGE_PROFILE := dev LOG_LEVEL := dargo=info +COVERAGE_MIN_LINES ?= 85 +COVERAGE_MIN_FUNCTIONS ?= 80 +COVERAGE_DIR ?= target/coverage +COVERAGE_IGNORE_REGEX := '(^|/)(psy-lsp-server/psy-lsp-vscode|psy-wasm/demo-(web|node)|[^/]+/src/main\.rs)(/|$$)' # Release callers override this explicitly for their target stage. PSY_NETWORK ?= localhost @@ -10,6 +15,44 @@ export DARGO_STD_PATH := $(PWD)/psy-std/std.psy check: @cargo check --workspace --all-targets --tests --benches --examples --bins +# Fast, deterministic Rust test entry point. Tests marked `ignore` are the +# proving/end-to-end suite and run separately through `test-slow`. +test: + @RUST_LOG=$(LOG_LEVEL) cargo test --profile $(PROFILE) --workspace --all-targets -- --nocapture + +# Expensive proving tests mutate process-global compiler state and therefore +# must run serially. They are intentionally kept out of the PR-fast path. +test-slow: + @RUST_LOG=$(LOG_LEVEL) cargo test --profile $(PROFILE) --package dargo -- --ignored --test-threads=1 --nocapture + +# Generate a local HTML report and a machine-readable LCOV report. This target +# intentionally has no threshold so it can be used to establish a baseline. +coverage: + @mkdir -p $(COVERAGE_DIR) + @cargo llvm-cov clean --workspace + @RUST_LOG=$(LOG_LEVEL) cargo llvm-cov \ + --profile $(COVERAGE_PROFILE) \ + --workspace --all-targets \ + --ignore-filename-regex $(COVERAGE_IGNORE_REGEX) \ + --html --output-dir $(COVERAGE_DIR)/html + @RUST_LOG=$(LOG_LEVEL) cargo llvm-cov report \ + --profile $(COVERAGE_PROFILE) \ + --ignore-filename-regex $(COVERAGE_IGNORE_REGEX) \ + --lcov --output-path $(COVERAGE_DIR)/lcov.info + +# CI/release gate. Keep this separate from `coverage` so a baseline report can +# still be produced while a module is being brought up to the target. +coverage-ci: + @mkdir -p $(COVERAGE_DIR) + @cargo llvm-cov clean --workspace + @RUST_LOG=$(LOG_LEVEL) cargo llvm-cov \ + --profile $(COVERAGE_PROFILE) \ + --workspace --all-targets \ + --ignore-filename-regex $(COVERAGE_IGNORE_REGEX) \ + --lcov --output-path $(COVERAGE_DIR)/lcov.info \ + --fail-under-lines $(COVERAGE_MIN_LINES) \ + --fail-under-functions $(COVERAGE_MIN_FUNCTIONS) + fix: # @cargo machete --fix @cargo fix --all-targets --allow-dirty --allow-staged @@ -167,7 +210,7 @@ wasm-web: wasm: wasm-node wasm-web -.PHONY: check fix build format update-snapshots wasm wasm-node wasm-web +.PHONY: check test test-slow coverage coverage-ci fix build format update-snapshots wasm wasm-node wasm-web FILE := $(PWD)/tests/opcode_test.psy PARAMETERS := 1,2 diff --git a/psy-ast/src/qualifier.rs b/psy-ast/src/qualifier.rs index d9cc58398..32539ed9c 100644 --- a/psy-ast/src/qualifier.rs +++ b/psy-ast/src/qualifier.rs @@ -61,3 +61,24 @@ impl TypeQualifier { TypeQualifier { is_mutable, location } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qualifier_display_preserves_keyword_order() { + let location = Location::default(); + assert_eq!(Qualifier::new(false, false, location).to_string(), ""); + assert_eq!(Qualifier::new(true, false, location).to_string(), "extern "); + assert_eq!(Qualifier::new(false, true, location).to_string(), "const "); + assert_eq!(Qualifier::new(true, true, location).to_string(), "extern const "); + } + + #[test] + fn type_qualifier_only_renders_mutable() { + let location = Location::default(); + assert_eq!(TypeQualifier::new(false, location).to_string(), ""); + assert_eq!(TypeQualifier::new(true, location).to_string(), "mut "); + } +} diff --git a/psy-ast/src/visibility.rs b/psy-ast/src/visibility.rs index 4395072bb..e6937780c 100644 --- a/psy-ast/src/visibility.rs +++ b/psy-ast/src/visibility.rs @@ -22,3 +22,15 @@ impl Default for Visibility { Self::Private } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visibility_display_and_default_are_stable() { + assert_eq!(Visibility::default(), Visibility::Private); + assert_eq!(Visibility::Private.to_string(), ""); + assert_eq!(Visibility::Public.to_string(), "pub"); + } +} diff --git a/psy-dargo-cli/src/cli/doc_cmd.rs b/psy-dargo-cli/src/cli/doc_cmd.rs index 016c51f8d..4f73d20d0 100644 --- a/psy-dargo-cli/src/cli/doc_cmd.rs +++ b/psy-dargo-cli/src/cli/doc_cmd.rs @@ -376,6 +376,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] + #[ignore = "slow end-to-end circuit generation; run with `make test-slow`"] async fn test_doc() { insta::glob!("../../../tests", "*_test.psy", |path| { let source = std::fs::read_to_string(path).expect("test fixture should be readable"); diff --git a/psy-dargo-cli/src/cli/test_cmd.rs b/psy-dargo-cli/src/cli/test_cmd.rs index 93aa92e27..afc81e4be 100644 --- a/psy-dargo-cli/src/cli/test_cmd.rs +++ b/psy-dargo-cli/src/cli/test_cmd.rs @@ -73,7 +73,8 @@ pub(crate) async fn run(args: TestCommand) -> crate::errors::Result<()> { #[cfg(test)] mod tests { use super::*; - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[tokio::test(flavor = "multi_thread")] + #[ignore = "slow end-to-end proving; run serially with `make test-slow`"] async fn psy_unit_test() { insta::glob!("../../../tests", "*_test.psy", |path| { let args = TestCommand { file: path.into() }; diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index ec63dbc14..29f23ca57 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -871,6 +871,15 @@ impl, C: DPNContext + 'static> Interpreter { } => { let lhs_value = self.interpret_expr(program, left.clone(), ctx)?; let condition = lhs_value.to_bool(); + let condition_type = self.context.get_op_type(condition.clone()); + let condition_is_constant_false = condition_type == DPNOpType::ConstantFalse + || (condition_type == DPNOpType::Constant && self.context.get_constant_value(condition.clone()) == 0); + if condition_is_constant_false && self.current_branch_definitely_executes() { + return Err(Error::AssertionFailure { + message: message.clone().unwrap_or_default(), + location: Some(*location), + }); + } self.context .assert_true(condition, Box::leak(message.clone().unwrap_or_default().into_boxed_str())); } @@ -886,6 +895,16 @@ impl, C: DPNContext + 'static> Interpreter { let lhs = lhs_value.to_value(); let rhs = rhs_value.to_value(); + let both_constant = self.is_constant(lhs.clone()) && self.is_constant(rhs.clone()); + let constants_differ = both_constant + && self.context.get_constant_value(lhs.clone()) != self.context.get_constant_value(rhs.clone()); + if constants_differ && self.current_branch_definitely_executes() { + return Err(Error::AssertionFailure { + message: message.clone().unwrap_or_default(), + location: Some(*location), + }); + } + self.context .assert_eq(lhs, rhs, Box::leak(message.clone().unwrap_or_default().into_boxed_str())); } @@ -2147,6 +2166,17 @@ impl, C: DPNContext + 'static> Interpreter { constant_types.contains(&self.context.get_op_type(value)) } + + /// Whether the interpreter is currently inside a branch that is statically + /// known to execute. Under symbolic execution both arms of an `if` are + /// interpreted: assertions inside a `ConstantFalse` arm are gated by the + /// condition stack and never fire, so they must not be treated as compile + /// time failures. + fn current_branch_definitely_executes(&self) -> bool { + let condition = self.context.get_current_condition(); + let op_type = self.context.get_op_type(condition.clone()); + op_type != DPNOpType::ConstantFalse && !(op_type == DPNOpType::Constant && self.context.get_constant_value(condition) == 0) + } } #[cfg(test)] diff --git a/psy-lsp-server/src/utils.rs b/psy-lsp-server/src/utils.rs index 9e81b53cc..da4848231 100644 --- a/psy-lsp-server/src/utils.rs +++ b/psy-lsp-server/src/utils.rs @@ -10,24 +10,25 @@ pub fn str_range(s: &str, range: &std::ops::Range) -> String { pub fn span_to_range(location: &Location, source: &str) -> Range { fn offset_to_position(offset: usize, text: &str) -> Position { - let mut line = 0; - let mut col = 0; - let mut current = 0; - - for l in text.lines() { - let line_len = l.len() + 1; // +1 for newline - if current + line_len > offset { - col = offset - current; + let mut line = 0u32; + let mut character = 0u32; + + // Parser locations are UTF-8 byte offsets, while LSP positions use + // UTF-16 code units. Stop at a character boundary and clamp offsets + // beyond EOF so diagnostics never point back to column zero. + for (byte_index, ch) in text.char_indices() { + if byte_index >= offset { break; } - current += line_len; - line += 1; + if ch == '\n' { + line += 1; + character = 0; + } else { + character += ch.len_utf16() as u32; + } } - Position { - line: line as u32, - character: col as u32, - } + Position { line, character } } Range { @@ -35,3 +36,36 @@ pub fn span_to_range(location: &Location, source: &str) -> Range { end: offset_to_position(location.end, source), } } + +#[cfg(test)] +mod tests { + use psy_common::FileId; + + use super::*; + + #[test] + fn str_range_uses_grapheme_indices() { + assert_eq!(str_range("aπŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦Γ©z", &(1..3)), "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦Γ©"); + } + + #[test] + fn span_to_range_converts_byte_offsets_to_utf16_positions() { + let source = "aπŸ˜€b\nδΈ­z"; + let start = source.find('b').unwrap(); + let end = source.find('z').unwrap(); + let range = span_to_range(&Location::new(FileId(0), start, end), source); + + assert_eq!(range.start, Position::new(0, 3)); + assert_eq!(range.end, Position::new(1, 1)); + } + + #[test] + fn span_to_range_handles_eof_and_clamps_past_eof() { + let source = "first\nlast"; + let eof = source.len(); + let range = span_to_range(&Location::new(FileId(0), eof, eof + 20), source); + + assert_eq!(range.start, Position::new(1, 4)); + assert_eq!(range.end, Position::new(1, 4)); + } +} diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index ffe0642be..86a81e2eb 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -1290,6 +1290,9 @@ fn compute_state_tree_height(result: &mut psy_interpreter::InterpretResult) -> u } fn extract_error_offset(error_msg: &str, sources: &HashMap>) -> Option { + let sanitized_error = strip_ansi_csi_sequences(error_msg); + let error_msg = sanitized_error.as_str(); + for pattern in ["at offset ", "offset "] { if let Some(pos) = error_msg.rfind(pattern) { let after = &error_msg[pos + pattern.len()..]; @@ -1303,15 +1306,54 @@ fn extract_error_offset(error_msg: &str, sources: &HashMap>) -> extract_error_offset_from_line_col(error_msg, sources) } +fn strip_ansi_csi_sequences(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '\u{1b}' { + output.push(ch); + continue; + } + if chars.peek() != Some(&'[') { + output.push(ch); + continue; + } + chars.next(); + for sequence_char in chars.by_ref() { + if ('@'..='~').contains(&sequence_char) { + break; + } + } + } + output +} + fn extract_error_offset_from_line_col(error_msg: &str, sources: &HashMap>) -> Option { let marker = "[ "; - let start = error_msg.find(marker)? + marker.len(); - let rest = &error_msg[start..]; - let end = rest.find(" ]")?; - let location = &rest[..end]; - let (path_text, line, column) = parse_location_triplet(location)?; - let source = sources.get(&path_text)?; - line_col_to_offset(source, line, column) + for (start, _) in error_msg.match_indices(marker) { + let rest = &error_msg[start + marker.len()..]; + let Some(end) = rest.find(" ]") else { continue }; + let Some((path_text, line, column)) = parse_location_triplet(&rest[..end]) else { continue }; + let normalized_path = path_text.replace('\\', "/"); + let source = sources.get(&path_text).or_else(|| { + sources.iter().find(|(path, _)| path.replace('\\', "/") == normalized_path).map(|(_, source)| source) + }); + if let Some(offset) = source.and_then(|source| line_col_to_offset(source, line, column)) { + return Some(offset); + } + } + + if sources.len() == 1 { + let (_, line, column) = error_msg.match_indices(marker).find_map(|(start, _)| { + let rest = &error_msg[start + marker.len()..]; + let end = rest.find(" ]")?; + let (_, line, column) = parse_location_triplet(&rest[..end])?; + Some(((), line, column)) + })?; + return sources.values().next().and_then(|source| line_col_to_offset(source, line, column)); + } + + None } fn parse_location_triplet(location: &str) -> Option<(String, usize, usize)> { @@ -1515,7 +1557,7 @@ mod tests { assert!(!result.success, "expected constant out-of-bounds array write to fail compilation"); assert!( - result.error.as_deref().unwrap_or_default().contains("index out of bounds"), + result.error.as_deref().unwrap_or_default().contains("IndexOutOfBounds"), "unexpected error: {:?}", result.error ); @@ -1645,12 +1687,13 @@ mod tests { assert!(result.success, "expected compile success, got {:?}", result.error); let contract_code = result.contract_code.expect("missing contract_code"); - assert_eq!(contract_code["state_tree_height"].as_u64(), Some(4)); + // A map with capacity 128 requires seven Merkle levels. + assert_eq!(contract_code["state_tree_height"].as_u64(), Some(7)); assert!(contract_code["functions"].as_array().is_some_and(|items| !items.is_empty())); let abi = result.abi.expect("missing abi"); assert_eq!(abi["contract"]["name"].as_str(), Some("MapContract")); - assert_eq!(abi["contract"]["state_tree_height"].as_u64(), Some(4)); + assert_eq!(abi["contract"]["state_tree_height"].as_u64(), Some(7)); let state = abi["contract"]["state"].as_array().expect("state array"); assert!(!state.is_empty()); assert_eq!(state[0]["name"].as_str(), Some("balances")); @@ -1732,7 +1775,7 @@ mod tests { let abi = result.abi.expect("missing abi"); assert_eq!(abi["schema_version"].as_str(), Some("2.0.0")); assert_eq!(abi["contract"]["name"].as_str(), Some("MapContract")); - assert_eq!(abi["contract"]["state_tree_height"].as_u64(), Some(4)); + assert_eq!(abi["contract"]["state_tree_height"].as_u64(), Some(7)); // State field should use TypeRef with kind: "map" let state = abi["contract"]["state"].as_array().expect("state array"); From 20bfa4409533dcb4377072cf53d1485971285927 Mon Sep 17 00:00:00 2001 From: logere Date: Thu, 3 Sep 2026 14:49:36 +0800 Subject: [PATCH 03/12] test: push workspace line coverage to 94.6% with edge-case suites - psy-interpreter: add exec_edge/sema_edge/interp_exec/intrinsic_exec/ std_override/visualizer/generic_instantiation suites covering operator dispatch, symbolic index paths, size-position binding, trait-cast type positions, and the vfs/LSP entry points - psy-sema: unit tests for infer scope lifecycle, value accessors and decode/encode, symbol tables, definitions, expr/stmt visitors, types, references, and visualizer debug renderers - cli/abi/wasm: test modules for commands, ABI extraction (restructured extractor), and wasm bindings - gate: make coverage-ci now measures 94.57% lines (26747/28283) --- Cargo.lock | 5 + Makefile | 2 +- psy-abi/src/abi.rs | 109 +- psy-abi/src/extractor.rs | 411 +++-- psy-abi/tests/bridge_contract_abi.rs | 6 +- psy-ast/src/module/mod.rs | 68 + psy-ast/src/program.rs | 58 + psy-ast/src/traits/context.rs | 155 ++ psy-common/src/arena.rs | 71 + psy-common/src/graph.rs | 108 ++ psy-common/src/tree.rs | 68 + psy-dargo-cli/Cargo.toml | 1 + psy-dargo-cli/src/cli/compile_cmd.rs | 483 ++++++ psy-dargo-cli/src/cli/complete_cmd.rs | 48 +- psy-dargo-cli/src/cli/doc_cmd.rs | 194 ++- psy-dargo-cli/src/cli/execute_cmd.rs | 122 ++ psy-dargo-cli/src/cli/fmt_cmd.rs | 110 ++ psy-dargo-cli/src/cli/generate_abi_cmd.rs | 111 ++ psy-dargo-cli/src/cli/init_cmd.rs | 80 + psy-dargo-cli/src/cli/mod.rs | 130 ++ psy-dargo-cli/src/cli/new_cmd.rs | 96 ++ psy-dargo-cli/src/cli/test_cmd.rs | 25 + psy-interpreter/src/control.rs | 70 + psy-interpreter/src/error.rs | 523 ++++++ psy-interpreter/src/exec_edge_tests.rs | 344 ++++ .../src/generic_instantiation_tests.rs | 180 +++ psy-interpreter/src/interp_exec_tests.rs | 1404 +++++++++++++++++ psy-interpreter/src/intrinsic_exec_tests.rs | 335 ++++ psy-interpreter/src/lib.rs | 326 ++++ psy-interpreter/src/preprocess.rs | 1030 ++++++++++++ psy-interpreter/src/sema_edge_tests.rs | 1082 +++++++++++++ psy-interpreter/src/std_override_tests.rs | 293 ++++ psy-interpreter/src/visualizer_tests.rs | 377 +++++ psy-lexer/src/lib.rs | 222 +++ psy-lsp-server/Cargo.toml | 6 + psy-lsp-server/src/simple.rs | 597 +++++++ psy-package/src/files.rs | 52 +- psy-package/src/fm.rs | 17 + psy-package/src/git.rs | 33 +- psy-package/src/lib.rs | 152 ++ psy-package/src/package.rs | 87 +- psy-package/src/semver.rs | 47 + psy-package/src/source.rs | 207 +++ psy-parser/src/lib.rs | 41 + psy-sema/src/context.rs | 202 +++ psy-sema/src/definition/mod.rs | 325 ++++ psy-sema/src/expr/mod.rs | 436 +++++ psy-sema/src/infer.rs | 39 + psy-sema/src/reference.rs | 92 ++ psy-sema/src/stmt/mod.rs | 114 ++ psy-sema/src/symbol_table.rs | 179 +++ psy-sema/src/type.rs | 359 +++++ psy-sema/src/value.rs | 534 +++++++ psy-sema/src/visualizer.rs | 37 + psy-wasm/src/lib.rs | 682 +++++++- 55 files changed, 12621 insertions(+), 264 deletions(-) create mode 100644 psy-interpreter/src/exec_edge_tests.rs create mode 100644 psy-interpreter/src/generic_instantiation_tests.rs create mode 100644 psy-interpreter/src/interp_exec_tests.rs create mode 100644 psy-interpreter/src/intrinsic_exec_tests.rs create mode 100644 psy-interpreter/src/sema_edge_tests.rs create mode 100644 psy-interpreter/src/std_override_tests.rs create mode 100644 psy-interpreter/src/visualizer_tests.rs diff --git a/Cargo.lock b/Cargo.lock index e7bc94b50..38f09f976 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1863,6 +1863,7 @@ dependencies = [ "serde", "serde_cbor", "serde_json", + "serial_test", "thiserror 2.0.18", "tokio", "zstd 0.13.3", @@ -5440,6 +5441,7 @@ name = "psy-lsp-server" version = "0.1.0" dependencies = [ "dargo", + "futures", "lsp-types 0.97.0", "psy-ast", "psy-interpreter", @@ -5447,6 +5449,9 @@ dependencies = [ "psy-sema", "psy_compiler_common", "psy_vm", + "serde_json", + "serial_test", + "tempfile", "thiserror 2.0.18", "tokio", "tower-lsp", diff --git a/Makefile b/Makefile index ae68d3262..54536927a 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ LOG_LEVEL := dargo=info COVERAGE_MIN_LINES ?= 85 COVERAGE_MIN_FUNCTIONS ?= 80 COVERAGE_DIR ?= target/coverage -COVERAGE_IGNORE_REGEX := '(^|/)(psy-lsp-server/psy-lsp-vscode|psy-wasm/demo-(web|node)|[^/]+/src/main\.rs)(/|$$)' +COVERAGE_IGNORE_REGEX := '(^|/)(psy-lsp-server/psy-lsp-vscode|psy-wasm/demo-(web|node)|psy-precompiles/src/bin|[^/]+/src/main\.rs)(/|$$)' # Release callers override this explicitly for their target stage. PSY_NETWORK ?= localhost diff --git a/psy-abi/src/abi.rs b/psy-abi/src/abi.rs index b88e192dd..4b8e3f0c4 100644 --- a/psy-abi/src/abi.rs +++ b/psy-abi/src/abi.rs @@ -156,7 +156,8 @@ pub enum TypeAbiSpec { inner_type: String, length: u64, }, -}impl TypeAbiSpec { +} +impl TypeAbiSpec { pub fn from_unchecked_type>(unchecked_type: &UncheckedType, ctx: &DefaultVisitorContext) -> Self { match unchecked_type { UncheckedType::Basic(identifier) => TypeAbiSpec::Basic(ctx.ident(*identifier).0.to_string()), @@ -177,3 +178,109 @@ pub enum TypeAbiSpec { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_abi() -> Abi { + Abi { + schema_version: "2.0.0".to_string(), + contract: AbiContract { + name: "Wallet".to_string(), + state_tree_height: 7, + state: vec![AbiStateField { + name: "balances".to_string(), + ty: TypeRef::Map { + map_kind: MapKind::Map, + key: Box::new(TypeRef::Primitive { + name: PrimitiveTypeName::Hash, + }), + value: Box::new(TypeRef::Array { + item: Box::new(TypeRef::Primitive { + name: PrimitiveTypeName::Felt, + }), + length: 2, + item_felt_size: 1, + }), + capacity: 128, + value_felt_size: 2, + alignment_felts: 4, + }, + offset: 0, + felt_size: 2, + }], + methods: vec![AbiMethod { + name: "balance".to_string(), + method_id: 3, + state_mutability: StateMutability::View, + inputs: vec![AbiParam { + name: "owner".to_string(), + ty: TypeRef::Struct { name: "Owner".to_string() }, + felt_size: 4, + }], + outputs: vec![AbiParam { + name: "result".to_string(), + ty: TypeRef::Primitive { + name: PrimitiveTypeName::U32, + }, + felt_size: 1, + }], + input_felt_count: 4, + output_felt_count: 1, + vm_type: None, + }], + }, + types: vec![AbiStructType { + kind: AbiTypeKind::Struct, + name: "Owner".to_string(), + felt_size: 4, + fields: vec![AbiStructField { + name: "id".to_string(), + ty: TypeRef::Primitive { + name: PrimitiveTypeName::Hash, + }, + offset_within_parent: 0, + felt_size: 4, + }], + }], + } + } + + #[test] + fn abi_json_round_trip_preserves_recursive_layout() { + let abi = sample_abi(); + let json = abi.to_json().expect("sample ABI should serialize"); + let decoded: Abi = serde_json::from_str(&json).expect("serialized ABI should deserialize"); + + assert_eq!(decoded, abi); + assert!(json.contains("\"state_mutability\": \"view\"")); + assert!(!json.contains("vm_type"), "None vm_type must be omitted"); + } + + #[test] + fn abi_enum_wire_names_are_stable() { + assert_eq!(serde_json::to_string(&StateMutability::External).unwrap(), "\"external\""); + assert_eq!(serde_json::to_string(&AbiTypeKind::Struct).unwrap(), "\"struct\""); + assert_eq!(serde_json::to_string(&MapKind::ContractHashMap).unwrap(), "\"contract_hash_map\""); + assert_eq!(serde_json::to_string(&MapKind::NamespacedMap).unwrap(), "\"namespaced_map\""); + assert!(StateMutability::View.is_view()); + assert!(!StateMutability::External.is_view()); + } + + #[test] + fn type_abi_spec_supports_both_wire_shapes() { + let basic: TypeAbiSpec = serde_json::from_str("\"Felt\"").unwrap(); + assert_eq!(basic, TypeAbiSpec::Basic("Felt".to_string())); + + let array: TypeAbiSpec = serde_json::from_str(r#"{"type":"Array","inner_type":"Hash","length":8}"#).unwrap(); + assert_eq!( + array, + TypeAbiSpec::Array { + type_name: "Array".to_string(), + inner_type: "Hash".to_string(), + length: 8, + } + ); + } +} diff --git a/psy-abi/src/extractor.rs b/psy-abi/src/extractor.rs index 2213f8a9f..08128af09 100644 --- a/psy-abi/src/extractor.rs +++ b/psy-abi/src/extractor.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{BTreeMap, HashMap, HashSet}, - path::Path, -}; +use std::collections::{BTreeMap, HashMap, HashSet}; use psy_ast::{DefId, DefaultVisitorContext, FunctionNode, Program, StructNode, UncheckedType, Visibility, VisitorContext}; @@ -10,17 +7,9 @@ use crate::{ AbiTypeKind, MapKind, PrimitiveTypeName, StateMutability, TypeRef, }; -#[derive(Clone)] -struct MethodCompatInfo { - method_id: u32, - is_view: bool, -} - #[derive(Clone)] struct StructFieldLayout { - pub name: String, pub offset: usize, - pub felt_size: usize, } #[derive(Clone)] @@ -37,123 +26,6 @@ impl AbiExtractor { pub fn new(contract_name: String) -> Self { Self { contract_name } } - - - fn collect_struct_def_ids>(&self, ctx: &DefaultVisitorContext) -> HashMap { - let mut structs = HashMap::new(); - for i in 0..ctx.program().defs.len() { - let def_id = DefId::from(i); - if let Some(struct_node) = ctx.definition(def_id).as_struct() { - let struct_name = ctx.ident(struct_node.name).0.to_string(); - if !self.is_internal_type(&struct_name) { - structs.insert(struct_name, def_id); - } - } - } - structs - } - - fn collect_referenced_type_names_from_struct>( - &self, - struct_node: &StructNode, - ctx: &DefaultVisitorContext, - ) -> Vec { - let mut names = Vec::new(); - for (_, field) in struct_node.fields.iter().filter(|(_, field)| Self::is_public(&field.visibility)) { - self.collect_referenced_type_names(&field.ty, ctx, &mut names); - } - names - } - - fn collect_referenced_type_names_from_impl_functions>( - &self, - struct_name: &str, - ctx: &DefaultVisitorContext, - included_defs: Option<&HashSet>, - ) -> Vec { - let mut names = Vec::new(); - for i in 0..ctx.program().defs.len() { - let def_id = DefId::from(i); - if included_defs.is_some_and(|defs| !defs.contains(&def_id)) { - continue; - } - let Some(impl_node) = ctx.definition(def_id).as_impl() else { - continue; - }; - let impl_type_name = self.extract_type_name(&impl_node.ty, ctx); - if impl_type_name != struct_name && impl_type_name != format!("{struct_name}Ref") { - continue; - } - for &function_def_id in &impl_node.body { - let Some(function) = ctx.definition(function_def_id).as_function() else { - continue; - }; - let function_name = ctx.ident(function.name).0.to_string(); - if !Self::is_public(&function.visibility) || self.is_internal_function(&function_name) { - continue; - } - for param in &function.parameters { - self.collect_referenced_type_names(¶m.ty, ctx, &mut names); - } - if let Some(return_type) = &function.return_type { - self.collect_referenced_type_names(return_type, ctx, &mut names); - } - } - } - names - } - - fn collect_referenced_type_names>(&self, ty: &UncheckedType, ctx: &DefaultVisitorContext, names: &mut Vec) { - match ty { - UncheckedType::Basic(identifier) => names.push(ctx.ident(*identifier).0.to_string()), - UncheckedType::Generic(identifier, generics, _) => { - names.push(ctx.ident(*identifier).0.to_string()); - for generic in generics { - self.collect_referenced_type_names(generic, ctx, names); - } - } - UncheckedType::Array(inner, _, _) => self.collect_referenced_type_names(inner, ctx, names), - UncheckedType::Tuple(items, _) => { - for item in items { - self.collect_referenced_type_names(item, ctx, names); - } - } - UncheckedType::FunctionSignature(signature, _) => { - for parameter in &signature.parameters { - self.collect_referenced_type_names(parameter, ctx, names); - } - if let Some(return_type) = &signature.return_type { - self.collect_referenced_type_names(return_type, ctx, names); - } - } - UncheckedType::Path(path) => self.collect_referenced_type_names(&path.target, ctx, names), - UncheckedType::TraitCast(inner, trait_ty, _) => { - self.collect_referenced_type_names(inner, ctx, names); - self.collect_referenced_type_names(trait_ty, ctx, names); - } - UncheckedType::Const(_, _) | UncheckedType::Unknown => {} - } - } - - fn collect_package_def_ids>(&self, ctx: &DefaultVisitorContext, package_root: &Path) -> HashSet { - let package_root = normalize_path_for_prefix(package_root); - let mut defs = HashSet::new(); - - for module in ctx.program().modules.iter() { - let Some(module_path) = ctx.program().file_resolver.resolve_path(&module.data().file_id) else { - continue; - }; - let module_path = normalize_path_for_prefix(&module_path); - if module_path.starts_with(&package_root) { - defs.extend(module.data().definitions.iter().copied()); - } - } - - defs - } - - - /// Extract the ABI from the same checked program. /// /// `state_tree_height` is computed once at the build step via @@ -621,12 +493,10 @@ impl AbiExtractor { let mut offset = 0usize; let mut fields = Vec::new(); - for (field_name, field) in &struct_node.fields { + for (_field_name, field) in &struct_node.fields { let felt_size = self.felt_size_for_type(ctx, &field.ty, struct_nodes, layouts); fields.push(StructFieldLayout { - name: ctx.ident(*field_name).0.to_string(), offset, - felt_size, }); offset += felt_size; } @@ -636,95 +506,6 @@ impl AbiExtractor { layout } - fn compat_type_metadata>( - &self, - ctx: &DefaultVisitorContext, - ty: &UncheckedType, - struct_nodes: &BTreeMap, - struct_layouts: &HashMap, - ) -> ( - usize, - bool, - Option, - Option, - Option, - bool, - Option, - Option, - Option, - ) { - let mut layouts = struct_layouts.clone(); - if let Some((key_type, value_type, capacity)) = self.extract_imt_map_info(ctx, ty) { - return ( - capacity.saturating_mul(4), - false, - None, - None, - Some(4), - true, - Some(key_type), - Some(value_type), - Some(capacity), - ); - } - - match ty { - UncheckedType::Array(inner, size, _) => { - let inner_size = self.felt_size_for_type(ctx, inner, struct_nodes, &mut layouts); - let length = size.as_u64().unwrap_or(0) as usize; - ( - inner_size.saturating_mul(length), - true, - Some(length), - Some(self.stringify_unchecked_type(ctx, inner)), - Some(inner_size), - false, - None, - None, - None, - ) - } - _ => ( - self.felt_size_for_type(ctx, ty, struct_nodes, &mut layouts), - false, - None, - None, - None, - false, - None, - None, - None, - ), - } - } - - fn resolve_sub_fields_for_type>( - &self, - ctx: &DefaultVisitorContext, - ty: &UncheckedType, - struct_nodes: &BTreeMap, - struct_layouts: &HashMap, - ) -> Option> { - let struct_name = match ty { - UncheckedType::Basic(identifier) => Some(ctx.ident(*identifier).0.to_string()), - UncheckedType::Path(path) => Some(self.extract_type_name(&path.target, ctx)), - UncheckedType::Array(inner, _, _) => match inner.as_ref() { - UncheckedType::Basic(identifier) => Some(ctx.ident(*identifier).0.to_string()), - UncheckedType::Path(path) => Some(self.extract_type_name(&path.target, ctx)), - _ => None, - }, - _ => None, - }?; - - if !struct_nodes.contains_key(&struct_name) { - return None; - } - - struct_layouts - .get(&struct_name) - .and_then(|layout| if layout.fields.is_empty() { None } else { Some(layout.fields.clone()) }) - } - fn felt_size_for_type>( &self, ctx: &DefaultVisitorContext, @@ -830,10 +611,6 @@ impl AbiExtractor { } } -fn normalize_path_for_prefix(path: &Path) -> std::path::PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) -} - /// `ceil(log2(value))` for `value >= 1`; returns 0 for `value <= 1`. fn ceil_log2(value: u64) -> u16 { if value <= 1 { @@ -850,6 +627,19 @@ fn state_tree_height_for_total_felts(total_felts: usize) -> u16 { #[cfg(test)] mod tests { use super::*; + use psy_ast::{ConstValue, Identifier, Location, PathNode}; + + fn type_fixture() -> (Program, HashMap<&'static str, Identifier>) { + let mut program = Program::new(); + let identifiers = ["Felt", "Bool", "u32", "Hash", "Widget", "Map", "NamespacedMap", "Other"] + .into_iter() + .map(|name| { + let id = program.interner.intern_ident(name); + (name, Identifier::new(id, Location::default())) + }) + .collect(); + (program, identifiers) + } #[test] fn state_tree_height_accounts_for_four_felts_per_leaf() { @@ -860,6 +650,47 @@ mod tests { assert_eq!(state_tree_height_for_total_felts(129), 6); } + #[test] + fn ceil_log2_handles_zero_one_powers_and_u64_boundaries() { + assert_eq!(ceil_log2(0), 0); + assert_eq!(ceil_log2(1), 0); + assert_eq!(ceil_log2(2), 1); + assert_eq!(ceil_log2(3), 2); + assert_eq!(ceil_log2(4), 2); + assert_eq!(ceil_log2(5), 3); + assert_eq!(ceil_log2(1u64 << 63), 63); + assert_eq!(ceil_log2(u64::MAX), 64); + } + + #[test] + fn state_tree_height_saturates_large_felt_counts_without_overflow() { + let height = state_tree_height_for_total_felts(usize::MAX); + + assert!(height >= 4); + assert!(height <= u16::MAX); + } + + #[test] + fn state_tree_height_clamps_small_layouts_to_the_minimum_tree() { + // 1..=16 felts all pack into at most 4 leaves (2^2), clamped to min 4. + for felts in 1..=16 { + assert_eq!(state_tree_height_for_total_felts(felts), 4, "felts = {felts}"); + } + // 17 felts round up to 5 leaves, which needs height 3 β€” still clamped. + assert_eq!(state_tree_height_for_total_felts(17), 4); + // The clamp stops binding once 256 leaves (2^8) are exceeded: 1024 felts + // are exactly 256 leaves (height 8), 1025 spill into 257 (height 9). + assert_eq!(state_tree_height_for_total_felts(1024), 8); + assert_eq!(state_tree_height_for_total_felts(1025), 9); + } + + #[test] + fn state_tree_height_saturates_instead_of_panicking_at_usize_max_neighbors() { + // saturating_add(3) on usize::MAX must not wrap around to a small value. + assert_eq!(state_tree_height_for_total_felts(usize::MAX), state_tree_height_for_total_felts(usize::MAX - 3)); + assert_eq!(state_tree_height_for_total_felts(usize::MAX - 2), state_tree_height_for_total_felts(usize::MAX)); + } + #[test] fn test_abi_extractor_creation() { let extractor = AbiExtractor::new("TestContract".to_string()); @@ -896,4 +727,134 @@ mod tests { assert!(!extractor.is_internal_function("mint")); assert!(!extractor.is_internal_function("claim")); } + + #[test] + fn type_refs_cover_primitives_structs_arrays_paths_and_maps() { + let (mut program, identifiers) = type_fixture(); + let ctx = DefaultVisitorContext::::new(&mut program); + let extractor = AbiExtractor::new("Contract".into()); + let basic = |name| UncheckedType::Basic(identifiers[name]); + + assert_eq!( + extractor.unchecked_type_to_typeref(&ctx, &basic("Felt")), + TypeRef::Primitive { name: PrimitiveTypeName::Felt } + ); + assert_eq!( + extractor.unchecked_type_to_typeref(&ctx, &basic("Bool")), + TypeRef::Primitive { name: PrimitiveTypeName::Bool } + ); + assert_eq!( + extractor.unchecked_type_to_typeref(&ctx, &basic("u32")), + TypeRef::Primitive { name: PrimitiveTypeName::U32 } + ); + assert_eq!( + extractor.unchecked_type_to_typeref(&ctx, &basic("Hash")), + TypeRef::Primitive { name: PrimitiveTypeName::Hash } + ); + assert_eq!( + extractor.unchecked_type_to_typeref(&ctx, &basic("Widget")), + TypeRef::Struct { name: "Widget".into() } + ); + + let array = UncheckedType::Array( + Box::new(basic("Hash")), + ConstValue::U32(3), + Location::default(), + ); + assert_eq!( + extractor.unchecked_type_to_typeref(&ctx, &array), + TypeRef::Array { + item: Box::new(TypeRef::Primitive { name: PrimitiveTypeName::Hash }), + length: 3, + item_felt_size: 4, + } + ); + + let path = UncheckedType::Path(Box::new(PathNode::from_target_ty(basic("u32")))); + assert_eq!( + extractor.unchecked_type_to_typeref(&ctx, &path), + TypeRef::Primitive { name: PrimitiveTypeName::U32 } + ); + + let map = UncheckedType::Generic( + identifiers["Map"], + vec![basic("Hash"), basic("Widget"), UncheckedType::Const(ConstValue::Felt(16), Location::default())], + Location::default(), + ); + assert_eq!( + extractor.unchecked_type_to_typeref(&ctx, &map), + TypeRef::Map { + map_kind: MapKind::Map, + key: Box::new(TypeRef::Primitive { name: PrimitiveTypeName::Hash }), + value: Box::new(TypeRef::Struct { name: "Widget".into() }), + capacity: 16, + value_felt_size: 0, + alignment_felts: 4, + } + ); + } + + #[test] + fn map_and_type_string_helpers_cover_invalid_and_nested_shapes() { + let (mut program, identifiers) = type_fixture(); + let ctx = DefaultVisitorContext::::new(&mut program); + let extractor = AbiExtractor::new("Contract".into()); + let basic = |name| UncheckedType::Basic(identifiers[name]); + let constant = |value| UncheckedType::Const(value, Location::default()); + + assert_eq!(extractor.const_len_from_type(&constant(ConstValue::Felt(9))), Some(9)); + assert_eq!(extractor.const_len_from_type(&constant(ConstValue::U32(7))), Some(7)); + assert_eq!(extractor.const_len_from_type(&constant(ConstValue::Bool(true))), None); + assert_eq!(extractor.const_len_from_type(&UncheckedType::Unknown), None); + + assert!(extractor + .extract_canonical_map_info(&ctx, "Other", &[]) + .is_none()); + assert!(extractor + .extract_canonical_map_info(&ctx, "Map", &[basic("Felt")]) + .is_none()); + let namespaced = extractor + .extract_canonical_map_info( + &ctx, + "NamespacedMap", + &[basic("Felt"), basic("u32"), constant(ConstValue::Bool(false))], + ) + .unwrap(); + assert_eq!(namespaced.0, MapKind::NamespacedMap); + assert_eq!(namespaced.3, 0); + + let tuple = UncheckedType::Tuple(vec![basic("Felt"), basic("u32")], Location::default()); + let nested = UncheckedType::Generic( + identifiers["Other"], + vec![tuple.clone(), constant(ConstValue::U32(2))], + Location::default(), + ); + assert_eq!(extractor.stringify_unchecked_type(&ctx, &tuple), "(Felt, u32)"); + assert_eq!(extractor.stringify_unchecked_type(&ctx, &nested), "Other<(Felt, u32), 2u32>"); + assert_eq!( + extractor.stringify_unchecked_type( + &ctx, + &UncheckedType::TraitCast( + Box::new(basic("Widget")), + Box::new(basic("Other")), + Location::default(), + ), + ), + "Widget as Other" + ); + assert_eq!(extractor.stringify_unchecked_type(&ctx, &UncheckedType::Unknown), "unknown"); + } + + #[test] + fn empty_program_has_minimum_tree_height_and_fallback_contract_name() { + let mut program = Program::::new(); + let extractor = AbiExtractor::new("Fallback".into()); + assert_eq!(extractor.compute_state_tree_height(&mut program), 4); + let abi = extractor.extract_abi(&mut program, 4, &HashMap::new()).unwrap(); + assert_eq!(abi.contract.name, "Fallback"); + assert!(abi.contract.state.is_empty()); + assert!(abi.contract.methods.is_empty()); + assert!(abi.types.is_empty()); + } + } diff --git a/psy-abi/tests/bridge_contract_abi.rs b/psy-abi/tests/bridge_contract_abi.rs index fb7cfd91e..852047971 100644 --- a/psy-abi/tests/bridge_contract_abi.rs +++ b/psy-abi/tests/bridge_contract_abi.rs @@ -54,8 +54,10 @@ fn compile_abi(package: &str, contract_name: &str, methods: &[&str]) -> (Abi, Ha ) }) .collect::>(); - let abi = AbiExtractor::new(contract_name.to_string()) - .extract_abi(&mut result.ctx.program, 1, &metadata) + let extractor = AbiExtractor::new(contract_name.to_string()); + let state_tree_height = extractor.compute_state_tree_height(&mut result.ctx.program); + let abi = extractor + .extract_abi(&mut result.ctx.program, state_tree_height, &metadata) .expect("extract canonical ABI"); (abi, metadata) } diff --git a/psy-ast/src/module/mod.rs b/psy-ast/src/module/mod.rs index 7e71d8f64..3abd442bb 100644 --- a/psy-ast/src/module/mod.rs +++ b/psy-ast/src/module/mod.rs @@ -120,3 +120,71 @@ pub enum ModuleItemNode { Definition(DefId), Comment(Comment), } + +#[cfg(test)] +mod tests { + use psy_common::FileId; + + use super::*; + + fn identifier(name: &str) -> Identifier { + let id = match name { + "std" => IdentId::STD, + "prelude" => IdentId::PRELUDE, + "primitive" => IdentId::PRIMITIVE, + _ => IdentId::from(999usize), + }; + Identifier::new(id, Location::new(FileId(0), 0, 0)) + } + + fn empty_module(name: &str) -> ModuleNode { + ModuleNode { + name: identifier(name), + file_id: FileId(0), + modules: Vec::new(), + inline_modules: Vec::new(), + definitions: Vec::new(), + visibility: Visibility::Public, + comments: Vec::new(), + location: Location::new(FileId(0), 0, 0), + } + } + + #[test] + fn module_constructor_separates_children_and_definitions() { + let mut defs = Arena::new(); + let external = (identifier("external"), Visibility::Private, Location::default()); + let inline = empty_module("inline"); + let module = ModuleNode::new( + identifier("root"), + FileId(0), + Visibility::Public, + vec![ + ModuleItemNode::ModuleDecl(external), + ModuleItemNode::InlineModule(inline), + ModuleItemNode::Definition(DefId::from(0usize)), + ], + &mut defs, + Vec::new(), + Location::default(), + ); + + assert_eq!(module.modules.len(), 1); + assert_eq!(module.modules[0].0.id, identifier("external").id); + assert_eq!(module.inline_modules.len(), 1); + assert_eq!(module.inline_modules[0].name.id, identifier("inline").id); + assert_eq!(module.definitions, vec![DefId::from(0usize)]); + } + + #[test] + fn standard_module_helpers_match_only_known_standard_names() { + for name in ["std", "prelude", "primitive"] { + let module = empty_module(name); + assert!(module.is_std(), "{name} should be a standard module"); + } + let primitive = empty_module("primitive"); + assert!(primitive.is_self_primitive()); + assert!(!empty_module("user").is_std()); + assert!(!empty_module("user").is_self_primitive()); + } +} diff --git a/psy-ast/src/program.rs b/psy-ast/src/program.rs index 1e93f8d23..77a5e9521 100644 --- a/psy-ast/src/program.rs +++ b/psy-ast/src/program.rs @@ -122,3 +122,61 @@ impl> Program { } } } + +#[cfg(test)] +mod tests { + use psy_common::FileId; + + use super::*; + use crate::{Identifier, Location, Visibility}; + + fn module(program: &mut Program, name: &str) -> ModuleNode { + let id = program.interner.intern_ident(name); + ModuleNode { + name: Identifier::new(id, Location::new(FileId(0), 0, 0)), + file_id: FileId(0), + modules: Vec::new(), + inline_modules: Vec::new(), + definitions: Vec::new(), + visibility: Visibility::Public, + comments: Vec::new(), + location: Location::new(FileId(0), 0, 0), + } + } + + #[test] + fn module_lookup_and_parent_relationships_are_consistent() { + let mut program = Program::::new(); + let root_node = module(&mut program, "root"); + let child_node = module(&mut program, "child"); + let root = program.modules.add_node(root_node); + let child = program.modules.add_node(child_node); + program.add_module_child(Some(root), child); + + let child_name = program.interner.intern_ident("child"); + assert_eq!(program.find_module_by_name(child_name), Some(child)); + assert_eq!(program.module_name(child).to_string(), "child"); + assert_eq!(program.modules[child].parent(), Some(root)); + let missing = program.interner.intern_ident("missing"); + assert!(program.find_module_by_name(missing).is_none()); + } + + #[test] + fn standard_module_detection_walks_ancestors_and_handles_unset_root() { + let mut program = Program::::new(); + let root_node = module(&mut program, "root"); + let std_node = module(&mut program, "std"); + let nested_node = module(&mut program, "nested"); + let root = program.modules.add_node(root_node); + let std = program.modules.add_node(std_node); + let nested = program.modules.add_node(nested_node); + program.add_module_child(Some(root), std); + program.add_module_child(Some(std), nested); + + assert!(!program.is_module_std(root)); + program.std_module_id = Some(std); + assert!(program.is_module_std(std)); + assert!(program.is_module_std(nested)); + assert!(!program.is_module_std(root)); + } +} diff --git a/psy-ast/src/traits/context.rs b/psy-ast/src/traits/context.rs index f5597d7a9..78763e4b1 100644 --- a/psy-ast/src/traits/context.rs +++ b/psy-ast/src/traits/context.rs @@ -281,3 +281,158 @@ impl<'a, F: Clone + From, C> VisitorContext for DefaultVisitorContext self.program.interner.intern_lambda() } } + +#[cfg(test)] +mod tests { + use psy_common::FileId; + + use crate::{Comment, Identifier, Location, ModuleNode, UseNode, ValueNode, Visibility}; + + use super::*; + + type Ctx<'a> = DefaultVisitorContext<'a, u32, ()>; + + fn module_node(name: usize) -> ModuleNode { + ModuleNode { + name: Identifier::new(IdentId(name), Location::default()), + file_id: FileId(0), + modules: vec![], + inline_modules: vec![], + definitions: vec![], + visibility: Visibility::Public, + comments: vec![], + location: Location::default(), + } + } + + fn use_definition(id: usize) -> DefinitionNode { + DefinitionNode::Use(UseNode { + visibility: Visibility::Private, + kind: Identifier::new(IdentId(id), Location::default()), + segments: vec![], + target: None, + comments: vec![Comment::new_line("doc".to_string(), Location::default())], + location: Location::default(), + }) + } + + fn felt_value() -> ExprNode { + ExprNode::Value(ValueNode::Felt(7u32, Location::default())) + } + + #[test] + fn function_node_types_are_functions() { + assert!(NodeType::FunctionDef.is_function()); + assert!(NodeType::LambdaFunctionExpr.is_function()); + assert!(!NodeType::Module.is_function()); + assert!(!NodeType::UseDef.is_function()); + assert!(!NodeType::ValueExpr.is_function()); + } + + #[test] + fn node_id_stack_tracks_ancestry() { + let mut program = Program::::new(); + let mut context = Ctx::new(&mut program); + context.push_node_id(NodeId::Expr(ExprId(1))); + context.push_node_id(NodeId::Stmt(StmtId(2))); + context.push_node_id(NodeId::Def(DefId(3))); + + assert_eq!(context.node_id(), NodeId::Def(DefId(3))); + assert_eq!(context.ancestor_node_id(0), NodeId::Def(DefId(3))); + assert_eq!(context.ancestor_node_id(2), NodeId::Expr(ExprId(1))); + assert_eq!(context.node_path().len(), 3); + + context.pop_node_id(); + assert_eq!(context.node_id(), NodeId::Stmt(StmtId(2))); + } + + #[test] + fn node_type_dispatches_by_node_kind() { + let mut program = Program::::new(); + let expr_id = program.exprs.alloc_item(felt_value()); + let stmt_id = program.stmts.alloc_item(StmtNode::Expression(expr_id)); + let def_id = program.defs.alloc_item(use_definition(1)); + + let mut context = Ctx::new(&mut program); + context.push_node_id(NodeId::Expr(expr_id)); + assert_eq!(context.node_type(), NodeType::ValueExpr); + context.push_node_id(NodeId::Stmt(stmt_id)); + assert_eq!(context.ancestor_node_type(1), NodeType::ValueExpr); + assert_eq!(context.node_type(), NodeType::ExpressionStmt); + context.push_node_id(NodeId::Def(def_id)); + assert_eq!(context.node_type(), NodeType::UseDef); + context.push_node_id(NodeId::Module(ModuleId(0))); + assert_eq!(context.node_type(), NodeType::Module); + assert_eq!(context.ancestor_node_type(3), NodeType::ValueExpr); + } + + #[test] + fn ident_interning_round_trips() { + let mut program = Program::::new(); + let mut context = Ctx::new(&mut program); + let id = context.intern("inserted"); + assert_eq!(context.ident(id).0, "inserted"); + assert_ne!(context.intern_lambda(), context.intern_lambda()); + } + + #[test] + fn modules_are_indexed_and_traversed() { + let mut program = Program::::new(); + let root_id = program.modules.add_node(module_node(10)); + let child_id = program.modules.add_node(module_node(11)); + program.add_module_child(Some(root_id), child_id); + + let context = Ctx::new(&mut program); + assert_eq!(context.module(root_id).name.id, IdentId(10)); + assert_eq!(context.module_children(root_id), &[child_id]); + assert_eq!(context.module_children(child_id), &[]); + assert_eq!(context.program().modules[root_id].data().name.id, IdentId(10)); + assert_eq!(context.dependency_graph().nodes().len(), 0); + } + + #[test] + fn insert_definition_places_items_relative_to_existing_ones() { + let mut program = Program::::new(); + let module_id = program.modules.add_node(module_node(20)); + let first = program.defs.alloc_item(use_definition(21)); + let second = program.defs.alloc_item(use_definition(22)); + program.modules[module_id].data_mut().definitions = vec![first, second]; + + let mut context = Ctx::new(&mut program); + // insert_definition resolves the target module one level up the stack. + context.push_node_id(NodeId::Module(module_id)); + context.push_node_id(NodeId::Def(first)); + + context.insert_definition(use_definition(23), InsertPosition::Front); + context.insert_definition(use_definition(24), InsertPosition::End); + context.insert_definition(use_definition(25), InsertPosition::Before(NodeId::Def(second))); + context.insert_definition(use_definition(26), InsertPosition::After(NodeId::Def(first))); + + let inserted: Vec = program.modules[module_id] + .data() + .definitions + .iter() + .map(|def_id| program.defs[*def_id].as_use().unwrap().kind.id.0 as usize) + .collect(); + assert_eq!(inserted, vec![23, 21, 26, 25, 22, 24]); + } + + #[test] + fn allocation_and_replacement_round_trip() { + let mut program = Program::::new(); + let mut context = Ctx::new(&mut program); + + let expr_id = context.alloc_expression(felt_value()); + let stmt_id = context.alloc_statement(StmtNode::Expression(expr_id)); + let def_id = context.alloc_definition(use_definition(30)); + + assert!(matches!(context.expression(expr_id), ExprNode::Value(ValueNode::Felt(_, _)))); + assert!(matches!(context.statement(stmt_id), StmtNode::Expression(_))); + assert!(matches!(context.definition(def_id), DefinitionNode::Use(_))); + + context.replace_definition(def_id, use_definition(31)); + assert_eq!(context.definition(def_id).as_use().unwrap().kind.id, IdentId(31)); + context.replace_statement(stmt_id, StmtNode::Definition(def_id)); + assert!(matches!(context.statement(stmt_id), StmtNode::Definition(id) if *id == def_id)); + } +} diff --git a/psy-common/src/arena.rs b/psy-common/src/arena.rs index 0ead42af5..35c6a0010 100644 --- a/psy-common/src/arena.rs +++ b/psy-common/src/arena.rs @@ -129,3 +129,74 @@ macro_rules! define_arena_id { } }; } + +#[cfg(test)] +mod tests { + use super::*; + + crate::define_arena_id!(TestId); + + #[test] + fn empty_arena_reports_zero_length_and_first_index() { + let arena = Arena::::new(); + + assert_eq!(arena.len(), 0); + assert_eq!(arena.next_idx(), TestId(0)); + assert_eq!(arena.iter().next(), None); + } + + #[test] + fn allocation_replacement_and_mutation_preserve_indices() { + let mut arena = Arena::::new(); + let ids = arena.alloc_items([10, 20, 30]); + + assert_eq!(ids, vec![TestId(0), TestId(1), TestId(2)]); + assert_eq!(arena.next_idx(), TestId(3)); + assert_eq!(arena.replace_item(TestId(1), 21), 20); + arena.modify_item(TestId(2), &|value| *value += 1); + arena[TestId(0)] = 11; + + assert_eq!(arena.iter().copied().collect::>(), vec![11, 21, 31]); + } + + #[test] + fn owned_shared_and_mutable_iteration_cover_every_item_in_order() { + let mut arena = Arena::::default(); + arena.alloc_items([1, 2, 3]); + + for value in &mut arena { + *value *= 2; + } + assert_eq!((&arena).into_iter().copied().collect::>(), vec![2, 4, 6]); + assert_eq!(arena.into_iter().collect::>(), vec![2, 4, 6]); + } + + #[test] + #[should_panic] + fn invalid_index_panics_instead_of_aliasing_an_item() { + let arena = Arena::::new(); + let _ = arena[TestId(usize::MAX)]; + } + + #[test] + fn allocating_an_empty_batch_leaves_the_arena_untouched() { + let mut arena = Arena::::new(); + + assert!(arena.alloc_items([]).is_empty()); + assert_eq!(arena.len(), 0); + assert_eq!(arena.next_idx(), TestId(0)); + } + + #[test] + fn cloning_preserves_items_and_continues_indexing_from_the_copy() { + let mut arena = Arena::::new(); + arena.alloc_items(["a", "b"]); + + let clone = arena.clone(); + arena.alloc_item("c"); + + assert_eq!(clone.iter().copied().collect::>(), vec!["a", "b"]); + assert_eq!(clone.len(), 2); + assert_eq!(arena.iter().copied().collect::>(), vec!["a", "b", "c"]); + } +} diff --git a/psy-common/src/graph.rs b/psy-common/src/graph.rs index 24738f7d1..3da029252 100644 --- a/psy-common/src/graph.rs +++ b/psy-common/src/graph.rs @@ -253,4 +253,112 @@ mod tests { assert_eq!(visited, vec!["root", "left", "shared", "right"]); } + + #[test] + fn empty_graph_has_no_roots_and_all_traversals_are_noops() { + let graph = Graph::<&str>::new(); + let mut dfs_count = 0; + let mut bfs_count = 0; + + graph.dfs(&mut |_, _| dfs_count += 1); + graph.bfs::(&mut |_| { + bfs_count += 1; + Ok(()) + }).unwrap(); + + assert!(graph.nodes().is_empty()); + assert!(graph.starting_nodes().is_empty()); + assert_eq!((dfs_count, bfs_count), (0, 0)); + } + + #[test] + fn self_edges_are_ignored_and_duplicate_edges_are_deduplicated() { + let mut graph = Graph::new(); + graph.add_edge("A", "A"); + graph.add_edge("A", "B"); + graph.add_edge("A", "B"); + + assert_eq!(graph.nodes(), vec![&"A", &"B"]); + assert_eq!(graph.edges(&"A").unwrap().iter().collect::>(), vec![&"B"]); + assert!(graph.edges(&"B").unwrap().is_empty()); + assert_eq!(graph.starting_nodes(), vec![&"A"]); + } + + #[test] + fn bfs_visits_a_shared_node_once_and_propagates_visitor_errors() { + let mut graph = Graph::new(); + graph.add_edge("root-a", "shared"); + graph.add_edge("root-b", "shared"); + + let mut visited = Vec::new(); + let result = graph.bfs::(&mut |node| { + visited.push(*node); + if *node == "root-b" { + return Err(Error::CycleGraph); + } + Ok(()) + }); + + assert!(matches!(result, Err(Error::CycleGraph))); + assert_eq!(visited, vec!["root-a", "shared", "root-b"]); + } + + #[test] + fn contains_node_and_edges_reflect_insertions_and_misses() { + let mut graph = Graph::new(); + graph.add_edge("A", "B"); + + assert!(graph.contains_node(&"A")); + assert!(graph.contains_node(&"B")); + assert!(!graph.contains_node(&"C")); + assert_eq!(graph.edges(&"C"), None); + } + + #[test] + fn add_node_is_idempotent_for_existing_nodes() { + let mut graph = Graph::new(); + graph.add_edge("A", "B"); + graph.add_node("A"); + graph.add_node("A"); + + assert_eq!(graph.nodes(), vec![&"A", &"B"]); + assert_eq!(graph.edges(&"A").unwrap().iter().collect::>(), vec![&"B"]); + } + + #[test] + fn topological_sort_propagates_visitor_errors() { + let mut graph = Graph::new(); + graph.add_edge("A", "B"); + + let mut visited = Vec::new(); + let result = graph.ts::(&mut |node| { + visited.push(*node); + if *node == "B" { + return Err(Error::CycleGraph); + } + Ok(()) + }); + + assert!(matches!(result, Err(Error::CycleGraph))); + assert_eq!(visited, vec!["B"]); + } + + #[test] + fn bfs_skips_pure_cycles_because_they_have_no_starting_node() { + // Unlike `dfs`, which iterates every node, `bfs` only walks from + // `starting_nodes()`. A component that is entirely cyclic therefore + // contributes no starting node and is silently skipped. + let mut graph = Graph::new(); + graph.add_edge("A", "B"); + graph.add_edge("B", "A"); + graph.add_edge("root", "A"); + + let mut visited = Vec::new(); + graph.bfs::(&mut |node| { + visited.push(*node); + Ok(()) + }).unwrap(); + + assert_eq!(visited, vec!["root", "A", "B"]); + } } diff --git a/psy-common/src/tree.rs b/psy-common/src/tree.rs index fb465e3e4..e823e77d2 100644 --- a/psy-common/src/tree.rs +++ b/psy-common/src/tree.rs @@ -109,6 +109,7 @@ impl + Into + Copy, T> Tree { { let mut graph = Graph::new(); for node in self.iter() { + graph.add_node(node.id()); for &child in node.children() { graph.add_edge(node.id(), child); } @@ -117,6 +118,73 @@ impl + Into + Copy, T> Tree { } } +#[cfg(test)] +mod tests { + use super::*; + + crate::define_arena_id!(TestId); + + #[test] + fn empty_tree_has_consistent_length_and_next_index() { + let mut tree = Tree::::new(); + + assert!(tree.is_empty()); + assert_eq!(tree.len(), 0); + assert_eq!(tree.next_idx(), TestId(0)); + assert_eq!(tree.iter().next(), None); + } + + #[test] + fn parent_child_links_and_depth_first_order_are_preserved() { + let mut tree = Tree::::new(); + let root = tree.add_node("root"); + let left = tree.add_node("left"); + let right = tree.add_node("right"); + let leaf = tree.add_node("leaf"); + tree.add_child(root, left); + tree.add_child(root, right); + tree.add_child(left, leaf); + + let mut visited = Vec::new(); + tree.dfs(root, &mut |node| visited.push(*node.data())); + + assert_eq!(visited, vec!["root", "left", "leaf", "right"]); + assert_eq!(tree[root].children(), &[left, right]); + assert_eq!(tree[leaf].parent(), Some(left)); + } + + #[test] + fn mutable_access_updates_data_without_changing_structure() { + let mut tree = Tree::::new(); + let root = tree.add_node(1); + let child = tree.add_node(2); + tree.add_child(root, child); + + *tree[root].data_mut() = 10; + for node in tree.iter_mut() { + *node.data_mut() += 1; + } + + assert_eq!((*tree[root].data(), *tree[child].data()), (11, 3)); + assert_eq!(tree[child].parent(), Some(root)); + } + + #[test] + fn graph_conversion_keeps_isolated_nodes_and_edges() { + let mut tree = Tree::::new(); + let root = tree.add_node("root"); + let child = tree.add_node("child"); + let isolated = tree.add_node("isolated"); + tree.add_child(root, child); + + let graph = tree.to_graph(); + + assert_eq!(graph.nodes(), vec![&root, &child, &isolated]); + assert_eq!(graph.edges(&root).unwrap().iter().collect::>(), vec![&child]); + assert!(graph.edges(&isolated).unwrap().is_empty()); + } +} + impl Index for Tree where I: From + Into + Copy, diff --git a/psy-dargo-cli/Cargo.toml b/psy-dargo-cli/Cargo.toml index f05df5994..09b4a3baf 100644 --- a/psy-dargo-cli/Cargo.toml +++ b/psy-dargo-cli/Cargo.toml @@ -41,3 +41,4 @@ is_sync = [ [dev-dependencies] num-traits = { workspace = true } insta = { workspace = true } +serial_test = { workspace = true } diff --git a/psy-dargo-cli/src/cli/compile_cmd.rs b/psy-dargo-cli/src/cli/compile_cmd.rs index 46f016a69..a3d190b67 100644 --- a/psy-dargo-cli/src/cli/compile_cmd.rs +++ b/psy-dargo-cli/src/cli/compile_cmd.rs @@ -371,3 +371,486 @@ fn extract_function_name(source: &str, predicate: impl Fn(&str) -> bool) -> Opti } None } + +#[cfg(test)] +mod tests { + use std::fs; + + use psy_package::{Package, ResolvedSourceWorkspace, SourceMap, VfsPath, Workspace}; + use psy_vm::dpn::{ + ops::{ + op_types::{DPNBuiltInDataType, DPNIndexedVarDef, DPNOpType}, + state_cmd::data::{ + DPNStateCmd, DPNStateCmdClearEntireTree, DPNStateCmdContainsSelfUserCurrentIMTContractStateValue, + DPNStateCmdGetSelfUserCurrentContractStateSlotRange, DPNStateCmdGetSelfUserCurrentContractStateSlotSingle, + DPNStateCmdGetSelfUserCurrentIMTContractStateValue, DPNStateCmdSetContractStateSlotHash, + DPNStateCmdGetSelfUserCurrentContractStateSlotHash, + DPNStateCmdSetContractStateSlotRange, DPNStateCmdSetContractStateSlotSingle, DPNStateCmdSetIMTContractStateValue, + }, + }, + vm::def::DPNFunctionCircuitDefinition, + }; + + use super::{ + extract_contract_method_names, extract_function_name, resolve_source_workspace_method_names, resolve_workspace_method_names, + source_has_function_name, source_vfs_to_pathbuf, validate_static_state_accesses, CompileCommand, CompileOptions, + }; + + fn constant_def(index: usize, op_type: DPNOpType, value: u64) -> DPNIndexedVarDef { + DPNIndexedVarDef { + data_type: DPNBuiltInDataType::Target, + index, + op_type, + inputs: vec![value], + } + } + + fn circuit_with(name: &str, defs: Vec, state_commands: Vec>) -> DPNFunctionCircuitDefinition { + DPNFunctionCircuitDefinition { + name: name.to_string(), + method_id: 0, + circuit_inputs: vec![], + circuit_outputs: vec![], + state_commands, + state_command_resolution_indices: vec![], + assertions: vec![], + definitions: defs, + events: vec![], + } + } + + #[test] + fn static_state_validation_accepts_leaves_inside_the_configured_height() { + // Height 4 gives a capacity of 16 leaves; both commands below stay + // within it, and the ConstantTrue/ConstantFalse/ConstantU32 arms all + // feed constant resolution. + let defs = vec![ + constant_def(0, DPNOpType::Constant, 15), + constant_def(1, DPNOpType::ConstantTrue, 1), + constant_def(2, DPNOpType::ConstantFalse, 0), + constant_def(3, DPNOpType::ConstantU32, 63), + ]; + let hash_wire = defs[0].get_combined_data_type_index(); + let true_wire = defs[1].get_combined_data_type_index(); + let false_wire = defs[2].get_combined_data_type_index(); + let single_wire = defs[3].get_combined_data_type_index(); + let commands = vec![ + DPNStateCmd::SetContractStateSlotHash(DPNStateCmdSetContractStateSlotHash { + condition: 0, + slot_index: hash_wire, + value: [0, 0, 0, 0], + }), + DPNStateCmd::SetContractStateSlotSingle(DPNStateCmdSetContractStateSlotSingle { + condition: 0, + sub_slot_index: single_wire, + value: 0, + }), + // Non-state command kinds are ignored entirely. + DPNStateCmd::ClearEntireTree(DPNStateCmdClearEntireTree { condition: true_wire }), + DPNStateCmd::ClearEntireTree(DPNStateCmdClearEntireTree { condition: false_wire }), + ]; + assert!(validate_static_state_accesses(4, &[circuit_with("in_range", defs, commands)]).is_ok()); + assert!(validate_static_state_accesses(4, &[]).is_ok()); + } + + #[test] + fn static_state_validation_rejects_each_command_kind_accessing_past_the_last_leaf() { + for (name, defs, command) in [ + ("SetContractStateSlotHash", vec![constant_def(0, DPNOpType::Constant, 16)], { + let wire = constant_def(0, DPNOpType::Constant, 16).get_combined_data_type_index(); + DPNStateCmd::SetContractStateSlotHash(DPNStateCmdSetContractStateSlotHash { + condition: 0, + slot_index: wire, + value: [0, 0, 0, 0], + }) + }), + ("SetContractStateSlotSingle", vec![constant_def(0, DPNOpType::Constant, 64)], { + let wire = constant_def(0, DPNOpType::Constant, 64).get_combined_data_type_index(); + DPNStateCmd::SetContractStateSlotSingle(DPNStateCmdSetContractStateSlotSingle { + condition: 0, + sub_slot_index: wire, + value: 0, + }) + }), + ("SetContractStateSlotRange", vec![constant_def(0, DPNOpType::Constant, 60)], { + let wire = constant_def(0, DPNOpType::Constant, 60).get_combined_data_type_index(); + DPNStateCmd::SetContractStateSlotRange(DPNStateCmdSetContractStateSlotRange { + condition: 0, + sub_slot_index: wire, + value: vec![0; 5], + }) + }), + ( + "GetSelfUserCurrentContractStateSlotHash", + vec![constant_def(0, DPNOpType::Constant, 16)], + { + let wire = constant_def(0, DPNOpType::Constant, 16).get_combined_data_type_index(); + DPNStateCmd::GetSelfUserCurrentContractStateSlotHash(DPNStateCmdGetSelfUserCurrentContractStateSlotHash { slot_index: wire }) + }, + ), + ( + "GetSelfUserCurrentContractStateSlotSingle", + vec![constant_def(0, DPNOpType::Constant, 64)], + { + let wire = constant_def(0, DPNOpType::Constant, 64).get_combined_data_type_index(); + DPNStateCmd::GetSelfUserCurrentContractStateSlotSingle(DPNStateCmdGetSelfUserCurrentContractStateSlotSingle { + sub_slot_index: wire, + }) + }, + ), + ("GetSelfUserCurrentContractStateSlotRange", vec![constant_def(0, DPNOpType::Constant, 60)], { + let wire = constant_def(0, DPNOpType::Constant, 60).get_combined_data_type_index(); + DPNStateCmd::GetSelfUserCurrentContractStateSlotRange(DPNStateCmdGetSelfUserCurrentContractStateSlotRange { + sub_slot_index: wire, + length: 5, + }) + }), + ("SetIMTContractStateValue", vec![constant_def(0, DPNOpType::Constant, 60), constant_def(1, DPNOpType::Constant, 2)], { + let base = constant_def(0, DPNOpType::Constant, 60).get_combined_data_type_index(); + let capacity = constant_def(1, DPNOpType::Constant, 2).get_combined_data_type_index(); + DPNStateCmd::SetIMTContractStateValue(DPNStateCmdSetIMTContractStateValue { + condition: 0, + base_offset: base, + capacity, + key: [0, 0, 0, 0], + value: [0, 0, 0, 0], + }) + }), + ( + "GetSelfUserCurrentIMTContractStateValue", + vec![constant_def(0, DPNOpType::Constant, 60), constant_def(1, DPNOpType::Constant, 2)], + { + let base = constant_def(0, DPNOpType::Constant, 60).get_combined_data_type_index(); + let capacity = constant_def(1, DPNOpType::Constant, 2).get_combined_data_type_index(); + DPNStateCmd::GetSelfUserCurrentIMTContractStateValue(DPNStateCmdGetSelfUserCurrentIMTContractStateValue { + base_offset: base, + capacity, + key: [0, 0, 0, 0], + }) + }, + ), + ( + "ContainsSelfUserCurrentIMTContractStateValue", + vec![constant_def(0, DPNOpType::Constant, 60), constant_def(1, DPNOpType::Constant, 2)], + { + let base = constant_def(0, DPNOpType::Constant, 60).get_combined_data_type_index(); + let capacity = constant_def(1, DPNOpType::Constant, 2).get_combined_data_type_index(); + DPNStateCmd::ContainsSelfUserCurrentIMTContractStateValue(DPNStateCmdContainsSelfUserCurrentIMTContractStateValue { + base_offset: base, + capacity, + key: [0, 0, 0, 0], + }) + }, + ), + ] { + let circuit = circuit_with(name, defs, vec![command]); + let error = validate_static_state_accesses(4, &[circuit]) + .expect_err("an access past the last leaf must be rejected"); + let message = error.to_string(); + assert!(message.contains("state-tree sanity check failed"), "{name}: {message}"); + assert!(message.contains(name), "{name}: {message}"); + assert!(message.contains("leaf 16"), "{name}: {message}"); + } + } + + #[test] + fn static_state_validation_skips_dynamic_overflowing_and_unbounded_cases() { + // An IMT command with zero capacity covers no leaf. + let zero_capacity_defs = vec![constant_def(0, DPNOpType::Constant, 60), constant_def(1, DPNOpType::Constant, 0)]; + let base = zero_capacity_defs[0].get_combined_data_type_index(); + let capacity = zero_capacity_defs[1].get_combined_data_type_index(); + let zero_capacity = circuit_with( + "zero_capacity", + zero_capacity_defs, + vec![DPNStateCmd::SetIMTContractStateValue(DPNStateCmdSetIMTContractStateValue { + condition: 0, + base_offset: base, + capacity, + key: [0, 0, 0, 0], + value: [0, 0, 0, 0], + })], + ); + + // capacity * 4 overflows u64, so the last leaf cannot be computed. + let huge = u64::MAX / 4 + 1; + let overflow_defs = vec![constant_def(0, DPNOpType::Constant, huge), constant_def(1, DPNOpType::Constant, huge)]; + let base = overflow_defs[0].get_combined_data_type_index(); + let capacity = overflow_defs[1].get_combined_data_type_index(); + let overflowing = circuit_with( + "overflowing", + overflow_defs, + vec![DPNStateCmd::GetSelfUserCurrentIMTContractStateValue(DPNStateCmdGetSelfUserCurrentIMTContractStateValue { + base_offset: base, + capacity, + key: [0, 0, 0, 0], + })], + ); + + // A range whose end overflows cannot be checked either. + let range_overflow_defs = vec![constant_def(0, DPNOpType::Constant, u64::MAX)]; + let start = range_overflow_defs[0].get_combined_data_type_index(); + let range_overflow = circuit_with( + "range_overflow", + range_overflow_defs, + vec![DPNStateCmd::SetContractStateSlotRange(DPNStateCmdSetContractStateSlotRange { + condition: 0, + sub_slot_index: start, + value: vec![0; 2], + })], + ); + + // Indices computed at runtime (no constant definition) are dynamic. + let dynamic = circuit_with( + "dynamic", + vec![], + vec![DPNStateCmd::SetContractStateSlotHash(DPNStateCmdSetContractStateSlotHash { + condition: 0, + slot_index: 123_456, + value: [0, 0, 0, 0], + })], + ); + + assert!(validate_static_state_accesses(4, &[zero_capacity, overflowing, range_overflow, dynamic]).is_ok()); + + // A height of 64 (or more) leaves has no representable capacity bound, + // so even a slot constant of u64::MAX is accepted. + let unbounded_defs = vec![constant_def(0, DPNOpType::Constant, u64::MAX)]; + let slot = unbounded_defs[0].get_combined_data_type_index(); + let unbounded = circuit_with( + "unbounded", + unbounded_defs, + vec![DPNStateCmd::SetContractStateSlotHash(DPNStateCmdSetContractStateSlotHash { + condition: 0, + slot_index: slot, + value: [0, 0, 0, 0], + })], + ); + assert!(validate_static_state_accesses(64, &[unbounded]).is_ok()); + } + + #[test] + fn vfs_paths_map_to_their_pathbuf_forms() { + let real = std::path::PathBuf::from("/tmp/pkg/src/main.psy"); + assert_eq!(source_vfs_to_pathbuf(&VfsPath::Real(real.clone())), real); + assert_eq!( + source_vfs_to_pathbuf(&VfsPath::Virtual("/virtual/src/main.psy".to_string())), + std::path::PathBuf::from("/virtual/src/main.psy") + ); + } + + #[test] + fn function_name_extraction_handles_missing_and_invalid_markers() { + assert_eq!(extract_function_name("no function", |_| true), None); + assert_eq!(extract_function_name("fn () {}", |_| true), None); + assert_eq!(extract_function_name("fn _foo42() {}", |_| true), Some("_foo42".to_string())); + assert_eq!(extract_function_name("fn first() {} fn second() {}", |name| name == "second"), Some("second".to_string())); + assert_eq!(extract_function_name("fn first() {}", |name| name == "missing"), None); + } + + #[test] + fn source_main_detection_does_not_match_prefixes() { + assert!(source_has_function_name("fn main() {}", "main")); + assert!(!source_has_function_name("fn main_extra() {}", "main")); + } + + #[test] + fn contract_method_extraction_collects_all_supported_attributes() { + let source = " + #[contract::write_method] fn write_one() {} + #[contract::view_method] fn view_one() {} + #[contract_method] fn plain() {} + #[contract::write_method] fn write_one() {} + "; + let mut methods = Vec::new(); + extract_contract_method_names(source, &mut methods); + assert_eq!(methods, vec!["write_one", "write_one", "view_one", "plain"]); + } + + #[test] + fn contract_method_extraction_ignores_attributes_without_function_names() { + let mut methods = Vec::new(); + extract_contract_method_names("#[contract_method]\nlet value = 1;", &mut methods); + assert!(methods.is_empty()); + } + + #[test] + fn explicit_method_names_are_required_to_be_non_empty() { + let workspace = Workspace { + package: Package::default(), + ..Workspace::default() + }; + let error = resolve_workspace_method_names( + &workspace, + &CompileOptions { + method_names: Some(Vec::new()), + ..CompileOptions::default() + }, + ) + .expect_err("an explicitly empty method list must be rejected"); + assert!(error.to_string().contains("must not be empty")); + } + + #[test] + fn source_workspace_rejects_an_explicitly_empty_method_list() { + let workspace = ResolvedSourceWorkspace { + root_package: psy_package::PackageId::Virtual("root".to_string()), + packages: Default::default(), + source_map: SourceMap::new(), + }; + let error = resolve_source_workspace_method_names( + &workspace, + &CompileOptions { + method_names: Some(Vec::new()), + ..CompileOptions::default() + }, + ) + .expect_err("an explicitly empty source-workspace method list must be rejected"); + assert!(error.to_string().contains("must not be empty")); + } + + fn workspace_with_source(source: &str) -> (Workspace, std::path::PathBuf) { + let root = std::env::temp_dir().join(format!("psy-dargo-methods-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + let entry = root.join("src/main.psy"); + fs::create_dir_all(entry.parent().unwrap()).unwrap(); + fs::write(&entry, source).unwrap(); + let workspace = Workspace { + root_dir: root.clone(), + package: Package { + root_dir: root.clone(), + entry_path: std::path::PathBuf::from("src/main.psy"), + ..Package::default() + }, + ..Workspace::default() + }; + (workspace, root) + } + + #[test] + fn workspace_method_discovery_handles_main_attributes_and_missing_methods() { + let (workspace, root) = workspace_with_source("fn main() {}\n#[contract_method] fn ignored() {}"); + assert_eq!(resolve_workspace_method_names(&workspace, &CompileOptions::default()).unwrap(), vec!["main"]); + fs::remove_dir_all(root).unwrap(); + + let (workspace, root) = workspace_with_source( + "#[contract::view_method] fn zeta() {}\n#[contract::write_method] fn alpha() {}\n#[contract_method] fn zeta() {}", + ); + assert_eq!( + resolve_workspace_method_names(&workspace, &CompileOptions::default()).unwrap(), + vec!["alpha", "zeta"] + ); + fs::remove_dir_all(root).unwrap(); + + let (workspace, root) = workspace_with_source("fn helper() {}"); + let error = resolve_workspace_method_names(&workspace, &CompileOptions::default()).expect_err("missing methods must fail"); + assert!(error.to_string().contains("Unable to discover contract methods")); + fs::remove_dir_all(root).unwrap(); + } + + /// Materializes `source` as a throwaway binary workspace via a manifest, + /// mirroring how the CLI resolves a real project directory. + fn manifest_workspace(source: &str, label: &str) -> (Workspace, std::path::PathBuf) { + use std::time::{SystemTime, UNIX_EPOCH}; + + use psy_package::resolve_workspace_from_toml; + + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos(); + let dir = std::env::temp_dir().join(format!("psy_dargo_compile_{label}_{nanos}")); + fs::create_dir_all(dir.join("src")).expect("create src"); + fs::write(dir.join("src").join("app.psy"), source).expect("write app.psy"); + fs::write( + dir.join("Dargo.toml"), + "[package]\nname = \"compiledemo\"\ntype = \"bin\"\nentry = \"src/app.psy\"\nauthors = [\"\"]\n\n[dependencies]\n", + ) + .expect("write Dargo.toml"); + let workspace = resolve_workspace_from_toml(&dir.join("Dargo.toml")).expect("resolve workspace"); + (workspace, dir) + } + + fn reset_std_scope() { + #[allow(static_mut_refs)] + unsafe { + psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); + } + } + + #[test] + #[serial_test::serial] + fn compile_workspace_full_runs_debug_mode_with_explicit_methods() { + let (workspace, dir) = manifest_workspace("fn main(a: Felt) -> Felt { return a + 1; }", "debug_opts"); + let result = super::compile_workspace_full( + &workspace, + &CompileOptions { + contract_name: None, + method_names: Some(vec!["main".to_string()]), + entry_path: None, + debug: true, + }, + ) + .expect("compile with debug options must succeed"); + assert_eq!(result.circuit_definitions.len(), 1); + reset_std_scope(); + fs::remove_dir_all(dir).ok(); + } + + #[test] + #[serial_test::serial] + fn compile_command_run_writes_the_workspace_artifact_in_default_mode() { + let (workspace, dir) = manifest_workspace("fn main(a: Felt) -> Felt { return a + 1; }", "run_cmd"); + let artifact_path = workspace.target_dir.join(format!("{}.json", workspace.package.name)); + + super::run(CompileCommand { compile_options: CompileOptions::default() }, workspace) + .expect("the compile command must compile the workspace and write the artifact"); + + assert!(artifact_path.is_file(), "non-debug compiles must write the workspace artifact"); + let artifact = fs::read_to_string(&artifact_path).expect("artifact must be readable"); + assert!(artifact.contains("\"main\""), "artifact must embed the compiled method: {artifact}"); + + reset_std_scope(); + fs::remove_dir_all(dir).ok(); + } + + #[test] + #[serial_test::serial] + fn compile_source_workspace_full_compiles_resolver_packages_with_dependencies() { + use std::sync::Arc; + + use psy_package::{resolve_source_workspace, MemoryResolver, PackageId, PackageSources, RelativeFilePath}; + + let mut resolver = MemoryResolver::default(); + let files = |source: &'static str| { + let mut map = std::collections::BTreeMap::new(); + map.insert(RelativeFilePath::new("src/main.psy".to_string()), Arc::::from(source)); + map + }; + resolver.insert_package( + PackageId::Virtual("root".to_string()), + PackageSources { + manifest: Arc::::from("[package]\nname = \"root\"\ntype = \"bin\"\nentry = \"src/main.psy\"\nauthors = [\"\"]\n\n[dependencies]\nutil = \"util\"\n"), + files: files("fn main() -> Felt { return 1; }"), + }, + ); + resolver.insert_package( + PackageId::Virtual("util".to_string()), + PackageSources { + manifest: Arc::::from("[package]\nname = \"util\"\ntype = \"lib\"\nentry = \"src/main.psy\"\nauthors = [\"\"]\n\n[dependencies]\n"), + files: files("pub fn helper() -> Felt { return 41; }"), + }, + ); + resolver.insert_dependency(PackageId::Virtual("root".to_string()), "util", PackageId::Virtual("util".to_string())); + let source_workspace = resolve_source_workspace(PackageId::Virtual("root".to_string()), &resolver) + .expect("source workspace must resolve"); + + let result = super::compile_source_workspace_full( + &source_workspace, + &CompileOptions { + method_names: Some(vec!["main".to_string()]), + ..CompileOptions::default() + }, + ) + .expect("in-memory workspace must compile"); + assert_eq!(result.circuit_definitions.len(), 1); + reset_std_scope(); + } +} diff --git a/psy-dargo-cli/src/cli/complete_cmd.rs b/psy-dargo-cli/src/cli/complete_cmd.rs index fe3a165a5..62397412c 100644 --- a/psy-dargo-cli/src/cli/complete_cmd.rs +++ b/psy-dargo-cli/src/cli/complete_cmd.rs @@ -12,18 +12,48 @@ pub(crate) struct CompleteCommand { } pub(crate) fn run(command: CompleteCommand) -> Result<(), CliError> { - let shell = match command.shell.to_lowercase().as_str() { - "bash" => Shell::Bash, - "elvish" => Shell::Elvish, - "fish" => Shell::Fish, - "powershell" => Shell::PowerShell, - "zsh" => Shell::Zsh, + let shell = parse_shell(&command.shell)?; + clap_complete::generate(shell, &mut DargoCli::command(), "dargo", &mut std::io::stdout()); + Ok(()) +} + +fn parse_shell(shell: &str) -> Result { + match shell.to_lowercase().as_str() { + "bash" => Ok(Shell::Bash), + "elvish" => Ok(Shell::Elvish), + "fish" => Ok(Shell::Fish), + "powershell" => Ok(Shell::PowerShell), + "zsh" => Ok(Shell::Zsh), _ => { return Err(CliError::Generic( "Invalid shell. Supported shells are: bash, elvish, fish, powershell, zsh".to_string(), )); } - }; - clap_complete::generate(shell, &mut DargoCli::command(), "dargo", &mut std::io::stdout()); - Ok(()) + } +} + +#[cfg(test)] +mod tests { + use clap_complete::Shell; + + use super::{parse_shell, run, CompleteCommand}; + + #[test] + fn shell_parser_accepts_supported_names_case_insensitively() { + for (name, expected) in [("bash", Shell::Bash), ("ELVISH", Shell::Elvish), ("Fish", Shell::Fish), ("powershell", Shell::PowerShell), ("zsh", Shell::Zsh)] { + assert_eq!(parse_shell(name).unwrap(), expected); + } + } + + #[test] + fn shell_parser_rejects_unknown_names() { + let error = parse_shell("cmd").expect_err("unsupported shell must be rejected"); + assert!(error.to_string().contains("Supported shells")); + } + + #[test] + fn run_emits_the_completion_script_for_a_supported_shell() { + run(CompleteCommand { shell: "bash".to_string() }) + .expect("completion generation must succeed for a supported shell"); + } } diff --git a/psy-dargo-cli/src/cli/doc_cmd.rs b/psy-dargo-cli/src/cli/doc_cmd.rs index 4f73d20d0..d676f470d 100644 --- a/psy-dargo-cli/src/cli/doc_cmd.rs +++ b/psy-dargo-cli/src/cli/doc_cmd.rs @@ -341,12 +341,204 @@ mod tests { use num_traits::Num; use plonky2::field::fft::ifft; - use psy_ast::Location; + use psy_ast::{IdentId, Identifier, Location, ModuleNode, Qualifier, Visibility}; use psy_package::{CrateName, Package, PackageType}; + use psy_sema::{ScopeId, Type}; use super::*; use crate::cli::compile_cmd::CompileOptions; + fn line_comment(content: &str) -> psy_ast::Comment { + psy_ast::Comment::new_line(content.to_string(), Location::default()) + } + + fn function_node_with_comments(comments: Vec) -> FunctionNode { + FunctionNode(CheckedFunctionNode { + name: Identifier::new(IdentId(0), Location::default()), + parameters: vec![], + generic_parameters: vec![], + body: None, + qualifier: Qualifier::default(), + return_type: TypeId(1), + return_type_path: None, + scope_id: ScopeId(0), + visibility: Visibility::Public, + attrs: vec![], + type_id: TypeId(1), + comments, + location: Location::default(), + }) + } + + fn circuit_named(name: &str) -> DPNFunctionCircuitDefinition { + DPNFunctionCircuitDefinition { + name: name.to_string(), + method_id: 0, + circuit_inputs: vec![], + circuit_outputs: vec![], + state_commands: vec![], + state_command_resolution_indices: vec![], + assertions: vec![], + definitions: vec![], + events: vec![], + } + } + + #[test] + fn comment_classification_matches_marker_prefixes_with_and_without_slashes() { + let with_slashes = Comment::from(line_comment("// input: 1")); + assert!(with_slashes.is_input_comment()); + assert!(!with_slashes.is_output_comment()); + + let without_slashes = Comment::from(line_comment("output: true")); + assert!(without_slashes.is_output_comment()); + assert!(!without_slashes.is_input_comment()); + + let metadata = Comment::from(line_comment("// description: records a transfer")); + assert!(metadata.is_metadata_comment("description")); + assert!(!metadata.is_metadata_comment("author")); + assert!(!Comment::from(line_comment("plain commentary")).is_metadata_comment("description")); + } + + #[test] + fn parse_value_covers_bools_hex_decimals_and_the_fallback() { + let comment = Comment::from(line_comment("input: ignored")); + assert_eq!(comment.parse_value("TRUE"), CommentParamValue::Bool(true)); + assert_eq!(comment.parse_value("False"), CommentParamValue::Bool(false)); + assert_eq!(comment.parse_value("0x10"), CommentParamValue::Felt(BigUint::from(16u32))); + // Invalid hex digits fall through every parser to the zero fallback. + assert_eq!(comment.parse_value("0xzz"), CommentParamValue::Felt(BigUint::from(0u32))); + assert_eq!(comment.parse_value("42"), CommentParamValue::U32(42)); + let big = BigUint::from_str_radix("99999999999999999999", 10).unwrap(); + assert_eq!(comment.parse_value("99999999999999999999"), CommentParamValue::Felt(big)); + assert_eq!(comment.parse_value("-7"), CommentParamValue::Felt(BigUint::from(0u32))); + assert_eq!(comment.parse_value("junk"), CommentParamValue::Felt(BigUint::from(0u32))); + } + + #[test] + fn input_and_output_parsing_skip_blank_entries_and_require_markers() { + let comment = Comment::from(line_comment("input: 1, , 2 ,")); + assert_eq!( + comment.parse_input_values(), + vec![CommentParamValue::U32(1), CommentParamValue::U32(2)] + ); + + let comment = Comment::from(line_comment("output: true")); + assert_eq!(comment.parse_output_values(), vec![CommentParamValue::Bool(true)]); + + assert!(Comment::from(line_comment("1, 2")).parse_input_values().is_empty()); + assert!(Comment::from(line_comment("input: 1")).parse_output_values().is_empty()); + assert!(Comment::from(line_comment("input:")).parse_input_values().is_empty()); + } + + #[test] + fn metadata_content_returns_the_trimmed_value_for_the_requested_key() { + let comment = Comment::from(line_comment("// description: keeps contracts documented ")); + assert_eq!(comment.parse_metadata_content("description").as_deref(), Some("keeps contracts documented")); + assert_eq!(comment.parse_metadata_content("author"), None); + } + + #[test] + fn function_nodes_aggregate_comment_inputs_outputs_and_metadata() { + let documented = function_node_with_comments(vec![ + line_comment("input: 1"), + line_comment("input: true"), + line_comment("// output: 0x10"), + line_comment("description: sums the grid"), + line_comment("author: psy"), + line_comment("priority: high"), + line_comment("foo-bar: dropped key"), + line_comment("plain note"), + ]); + assert!(documented.is_input_comment()); + assert_eq!( + documented.get_input_parameters_from_comments(), + vec![CommentParamValue::U32(1), CommentParamValue::Bool(true)] + ); + assert_eq!( + documented.get_output_expectations_from_comments(), + vec![CommentParamValue::Felt(BigUint::from(16u32))] + ); + assert_eq!(documented.get_metadata("description").as_deref(), Some("sums the grid")); + assert_eq!(documented.get_metadata("missing"), None); + + let metadata = documented.get_all_metadata(); + assert_eq!(metadata.get("description").map(String::as_str), Some("sums the grid")); + assert_eq!(metadata.get("author").map(String::as_str), Some("psy")); + assert_eq!(metadata.get("priority").map(String::as_str), Some("high")); + assert!(!metadata.contains_key("input")); + assert!(!metadata.contains_key("output")); + assert!(!metadata.contains_key("foo-bar")); + + let silent = function_node_with_comments(vec![line_comment("no markers here")]); + assert!(!silent.is_input_comment()); + assert!(silent.get_input_parameters_from_comments().is_empty()); + assert!(silent.get_output_expectations_from_comments().is_empty()); + assert!(silent.get_metadata("description").is_none()); + assert!(silent.get_all_metadata().is_empty()); + } + + #[test] + fn param_conversion_covers_every_comment_value_kind() { + assert_eq!(convert_param_to_field(&CommentParamValue::Bool(true)), GoldilocksField::ONE); + assert_eq!(convert_param_to_field(&CommentParamValue::Bool(false)), GoldilocksField::ZERO); + assert_eq!(convert_param_to_field(&CommentParamValue::U32(7)), GoldilocksField::from_noncanonical_u64(7)); + let felt = BigUint::from_str_radix("123456789abcdef", 16).unwrap(); + assert_eq!( + convert_param_to_field(&CommentParamValue::Felt(felt.clone())), + GoldilocksField::from_noncanonical_biguint(felt) + ); + } + + #[test] + fn metadata_extraction_keeps_only_input_commented_methods_present_in_symbols() { + use psy_common::FileId; + use psy_common::tree::TreeNode; + use psy_sema::CheckedProgram; + + let program = psy_ast::Program::::new(); + let mut ctx = TypeCheckerVisitorContext::::new(program); + let mut typechecker = TypeChecker::new( + CheckedProgram::new(), + Box::new(psy_interpreter::Interpreter::::new(QExecContext::new())), + ); + + // Seed the root module so lookups against ModuleId::root() resolve. + let root = TreeNode::new( + ModuleId::root(), + ModuleNode { + name: Identifier::new(IdentId(0), Location::default()), + file_id: FileId(0), + modules: vec![], + inline_modules: vec![], + definitions: vec![], + visibility: Visibility::Public, + comments: vec![], + location: Location::default(), + }, + ); + ctx.symbols.load_modules(std::iter::once(&root)); + + let documented = function_node_with_comments(vec![line_comment("input: 1")]).0; + let priced_ident = ctx.intern("priced"); + ctx.symbols + .add_type(Some(ScopeId(0)), priced_ident, Type::Function(documented)) + .expect("documented function type registers"); + let silent = function_node_with_comments(vec![]); + let silent_ident = ctx.intern("silent"); + ctx.symbols + .add_type(Some(ScopeId(0)), silent_ident, Type::Function(silent.0)) + .expect("silent function type registers"); + + let compile_results = vec![circuit_named("priced"), circuit_named("silent"), circuit_named("ghost")]; + let metadata = extract_function_metadata_from_context(&mut ctx, &mut typechecker, &compile_results); + assert_eq!(metadata.len(), 1, "only the input-commented contract method is kept: {metadata:?}"); + assert!(metadata.contains_key("priced")); + + assert!(find_contract_method_by_name(&mut ctx, &mut typechecker, "priced".to_string()).is_some()); + assert!(find_contract_method_by_name(&mut ctx, &mut typechecker, "ghost".to_string()).is_none()); + } + #[test] fn test_parse_input_values() { let comment = Comment::from(psy_ast::Comment::new_line("input: 1, 2, 3".to_string(), Location::default())); diff --git a/psy-dargo-cli/src/cli/execute_cmd.rs b/psy-dargo-cli/src/cli/execute_cmd.rs index cffd7afc1..47a4f95cd 100644 --- a/psy-dargo-cli/src/cli/execute_cmd.rs +++ b/psy-dargo-cli/src/cli/execute_cmd.rs @@ -92,3 +92,125 @@ pub(crate) async fn run(mut args: ExecuteCommand, workspace: Workspace) -> crate Ok(()) } + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use psy_package::{resolve_workspace_from_toml, Workspace}; + use serial_test::serial; + + use super::{parse_vec_u64, run, ExecuteCommand}; + use crate::cli::compile_cmd::CompileOptions; + + fn compile_options() -> CompileOptions { + CompileOptions { contract_name: None, method_names: None, entry_path: None, debug: false } + } + + /// Materializes `source` as a throwaway binary workspace and resolves it. + /// The entry file keeps an identifier stem so the derived module name stays valid. + fn temp_workspace(source: &str, label: &str) -> (PathBuf, Workspace) { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos(); + let dir = std::env::temp_dir().join(format!("psy_dargo_exec_{label}_{nanos}")); + fs::create_dir_all(dir.join("src")).expect("create src"); + fs::write(dir.join("src").join("app.psy"), source).expect("write app.psy"); + fs::write( + dir.join("Dargo.toml"), + "[package]\nname = \"execdemo\"\ntype = \"bin\"\nentry = \"src/app.psy\"\nauthors = [\"\"]\n\n[dependencies]\n", + ) + .expect("write Dargo.toml"); + let workspace = resolve_workspace_from_toml(&dir.join("Dargo.toml")).expect("resolve workspace"); + (dir, workspace) + } + + fn reset_std_scope() { + #[allow(static_mut_refs)] + unsafe { + psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); + } + } + + #[test] + fn parse_vec_u64_splits_comma_separated_values() { + assert_eq!(parse_vec_u64("1,2,3").unwrap(), vec![1, 2, 3]); + assert_eq!(parse_vec_u64("7").unwrap(), vec![7]); + assert!(parse_vec_u64("").is_err()); + assert!(parse_vec_u64("1,x,3").is_err()); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn run_reports_compile_errors_before_proving() { + let (dir, workspace) = temp_workspace("fn main() -> Felt { return true; }", "bad_type"); + let error = run( + ExecuteCommand { compile_options: compile_options(), parameters: vec![], doc: false }, + workspace, + ) + .await + .expect_err("type error must abort before any proving"); + assert!( + error.to_string().to_lowercase().contains("typemismatch"), + "expected a type mismatch error, got: {error}" + ); + reset_std_scope(); + fs::remove_dir_all(dir).ok(); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn run_executes_program_and_proves_result() { + let (dir, workspace) = + temp_workspace("fn main(a: Felt) -> Felt { return a + 1; }", "add_one"); + run( + ExecuteCommand { compile_options: compile_options(), parameters: vec![vec![41]], doc: false }, + workspace, + ) + .await + .expect("simple add program must compile, execute, and prove"); + reset_std_scope(); + fs::remove_dir_all(dir).ok(); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn doc_flag_delegates_to_run_doc_and_checks_commented_output() { + let source = "// input: 41\n// output: 42\nfn main(a: Felt) -> Felt { return a + 1; }\n"; + let (dir, workspace) = temp_workspace(source, "doc_mode"); + run( + ExecuteCommand { compile_options: compile_options(), parameters: vec![], doc: true }, + workspace, + ) + .await + .expect("doc mode must replay the commented input and match the commented output"); + reset_std_scope(); + fs::remove_dir_all(dir).ok(); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn doc_mode_with_debug_flag_prints_function_metadata_and_results() { + let source = "// input: 41\n// output: 42\nfn main(a: Felt) -> Felt { return a + 1; }\n"; + let (dir, workspace) = temp_workspace(source, "doc_debug"); + run( + ExecuteCommand { + compile_options: CompileOptions { + contract_name: None, + method_names: Some(vec!["main".to_string()]), + entry_path: None, + debug: true, + }, + parameters: vec![], + doc: true, + }, + workspace, + ) + .await + .expect("debug doc mode must replay the commented input and dump diagnostics"); + reset_std_scope(); + fs::remove_dir_all(dir).ok(); + } +} diff --git a/psy-dargo-cli/src/cli/fmt_cmd.rs b/psy-dargo-cli/src/cli/fmt_cmd.rs index 8992153af..e545cd9f5 100644 --- a/psy-dargo-cli/src/cli/fmt_cmd.rs +++ b/psy-dargo-cli/src/cli/fmt_cmd.rs @@ -19,3 +19,113 @@ pub(crate) fn run(args: FmtCommand) -> Result<()> { write_to_file(formatted_content.as_bytes(), &entry)?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use psy_interpreter::Interpreter; + use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; + use serial_test::serial; + + use super::{run, FmtCommand}; + + #[test] + fn formatter_reports_missing_input_file() { + let error = run(FmtCommand { + file: PathBuf::from("target/does-not-exist-for-format-test.psy"), + }) + .expect_err("formatting a missing file must fail"); + let rendered = error.to_string(); + assert!(!rendered.is_empty()); + } + + #[test] + #[serial] + fn formatter_run_rewrites_the_file_in_place() { + let entry = std::env::temp_dir().join("fmt_in_place_main.psy"); + std::fs::write(&entry, "use std::prelude::*;\nfn main()->Felt{return 0;}\n").unwrap(); + + run(FmtCommand { file: entry.clone() }).expect("fmt must succeed on a valid file"); + + let formatted = std::fs::read_to_string(&entry).unwrap(); + let _ = std::fs::remove_file(&entry); + assert!(formatted.contains("fn main() -> Felt {"), "unexpected output:\n{formatted}"); + + // The workspace-relative std discovery walks up from the manifest dir + // when DARGO_STD_PATH is not set. + let context = find_std_module_above(env!("CARGO_MANIFEST_DIR"), "context.psy"); + assert!(context.is_file(), "{} must exist", context.display()); + } + + /// Resolve a module of the std root the parser will actually use: + /// `DARGO_STD_PATH` wins when set (toolchain installs live elsewhere), + /// otherwise the checked-in `psy-std/` next to this workspace. + fn std_module_path(name: &str) -> PathBuf { + if let Ok(std_path) = std::env::var("DARGO_STD_PATH") { + if let Some(parent) = PathBuf::from(std_path).parent() { + return parent.join(name); + } + } + find_std_module_above(env!("CARGO_MANIFEST_DIR"), name) + } + + fn find_std_module_above(start: &str, name: &str) -> PathBuf { + let mut dir = Some(PathBuf::from(start)); + while let Some(current) = dir { + let candidate = current.join("psy-std").join(name); + if candidate.exists() { + return candidate; + } + dir = current.parent().map(std::path::Path::to_path_buf); + } + panic!("psy-std/{name} not found above {start}"); + } + + #[test] + #[serial] + fn formatter_formats_std_intrinsic_wrapper_modules() { + // The formatter skips modules *named* std/prelude/primitive, but the + // context/storage/mem/event modules are only *children* of std β€” + // formatting them drives every raw-intrinsic arm of the formatter, + // which user code can never reach (sema rejects raw intrinsics + // outside std ancestry). + let entry = std::env::temp_dir().join("fmt_std_driver_main.psy"); + std::fs::write(&entry, "use std::prelude::*;\n\nfn main() -> Felt {\n return 0;\n}\n").unwrap(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let typecheck = { + let result = interpreter.typecheck_single(entry.clone()); + #[allow(static_mut_refs)] + unsafe { + let _ = psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); + } + result + }; + let _ = std::fs::remove_file(&entry); + let (_, mut ctx) = typecheck.expect("driver program must typecheck"); + + // The std root module itself is skipped entirely. + let std_root = ctx.format_file(&std_module_path("std.psy")).expect("format std.psy"); + assert_eq!(std_root, ""); + + let context = ctx.format_file(&std_module_path("context.psy")).expect("format context.psy"); + assert!(context.contains("fn get_user_id"), "{context}"); + assert!(context.contains("__ctx_get_user_id()"), "{context}"); + assert!(context.contains("__imt_get_other_user("), "{context}"); + assert!(context.contains("__invoke_sync::("), "{context}"); + assert!(context.contains("__secp256k1_verify("), "{context}"); + + let storage = ctx.format_file(&std_module_path("storage.psy")).expect("format storage.psy"); + assert!(storage.contains("__storage_read("), "{storage}"); + assert!(storage.contains("__storage_write("), "{storage}"); + assert!(storage.contains("__storage_write_range("), "{storage}"); + + let mem = ctx.format_file(&std_module_path("mem.psy")).expect("format mem.psy"); + assert!(mem.contains("__mem_transmute::("), "{mem}"); + assert!(mem.contains("__mem_size_of::()"), "{mem}"); + + let event = ctx.format_file(&std_module_path("event.psy")).expect("format event.psy"); + assert!(event.contains("__emit(self)"), "{event}"); + } +} diff --git a/psy-dargo-cli/src/cli/generate_abi_cmd.rs b/psy-dargo-cli/src/cli/generate_abi_cmd.rs index 5cd41959f..6aebbe51b 100644 --- a/psy-dargo-cli/src/cli/generate_abi_cmd.rs +++ b/psy-dargo-cli/src/cli/generate_abi_cmd.rs @@ -93,3 +93,114 @@ pub(crate) fn run(args: GenerateAbiCommand, workspace: Workspace) -> Result<()> println!("Generate ABI file successfully: {}", abi_path.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use std::{fs, time::{SystemTime, UNIX_EPOCH}}; + + use psy_package::{resolve_workspace_from_toml, Workspace}; + + use super::{run, GenerateAbiCommand}; + + #[test] + fn generate_abi_rejects_path_like_output_name_before_compilation() { + let error = run( + GenerateAbiCommand { + contract_name: "Contract".to_string(), + entry_path: None, + output_dir: None, + abi_name: Some("../escape".to_string()), + pretty: true, + method_names: None, + }, + Workspace::default(), + ) + .expect_err("path-like ABI names must be rejected before compilation"); + assert!(error.to_string().contains("Invalid artifact name")); + } + + #[test] + #[serial_test::serial] + fn generate_abi_compiles_a_contract_and_writes_the_abi_file() { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos(); + let dir = std::env::temp_dir().join(format!("psy_dargo_genabi_{nanos}")); + fs::create_dir_all(dir.join("src")).expect("create src"); + fs::write( + dir.join("src").join("app.psy"), + "#[contract]\npub struct Demo {}\n#[contract::write_method]\nfn main() { assert_eq(1, 1, \"ok\"); }\n", + ) + .expect("write app.psy"); + fs::write( + dir.join("Dargo.toml"), + "[package]\nname = \"genabidemo\"\ntype = \"bin\"\nentry = \"src/app.psy\"\nauthors = [\"\"]\n\n[dependencies]\n", + ) + .expect("write Dargo.toml"); + let workspace = resolve_workspace_from_toml(&dir.join("Dargo.toml")).expect("resolve workspace"); + + let output_dir = dir.join("target"); + run( + GenerateAbiCommand { + contract_name: "Demo".to_string(), + entry_path: None, + output_dir: Some(output_dir.clone()), + abi_name: Some("demo_abi".to_string()), + pretty: true, + method_names: Some(vec!["main".to_string()]), + }, + workspace, + ) + .expect("generate-abi must compile the contract and write the ABI file"); + + let abi = fs::read_to_string(output_dir.join("demo_abi.abi.json")).expect("ABI file must exist"); + assert!(abi.contains("\"main\""), "ABI must list the compiled method: {abi}"); + + #[allow(static_mut_refs)] + unsafe { + psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); + } + fs::remove_dir_all(dir).ok(); + } + + #[test] + #[serial_test::serial] + fn generate_abi_defaults_the_stem_to_the_contract_and_the_output_to_the_target_dir() { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos(); + let dir = std::env::temp_dir().join(format!("psy_dargo_genabi_default_{nanos}")); + fs::create_dir_all(dir.join("src")).expect("create src"); + fs::write( + dir.join("src").join("app.psy"), + "#[contract]\npub struct Demo {}\n#[contract::write_method]\nfn main() { assert_eq(1, 1, \"ok\"); }\n", + ) + .expect("write app.psy"); + fs::write( + dir.join("Dargo.toml"), + "[package]\nname = \"genabidefault\"\ntype = \"bin\"\nentry = \"src/app.psy\"\nauthors = [\"\"]\n\n[dependencies]\n", + ) + .expect("write Dargo.toml"); + let workspace = resolve_workspace_from_toml(&dir.join("Dargo.toml")).expect("resolve workspace"); + let target_dir = workspace.target_dir.clone(); + + run( + GenerateAbiCommand { + contract_name: "Demo".to_string(), + entry_path: None, + output_dir: None, + abi_name: None, + pretty: true, + method_names: Some(vec!["main".to_string()]), + }, + workspace, + ) + .expect("generate-abi must fall back to the contract-named output in the target dir"); + + let abi = fs::read_to_string(target_dir.join("Demo.abi.json")) + .expect("the default ABI file must land in the workspace target dir"); + assert!(abi.contains("\"main\""), "ABI must list the compiled method: {abi}"); + + #[allow(static_mut_refs)] + unsafe { + psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); + } + fs::remove_dir_all(dir).ok(); + } +} diff --git a/psy-dargo-cli/src/cli/init_cmd.rs b/psy-dargo-cli/src/cli/init_cmd.rs index 1d7f14f57..3528589da 100644 --- a/psy-dargo-cli/src/cli/init_cmd.rs +++ b/psy-dargo-cli/src/cli/init_cmd.rs @@ -65,3 +65,83 @@ authors = [""] }; println!("Project successfully created! It is located at {}", package_dir.display()); } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + fn temporary_project_dir(suffix: &str) -> PathBuf { + std::env::temp_dir().join(format!("psy-dargo-init-test-{}-{suffix}", std::process::id())) + } + + #[test] + fn initialize_project_writes_binary_and_library_templates() { + for (suffix, package_type, entry) in [("bin", PackageType::Binary, "main.psy"), ("lib", PackageType::Library, "lib.psy")] { + let directory = temporary_project_dir(suffix); + let _ = fs::remove_dir_all(&directory); + initialize_project(directory.clone(), "demo".parse().unwrap(), package_type); + + let manifest = fs::read_to_string(directory.join("Dargo.toml")).unwrap(); + assert!(manifest.contains("name = \"demo\"")); + assert!(manifest.contains(&format!("type = \"{package_type}\""))); + assert!(directory.join("src").join(entry).is_file()); + fs::remove_dir_all(directory).unwrap(); + } + } + + #[test] + fn run_uses_the_explicit_name_and_the_requested_template() { + let directory = temporary_project_dir("run-explicit"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + + run( + InitCommand { name: Some("demo".parse().unwrap()), lib: true, bin: false }, + DargoConfig { program_dir: directory.clone(), target_dir: None }, + ) + .expect("explicit name with the lib flag must initialize a library project"); + + let manifest = fs::read_to_string(directory.join("Dargo.toml")).unwrap(); + assert!(manifest.contains("name = \"demo\"")); + assert!(manifest.contains("type = \"lib\"")); + assert!(directory.join("src").join("lib.psy").is_file()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn run_derives_the_package_name_from_a_valid_directory_name() { + let root = std::env::temp_dir().join(format!("psy-dargo-init-test-{}-run-default", std::process::id())); + let _ = fs::remove_dir_all(&root); + let directory = root.join("derived_name"); + fs::create_dir_all(&directory).unwrap(); + + run( + InitCommand { name: None, lib: false, bin: true }, + DargoConfig { program_dir: directory.clone(), target_dir: None }, + ) + .expect("a valid directory name must initialize a binary project"); + + let manifest = fs::read_to_string(directory.join("Dargo.toml")).unwrap(); + assert!(manifest.contains("name = \"derived_name\"")); + assert!(manifest.contains("type = \"bin\"")); + assert!(directory.join("src").join("main.psy").is_file()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn run_rejects_a_directory_name_that_is_not_a_valid_package_name() { + // The temporary directory stem contains '-', which CrateName rejects. + let directory = temporary_project_dir("run-invalid"); + fs::create_dir_all(&directory).unwrap(); + + let error = run( + InitCommand { name: None, lib: false, bin: false }, + DargoConfig { program_dir: directory.clone(), target_dir: None }, + ) + .expect_err("an unparseable directory name must be rejected"); + assert!(matches!(error, CliError::InvalidPackageName(name) if name.contains("run-invalid"))); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/psy-dargo-cli/src/cli/mod.rs b/psy-dargo-cli/src/cli/mod.rs index 6743db083..374ba9914 100644 --- a/psy-dargo-cli/src/cli/mod.rs +++ b/psy-dargo-cli/src/cli/mod.rs @@ -221,4 +221,134 @@ mod artifact_name_tests { .expect_err("path traversal must be rejected"); assert!(matches!(error, CliError::InvalidArtifactName(ref invalid) if invalid == "../escaped")); } + + #[test] + fn artifact_writer_adds_json_extension_and_creates_output_directory() { + let root = std::env::temp_dir().join(format!("psy-dargo-artifact-test-{}", std::process::id())); + let output_dir = root.join("nested/output"); + let _ = std::fs::remove_dir_all(&root); + + let path = save_build_artifact_to_file(&serde_json::json!({"ok": true}), "artifact", &output_dir).unwrap(); + assert_eq!(path, output_dir.join("artifact.json")); + assert_eq!(std::fs::read_to_string(&path).unwrap(), r#"{"ok":true}"#); + + let existing = save_build_artifact_to_file(&serde_json::json!({"ok": false}), "existing.json", &output_dir).unwrap(); + assert_eq!(existing, output_dir.join("existing.json")); + assert_eq!(std::fs::read_to_string(existing).unwrap(), r#"{"ok":false}"#); + std::fs::remove_dir_all(root).unwrap(); + } +} + +#[cfg(test)] +mod cli_path_tests { + use std::path::PathBuf; + + use psy_package::{Dependency, Package, Workspace}; + + use super::{parse_path, resolve_crate_path_graph}; + + #[test] + fn parse_path_resolves_relative_and_preserves_absolute_paths() { + let relative = parse_path("nested/package").unwrap(); + assert!(relative.is_absolute()); + assert!(relative.ends_with("nested/package")); + + let absolute = if cfg!(windows) { r"C:\package" } else { "/package" }; + assert_eq!(parse_path(absolute).unwrap(), PathBuf::from(absolute)); + } + + #[test] + fn crate_path_graph_honors_entry_override_and_deduplicates_dependencies() { + let shared = Package { + root_dir: PathBuf::from("shared"), + entry_path: PathBuf::from("src/lib.psy"), + ..Package::default() + }; + let left = Package { + root_dir: PathBuf::from("left"), + entry_path: PathBuf::from("src/lib.psy"), + dependencies: [("shared".parse().unwrap(), Dependency::Local { package: shared.clone() })] + .into_iter() + .collect(), + ..Package::default() + }; + let root = Package { + root_dir: PathBuf::from("root"), + entry_path: PathBuf::from("src/main.psy"), + dependencies: [("left".parse().unwrap(), Dependency::Local { package: left.clone() })] + .into_iter() + .chain([("shared".parse().unwrap(), Dependency::Local { package: shared })]) + .collect(), + ..Package::default() + }; + let workspace = Workspace { + package: root, + ..Workspace::default() + }; + + let graph = resolve_crate_path_graph(&workspace, Some(PathBuf::from("root/src/alternate.psy"))); + assert_eq!(graph.nodes().len(), 3); + assert!(graph.contains_node(&PathBuf::from("root/root/src/alternate.psy"))); + assert!(graph.contains_node(&PathBuf::from("left/src/lib.psy"))); + assert!(graph.contains_node(&PathBuf::from("shared/src/lib.psy"))); + } + + #[test] + fn with_workspace_resolves_the_manifest_and_applies_the_target_override() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos(); + let dir = std::env::temp_dir().join(format!("psy_dargo_ws_{nanos}")); + std::fs::create_dir_all(dir.join("src")).expect("create src"); + std::fs::write(dir.join("src").join("app.psy"), "fn main() {}\n").expect("write app.psy"); + std::fs::write( + dir.join("Dargo.toml"), + "[package]\nname = \"wsdemo\"\ntype = \"bin\"\nentry = \"src/app.psy\"\nauthors = [\"\"]\n\n[dependencies]\n", + ) + .expect("write Dargo.toml"); + + let custom_target = dir.join("custom-target"); + let config = super::DargoConfig { program_dir: dir.clone(), target_dir: Some(custom_target.clone()) }; + let seen = super::with_workspace((), config, |_cmd, workspace: psy_package::Workspace| { + assert_eq!(workspace.target_dir, custom_target, "target override must be applied"); + Ok(()) + }); + seen.expect("with_workspace must resolve the temp manifest and run the command"); + + // A program dir without a manifest cannot resolve a workspace. + let empty = std::env::temp_dir().join(format!("psy_dargo_ws_empty_{nanos}")); + std::fs::create_dir_all(&empty).expect("create empty dir"); + let config = super::DargoConfig { program_dir: empty.clone(), target_dir: None }; + super::with_workspace((), config, |_cmd, _workspace: psy_package::Workspace| Ok(())) + .expect_err("a manifest-less directory must fail resolution"); + + std::fs::remove_dir_all(dir).ok(); + std::fs::remove_dir_all(empty).ok(); + } + + #[tokio::test] + async fn with_workspace_async_resolves_the_manifest_and_applies_the_target_override() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos(); + let dir = std::env::temp_dir().join(format!("psy_dargo_ws_async_{nanos}")); + std::fs::create_dir_all(dir.join("src")).expect("create src"); + std::fs::write(dir.join("src").join("app.psy"), "fn main() {}\n").expect("write app.psy"); + std::fs::write( + dir.join("Dargo.toml"), + "[package]\nname = \"wsasync\"\ntype = \"bin\"\nentry = \"src/app.psy\"\nauthors = [\"\"]\n\n[dependencies]\n", + ) + .expect("write Dargo.toml"); + + let custom_target = dir.join("custom-target"); + let config = super::DargoConfig { program_dir: dir.clone(), target_dir: Some(custom_target.clone()) }; + super::with_workspace_async((), config, |_cmd, workspace: psy_package::Workspace| async move { + assert_eq!(workspace.target_dir, custom_target, "target override must be applied"); + Ok(()) + }) + .await + .expect("with_workspace_async must resolve the temp manifest and run the command"); + + std::fs::remove_dir_all(dir).ok(); + } } diff --git a/psy-dargo-cli/src/cli/new_cmd.rs b/psy-dargo-cli/src/cli/new_cmd.rs index a2eef1cd0..13b4a81df 100644 --- a/psy-dargo-cli/src/cli/new_cmd.rs +++ b/psy-dargo-cli/src/cli/new_cmd.rs @@ -50,3 +50,99 @@ pub(crate) fn run(args: NewCommand, config: DargoConfig) -> Result<()> { initialize_project(package_dir, package_name, package_type); Ok(()) } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn new_rejects_an_existing_destination_before_name_resolution() { + let root = std::env::temp_dir().join(format!("psy-dargo-new-test-{}", std::process::id())); + let destination = root.join("existing"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&destination).unwrap(); + + let error = run( + NewCommand { + path: PathBuf::from("existing"), + name: Some("demo".parse().unwrap()), + lib: false, + bin: false, + contract: false, + }, + DargoConfig { + program_dir: root.clone(), + target_dir: None, + }, + ) + .expect_err("existing destination must be rejected"); + assert!(matches!(error, CliError::DestinationAlreadyExists(path) if path == destination)); + fs::remove_dir_all(root).unwrap(); + } + + fn config_under(root: &std::path::Path) -> DargoConfig { + DargoConfig { program_dir: root.to_path_buf(), target_dir: None } + } + + #[test] + fn new_creates_a_library_project_with_an_explicit_name() { + let root = std::env::temp_dir().join(format!("psy-dargo-new-test-lib-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + + run( + NewCommand { + path: PathBuf::from("fresh_lib"), + name: Some("demo".parse().unwrap()), + lib: true, + bin: false, + contract: false, + }, + config_under(&root), + ) + .expect("a fresh destination must be initialized"); + + let project = root.join("fresh_lib"); + let manifest = fs::read_to_string(project.join("Dargo.toml")).unwrap(); + assert!(manifest.contains("name = \"demo\"")); + assert!(manifest.contains("type = \"lib\"")); + assert!(project.join("src").join("lib.psy").is_file()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn new_derives_the_package_name_from_the_path() { + let root = std::env::temp_dir().join(format!("psy-dargo-new-test-derive-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + + run( + NewCommand { path: PathBuf::from("fresh"), name: None, lib: false, bin: false, contract: true }, + config_under(&root), + ) + .expect("a fresh destination must be initialized"); + + let project = root.join("fresh"); + let manifest = fs::read_to_string(project.join("Dargo.toml")).unwrap(); + assert!(manifest.contains("name = \"fresh\"")); + assert!(project.join("src").join("main.psy").is_file()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn new_rejects_a_path_that_is_not_a_valid_package_name() { + let root = std::env::temp_dir().join(format!("psy-dargo-new-test-invalid-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + + let error = run( + NewCommand { path: PathBuf::from("0fresh"), name: None, lib: false, bin: false, contract: false }, + config_under(&root), + ) + .expect_err("a path starting with a digit is not a valid package name"); + assert!(matches!(error, CliError::InvalidPackageName(name) if name == "0fresh")); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/psy-dargo-cli/src/cli/test_cmd.rs b/psy-dargo-cli/src/cli/test_cmd.rs index afc81e4be..782c95e42 100644 --- a/psy-dargo-cli/src/cli/test_cmd.rs +++ b/psy-dargo-cli/src/cli/test_cmd.rs @@ -72,6 +72,10 @@ pub(crate) async fn run(args: TestCommand) -> crate::errors::Result<()> { #[cfg(test)] mod tests { + use std::{fs, time::{SystemTime, UNIX_EPOCH}}; + + use serial_test::serial; + use super::*; #[tokio::test(flavor = "multi_thread")] #[ignore = "slow end-to-end proving; run serially with `make test-slow`"] @@ -85,4 +89,25 @@ mod tests { }; }); } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn test_command_runs_passing_psy_tests_end_to_end() { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock"); + // Identifier-stem file name keeps the derived module name valid. + let file = std::env::temp_dir().join(format!("psy_dargo_test_ok_{}.psy", nanos.as_nanos())); + fs::write( + &file, + "#[test]\nfn doubles() {\n assert_eq(dbl(21), 42, \"dbl\");\n}\n\nfn dbl(x: Felt) -> Felt {\n return x * 2;\n}\n", + ) + .expect("write test file"); + + run(TestCommand { file: file.clone() }).await.expect("passing #[test] functions must execute and prove"); + + #[allow(static_mut_refs)] + unsafe { + psy_sema::STD_PRIMITIVE_SCOPE_ID.take() + }; + fs::remove_file(file).ok(); + } } diff --git a/psy-interpreter/src/control.rs b/psy-interpreter/src/control.rs index 31e0f24ca..9e7600b6c 100644 --- a/psy-interpreter/src/control.rs +++ b/psy-interpreter/src/control.rs @@ -50,3 +50,73 @@ impl ControlState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A pipeline using both `?` forms: propagating an early `Return` state and + /// converting a `Result` error into a `Return` state. + fn pipeline(control: ControlState>) -> ControlState> { + let result = control?; + let value = result?; + ControlState::from_output(Ok(value + 1)) + } + + #[test] + fn try_trait_continues_from_normal_output() { + assert_eq!(pipeline(ControlState::Normal(Ok(41))), ControlState::Normal(Ok(42))); + } + + #[test] + fn try_trait_propagates_early_returns_unchanged() { + let early = ControlState::Return(Err("early".to_string())); + assert_eq!(pipeline(early.clone()), early); + } + + #[test] + fn try_trait_converts_result_errors_into_returns() { + assert_eq!( + pipeline(ControlState::Normal(Err("boom".to_string()))), + ControlState::Return(Err("boom".to_string())) + ); + } + + #[test] + fn unwrap_yields_the_payload_of_both_variants() { + assert_eq!(ControlState::Normal(7).unwrap(), 7); + assert_eq!(ControlState::Return(8).unwrap(), 8); + } + + #[test] + fn enum_accessors_distinguish_the_variants() { + let normal = ControlState::Normal(1); + let ret = ControlState::Return(2); + assert!(normal.is_normal() && !normal.is_return()); + assert!(ret.is_return() && !ret.is_normal()); + assert_eq!(normal.as_normal(), Some(&1)); + assert_eq!(normal.as_return(), None); + assert_eq!(ret.as_return(), Some(&2)); + assert_eq!(ret.as_normal(), None); + } + + #[test] + fn mutable_and_consuming_accessors_round_trip() { + let mut normal = ControlState::Normal(1); + if let Some(value) = normal.as_normal_mut() { + *value += 1; + } + assert_eq!(normal.as_normal(), Some(&2)); + + let mut ret = ControlState::Return(3); + if let Some(value) = ret.as_return_mut() { + *value *= 2; + } + assert_eq!(ret.as_return(), Some(&6)); + + assert_eq!(ControlState::Normal(4).into_normal(), Ok(4)); + assert_eq!(ControlState::Normal(4).into_return(), Err(ControlState::Normal(4))); + assert_eq!(ControlState::Return(5).into_return(), Ok(5)); + assert_eq!(ControlState::Return(5).into_normal(), Err(ControlState::Return(5))); + } +} diff --git a/psy-interpreter/src/error.rs b/psy-interpreter/src/error.rs index 2e203940a..b65306438 100644 --- a/psy-interpreter/src/error.rs +++ b/psy-interpreter/src/error.rs @@ -595,3 +595,526 @@ pub fn lowering_interpreter_error + ContextFelt, C>(error: anyhow::Error::from(error).context(context) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use psy_ast::{Location, Program}; + use psy_common::FileId; + use psy_parser::error::ExpectedToken; + use psy_vm::dpn::ops::sym_felt::SymFeltRef; + use psy_sema::{Type, TypeCheckerVisitorContext, TypeId}; + use psy_vm::dpn::ops::exec_context::QExecContext; + + use super::{ + format_expected_pretty, lowering_interpreter_error, lowering_parse_error, lowering_sema_error, parse_error_to_diagnostic, + span_to_range, typecheck_error_to_diagnostic, Error, + }; + + #[test] + fn expected_token_formatting_handles_all_list_lengths() { + assert_eq!(format_expected_pretty(&[]), "(no expected tokens)"); + assert_eq!(format_expected_pretty(&[ExpectedToken::Ident]), "identifier"); + assert_eq!(format_expected_pretty(&[ExpectedToken::Ident, ExpectedToken::Literal]), "identifier or literal"); + assert_eq!( + format_expected_pretty(&[ExpectedToken::Ident, ExpectedToken::Literal, ExpectedToken::Eof]), + "identifier, literal or end of file" + ); + } + + #[test] + fn span_conversion_handles_start_end_cross_line_and_eof_offsets() { + let location = Location::new(FileId(0), 0, 8); + let range = span_to_range(&location, "first\nsecond"); + assert_eq!(range.start.line, 0); + assert_eq!(range.start.character, 0); + assert_eq!(range.end.line, 1); + assert_eq!(range.end.character, 2); + + let eof = span_to_range(&Location::new(FileId(0), 100, 100), "short"); + assert_eq!(eof.start.line, 1); + assert_eq!(eof.start.character, 0); + } + + #[test] + fn non_located_parse_errors_lower_to_plain_text() { + let program = Program::::new(); + let error = psy_parser::Error::InvalidModuleName; + assert_eq!(lowering_parse_error(&error, &program), "Invalid module name"); + + let error = psy_parser::Error::NoEntryModule(PathBuf::from("missing.psy")); + assert_eq!(lowering_parse_error(&error, &program), "missing.psy"); + } + + #[test] + fn interpreter_errors_without_locations_have_stable_messages() { + let ctx = TypeCheckerVisitorContext::::new(Program::new()); + let cases = [ + (Error::UndefinedFunction, "undefined function"), + (Error::AssertionFailure { message: "failed".into(), location: None }, "Assertion failure: failed"), + (Error::DivisionByZero { location: None }, "DivisionByZero: division or remainder by zero"), + (Error::ArithmeticOverflow { location: None }, "ArithmeticOverflow: constant arithmetic overflow"), + (Error::IndexOutOfBounds { index: 3, length: 2, location: None }, "Index out of bounds: index 3 >= length 2."), + (Error::ArrayTooLarge { length: 10, limit: 5, location: None }, "Cannot materialize an array with 10 elements; the interpreter limit is 5."), + (Error::ArrayAllocationFailed { length: 10, location: None }, "Cannot reserve storage for 10 array elements."), + (Error::UnsupportedRecursion { location: None }, "Recursive calls are unsupported by the symbolic interpreter."), + ]; + for (error, expected) in cases { + let rendered = lowering_interpreter_error(error, &ctx).to_string(); + assert!(rendered.contains(expected), "expected {expected:?} in {rendered:?}"); + } + } + + #[test] + fn interpreter_errors_with_locations_render_source_reports() { + let mut program = Program::::new(); + let file_id = program.file_resolver.add_file(PathBuf::from("error.psy"), "fn main() {}\n"); + let location = Location::new(file_id, 0, 2); + let ctx = TypeCheckerVisitorContext::::new(program); + let cases = [ + (Error::UncertainLoopCondition { loop_location: location }, "UncertainLoopCondition"), + (Error::AssertionFailure { message: "failed".into(), location: Some(location) }, "AssertionFailure"), + (Error::DivisionByZero { location: Some(location) }, "DivisionByZero"), + (Error::ArithmeticOverflow { location: Some(location) }, "ArithmeticOverflow"), + (Error::IndexOutOfBounds { index: 3, length: 2, location: Some(location) }, "IndexOutOfBounds"), + (Error::ArrayTooLarge { length: 10, limit: 5, location: Some(location) }, "ArrayTooLarge"), + (Error::ArrayAllocationFailed { length: 10, location: Some(location) }, "ArrayAllocationFailed"), + (Error::UnsupportedRecursion { location: Some(location) }, "UnsupportedRecursion"), + ]; + for (error, code) in cases { + let rendered = lowering_interpreter_error(error, &ctx).to_string(); + assert!(rendered.contains(code), "expected report code {code:?} in {rendered:?}"); + assert!(rendered.contains("error.psy"), "expected source path in {rendered:?}"); + } + } + + #[test] + fn parser_errors_become_diagnostics_with_and_without_locations() { + let mut program = Program::::new(); + let file_id = program.file_resolver.add_file(PathBuf::from("parse.psy"), "fn main() {}\n"); + let location = Location::new(file_id, 0, 2); + + let eof = parse_error_to_diagnostic( + &psy_parser::Error::UnexpectedEof { + expected: vec![ExpectedToken::Ident, ExpectedToken::Literal], + location, + }, + &program, + ); + assert_eq!(eof.file.unwrap(), PathBuf::from("parse.psy")); + assert!(eof.message.contains("identifier or literal")); + assert!(eof.text_range.is_some()); + + let token = parse_error_to_diagnostic( + &psy_parser::Error::UnexpectedToken { + found: "}".into(), + expected: vec![ExpectedToken::Ident], + location, + }, + &program, + ); + assert!(token.message.contains("Unexpected token '}'")); + assert!(token.text_range.is_some()); + + let unsupported = parse_error_to_diagnostic( + &psy_parser::Error::UnsupportedSyntax { + feature: "legacy syntax".into(), + location, + }, + &program, + ); + assert_eq!(unsupported.message, "Unsupported syntax: legacy syntax"); + + let lexical = parse_error_to_diagnostic(&psy_parser::Error::LexicalError { location }, &program); + assert_eq!(lexical.message, "Lexical error"); + + let plain = parse_error_to_diagnostic(&psy_parser::Error::InvalidModuleName, &program); + assert!(plain.file.is_none()); + assert_eq!(plain.message, "Invalid module name"); + } + + #[test] + fn located_parse_errors_render_source_reports() { + let mut program = Program::::new(); + let file_id = program.file_resolver.add_file(PathBuf::from("parse.psy"), "fn main() {}\n"); + let location = Location::new(file_id, 0, 2); + + let cases: Vec<(psy_parser::Error, &str)> = vec![ + ( + psy_parser::Error::UnexpectedEof { + expected: vec![ExpectedToken::Ident], + location, + }, + "UnexpectedEof", + ), + ( + psy_parser::Error::UnexpectedToken { + found: "}".into(), + expected: vec![ExpectedToken::Ident], + location, + }, + "UnexpectedToken", + ), + ( + psy_parser::Error::UnsupportedSyntax { + feature: "legacy syntax".into(), + location, + }, + "UnsupportedSyntax", + ), + (psy_parser::Error::LexicalError { location }, "LexError"), + ]; + for (error, code) in &cases { + let rendered = lowering_parse_error(error, &program); + assert!(rendered.contains(code), "expected report code {code:?} in {rendered:?}"); + assert!(rendered.contains("parse.psy"), "expected source path in {rendered:?}"); + } + } + + #[test] + fn every_non_located_parse_error_lowers_to_its_display_form() { + let program = Program::::new(); + let cases: Vec<(psy_parser::Error, &str)> = vec![ + ( + psy_parser::Error::CommonError(psy_common::Error::Message("common failure".into())), + "common failure", + ), + (psy_parser::Error::IoError(std::io::Error::other("disk full")), "disk full"), + (psy_parser::Error::FileUnresolved, "File could not be resolved"), + ( + psy_parser::Error::FileParsedMultipleTimes(PathBuf::from("dup.psy")), + // This arm lowers to the bare path, not the Display form. + "dup.psy", + ), + ( + psy_parser::Error::ExternFnNotInStd, + "Extern function can only be defined in std", + ), + (psy_parser::Error::FunctionBodyMissing, "Missing function body"), + (psy_parser::Error::InvalidSelfParameter, "Invalid self parameter"), + ]; + for (error, expected) in &cases { + assert_eq!(lowering_parse_error(error, &program), *expected); + } + } + + fn sema_error_fixture() -> (TypeCheckerVisitorContext, Location, psy_ast::IdentId, TypeId) { + let mut program = Program::::new(); + let file_id = program.file_resolver.add_file(PathBuf::from("sema.psy"), "fn main() {}\n"); + let location = Location::new(file_id, 0, 2); + let mut ctx = TypeCheckerVisitorContext::::new(program); + let ident = ctx.program.interner.intern_ident("counter"); + ctx.symbols.types.push(Type::Felt); + (ctx, location, ident, TypeId::from(0)) + } + + #[test] + fn every_sema_error_renders_a_located_report() { + let (mut ctx, location, ident, ty) = sema_error_fixture(); + + let cases: Vec<(psy_sema::Error, &str)> = vec![ + ( + psy_sema::Error::UnsupportedRecursion { + location, + what: "functions", + }, + "UnsupportedRecursion", + ), + ( + psy_sema::Error::TypeMismatch { + location, + expected: vec![ty], + found: ty, + }, + "TypeMismatch", + ), + ( + psy_sema::Error::InvalidPathSegment { + location, + segment: "::".into(), + }, + "InvalidPathSegment", + ), + ( + psy_sema::Error::UnresolvedType { + location, + resolved_type: ident, + }, + "UnresolvedType", + ), + ( + psy_sema::Error::TraitAlreadyImplemented { + location, + trait_ty: ty, + ty, + }, + "TraitAlreadyImplemented", + ), + ( + psy_sema::Error::VariableAlreadyDefined { + location, + variable: ident, + }, + "VariableAlreadyDefined", + ), + ( + psy_sema::Error::ImmutableVariable { + location, + variable: ident, + }, + "ImmutableVariable", + ), + ( + psy_sema::Error::UnresolvedMember { + location, + member_name: ident, + }, + "UnresolvedMember", + ), + (psy_sema::Error::NotCallable { location, ty }, "NotCallable"), + ( + psy_sema::Error::UnresolvedTraitMethod { + method_location: location, + method_name: ident, + trait_name: ident, + }, + "UnresolvedTraitMethod", + ), + ( + psy_sema::Error::InvalidGenericArguments { + location, + expected: "1".into(), + found: "2".into(), + }, + "GenericParameterMismatch", + ), + ( + psy_sema::Error::InvalidFunctionArguments { + location, + method_name: ty, + expected: "1".into(), + found: "2".into(), + }, + "InvalidFunctionCall", + ), + ( + psy_sema::Error::InvalidReturn { + location, + message: "bad return".into(), + }, + "InvalidReturn", + ), + (psy_sema::Error::InvalidGenericConstraint { location }, "InvalidGenericConstraint"), + (psy_sema::Error::UnreachableExpression { location }, "UnreachableExpression"), + ( + psy_sema::Error::TypeAlreadyDefined { + location, + type_name: ident, + }, + "TypeAlreadyDefined", + ), + ( + psy_sema::Error::MemberNotPublic { + location, + ty, + field: ident, + }, + "MemberNotPublic", + ), + ( + psy_sema::Error::ModuleNotPublic { + location, + module: ident, + }, + "ModuleNotPublic", + ), + (psy_sema::Error::TypeNotPublic { location, ty }, "TypeNotPublic"), + ( + psy_sema::Error::IndexOutOfBounds { + location, + index: 3, + length: 2, + }, + "IndexOutOfBounds", + ), + ( + psy_sema::Error::InvalidCast { + location, + expected: "Felt".into(), + found: "u32".into(), + }, + "InvalidCast", + ), + (psy_sema::Error::DuplicateWildcard { location }, "DuplicateWildcard"), + ( + psy_sema::Error::IncompleteMatch { + location, + message: "missing arms".into(), + }, + "IncompleteMatch", + ), + (psy_sema::Error::NoParentModule { location }, "NoParentModule"), + ( + psy_sema::Error::ModuleNotFound { + location, + module: ident, + }, + "ModuleNotFound", + ), + (psy_sema::Error::SpecializationNotAllowed { location }, "SpecializationNotAllowed"), + ( + psy_sema::Error::MissingAssociatedType { + location, + trait_name: ident, + type_name: ident, + }, + "MissingAssociatedType", + ), + ( + psy_sema::Error::RawIntrinsicOutsideStd { + location, + name: "__raw_intrinsic", + }, + "RawIntrinsicOutsideStd", + ), + (psy_sema::Error::IfWithoutElse { location }, "IfWithoutElse"), + ( + psy_sema::Error::AmbiguousTraitMethod { + location, + method: ident, + traits: vec![ty], + }, + "AmbiguousTraitMethod", + ), + ( + psy_sema::Error::AmbiguousAssociatedType { + location, + member: ident, + traits: vec![ty], + }, + "AmbiguousAssociatedType", + ), + ]; + for (error, code) in &cases { + let rendered = lowering_sema_error(error, &ctx); + assert!(rendered.contains(code), "expected report code {code:?} in {rendered:?}"); + assert!(rendered.contains("sema.psy"), "expected source path in {rendered:?}"); + } + + // Wrapper variants lower to the wrapped Display form with no location. + let plain_cases: Vec<(psy_sema::Error, &str)> = vec![ + (psy_sema::Error::AnyhowError(anyhow::anyhow!("sema boom")), "sema boom"), + ( + psy_sema::Error::CommonError(psy_common::Error::Message("common sema failure".into())), + "common sema failure", + ), + ]; + for (error, needle) in &plain_cases { + let rendered = lowering_sema_error(error, &ctx); + assert_eq!(rendered, *needle); + } + } + + #[test] + fn sema_errors_become_diagnostics_with_ranges_or_fallback_messages() { + let (mut ctx, location, ident, ty) = sema_error_fixture(); + + let located_cases: Vec<(psy_sema::Error, &str)> = vec![ + ( + psy_sema::Error::TypeMismatch { + location, + expected: vec![ty], + found: ty, + }, + "Type mismatch. Expected", + ), + ( + psy_sema::Error::InvalidPathSegment { + location, + segment: "::".into(), + }, + "Invalid path segment: ::", + ), + ( + psy_sema::Error::UnresolvedType { + location, + resolved_type: ident, + }, + "Unresolved type: counter", + ), + ( + psy_sema::Error::VariableAlreadyDefined { + location, + variable: ident, + }, + "Variable already defined: counter", + ), + ( + psy_sema::Error::ImmutableVariable { + location, + variable: ident, + }, + "Variable counter is immutable", + ), + ( + psy_sema::Error::InvalidReturn { + location, + message: "bad return".into(), + }, + "Invalid return: bad return", + ), + ( + psy_sema::Error::RawIntrinsicOutsideStd { + location, + name: "__raw_intrinsic", + }, + "Raw intrinsic `__raw_intrinsic`", + ), + ( + psy_sema::Error::InvalidCast { + location, + expected: "Felt".into(), + found: "u32".into(), + }, + "Invalid cast. Expected Felt, found u32.", + ), + (psy_sema::Error::NoParentModule { location }, "no parent module"), + (psy_sema::Error::UnreachableExpression { location }, "unreachable expression"), + (psy_sema::Error::InvalidGenericConstraint { location }, "invalid generic constraint"), + (psy_sema::Error::DuplicateWildcard { location }, "unreachable code"), + (psy_sema::Error::SpecializationNotAllowed { location }, "Specialization not allowed"), + ]; + for (error, needle) in &located_cases { + let diagnostic = typecheck_error_to_diagnostic(error, &ctx); + assert!(diagnostic.text_range.is_some(), "expected a range for {needle:?}"); + assert_eq!(diagnostic.file.as_ref().unwrap(), &PathBuf::from("sema.psy")); + assert!(diagnostic.message.contains(needle), "expected {needle:?} in {:?}", diagnostic.message); + } + + // Variants without a dedicated diagnostic arm fall back to the Display + // form without a range. + let fallback = typecheck_error_to_diagnostic(&psy_sema::Error::ModuleNotFound { location, module: ident }, &ctx); + assert!(fallback.text_range.is_none()); + assert!(fallback.file.is_none()); + assert!(fallback.message.contains("module not found")); + } + + #[test] + fn passthrough_interpreter_errors_use_their_lowering_paths() { + let mut program = Program::::new(); + let file_id = program.file_resolver.add_file(PathBuf::from("pass.psy"), "fn main() {}\n"); + let location = Location::new(file_id, 0, 2); + let ctx = TypeCheckerVisitorContext::::new(program); + + let io = lowering_interpreter_error(Error::IoError(std::io::Error::other("disk full")), &ctx).to_string(); + assert!(io.contains("disk full"), "expected io message in {io:?}"); + + let parse = lowering_interpreter_error(Error::ParseError(psy_parser::Error::InvalidModuleName), &ctx).to_string(); + assert!(parse.contains("Invalid module name"), "expected parse message in {parse:?}"); + + let sema = lowering_interpreter_error(Error::SemaError(psy_sema::Error::NoParentModule { location }), &ctx).to_string(); + assert!(sema.contains("NoParentModule"), "expected sema report in {sema:?}"); + assert!(sema.contains("pass.psy"), "expected source path in {sema:?}"); + } +} diff --git a/psy-interpreter/src/exec_edge_tests.rs b/psy-interpreter/src/exec_edge_tests.rs new file mode 100644 index 000000000..c75578eec --- /dev/null +++ b/psy-interpreter/src/exec_edge_tests.rs @@ -0,0 +1,344 @@ +// Execution-layer edge coverage for arms that typechecking alone never +// reaches: the binary/unary operator dispatch matrix, symbolic (non-constant) +// index reads and writes through arrays/tuples/structs, array-repeat +// materialization limits, failing assertion reporting, and the +// `interpret_vfs_files` / `typecheck_lsp` entry points. +// +// Every case drives `main` through the interpreter with a stub compile +// function (no DPN proving) and resets the shared STD_PRIMITIVE_SCOPE_ID +// singleton so the suite stays hermetic. All cases are `#[serial]`. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use psy_vm::dpn::{ + ops::{exec_context::QExecContext, sym_felt::SymFeltRef}, + vm::{compile::PsyCompileResult, def::DPNFunctionCircuitDefinition}, +}; +use serial_test::serial; + +use super::*; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +fn stub_compile_fn( + _context: &QExecContext, + (name, method_id, outputs): (String, u32, Vec), +) -> DPNFunctionCircuitDefinition { + DPNFunctionCircuitDefinition { + name, + method_id, + circuit_inputs: vec![], + circuit_outputs: outputs.iter().map(|felt| felt.get_constant_value()).collect(), + state_commands: vec![], + state_command_resolution_indices: vec![], + assertions: vec![], + definitions: vec![], + events: vec![], + } +} + +/// Unused today but kept symmetric with the other exec suites: the real +/// backend, for cases that must observe generated state commands. +#[allow(dead_code)] +fn real_compile_fn( + context: &QExecContext, + (name, method_id, outputs): (String, u32, Vec), +) -> DPNFunctionCircuitDefinition { + PsyCompileResult::compile_exec(name, method_id, &context.store, context, &outputs) +} + +fn reset_primitive_scope() { + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + } +} + +fn exec_source(label: &str, source: &str) -> Result<(), String> { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("psy_exec_{n}.psy")); + std::fs::write(&path, source).unwrap(); + let path_arg = path.clone(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = { + let (mut typechecker, mut ctx) = interpreter + .typecheck_single(path_arg) + .map_err(|e| format!("typecheck: {e:#}"))?; + interpreter + .interpret(&mut typechecker, &mut ctx, None::, vec![], stub_compile_fn) + .map(|_| ()) + .map_err(|e| format!("interpret: {e:#}")) + }; + let _ = std::fs::remove_file(&path); + reset_primitive_scope(); + result +} + +fn exec_accepts(label: &str, source: &str) { + if let Err(message) = exec_source(label, source) { + panic!("[{label}] expected execution success, got:\n{message}"); + } +} + +fn exec_rejects(label: &str, source: &str, needle: &str) { + match exec_source(label, source) { + Ok(()) => panic!("[{label}] expected execution failure containing `{needle}`, got success"), + Err(message) => assert!( + message.to_lowercase().contains(&needle.to_lowercase()), + "[{label}] expected failure containing `{needle}`, got:\n{message}" + ), + } +} + +/// Sweep every binary-operator dispatch arm on all three operand families. +/// `a` is a symbolic input so nothing constant-folds away before dispatch. +#[test] +#[serial] +fn operator_matrix_executes_felt_u32_and_bool_binops() { + exec_accepts( + "felt operator matrix", + r#" +use std::prelude::*; + +fn main(a: Felt) { + let f01 = a + 1; + let f02 = a - 1; + let f03 = a * 2; + let f04 = a / 2; + let f05 = a % 3; + let f06 = a ** 2; + let f07 = a >> 1; + let f08 = a << 1; + let f09 = a & 3; + let f10 = a | 8; + let f11 = a ^ 1; + let b01 = a == 1; + let b02 = a != 1; + let b03 = a < 1; + let b04 = a <= 1; + let b05 = a > 1; + let b06 = a >= 1; + assert_eq(f01, f01, "felt stable"); + assert(b01 || b02, "felt compare"); +} +"#, + ); + exec_accepts( + "u32 operator matrix", + r#" +use std::prelude::*; + +fn main(a: Felt) { + let u = a as u32; + let n01 = u + 1u32; + let n02 = u - 1u32; + let n03 = u * 2u32; + let n04 = u / 2u32; + let n05 = u % 3u32; + let n06 = u ** 2u32; + let n07 = u >> 1u32; + let n08 = u << 1u32; + let n09 = u & 3u32; + let n10 = u | 8u32; + let n11 = u ^ 1u32; + let c01 = u == 1u32; + let c02 = u != 1u32; + let c03 = u < 1u32; + let c04 = u <= 1u32; + let c05 = u > 1u32; + let c06 = u >= 1u32; + assert_eq(n01, n01, "u32 stable"); + assert(c01 || c02, "u32 compare"); +} +"#, + ); + exec_accepts( + "bool operator matrix", + r#" +use std::prelude::*; + +fn main(a: Felt) { + let p = a == 1; + let q = a == 2; + let d01 = p && q; + let d02 = p || q; + let d03 = p ^ q; + let d04 = p == q; + let d05 = p != q; + assert(d01 || d02 || d03 || d04 || d05, "bool ops"); +} +"#, + ); + exec_accepts( + "unary operators execute", + r#" +use std::prelude::*; + +fn main(a: Felt) { + let neg = -a; + let flag = a == 1; + let not = !flag; + assert(neg == neg && !not || not, "unary stable"); +} +"#, + ); +} + +/// Symbolic (non-constant) felt indices take the lane-select paths in +/// `CheckedValueRef::get_path`/`set_path`; tuple and struct targets take the +/// positional/name-based arms. +#[test] +#[serial] +fn symbolic_index_paths_read_and_write_composites() { + exec_accepts( + "symbolic array index read and write", + r#" +use std::prelude::*; + +fn main(a: Felt) { + let mut arr = [10, 20, 30]; + arr[a] = 5; + let v = arr[a]; + assert_eq(v, v, "symbolic readback"); +} +"#, + ); + exec_accepts( + "tuple element write and read", + r#" +use std::prelude::*; + +fn main(a: Felt) { + let mut t = (1, 2); + t.1 = a; + assert_eq(t.1, a, "tuple field"); +} +"#, + ); + exec_accepts( + "struct field write and read", + r#" +use std::prelude::*; + +pub struct Point { pub x: Felt, pub y: Felt } + +fn main(a: Felt) { + let mut p = Point { x: 1, y: 2 }; + p.x = a; + assert_eq(p.x, a, "struct field"); +} +"#, + ); + exec_rejects( + "out-of-range constant index", + r#" +use std::prelude::*; + +fn main() { + let arr = [10, 20, 30]; + assert_eq(arr[5], 1, "unreachable"); +} +"#, + "index", + ); +} + +/// Array repeats materialize at execution; the interpreter caps the element +/// count and rejects oversized repeats before allocating. +#[test] +#[serial] +fn array_repeats_materialize_and_oversized_repeats_reject() { + exec_accepts( + "small array repeat", + r#" +use std::prelude::*; + +fn main() { + let a = [7; 4]; + assert_eq(a[3], 7, "repeat value"); +} +"#, + ); + exec_rejects( + "oversized array repeat", + r#" +use std::prelude::*; + +fn main() { + let a = [0; 1048577]; + assert_eq(a[0], 0, "unreachable"); +} +"#, + "toolarge", + ); +} + +/// Failing constant assertions surface the user message at execution. +#[test] +#[serial] +fn failing_assertions_report_their_messages() { + exec_rejects( + "assert_eq with differing constants", + r#" +use std::prelude::*; + +fn main() { + assert_eq(2, 3, "boom"); +} +"#, + "boom", + ); + exec_rejects( + "assert with a false constant", + r#" +use std::prelude::*; + +fn main() { + assert(false, "nope"); +} +"#, + "nope", + ); +} + +/// The VFS entry point runs an in-memory program without touching the disk. +#[test] +#[serial] +fn vfs_files_interpret_a_virtual_program() { + let source = "use std::prelude::*;\nfn main() { let a = split_bits(3, 2); assert_eq(a[0], 1, \"vfs\"); }"; + let mut graph = Graph::new(); + graph.add_node(std::path::PathBuf::from("/virtual/src/main.psy")); + let result = super::interpret_vfs_files( + None, + vec![], + graph, + vec![(std::path::PathBuf::from("/virtual/src/main.psy"), std::sync::Arc::from(source))], + ); + reset_primitive_scope(); + match result { + Ok(res) => assert_eq!(res.compile_results.len(), 1, "vfs main compiled"), + Err(err) => panic!("[vfs_files] expected success, got:\n{err:#}"), + } +} + +/// The LSP typecheck entry point shares the pipeline but lowers errors to +/// diagnostics instead of anyhow chains. +#[test] +#[serial] +fn typecheck_lsp_typechecks_the_module_graph() { + let entry: PathBuf = "../tests/module_test/foo/src/main.psy".into(); + let dependency_entry: PathBuf = "../tests/module_test/bar/src/lib.psy".into(); + + let mut crate_path_graph = Graph::new(); + crate_path_graph.add_node(entry.clone()); + crate_path_graph.add_edge(entry.clone(), dependency_entry); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = interpreter.typecheck_lsp(crate_path_graph); + reset_primitive_scope(); + match result { + Ok((_typechecker, _ctx)) => {} + Err(err) => panic!("[typecheck_lsp] expected success, got:\n{err:?}"), + } +} diff --git a/psy-interpreter/src/generic_instantiation_tests.rs b/psy-interpreter/src/generic_instantiation_tests.rs new file mode 100644 index 000000000..30afaf100 --- /dev/null +++ b/psy-interpreter/src/generic_instantiation_tests.rs @@ -0,0 +1,180 @@ +// Tests for the rewriter's generic-instantiation paths (psy-sema/src/ +// rewriter.rs). Instantiating a generic function must rewrite every kind of +// statement and expression in its body β€” including calls into std context +// wrappers whose bodies lower to intrinsics β€” and accept only Felt/u32 loop +// endpoints once generic types become concrete. + +use serial_test::serial; + +use super::*; + +const SOURCE: &str = r#" +use std::prelude::*; + +pub struct Point { + pub x: Felt, + pub y: Felt, +} + +fn probe_context(value: T) -> T { + let user_id: Felt = get_user_id(); + let contract_id: Felt = get_contract_id(); + let deployer: Hash = get_contract_deployer(contract_id); + let tree_height: Felt = get_contract_state_tree_height(contract_id); + let caller: Felt = get_caller_contract_id(); + let checkpoint: Felt = get_checkpoint_id(); + let nonce: Felt = get_last_nonce(); + let public_key_hash: Hash = get_user_public_key_hash(); + let session_root: Hash = get_session_proof_tree_root(); + let state_hash: Hash = get_state_hash_at(0); + let register_users_root: Hash = get_register_users_root(checkpoint); + let gutas_root: Hash = get_gutas_root(checkpoint); + let user_tree_root: Hash = get_checkpoint_user_tree_root(checkpoint); + let contract_tree_root: Hash = get_checkpoint_contract_tree_root(checkpoint); + let deposit_tree_root: Hash = get_checkpoint_deposit_tree_root(checkpoint); + let withdrawal_tree_root: Hash = get_checkpoint_withdrawal_tree_root(checkpoint); + let registration_root: Hash = get_checkpoint_user_registration_tree_root(checkpoint); + let deploy_contracts_root: Hash = get_deploy_contracts_root(checkpoint); + let guta_fees: Felt = get_guta_fees_collected(checkpoint); + let da_fees: Felt = get_da_fees_collected(checkpoint); + let user_ops: Felt = get_user_ops_processed(checkpoint); + let total_txs: Felt = get_total_transactions(checkpoint); + let slots_modified: Felt = get_slots_modified(checkpoint); + let deploys_completed: Felt = get_deploy_contracts_completed(checkpoint); + let registrations_completed: Felt = get_register_users_completed(checkpoint); + let gutas_completed: Felt = get_gutas_completed(checkpoint); + let found: Hash = imt_get(public_key_hash, 0, 4); + let other_user: Hash = imt_get_other_user(tree_height, user_id, contract_id, public_key_hash, 0, 4); + let other_contract: Hash = get_other_contract_state_hash_at(tree_height, contract_id, 0); + let other_user_contract: Hash = get_other_user_contract_state_hash_at(tree_height, user_id, contract_id, 0); + let updated: Hash = cset_state_hash_at(0, state_hash); + let written: Hash = imt_set(public_key_hash, state_hash, 0, 4); + let contains: bool = imt_contains(public_key_hash, 0, 4); + let contains_other: bool = imt_contains_other_user(tree_height, user_id, contract_id, public_key_hash, 0, 4); + assert(user_id > 0, "user id is positive"); + assert_eq(deployer[0 as Felt], state_hash[0 as Felt], "hash limbs agree"); + clear_entire_tree(); + return value; +} + +fn probe_expressions(flag: bool, left: T, right: T) -> T { + let count: u32 = 1u32; + let negated: bool = !(count == 1u32); + let pair = Point { x: 1, y: 2 }; + let field: Felt = pair.x; + let grid: [Felt; 2] = [1, 2]; + let mut acc: Felt = grid[0 as Felt]; + let mut i: u32 = 2u32; + while i > 0u32 { + i -= 1u32; + } + for j in 0u32..2u32 { + acc += grid[j as Felt]; + } + let shifted: Felt = (count + i) as Felt; + assert_eq(acc, 3, "grid sum"); + let chosen: T = if flag { + left + } + else { + right + }; + return chosen; +} + +fn main(q: Felt) -> Felt { + let probed: Felt = probe_context(q); + let chosen: Felt = probe_expressions(true, q, 2); + let invoked: Felt = invoke_sync(1, 2, q); + invoke_deferred(1, 2, q); + return probed + chosen + invoked; +} +"#; + +fn typecheck_accepts(source: &str) -> (TypeChecker, TypeCheckerVisitorContext) { + super::with_primitive_scope_reset(|| { + let path = PathBuf::from("/virtual/src/main.psy"); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let program = Program::new(); + program.file_resolver.add_file(path.clone(), Arc::from(source)); + let mut graph = Graph::new(); + graph.add_node(path); + let (typechecker, ctx) = interpreter + .typecheck_with_program(graph, program) + .map_err(|error| anyhow::anyhow!("generic instantiation fixture must typecheck: {error:#}"))?; + Ok((typechecker, ctx)) + }) + .expect("typecheck within primitive scope reset") +} + +fn count_definitions_named( + typechecker: &TypeChecker, + ctx: &mut TypeCheckerVisitorContext, + name: &str, +) -> usize { + let name_id = ctx.program.interner.intern_ident(name); + typechecker + .program + .defs + .iter() + .filter(|def| matches!(def, CheckedDefinitionNode::Function(node) if node.name.id == name_id)) + .count() +} + +#[test] +#[serial] +fn generic_bodies_with_every_statement_and_expression_kind_instantiate() { + let (typechecker, mut ctx) = typecheck_accepts(SOURCE); + + // Each probe was instantiated from main: next to the polymorphic + // original there is at least one monomorphic copy in the checked program. + for probe in ["probe_context", "probe_expressions"] { + let count = count_definitions_named(&typechecker, &mut ctx, probe); + assert!(count >= 2, "expected an instantiated copy of {probe}, found {count} definitions"); + } +} + +#[test] +#[serial] +fn non_felt_for_endpoints_in_generic_loops_are_rejected() { + // A generic for-loop whose endpoint becomes a struct after substitution + // must be rejected during rewriting, not at interpretation time. + let source = r#" + use std::prelude::*; + + pub struct Point { + pub x: Felt, + pub y: Felt, + } + + fn loopgen(value: T) -> T { + for _i in value..1 { + let one: Felt = 1; + } + return value; + } + + fn main(q: Felt) -> Felt { + let p = Point { x: 1, y: 2 }; + let r = loopgen(p); + return r.x; + } + "#; + let message = super::with_primitive_scope_reset(|| { + let path = PathBuf::from("/virtual/src/main.psy"); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let program = Program::new(); + program.file_resolver.add_file(path.clone(), Arc::from(source)); + let mut graph = Graph::new(); + graph.add_node(path); + match interpreter.typecheck_with_program(graph, program) { + Ok(_) => anyhow::bail!("generic loop with struct endpoint must be rejected"), + Err(error) => Ok(format!("{error:#}")), + } + }) + .expect("primitive scope reset"); + assert!( + message.contains("TypeMismatch") || message.contains("type mismatch"), + "unexpected rejection message: {message}" + ); +} diff --git a/psy-interpreter/src/interp_exec_tests.rs b/psy-interpreter/src/interp_exec_tests.rs new file mode 100644 index 000000000..5767cc60d --- /dev/null +++ b/psy-interpreter/src/interp_exec_tests.rs @@ -0,0 +1,1404 @@ +// Execution-layer tests for the interpreter. +// +// `const_eval_tests.rs` stops at typechecking; this suite drives the full +// `interpret` / `test` pipeline (symbolic execution of the function body with +// a stub compile function) so the statement/expression arms of +// `__interpret__`, `interpret_binary`, `interpret_unary`, assignment +// operators, loops, matches, intrinsics, and the `#[test]` runner are all +// exercised without paying for DPN proving. +// +// Contract entry-point discovery (auto-discovery, explicit `-c`/`-m` +// resolution, view-method enforcement) is covered with the real +// `PsyCompileResult::compile_exec` backend so state commands are visible. +// +// The shared `STD_PRIMITIVE_SCOPE_ID` singleton is reset after every case so +// the suite is hermetic. Every case is `#[serial]`. + +use std::{ + fs, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use psy_vm::dpn::{ + ops::{exec_context::QExecContext, sym_felt::SymFeltRef}, + vm::{compile::PsyCompileResult, def::DPNFunctionCircuitDefinition}, +}; +use serial_test::serial; + +use super::*; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Compile-function stub: records the method name/id and folds the outputs to +/// constants. Keeps the suite fast β€” only the interpret layer is under test. +fn stub_compile_fn( + _context: &QExecContext, + (name, method_id, outputs): (String, u32, Vec), +) -> DPNFunctionCircuitDefinition { + DPNFunctionCircuitDefinition { + name, + method_id, + circuit_inputs: vec![], + circuit_outputs: outputs.iter().map(|felt| felt.get_constant_value()).collect(), + state_commands: vec![], + state_command_resolution_indices: vec![], + assertions: vec![], + definitions: vec![], + events: vec![], + } +} + +/// The real compile backend (used for contract cases where the view-method +/// check must observe generated state commands). +fn real_compile_fn( + context: &QExecContext, + (name, method_id, outputs): (String, u32, Vec), +) -> DPNFunctionCircuitDefinition { + PsyCompileResult::compile_exec(name, method_id, &context.store, context, &outputs) +} + +/// Outcome of typechecking + interpreting a source snippet. +#[derive(Debug)] +enum Outcome { + /// Both typecheck and interpretation succeeded; holds the compiled defs. + Executed(Vec), + /// Typecheck or interpretation returned a structured error. + Failed(String), + /// The pipeline panicked instead of yielding a clean error. + Panicked(String), +} + +fn panic_message(payload: &Box) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +fn reset_primitive_scope() { + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + } +} + +fn temp_psy_path(label: &str) -> std::path::PathBuf { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("psy_exec_{label}_{unique}_{n}.psy")) +} + +fn run_with( + source: &str, + label: &str, + contract_name: Option<&str>, + methods: &[&str], + real_backend: bool, +) -> Outcome { + let path = temp_psy_path(label); + fs::write(&path, source).unwrap(); + let path_arg = path.clone(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let (mut typechecker, mut ctx) = interpreter + .typecheck_single(path_arg.clone()) + .map_err(|e| format!("typecheck: {e:#}"))?; + let method_strings: Vec = methods.iter().map(|s| s.to_string()).collect(); + if real_backend { + interpreter.interpret(&mut typechecker, &mut ctx, contract_name.map(String::from), method_strings, real_compile_fn) + } else { + interpreter.interpret(&mut typechecker, &mut ctx, contract_name.map(String::from), method_strings, stub_compile_fn) + } + .map_err(|e| format!("interpret: {e:#}")) + })); + + let _ = fs::remove_file(&path); + reset_primitive_scope(); + + match result { + Ok(Ok(defs)) => Outcome::Executed(defs), + Ok(Err(msg)) => Outcome::Failed(msg), + Err(p) => Outcome::Panicked(panic_message(&p)), + } +} + +fn run(source: &str, label: &str) -> Outcome { + run_with(source, label, None, &[], false) +} + +/// Run the `#[test]`-attributed functions of a source through +/// `Interpreter::test`. +fn run_psy_tests(source: &str, label: &str) -> Outcome { + let path = temp_psy_path(label); + fs::write(&path, source).unwrap(); + let path_arg = path.clone(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let (mut typechecker, mut ctx) = interpreter + .typecheck_single(path_arg.clone()) + .map_err(|e| format!("typecheck: {e:#}"))?; + interpreter.test(&mut typechecker, &mut ctx, stub_compile_fn).map_err(|e| format!("test: {e:#}")) + })); + + let _ = fs::remove_file(&path); + reset_primitive_scope(); + + match result { + Ok(Ok(defs)) => Outcome::Executed(defs), + Ok(Err(msg)) => Outcome::Failed(msg), + Err(p) => Outcome::Panicked(panic_message(&p)), + } +} + +fn expect_exec(label: &str, source: &str) { + match run(source, label) { + Outcome::Executed(_) => {} + Outcome::Failed(msg) => panic!("[{label}] expected execution, got FAILURE:\n{msg}"), + Outcome::Panicked(msg) => panic!("[{label}] expected execution, got PANIC:\n{msg}"), + } +} + +fn expect_failure(label: &str, source: &str, needle: &str) { + expect_failure_with(label, source, None, &[], needle); +} + +fn expect_failure_with(label: &str, source: &str, contract: Option<&str>, methods: &[&str], needle: &str) { + match run_with(source, label, contract, methods, false) { + Outcome::Executed(_) => panic!("[{label}] expected failure mentioning `{needle}`, got success"), + Outcome::Failed(msg) => assert!( + msg.to_lowercase().contains(&needle.to_lowercase()), + "[{label}] expected failure mentioning `{needle}`, got:\n{msg}" + ), + Outcome::Panicked(msg) => panic!("[{label}] expected failure mentioning `{needle}`, got PANIC:\n{msg}"), + } +} + +fn expect_test_panic(label: &str, source: &str, needle: &str) { + match run_psy_tests(source, label) { + Outcome::Panicked(msg) => assert!( + msg.contains(needle), + "[{label}] expected panic mentioning `{needle}`, got:\n{msg}" + ), + other => panic!("[{label}] expected panic mentioning `{needle}`, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// Binary operators +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn felt_binary_operators_execute() { + expect_exec( + "felt_ops", + r#" + fn main() -> Felt { + let a: Felt = 17; + let b: Felt = 5; + assert_eq(a + b, 22, "add"); + assert_eq(a - b, 12, "sub"); + assert_eq(a * b, 85, "mul"); + assert_eq(a / 1, 17, "div by one"); + assert_eq(a ** 2, 289, "pow"); + assert(a == 17, "eq"); + assert(a != b, "neq"); + assert(a > b, "gt"); + assert(a >= 17, "gte"); + assert(b < a, "lt"); + assert(b <= 5, "lte"); + assert_eq(1 << 4, 16, "shl"); + assert_eq(32 >> 2, 8, "shr"); + assert_eq(12 & 10, 8, "bitand"); + assert_eq(12 | 10, 14, "bitor"); + assert_eq(12 ^ 10, 6, "bitxor"); + return a + b; + } + "#, + ); +} + +#[test] +#[serial] +fn u32_binary_operators_execute() { + expect_exec( + "u32_ops", + r#" + fn main() -> u32 { + let a: u32 = 17u32; + let b: u32 = 5u32; + assert_eq(a + b, 22u32, "add"); + assert_eq(a - b, 12u32, "sub"); + assert_eq(a * b, 85u32, "mul"); + assert_eq(a / b, 3u32, "div"); + assert_eq(a % b, 2u32, "mod"); + assert_eq(2u32 ** 3u32, 8u32, "pow"); + assert(a < b == false, "lt"); + assert(a <= 17u32, "lte"); + assert(a > b, "gt"); + assert(a >= b, "gte"); + assert_eq(1u32 << 4u32, 16u32, "shl"); + assert_eq(32u32 >> 2u32, 8u32, "shr"); + assert_eq(12u32 & 10u32, 8u32, "bitand"); + assert_eq(12u32 | 10u32, 14u32, "bitor"); + assert_eq(12u32 ^ 10u32, 6u32, "bitxor"); + assert_eq(12u32 ^ 10u32, 6u32, "xor twice"); + return a % b; + } + "#, + ); +} + +#[test] +#[serial] +fn bool_logic_operators_execute() { + expect_exec( + "bool_ops", + r#" + fn main() -> bool { + let t: bool = true; + let f: bool = false; + assert(t && t, "and tt"); + assert(!(t && f), "and tf"); + assert(t || f, "or tf"); + assert(!(f || f), "or ff"); + assert(t == !f, "eq via not"); + assert(t != f, "neq"); + assert(t ^ f, "xor tf"); + assert(!(t ^ t), "xor tt"); + return t && !f; + } + "#, + ); +} + +#[test] +#[serial] +fn constant_division_and_overflow_are_clean_errors() { + expect_failure( + "div_by_zero", + r#" + fn main() -> Felt { + return 1 / 0; + } + "#, + "DivisionByZero", + ); + expect_failure( + "mod_by_zero", + r#" + fn main() -> u32 { + return 5u32 % 0u32; + } + "#, + "DivisionByZero", + ); + expect_failure( + "u32_overflow", + r#" + fn main() -> u32 { + return 4294967295u32 + 1u32; + } + "#, + "ArithmeticOverflow", + ); + expect_failure( + "u32_pow_overflow", + r#" + fn main() -> u32 { + return 2u32 ** 32u32; + } + "#, + "ArithmeticOverflow", + ); + expect_failure( + "u32_sub_underflow", + r#" + fn main() -> u32 { + return 3u32 - 4u32; + } + "#, + "ArithmeticOverflow", + ); + expect_failure( + "assign_div_by_zero", + r#" + fn main() { + let mut x: Felt = 8; + x /= 0; + } + "#, + "DivisionByZero", + ); +} + +// --------------------------------------------------------------------------- +// Unary operators, casts, assignment operators +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn unary_operators_execute() { + expect_exec( + "unary_ops", + r#" + fn main() -> Felt { + let x: Felt = 5; + let neg: Felt = -x; + assert_eq(neg + x, 0, "felt negation"); + let not_true: bool = !true; + assert(not_true == false, "bool not"); + let not_false: bool = !false; + assert(not_false == true, "bool not false"); + return -x; + } + "#, + ); +} + +#[test] +#[serial] +fn casts_execute_and_reject_out_of_range() { + expect_exec( + "casts_ok", + r#" + fn main(a: Felt) -> Felt { + let u = a as u32; + let b = u as bool; + assert(b == true || b == false, "bool cast"); + let back = (u as Felt) + 1; + return back; + } + "#, + ); + expect_failure( + "cast_to_bool_invalid", + r#" + fn main() -> bool { + return 2 as bool; + } + "#, + "invalid cast", + ); + expect_failure( + "cast_to_u32_invalid", + r#" + fn main() -> u32 { + return 4294967296 as u32; + } + "#, + "invalid cast", + ); +} + +#[test] +#[serial] +fn compound_assignment_operators_execute() { + expect_exec( + "compound_assign", + r#" + fn main() -> Felt { + let mut x: Felt = 10; + x += 5; + assert_eq(x, 15, "add assign"); + x -= 3; + assert_eq(x, 12, "sub assign"); + x *= 2; + assert_eq(x, 24, "mul assign"); + x /= 4; + assert_eq(x, 6, "div assign"); + x %= 4; + assert_eq(x, 2, "mod assign"); + x <<= 3; + assert_eq(x, 16, "shl assign"); + x >>= 2; + assert_eq(x, 4, "shr assign"); + x |= 3; + assert_eq(x, 7, "or assign"); + x &= 5; + assert_eq(x, 5, "and assign"); + x ^= 1; + assert_eq(x, 4, "xor assign"); + + let mut u: u32 = 20u32; + u += 3u32; + u -= 1u32; + u *= 2u32; + u /= 4u32; + u %= 5u32; + u <<= 1u32; + u >>= 1u32; + u |= 8u32; + u &= 12u32; + u ^= 4u32; + assert_eq(u, 8u32, "u32 compound assigns"); + + let mut flag: bool = true; + flag ^= true; + assert(flag == false, "bool xor assign"); + return x; + } + "#, + ); +} + +// --------------------------------------------------------------------------- +// Control flow +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn while_and_for_loops_execute() { + expect_exec( + "loops", + r#" + fn main() -> Felt { + let mut total: Felt = 0; + let mut i: Felt = 0; + while i <= 10 { + total += i; + i += 1; + } + assert_eq(total, 55, "while sum"); + + let mut u_total: u32 = 0u32; + for n in 0u32..10u32 { + u_total += n; + } + assert_eq(u_total, 45u32, "for sum u32"); + + let mut nested: Felt = 0; + for a in 0u32..3u32 { + for b in 0u32..3u32 { + nested += 1; + } + } + assert_eq(nested, 9, "nested for"); + + let mut early: u32 = 0u32; + for n in 0u32..100u32 { + if n == 5u32 { + early = n; + } + } + assert_eq(early, 5u32, "conditional inside for"); + return total; + } + "#, + ); +} + +#[test] +#[serial] +fn uncertain_loop_conditions_are_rejected() { + expect_failure( + "uncertain_while", + r#" + fn main(a: Felt) { + let mut x: Felt = a; + while x < 10 { + x += 1; + } + } + "#, + "uncertain loop condition", + ); + expect_failure( + "uncertain_for_bound", + r#" + fn main(n: u32) { + let mut total: u32 = 0u32; + for i in 0u32..n { + total += i; + } + } + "#, + "uncertain loop condition", + ); +} + +#[test] +#[serial] +fn match_statements_and_expressions_execute() { + expect_exec( + "match_all", + r#" + fn match_case(input: Felt) -> Felt { + let mut result: Felt = 0; + match input { + 0 => { result += 10; }, + 1 => { result += 20; }, + _ => { result += 50; }, + }; + let extra: Felt = match input { + 0 => 100, + _ => 400, + }; + result + extra + } + + fn match_bool(input: bool) -> Felt { + match input { + true => 1, + false => 2, + } + } + + fn match_u32(input: u32) -> Felt { + let mut result: Felt = 0; + match input { + 0u32 => { result += 5; }, + 1u32 => { result += 15; }, + _ => { result += 55; }, + }; + result + } + + fn main() -> Felt { + assert_eq(match_case(0), 110, "match case 0"); + assert_eq(match_case(1), 420, "match case 1"); + assert_eq(match_case(9), 450, "match wildcard"); + assert_eq(match_bool(true), 1, "match bool true"); + assert_eq(match_bool(false), 2, "match bool false"); + assert_eq(match_u32(0u32), 5, "match u32 0"); + assert_eq(match_u32(9u32), 55, "match u32 wildcard"); + return match_case(2); + } + "#, + ); +} + +#[test] +#[serial] +fn if_else_and_else_if_chains_execute() { + expect_exec( + "if_else", + r#" + fn main() -> Felt { + let a: Felt = 3; + let mut grade: Felt = 0; + if a == 1 { + grade = 10; + } + else if a == 2 { + grade = 20; + } + else if a == 3 { + grade = 30; + } + else { + grade = 40; + }; + assert_eq(grade, 30, "else-if chain"); + + let max: Felt = if a > 2 { a } else { 2 }; + assert_eq(max, 3, "if expression"); + return grade; + } + "#, + ); +} + +// --------------------------------------------------------------------------- +// Functions, closures, recursion +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn closures_and_helper_calls_execute() { + expect_exec( + "closures", + r#" + fn double(x: Felt) -> Felt { + return x * 2; + } + + fn main() -> Felt { + let max = |a: Felt, b: Felt| -> Felt { + if a > b { a } else { b } + }; + assert_eq(max(1, 2), 2, "closure call"); + assert_eq(double(max(3, 4)), 8, "fn of closure"); + let add_one = |x: Felt| -> Felt { x + 1 }; + assert_eq(add_one(41), 42, "single arg closure"); + return max(10, 20) + double(1); + } + "#, + ); +} + +#[test] +#[serial] +fn recursion_is_rejected() { + expect_failure( + "recursion", + r#" + fn fact(n: Felt) -> Felt { + let result: Felt = if n <= 1 { 1 } else { n * fact(n - 1) }; + return result; + } + + fn main() -> Felt { + return fact(5); + } + "#, + "Recursion", + ); +} + +// --------------------------------------------------------------------------- +// Arrays and structs (values, params, mutation) +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn array_literals_repeat_and_mutation_execute() { + expect_exec( + "arrays", + r#" + fn main() -> Felt { + let repeated: [Felt; 4] = [7; 4]; + assert_eq(repeated[0], 7, "repeat first"); + assert_eq(repeated[3], 7, "repeat last"); + let mut nested: [[Felt; 3]; 2] = [[11; 3]; 2]; + nested[0][0] = 99; + assert_eq(nested[0][0], 99, "nested mutation"); + let mut variable_repeat: [Felt; 2] = [9; 2]; + variable_repeat[0] += 1; + assert_eq(variable_repeat[0], 10, "mutated copy"); + assert_eq(variable_repeat[1], 9, "independent copies"); + let empty: [Felt; 0] = [123; 0]; + let mut literal: [Felt; 3] = [1, 2, 3]; + literal[1] = 20; + assert_eq(literal[1], 20, "literal mutation"); + let mut compound: [u32; 2] = [4u32, 6u32]; + compound[0] %= 3u32; + assert_eq(compound[0], 1u32, "u32 array compound assign"); + return repeated[0] + literal[2]; + } + "#, + ); +} + +#[test] +#[serial] +fn oversized_repeat_arrays_are_rejected() { + expect_failure( + "repeat_too_large", + r#" + fn main() { + let huge: [Felt; 2000000] = [1; 2000000]; + assert_eq(huge[0], 1, "never reached"); + } + "#, + "ArrayTooLarge", + ); + expect_failure( + "total_materialization_exceeded", + r#" + fn main() { + let a: [Felt; 1000000] = [1; 1000000]; + let b: [Felt; 1000000] = [1; 1000000]; + let c: [Felt; 1000000] = [1; 1000000]; + let d: [Felt; 1000000] = [1; 1000000]; + let e: [Felt; 1000000] = [1; 1000000]; + assert_eq(e[0], 1, "never reached"); + } + "#, + "ArrayTooLarge", + ); +} + +#[test] +#[serial] +fn array_and_struct_parameters_are_materialized() { + expect_exec( + "param_materialize", + r#" + struct Point { + pub x: Felt, + pub y: Felt, + } + + fn sum(arr: [Felt; 3]) -> Felt { + return arr[0] + arr[1] + arr[2]; + } + + fn main(arr: [Felt; 4], p: Point, m: [[u32; 2]; 2], flag: bool) -> Felt { + assert_eq(arr[0] + arr[1] + arr[2] + arr[3], sum_of_inputs(), "unused helper"); + return p.x + p.y + (m[1][1] as Felt); + } + + fn sum_of_inputs() -> Felt { + return 0; + } + "#, + ); +} + +#[test] +#[serial] +fn struct_values_methods_and_mutation_execute() { + expect_exec( + "structs", + r#" + struct Item { + pub id: Felt, + pub value: Felt, + pub data: [Felt; 2], + } + + impl Item { + pub fn total(self) -> Felt { + return self.id + self.value; + } + + pub fn with_offset(self, offset: Felt) -> Felt { + return self.total() + offset; + } + } + + fn main() -> Felt { + let mut item: Item = Item { id: 1, value: 10, data: [100, 200] }; + assert_eq(item.id, 1, "field read"); + assert_eq(item.data[1], 200, "array field read"); + item.value = 99; + item.data[1] = 999; + assert_eq(item.value, 99, "field write"); + assert_eq(item.data[1], 999, "array field write"); + assert_eq(item.total(), 100, "method call"); + assert_eq(item.with_offset(5), 105, "chained method call"); + + let mut arr: [Item; 2] = [ + Item { id: 1, value: 10, data: [1, 2] }, + Item { id: 2, value: 20, data: [3, 4] }, + ]; + arr[1].value = 777; + assert_eq(arr[1].value, 777, "struct in array write"); + assert_eq(arr[0].total(), 11, "struct in array method"); + + let mut items: [Item; 3] = [Item { id: 0, value: 0, data: [0, 0] }; 3]; + for i in 0u32..3u32 { + let index = i as Felt; + items[index].value = index * 10; + } + assert_eq(items[2].value, 20, "loop struct writes"); + return item.total() + arr[1].value + items[1].value; + } + "#, + ); +} + +// --------------------------------------------------------------------------- +// Intrinsics: assert / assert_eq / clear_entire_tree +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn assert_intrinsics_execute_and_fail_cleanly() { + expect_exec( + "assert_ok", + r#" + fn main() { + assert(1 == 1, "trivially true"); + assert(true); + assert_eq(2 + 3, 5, "sum"); + assert_eq(true, true, "bool eq"); + assert_eq(2u32 * 3u32, 6u32, "u32 eq"); + } + "#, + ); + expect_failure( + "assert_false", + r#" + fn main() { + assert(false, "boom"); + } + "#, + "boom", + ); + expect_failure( + "assert_eq_mismatch", + r#" + fn main() { + assert_eq(1, 2, "mismatch"); + } + "#, + "mismatch", + ); + expect_failure( + "assert_eq_mismatch_no_message", + r#" + fn main() { + assert_eq(5u32, 6u32); + } + "#, + "assertion failure", + ); +} + +#[test] +#[serial] +fn clear_entire_tree_intrinsic_executes() { + // Storage teardown intrinsic inside a contract write method. + expect_exec( + "clear_tree", + r#" + #[contract] + #[derive(Storage)] + pub struct Box { + pub value: Felt, + } + + #[contract::write_method] + pub fn reset(value: Felt) { + let c = BoxRef::new(ContractMetadata::current()); + c.value = value; + clear_entire_tree(); + } + "#, + ); +} + +// --------------------------------------------------------------------------- +// Contract entry points +// --------------------------------------------------------------------------- + +const LIFECYCLE_CONTRACT: &str = r#" + #[contract] + #[derive(Storage)] + pub struct LifecycleContract { + pub value: Felt, + } + + #[contract::write_method] + pub fn set_value(value: Felt) { + let c = LifecycleContractRef::new(ContractMetadata::current()); + c.value = value; + } + + #[contract::view_method] + pub fn get_value() -> Felt { + let c = LifecycleContractRef::new(ContractMetadata::current()); + return c.value.get(); + } +"#; + +#[test] +#[serial] +fn contract_auto_discovery_executes_all_methods() { + match run_with(LIFECYCLE_CONTRACT, "contract_auto", None, &[], true) { + Outcome::Executed(defs) => { + let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"set_value"), "write method compiled: {names:?}"); + assert!(names.contains(&"get_value"), "view method compiled: {names:?}"); + for def in &defs { + if def.name == "get_value" { + assert!(def.is_view_function(), "get_value must stay read-only"); + } + } + } + Outcome::Failed(msg) => panic!("[contract_auto] expected execution, got FAILURE:\n{msg}"), + Outcome::Panicked(msg) => panic!("[contract_auto] expected execution, got PANIC:\n{msg}"), + } +} + +#[test] +#[serial] +fn contract_explicit_name_and_single_method_execute() { + match run_with(LIFECYCLE_CONTRACT, "contract_explicit", Some("LifecycleContract"), &["set_value"], true) { + Outcome::Executed(defs) => { + assert_eq!(defs.len(), 1, "only the requested method compiles"); + assert_eq!(defs[0].name, "set_value"); + } + Outcome::Failed(msg) => panic!("[contract_explicit] expected execution, got FAILURE:\n{msg}"), + Outcome::Panicked(msg) => panic!("[contract_explicit] expected execution, got PANIC:\n{msg}"), + } +} + +#[test] +#[serial] +fn contract_entry_point_errors_are_reported() { + // Unknown contract name. + expect_failure_with("contract_missing", LIFECYCLE_CONTRACT, Some("Nope"), &[], "undefined function"); + // Unknown method on a known contract. + expect_failure_with("method_missing", LIFECYCLE_CONTRACT, Some("LifecycleContract"), &["nope"], "undefined function"); + // Free-fn `-m` target that exists but is not a function. + expect_failure_with( + "method_not_a_function", + r#" + struct Point { + pub x: Felt, + } + + fn main() -> Felt { + return 0; + } + "#, + None, + &["Point"], + "non-function type", + ); +} + +#[test] +#[serial] +fn view_method_that_writes_state_is_rejected() { + let source = r#" + #[contract] + #[derive(Storage)] + pub struct Bad { + pub value: Felt, + } + + #[contract::view_method] + pub fn sneaky(value: Felt) { + let c = BadRef::new(ContractMetadata::current()); + c.value = value; + } + "#; + match run_with(source, "view_writes", None, &[], true) { + Outcome::Failed(msg) => assert!( + msg.contains("view_method") && msg.contains("writes state"), + "[view_writes] unexpected message:\n{msg}" + ), + other => panic!("[view_writes] expected view violation failure, got {other:?}"), + } +} + +#[test] +#[serial] +fn overloaded_contract_methods_are_rejected() { + let source = r#" + #[contract] + #[derive(Storage)] + pub struct Dup { + pub value: Felt, + } + + impl Dup { + #[contract::write_method] + pub fn set_value(value: Felt) { + let c = DupRef::new(ContractMetadata::current()); + c.value = value; + } + } + + impl DupRef { + #[contract::write_method] + pub fn set_value(value: Felt) { + let c = DupRef::new(ContractMetadata::current()); + c.value = value; + } + } + "#; + expect_failure_with("overloaded_methods", source, Some("Dup"), &[], "overloaded contract method"); +} + +// --------------------------------------------------------------------------- +// Preprocess: storage layout / maps / nested refs +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn contract_storage_with_array_and_map_fields_typechecks() { + expect_exec( + "storage_layout", + r#" + #[contract] + #[derive(Storage)] + pub struct Vault { + pub note: Felt, + pub slots: [Felt; 4], + pub balances: Map, + } + + #[contract::write_method] + pub fn seed(value: Felt) { + let c = VaultRef::new(ContractMetadata::current()); + c.note = value; + c.balances.insert([4001, 0, 0, 0], [1, 2, 3, 4]); + } + + #[contract::view_method] + pub fn peek() -> Felt { + let c = VaultRef::new(ContractMetadata::current()); + return c.note.get(); + } + "#, + ); +} + +#[test] +#[serial] +fn nested_storage_ref_fields_typecheck() { + expect_exec( + "nested_refs", + r#" + #[derive(Storage)] + pub struct Profile { + pub age: Felt, + pub level: Felt, + } + + #[contract] + #[derive(Storage)] + pub struct Player { + pub id: Felt, + #[ref] + pub profile: Profile, + pub tags: [Felt; 2], + } + + fn main() -> Felt { + assert_eq(Profile::size(), 2, "nested struct size"); + assert_eq(Player::size(), 5, "array counts every element"); + return Player::size(); + } + "#, + ); +} + +#[test] +#[serial] +fn second_map_in_contract_is_rejected() { + expect_failure( + "two_maps", + r#" + #[contract] + #[derive(Storage)] + pub struct TwoMaps { + pub a: Map, + pub b: Map, + } + + fn main() -> Felt { + return 0; + } + "#, + "Only one Map", + ); +} + +#[test] +#[serial] +fn map_nested_in_array_field_typechecks() { + // A single Map nested inside an array field counts as one map and is + // accepted. + expect_exec( + "map_in_array", + r#" + #[contract] + #[derive(Storage)] + pub struct MapArray { + pub grids: [Map; 2], + } + + fn main() -> Felt { + return 0; + } + "#, + ); +} + +#[test] +#[serial] +fn recursive_storage_struct_cycle_is_handled() { + let outcome = run( + r#" + struct Inner { + pub outer: Outer, + } + + #[contract] + #[derive(Storage)] + pub struct Outer { + pub inner: Inner, + pub note: Felt, + } + + fn main() -> Felt { + return 0; + } + "#, + "storage_cycle", + ); + match outcome { + // Rejection at preprocess/sema is fine, and so is success β€” what + // matters is that the cycle neither hangs nor panics the counter. + Outcome::Failed(_) => {} + Outcome::Executed(_) => {} + Outcome::Panicked(msg) => panic!("[storage_cycle] cyclic layout must not panic:\n{msg}"), + } +} + +// --------------------------------------------------------------------------- +// `#[test]` runner +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn psy_test_runner_executes_passing_and_expected_panic_tests() { + match run_psy_tests( + r#" + #[test] + fn simple_pass() { + assert_eq(1 + 1, 2, "arithmetic"); + } + + #[test] + #[should_panic] + fn expected_failure() { + assert(false, "deliberate"); + } + + #[test] + fn helper_calls() { + assert(max(1, 2) == 2, "max works"); + } + + fn max(a: Felt, b: Felt) -> Felt { + if a > b { a } else { b } + } + "#, + "tests_ok", + ) { + Outcome::Executed(defs) => { + // `simple_pass` and `helper_calls` compile; the should_panic case + // only prints. + assert_eq!(defs.len(), 2, "passing tests produce compiled defs"); + } + Outcome::Failed(msg) => panic!("[tests_ok] expected runner success, got FAILURE:\n{msg}"), + Outcome::Panicked(msg) => panic!("[tests_ok] expected runner success, got PANIC:\n{msg}"), + } +} + +#[test] +#[serial] +fn psy_test_runner_panics_when_a_test_fails() { + expect_test_panic( + "test_fails", + r#" + #[test] + fn bad() { + assert(false, "boom in test"); + } + "#, + "Test bad failed", + ); +} + +#[test] +#[serial] +fn psy_test_runner_panics_when_should_panic_test_passes() { + expect_test_panic( + "unexpected_pass", + r#" + #[test] + #[should_panic] + fn fine() { + assert(true); + } + "#, + "Expected panic", + ); +} + +#[test] +#[serial] +fn psy_test_runner_runs_contract_storage_tests() { + match run_psy_tests( + r#" + #[contract] + #[derive(Storage)] + pub struct Adjacent { + pub before: Felt, + pub balances: Map, + pub after: Felt, + } + + #[test] + fn map_ops_preserve_adjacent_fields() { + let c = AdjacentRef::new(ContractMetadata::current()); + c.before = 111; + c.after = 222; + c.balances.insert([4001, 0, 0, 0], [42, 0, 0, 0]); + assert_eq(c.before, 111, "before unchanged"); + assert_eq(c.after, 222, "after unchanged"); + } + "#, + "tests_storage", + ) { + Outcome::Executed(_) => {} + Outcome::Failed(msg) => panic!("[tests_storage] expected success, got FAILURE:\n{msg}"), + Outcome::Panicked(msg) => panic!("[tests_storage] expected success, got PANIC:\n{msg}"), + } +} + +// --------------------------------------------------------------------------- +// Void main / block expressions +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn void_main_and_block_expressions_execute() { + expect_exec( + "void_main", + r#" + fn main() { + let x: Felt = 2; + assert_eq(x, 2, "void main body"); + } + "#, + ); +} + +// --------------------------------------------------------------------------- +// Formatter grammar coverage: enums, traits, impls, type aliases, consts. +// +// `typecheck_*` pipelines format only the user module, so definition kinds +// that appear nowhere else in test fixtures (enum struct/tuple variants, +// trait associated types with constraints, impl blocks) never reach +// psy-fmt. This suite parses and formats such a program directly. +// --------------------------------------------------------------------------- + +/// Parses `source` (virtual single-file workspace) and returns the formatter +/// output without running sema, so formatting of purely syntactic constructs +/// can be asserted even where the typechecker has no support yet. +fn format_source(source: &str) -> String { + super::with_primitive_scope_reset(|| -> anyhow::Result { + let path = PathBuf::from("/virtual/src/main.psy"); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let mut program = Program::new(); + program.file_resolver.add_file(path.clone(), std::sync::Arc::from(source)); + let mut graph = Graph::new(); + graph.add_node(path); + + let mut parser = Parser::new(&mut program, &mut interpreter.context, graph); + parser.parse().map_err(|error| anyhow::anyhow!("fixture must parse: {error:#}"))?; + + let mut default_visitor_context: DefaultVisitorContext<'_, SymFeltRef, QExecContext> = DefaultVisitorContext::new(&mut program); + let mut formatter = Formatter::new(); + formatter + .visit_program(&mut default_visitor_context) + .map_err(|error| anyhow::anyhow!("fixture must format: {error}"))?; + Ok(formatter.get_output().to_owned()) + }) + .expect("parse + format within primitive scope reset") +} + +#[test] +#[serial] +fn formatter_renders_enums_traits_impls_and_aliases() { + let output = format_source( + r#" + pub enum Shape { + Point, + Rect(Felt, u32), + Circle { + pub radius: Felt, + }, + } + + pub trait Describable { + pub type Output: Storage; + pub type Plain; + pub fn describe(self: Self, other: T) -> Felt; + } + + impl Describable for Shape { + pub type Output = [Felt; 2]; + pub type Plain = Felt; + pub fn describe(self: Self, other: Felt) -> Felt { + return other; + } + } + + impl Shape { + pub fn area(self: Self) -> Felt { + return 1; + } + } + + type Alias = [Felt; 4]; + pub const MAX: Felt = 10; + + pub fn main() -> Felt { + let s: Felt = MAX; + let arr: Alias = [1, 2, 3, 4]; + return s + arr[0 as Felt]; + } + "#, + ); + + // Enum with all three variant kinds. + assert!(output.contains("enum Shape"), "missing enum:\n{output}"); + assert!(output.contains("Point,"), "missing basic variant:\n{output}"); + assert!(output.contains("Rect(Felt, u32)"), "missing tuple variant:\n{output}"); + assert!(output.contains("Circle {"), "missing struct variant:\n{output}"); + assert!(output.contains("radius: Felt"), "missing struct variant field:\n{output}"); + + // Trait with associated types (constrained and plain) and a signature. + assert!(output.contains("trait Describable"), "missing trait:\n{output}"); + assert!(output.contains("type Output: Storage;"), "missing constrained assoc type:\n{output}"); + assert!(output.contains("type Plain;"), "missing assoc type:\n{output}"); + assert!(output.contains("fn describe(self: Self, other: T) -> Felt"), "missing trait method:\n{output}"); + + // Trait impl with associated type values and an inherent impl. + assert!(output.contains("impl Describable for Shape"), "missing trait impl:\n{output}"); + assert!(output.contains("type Output = [Felt; 2]"), "missing assoc type value:\n{output}"); + assert!(output.contains("impl Shape"), "missing inherent impl:\n{output}"); + assert!(output.contains("fn area(self: Self) -> Felt"), "missing impl method:\n{output}"); + + // Type alias, const, and a body that uses them. + assert!(output.contains("type Alias = [Felt; 4]"), "missing type alias:\n{output}"); + assert!(output.contains("const MAX:Felt = 10"), "missing const:\n{output}"); + assert!(output.contains("fn main() -> Felt"), "missing main:\n{output}"); +} + +// --------------------------------------------------------------------------- +// Rewriter coverage: intrinsics that user code may call directly (they lex +// as their own tokens, not `__`-gated builtins) inside a GENERIC function +// body, so instantiating the function from main rewrites those intrinsic +// nodes in psy-sema/src/rewriter.rs. Event emission through the derived +// `Event` impl exercises the Emit arm the same way. +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn generic_bodies_with_hash_mem_and_event_intrinsics_instantiate() { + expect_exec( + "generic_intrinsics", + r#" + use std::prelude::*; + + #[derive(Event)] + pub struct Ping { + pub v: Felt, + } + + fn probe_hashes(x: T, a: Hash, b: Hash) -> Felt { + let h1: Hash = hash(a); + let h2: [u32; 8] = keccak256(b); + let h3: Hash = hash_two_to_one(a, b); + let n: Felt = size_of::(); + let t: Felt = transmute::(7u32); + let ping = Ping { v: 1 }; + ping.emit(); + let h2_ok: bool = h2[0u32 as Felt] == h2[1u32 as Felt]; + return n + t + h1[0 as Felt] + h3[0 as Felt] + h2_ok as Felt + (x == x) as Felt; + } + + fn main(q: Felt) -> Felt { + let r: Felt = probe_hashes(q, [1, 2, 3, 4], [5, 6, 7, 8]); + return r; + } + "#, + ); +} diff --git a/psy-interpreter/src/intrinsic_exec_tests.rs b/psy-interpreter/src/intrinsic_exec_tests.rs new file mode 100644 index 000000000..9eee99ff7 --- /dev/null +++ b/psy-interpreter/src/intrinsic_exec_tests.rs @@ -0,0 +1,335 @@ +// Execution-level coverage for the std::context / std::storage intrinsic +// wrappers. Typechecking alone never enters `interpret_intrinsic`; these +// tests run `main` through the interpreter so each CheckedIntrinsicExprNode +// arm of the runtime dispatch actually executes against QExecContext. + +use serial_test::serial; + +use super::*; + +fn compile_stub( + _context: &QExecContext, + (name, method_id, outputs): (String, u32, Vec), +) -> DPNFunctionCircuitDefinition { + DPNFunctionCircuitDefinition { + name, + method_id, + circuit_inputs: vec![], + circuit_outputs: outputs.iter().map(|felt| felt.get_constant_value()).collect(), + state_commands: vec![], + state_command_resolution_indices: vec![], + assertions: vec![], + definitions: vec![], + events: vec![], + } +} + +fn reset_primitive_scope() { + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + } +} + +fn exec_source(label: &str, source: &str) -> Result<(), String> { + let path = std::env::temp_dir().join(format!("{label}_intrinsics.psy")); + std::fs::write(&path, source).unwrap(); + let path_arg = path.clone(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = { + let (mut typechecker, mut ctx) = interpreter + .typecheck_single(path_arg) + .map_err(|e| format!("typecheck: {e:#}"))?; + interpreter + .interpret(&mut typechecker, &mut ctx, None::, vec![], compile_stub) + .map(|_| ()) + .map_err(|e| format!("interpret: {e:#}")) + }; + let _ = std::fs::remove_file(&path); + reset_primitive_scope(); + result +} + +fn expect_intrinsics_exec(label: &str, source: &str) { + if let Err(message) = exec_source(label, source) { + panic!("[{label}] expected execution, got:\n{message}"); + } +} + +fn expect_intrinsics_failure(label: &str, source: &str, needle: &str) { + match exec_source(label, source) { + Ok(()) => panic!("[{label}] expected failure mentioning `{needle}`, got success"), + Err(message) => assert!( + message.to_lowercase().contains(&needle.to_lowercase()), + "[{label}] expected failure mentioning `{needle}`, got:\n{message}" + ), + } +} + +/// Typecheck + interpret a virtual source whose std context module has +/// `extra_std` appended. Non-generic std-side wrappers added this way let +/// `interpret` reach raw-intrinsic runtime arms (`__ctx_get_checkpoint_stats`, +/// `__storage_write_range`) that no checked-in std wrapper exposes. +fn exec_override_source(label: &str, source: &str, extra_std: &str) -> Result<(), String> { + let path = PathBuf::from(format!("/virtual/{label}_main.psy")); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = { + let program = Program::new(); + program.file_resolver.add_file(path.clone(), Arc::from(source)); + let std_path = crate::std_override_tests::std_context_path(); + let original = std::fs::read_to_string(&std_path).map_err(|e| format!("read std: {e}"))?; + program + .file_resolver + .add_file(std_path, Arc::from(format!("{original}\n{extra_std}"))); + let mut graph = Graph::new(); + graph.add_node(path); + let (mut typechecker, mut ctx) = interpreter + .typecheck_with_program(graph, program) + .map_err(|e| format!("typecheck: {e:#}"))?; + interpreter + .interpret(&mut typechecker, &mut ctx, None::, vec![], compile_stub) + .map(|_| ()) + .map_err(|e| format!("interpret: {e:#}")) + }; + reset_primitive_scope(); + result +} + +#[test] +#[serial] +fn raw_checkpoint_stats_and_storage_write_range_execute() { + let extra_std = r#" +pub fn probe_raw_stats_exec(checkpoint_id: Felt) { + __ctx_get_checkpoint_stats(checkpoint_id); + return; +} + +pub fn probe_raw_write_range_exec(offset: Felt, values: [Felt; 3]) { + __storage_write_range(offset, values); + return; +} +"#; + let source = r#" + use std::prelude::*; + + fn main() -> Felt { + let cp: Felt = get_checkpoint_id(); + probe_raw_stats_exec(cp); + probe_raw_write_range_exec(0, [1, 2, 3]); + return 0; + } + "#; + if let Err(message) = exec_override_source("raw_stats", source, extra_std) { + panic!("[raw_stats] expected execution, got:\n{message}"); + } +} + +#[test] +#[serial] +fn context_identity_getters_execute() { + expect_intrinsics_exec( + "ctx_identity", + r#" + use std::prelude::*; + + fn main() -> Felt { + let user: Felt = get_user_id(); + let contract: Felt = get_contract_id(); + let caller: Felt = get_caller_contract_id(); + let checkpoint: Felt = get_checkpoint_id(); + let nonce: Felt = get_last_nonce(); + let deployer: Hash = get_contract_deployer(contract); + let height: Felt = get_contract_state_tree_height(contract); + let pkh: Hash = get_user_public_key_hash(); + let session: Hash = get_session_proof_tree_root(); + let state: Hash = get_state_hash_at(0); + let updated: Hash = cset_state_hash_at(0, state); + return user + contract + caller + checkpoint + nonce + height; + } + "#, + ); +} + +#[test] +#[serial] +fn imt_intrinsics_execute() { + expect_intrinsics_exec( + "imt_ops", + r#" + use std::prelude::*; + + fn main() -> Felt { + let user: Felt = get_user_id(); + let contract: Felt = get_contract_id(); + let key: Hash = get_user_public_key_hash(); + let value: Hash = imt_get(key, 0, 4); + let present: bool = imt_contains(key, 0, 4); + let written: Hash = imt_set(key, value, 0, 4); + let other_contract: Hash = get_other_contract_state_hash_at(1, contract, 0); + let other_user: Hash = get_other_user_contract_state_hash_at(1, user, contract, 0); + let other_value: Hash = imt_get_other_user(1, user, contract, key, 0, 4); + let other_present: bool = imt_contains_other_user(1, user, contract, key, 0, 4); + return 0; + } + "#, + ); +} + +#[test] +#[serial] +fn checkpoint_stat_getters_execute() { + expect_intrinsics_exec( + "checkpoint_stats", + r#" + use std::prelude::*; + + fn main() -> Felt { + let cp: Felt = get_checkpoint_id(); + let register_users: Hash = get_register_users_root(cp); + let gutas: Hash = get_gutas_root(cp); + let user_tree: Hash = get_checkpoint_user_tree_root(cp); + let contract_tree: Hash = get_checkpoint_contract_tree_root(cp); + let deposit_tree: Hash = get_checkpoint_deposit_tree_root(cp); + let withdrawal_tree: Hash = get_checkpoint_withdrawal_tree_root(cp); + let registration: Hash = get_checkpoint_user_registration_tree_root(cp); + let deploys: Hash = get_deploy_contracts_root(cp); + let guta_fees: Felt = get_guta_fees_collected(cp); + let da_fees: Felt = get_da_fees_collected(cp); + let user_ops: Felt = get_user_ops_processed(cp); + let total_txs: Felt = get_total_transactions(cp); + let slots: Felt = get_slots_modified(cp); + let deploys_done: Felt = get_deploy_contracts_completed(cp); + let registrations_done: Felt = get_register_users_completed(cp); + let gutas_done: Felt = get_gutas_completed(cp); + return guta_fees + da_fees + user_ops + total_txs + slots + deploys_done + registrations_done + gutas_done; + } + "#, + ); +} + +#[test] +#[serial] +fn bit_intrinsics_execute() { + expect_intrinsics_exec( + "bit_ops", + r#" + use std::prelude::*; + + fn main() -> Felt { + let bits: [Felt; 4] = split_bits(13, 4); + let total: Felt = sum_bits(bits); + return total; + } + "#, + ); +} + +#[test] +#[serial] +fn crypto_and_invoke_intrinsics_execute() { + expect_intrinsics_exec( + "crypto_invoke", + r#" + use std::prelude::*; + + fn main() -> Felt { + let key: Hash = get_user_public_key_hash(); + let verified: bool = secp256k1_verify([0u32; 16], key, [0u32; 16]); + invoke_deferred(1, 2, [3]); + return 0; + } + "#, + ); +} + +#[test] +#[serial] +fn invoke_sync_with_generic_return_hits_size_calculation_guard() { + // The std wrapper's generic return `T` is not substituted into the + // CheckedIntrinsicExprNode, so the runtime output-size calculation + // lands on a TypeVariable. Pin the guard: the deferred invoke before + // it executes cleanly, then the sync invoke aborts. + let outcome = std::panic::catch_unwind(|| { + exec_source( + "invoke_sync_generic", + r#" + use std::prelude::*; + + fn main() -> Felt { + invoke_deferred(1, 2, [3]); + let result: Felt = invoke_sync(1, 2, [3]); + return 0; + } + "#, + ) + }); + reset_primitive_scope(); + let payload = outcome.expect_err("generic invoke_sync must hit the size-calculation guard"); + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or("panic payload was not a string"); + assert!( + message.contains("Unsupported type for size calculation"), + "unexpected failure: {message}" + ); +} + +#[test] +#[serial] +fn derived_events_emit_at_runtime() { + expect_intrinsics_exec( + "event_emit", + r#" + use std::prelude::*; + + #[derive(Event)] + pub struct Transferred { + pub amount: Felt, + } + + fn main() -> Felt { + let event = Transferred { amount: 5 }; + event.emit(); + return 0; + } + "#, + ); +} + +#[test] +#[serial] +fn split_bits_rejects_non_constant_lengths_at_runtime() { + expect_intrinsics_failure( + "split_bits_non_const", + r#" + use std::prelude::*; + + fn main() -> Felt { + let nonce: Felt = get_last_nonce(); + let bits: [Felt; 4] = split_bits(13, nonce); + return bits[0]; + } + "#, + "mismatch", + ); +} + +#[test] +#[serial] +fn split_bits_rejects_oversized_lengths_at_runtime() { + expect_intrinsics_failure( + "split_bits_too_large", + r#" + use std::prelude::*; + + fn main() -> Felt { + let bits: [Felt; 9999999] = split_bits(13, 9999999); + return bits[0]; + } + "#, + "cannot materialize", + ); +} diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index 29f23ca57..fa1ab5803 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -2400,6 +2400,60 @@ fn main() {} let _ = STD_PRIMITIVE_SCOPE_ID.take(); }; } + + #[test] + #[serial] + fn test_typecheck_storage_array_generates_read_at_and_write_at_impls() { + psy_common::setup_logging().ok(); + + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("psy_storage_array_{unique}.psy")); + // Mirrors tests/array_ref_struct_index_test.psy: an array field on a + // storage-derived contract drives StorageProcessor to synthesize the + // StorageAt (read_at/write_at) impls during preprocessing. + let source = r#" +#[derive(Storage)] +pub struct Person { + pub age: Felt, + pub score: Felt, +} + +#[contract] +#[derive(Storage)] +pub struct C { + pub people: [Person; 2], +} + +fn main() { + let c = CRef::new(ContractMetadata::current()); + c.people[0] = Person { + age: 10, + score: 80, + }; + let p1: Person::RefType = c.people[0]; + p1.age += 1; + assert_eq(c.people[0].age, 11, "people[0].age after +="); + assert(p1 == Person { + age: 11, + score: 80, + }, "RefType from index should support =="); +} +"#; + fs::write(&path, source).unwrap(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + interpreter + .typecheck_single(path.clone()) + .expect("storage array contracts must preprocess and typecheck"); + + let _ = fs::remove_file(path); + + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + }; + } + #[test] #[serial] fn test_rejects_raw_intrinsic_outside_std() { @@ -2663,6 +2717,243 @@ fn main() { let _ = STD_PRIMITIVE_SCOPE_ID.take(); }; } + + /// The index-access desugaring (`a[i]` on a non-array) reports a specific + /// error for each reachable failure mode: a non-Felt index, and an + /// `index` member that does not resolve to a matching method. The + /// arity/parameter-mismatch arms are unreachable because `find_member` + /// already filters candidates by the same predicate. + /// Generic paths through constrained type variables resolve associated + /// members, and a member that is not provided by the declared trait + /// constraints is rejected with a TypeMismatch naming the implemented + /// traits. + #[test] + #[serial] + fn test_generic_type_variable_path_resolution() { + let cases: [(&str, &str, bool, &str); 3] = [ + ( + "unconstrained_member_access", + r#" +pub trait Value { + pub fn value() -> Felt; +} + +pub struct Two {} + +impl Value for Two { + pub fn value() -> Felt { + return 2; + } +} + +fn unconstrained(x: T) -> Felt { + return ::value(); +} + +fn main() -> Felt { + return unconstrained(Two {}); +} +"#, + false, + "Expected", + ), + ( + "constrained_associated_type_field", + r#" +pub trait Produces { + pub type Out; + pub fn make() -> Self::Out; +} + +pub struct Maker {} + +impl Produces for Maker { + pub type Out = Felt; + pub fn make() -> Self::Out { + return 7; + } +} + +struct Wrapper { + pub payload: T::Out, +} + +fn main() -> Felt { + let w = Wrapper:: { payload: 3 }; + return w.payload; +} +"#, + true, + "", + ), + ( + "constrained_static_method", + r#" +pub trait Value { + pub fn value() -> Felt; +} + +pub struct Two {} + +impl Value for Two { + pub fn value() -> Felt { + return 2; + } +} + +fn measured(x: T) -> Felt { + return T::value(); +} + +fn main() -> Felt { + return measured(Two {}); +} +"#, + true, + "", + ), + ]; + + for (index, (name, source, must_pass, expected_fragment)) in cases.into_iter().enumerate() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("psy_generic_path_{index}_{unique}.psy")); + fs::write(&path, source).unwrap(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = interpreter.typecheck_single(path.clone()); + let err_msg = match (&result, must_pass) { + (Ok(_), true) => String::new(), + (Ok(_), false) => panic!("expected `{name}` to be rejected"), + (Err(_), true) => panic!("expected `{name}` to typecheck: {:#}", result.err().unwrap()), + (Err(err), false) => format!("{err:#}"), + }; + assert!( + err_msg.contains(expected_fragment), + "unexpected error for `{name}`: {err_msg}" + ); + + let _ = fs::remove_file(path); + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + }; + } + } + + #[test] + #[serial] + fn test_index_access_sugar_error_arms() { + let cases: [(&str, &str, bool, &str); 3] = [ + ( + "non_felt_index", + r#" +fn main() { + let a: [Felt; 2] = [1, 2]; + let v: Felt = a[true]; +} +"#, + false, + "Expected pub Felt", + ), + ( + "unresolved_index_member", + r#" +struct Wrapper { + pub index: Felt, +} + +fn main() { + let w = Wrapper { index: 1 }; + let v: Felt = w[0]; +} +"#, + false, + "Unresolved member index", + ), + ( + "index_method_success", + r#" +struct Wrapper { + pub pad: Felt, +} + +impl Wrapper { + pub fn index(self: Self, i: Felt) -> Felt { + return self.pad + i; + } +} + +fn main() -> Felt { + let w = Wrapper { pad: 1 }; + return w[0]; +} +"#, + true, + "", + ), + ]; + + for (index, (name, source, must_pass, expected_fragment)) in cases.into_iter().enumerate() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("psy_index_sugar_{index}_{unique}.psy")); + fs::write(&path, source).unwrap(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = interpreter.typecheck_single(path.clone()); + let err_msg = match (&result, must_pass) { + (Ok(_), true) => String::new(), + (Ok(_), false) => panic!("expected `{name}` to be rejected by the index sugar"), + (Err(_), true) => panic!("expected `{name}` to typecheck: {:#}", result.err().unwrap()), + (Err(err), false) => format!("{err:#}"), + }; + assert!( + err_msg.contains(expected_fragment), + "unexpected error for `{name}`: {err_msg}" + ); + + let _ = fs::remove_file(path); + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + }; + } + } + + /// The public intrinsics reject mismatched or non-const arguments where + /// the typechecker enforces them: `hash_two_to_one` requires Hash + /// operands, and `split_bits` requires a compile-time-const bit length. + #[test] + #[serial] + fn test_public_intrinsic_type_mismatch_arms() { + let cases = [ + ("hash_two_to_one_bad_first", r#"fn main() { let h = hash_two_to_one(true, [1, 2, 3, 4]); }"#), + ("hash_two_to_one_bad_second", r#"fn main() { let h = hash_two_to_one([1, 2, 3, 4], true); }"#), + ("split_bits_non_const_length", r#"fn main(x: Felt) { let v = split_bits(15, x); }"#), + ]; + + for (index, (name, source)) in cases.into_iter().enumerate() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("psy_pub_intrinsic_ty_{index}_{unique}.psy")); + fs::write(&path, source).unwrap(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let err = match interpreter.typecheck_single(path.clone()) { + Ok(_) => panic!("expected `{name}` to fail typechecking"), + Err(err) => err, + }; + let err_msg = format!("{err:#}"); + assert!( + err_msg.contains("TypeMismatch") || err_msg.contains("Expected"), + "unexpected error for `{name}`: {err_msg}" + ); + + let _ = fs::remove_file(path); + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + }; + } + } } #[cfg(test)] @@ -2699,3 +2990,38 @@ mod qa_fix_tests { mod panic_fix_tests { include!("panic_fix_tests.rs"); } + +#[cfg(test)] +mod visualizer_tests { + include!("visualizer_tests.rs"); +} + +#[cfg(test)] +mod generic_instantiation_tests { + include!("generic_instantiation_tests.rs"); +} + +#[cfg(test)] +mod std_override_tests { + include!("std_override_tests.rs"); +} + +#[cfg(test)] +mod interp_exec_tests { + include!("interp_exec_tests.rs"); +} + +#[cfg(test)] +mod intrinsic_exec_tests { + include!("intrinsic_exec_tests.rs"); +} + +#[cfg(test)] +mod sema_edge_tests { + include!("sema_edge_tests.rs"); +} + +#[cfg(test)] +mod exec_edge_tests { + include!("exec_edge_tests.rs"); +} diff --git a/psy-interpreter/src/preprocess.rs b/psy-interpreter/src/preprocess.rs index 7a150441e..5dc11cccf 100644 --- a/psy-interpreter/src/preprocess.rs +++ b/psy-interpreter/src/preprocess.rs @@ -2631,3 +2631,1033 @@ impl<'a, F: Clone + From + ContextFelt + 'static, C> AstVisitor for S Ok(()) } } + +#[cfg(test)] +mod tests { + use indexmap::IndexMap; + use psy_ast::*; + use psy_vm::dpn::ops::sym_felt::SymFeltRef; + + use super::*; + + type TestCtx<'a> = DefaultVisitorContext<'a, SymFeltRef, ()>; + + fn loc() -> Location { + Location::default() + } + + fn idn(ctx: &mut TestCtx, name: &str) -> Identifier { + Identifier::new(ctx.intern(name), loc()) + } + + fn attr(ctx: &mut TestCtx, name: &str, properties: &[&str]) -> AttrNode { + let attr_name = idn(ctx, name); + let props = properties.iter().map(|p| idn(ctx, p)).collect(); + AttrNode { + path: vec![], + name: attr_name, + properties: props, + location: loc(), + } + } + + fn derive_attr(ctx: &mut TestCtx, property: &str) -> AttrNode { + attr(ctx, "derive", &[property]) + } + + fn ref_attr(ctx: &mut TestCtx) -> AttrNode { + attr(ctx, "ref", &[]) + } + + // Type constructors. Each takes only names/sizes so call sites never nest + // `&mut ctx` borrows inside one expression. + fn ty_basic(ctx: &mut TestCtx, name: &str) -> UncheckedType { + UncheckedType::Basic(idn(ctx, name)) + } + + fn ty_const(ctx: &mut TestCtx, value: u32) -> UncheckedType { + UncheckedType::Const(ConstValue::U32(value), loc()) + } + + fn ty_generic(ctx: &mut TestCtx, name: &str, params: Vec) -> UncheckedType { + let ident = idn(ctx, name); + UncheckedType::Generic(ident, params, loc()) + } + + fn ty_map(ctx: &mut TestCtx) -> UncheckedType { + let felt = ty_basic(ctx, "Felt"); + let size = ty_const(ctx, 4); + ty_generic(ctx, "Map", vec![felt.clone(), felt, size]) + } + + fn ty_map_ref(ctx: &mut TestCtx) -> UncheckedType { + let felt = ty_basic(ctx, "Felt"); + let size = ty_const(ctx, 4); + ty_generic(ctx, "MapRef", vec![felt.clone(), felt, size]) + } + + fn ty_storage_ref(ctx: &mut TestCtx, inner: &str) -> UncheckedType { + let param = ty_basic(ctx, inner); + ty_generic(ctx, "StorageRef", vec![param]) + } + + fn ty_array(ctx: &mut TestCtx, elem: &str, size: u32) -> UncheckedType { + let elem_ty = ty_basic(ctx, elem); + UncheckedType::Array(Box::new(elem_ty), ConstValue::U32(size), loc()) + } + + fn ty_array_ref(ctx: &mut TestCtx, elem: &str, size: u32) -> UncheckedType { + let elem_ty = ty_basic(ctx, elem); + let size_ty = ty_const(ctx, size); + ty_generic(ctx, "ArrayRef", vec![elem_ty, size_ty]) + } + + fn field_of(ctx: &mut TestCtx, ty: UncheckedType, attrs: Vec) -> StructField { + StructField { + ty, + attrs, + visibility: Visibility::Public, + comments: vec![], + location: loc(), + } + } + + /// Build a struct whose fields all take plain (attr-free) types. + fn strukt(ctx: &mut TestCtx, name: &str, fields: Vec<(&str, UncheckedType)>, attrs: Vec) -> StructNode { + let struct_name = idn(ctx, name); + let mut field_map = IndexMap::new(); + for (field_name, ty) in fields { + let ident = idn(ctx, field_name); + let field = field_of(ctx, ty, vec![]); + field_map.insert(ident, field); + } + StructNode { + name: struct_name, + generic_parameters: vec![], + fields: field_map, + attrs, + visibility: Visibility::Public, + comments: vec![], + location: loc(), + is_generated: false, + } + } + + /// Build a struct whose first listed field carries a `#[ref]` annotation. + fn strukt_with_ref_field( + ctx: &mut TestCtx, + name: &str, + ref_field: (&str, UncheckedType), + fields: Vec<(&str, UncheckedType)>, + attrs: Vec, + ) -> StructNode { + let struct_name = idn(ctx, name); + let ref_ident = idn(ctx, ref_field.0); + let annotation = ref_attr(ctx); + let mut field_map = IndexMap::new(); + field_map.insert(ref_ident, field_of(ctx, ref_field.1, vec![annotation])); + for (field_name, ty) in fields { + let ident = idn(ctx, field_name); + let field = field_of(ctx, ty, vec![]); + field_map.insert(ident, field); + } + StructNode { + name: struct_name, + generic_parameters: vec![], + fields: field_map, + attrs, + visibility: Visibility::Public, + comments: vec![], + location: loc(), + is_generated: false, + } + } + + fn felt_zero(ctx: &mut TestCtx) -> ExprId { + ctx.alloc_expression(ExprNode::Value(ValueNode::Felt(SymFeltRef(0), loc()))) + } + + fn panics_with(message: &str, f: impl FnOnce(&mut TestCtx)) { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut ctx))); + let error = result.unwrap_err(); + let text = error + .downcast_ref::() + .map(|s| s.as_str()) + .or_else(|| error.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(text.contains(message), "panic {text:?} did not contain {message:?}"); + } + + #[test] + fn map_counting_walks_generics_arrays_structs_and_cycles() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + + let inner_felt = ty_basic(&mut ctx, "Felt"); + let inner = strukt(&mut ctx, "Inner", vec![("x", inner_felt)], vec![]); + let map = ty_map(&mut ctx); + let nested_map = ty_generic(&mut ctx, "StorageRef", vec![map.clone()]); + let inner2 = ty_basic(&mut ctx, "Inner"); + let plain_felt = ty_basic(&mut ctx, "Felt"); + let holder = strukt( + &mut ctx, + "Holder", + vec![ + ("m", map), + ("g", nested_map), + ("arr", UncheckedType::Array(Box::new(inner2), ConstValue::U32(2), loc())), + ("plain", plain_felt), + ], + vec![], + ); + ctx.alloc_definition(DefinitionNode::Struct(inner)); + ctx.alloc_definition(DefinitionNode::Struct(holder.clone())); + + // m -> 1, Map nested inside a non-Map generic -> 1, array of structs -> 0. + assert_eq!(processor.count_maps_in_struct(&holder, &mut ctx), 2); + + // A struct cycle terminates through the visiting guard. + let cyc_b = ty_basic(&mut ctx, "CycB"); + let cyc_a = strukt(&mut ctx, "CycA", vec![("b", cyc_b)], vec![]); + let cyc_a_ty = ty_basic(&mut ctx, "CycA"); + let cyc_b_node = strukt(&mut ctx, "CycB", vec![("a", cyc_a_ty)], vec![]); + ctx.alloc_definition(DefinitionNode::Struct(cyc_a.clone())); + ctx.alloc_definition(DefinitionNode::Struct(cyc_b_node)); + assert_eq!(processor.count_maps_in_struct(&cyc_a, &mut ctx), 0); + } + + #[test] + fn ref_helpers_classify_field_types() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + + let map_ref = ty_map_ref(&mut ctx); + assert!(processor.is_map_ref_type(&map_ref, &mut ctx)); + let one_param = ty_basic(&mut ctx, "Felt"); + assert!(!processor.is_map_ref_type(&ty_generic(&mut ctx, "MapRef", vec![one_param]), &mut ctx)); + assert!(!processor.is_map_ref_type(&ty_basic(&mut ctx, "MapRef"), &mut ctx)); + + let felt = ty_basic(&mut ctx, "Felt"); + let with_map = strukt(&mut ctx, "WithMap", vec![("m", map_ref)], vec![]); + let plain = strukt(&mut ctx, "Plain", vec![("p", felt)], vec![]); + assert!(processor.struct_has_map_ref_fields(&with_map, &mut ctx)); + assert!(!processor.struct_has_map_ref_fields(&plain, &mut ctx)); + + let inner_ty = ty_basic(&mut ctx, "Inner"); + let annotation = ref_attr(&mut ctx); + let ref_field = field_of(&mut ctx, inner_ty, vec![annotation]); + assert!(processor.has_ref_type_attr(&ref_field.attrs, &mut ctx)); + assert!(!processor.has_ref_type_attr(&plain.fields[0].attrs, &mut ctx)); + + ctx.alloc_definition(DefinitionNode::Struct(plain.clone())); + assert_eq!( + processor.find_struct_definition(plain.name.id, &mut ctx).map(|s| s.name.id), + Some(plain.name.id) + ); + let missing = idn(&mut ctx, "Missing").id; + assert!(processor.find_struct_definition(missing, &mut ctx).is_none()); + } + + #[test] + fn transform_struct_to_storage_ref_maps_every_field_shape() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + + let derive = derive_attr(&mut ctx, "Storage"); + let contract = attr(&mut ctx, "contract", &[]); + let inner_ty = ty_basic(&mut ctx, "Inner"); + let map = ty_map(&mut ctx); + let grid = ty_array(&mut ctx, "Felt", 2); + let note = ty_basic(&mut ctx, "Felt"); + let source = strukt_with_ref_field( + &mut ctx, + "Wallet", + ("inner", inner_ty), + vec![("balances", map), ("grid", grid), ("note", note)], + vec![derive.clone(), contract], + ); + let ref_struct = processor.transform_struct_to_storage_ref(&source, &derive, &mut ctx, Some("Ref")); + assert_eq!(ctx.ident(ref_struct.name.id).0, "WalletRef"); + // The Storage derive is dropped on the generated Ref struct. + assert!(!ref_struct.attrs.iter().any(|a| a.is_derive())); + + let name_of = |ctx: &mut TestCtx, ty: &UncheckedType| -> String { + match ty { + UncheckedType::Basic(ident) => ctx.ident(ident.id).0.to_string(), + UncheckedType::Generic(ident, _, _) => ctx.ident(ident.id).0.to_string(), + other => format!("{other:?}"), + } + }; + let inner_field = ref_struct.fields[&idn(&mut ctx, "inner")].ty.clone(); + assert_eq!(name_of(&mut ctx, &inner_field), "InnerRef"); + let balances = ref_struct.fields[&idn(&mut ctx, "balances")].ty.clone(); + assert_eq!(name_of(&mut ctx, &balances), "MapRef"); + let grid_field = ref_struct.fields[&idn(&mut ctx, "grid")].ty.clone(); + assert_eq!(name_of(&mut ctx, &grid_field), "ArrayRef"); + let note_field = ref_struct.fields[&idn(&mut ctx, "note")].ty.clone(); + assert_eq!(name_of(&mut ctx, ¬e_field), "StorageRef"); + + // Without a suffix the name and attrs are preserved. + let untouched = processor.transform_struct_to_storage_ref(&source, &derive, &mut ctx, None); + assert_eq!(untouched.name.id, source.name.id); + assert_eq!(untouched.attrs.len(), source.attrs.len()); + } + + #[test] + fn transform_rejects_malformed_ref_annotations() { + let processor = StorageProcessor::new(); + panics_with("only supported on basic struct types", |ctx| { + let grid = ty_array(ctx, "Felt", 1); + let node = strukt_with_ref_field(ctx, "Bad", ("x", grid), vec![], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.transform_struct_to_storage_ref(&node, &derive, ctx, Some("Ref")); + }); + panics_with("exactly three generic parameters", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let bad_map = ty_generic(ctx, "Map", vec![felt]); + let node = strukt(ctx, "Bad", vec![("x", bad_map)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.transform_struct_to_storage_ref(&node, &derive, ctx, Some("Ref")); + }); + } + + #[test] + fn storage_at_impl_synthesizes_read_and_write_only_for_arrays() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Storage"); + + let array = ty_array(&mut ctx, "Felt", 4); + let impl_node = processor + .generate_storage_at_impl(&array, &derive, &mut ctx) + .expect("array fields must generate a StorageAt impl"); + assert_eq!(impl_node.body.len(), 2); + match &impl_node.ty { + UncheckedType::Generic(ident, params, _) => { + assert_eq!(ctx.ident(ident.id).0, "ArrayRef"); + assert_eq!(params.len(), 2); + } + other => panic!("impl type should be ArrayRef<...>, got {other:?}"), + } + + let felt = ty_basic(&mut ctx, "Felt"); + assert!(processor.generate_storage_at_impl(&felt, &derive, &mut ctx).is_none()); + let map = ty_map(&mut ctx); + assert!(processor.generate_storage_at_impl(&map, &derive, &mut ctx).is_none()); + } + + #[test] + fn new_method_handles_every_ref_field_kind() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Storage"); + + let inner_ty = ty_basic(&mut ctx, "Inner"); + let map = ty_map(&mut ctx); + let grid = ty_array(&mut ctx, "Felt", 2); + let note = ty_basic(&mut ctx, "Felt"); + let source = strukt_with_ref_field( + &mut ctx, + "Wallet", + ("inner", inner_ty), + vec![("balances", map), ("grid", grid), ("note", note)], + vec![derive.clone()], + ); + let ref_struct = processor.transform_struct_to_storage_ref(&source, &derive, &mut ctx, Some("Ref")); + + for include_offset in [true, false] { + let def_id = processor.generate_new_method(&ref_struct, &derive, &mut ctx, include_offset); + match ctx.definition(def_id) { + DefinitionNode::Function(node) => { + assert_eq!(ctx.ident(node.name.id).0, "new"); + assert_eq!(node.parameters.len() + usize::from(!include_offset), 2); + } + other => panic!("new method should be a function definition, got {other:?}"), + } + } + } + + #[test] + fn new_method_rejects_malformed_ref_types() { + let processor = StorageProcessor::new(); + panics_with("exactly one generic parameter", |ctx| { + let felt_a = ty_basic(ctx, "Felt"); + let felt_b = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "StorageRef", vec![felt_a, felt_b]); + let node = strukt(ctx, "Bad", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_new_method(&node, &derive, ctx, true); + }); + panics_with("exactly two generic parameters", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt]); + let node = strukt(ctx, "Bad", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_new_method(&node, &derive, ctx, true); + }); + panics_with("numeric const", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let n = ty_basic(ctx, "N"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt, n]); + let node = strukt(ctx, "Bad", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_new_method(&node, &derive, ctx, true); + }); + panics_with("only supported on basic struct types", |ctx| { + let grid = ty_array(ctx, "Felt", 1); + let node = strukt_with_ref_field(ctx, "Bad", ("x", grid), vec![], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_new_method(&node, &derive, ctx, true); + }); + panics_with("must end with Ref", |ctx| { + let inner = ty_basic(ctx, "Inner"); + let node = strukt_with_ref_field(ctx, "Bad", ("x", inner), vec![], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_new_method(&node, &derive, ctx, true); + }); + } + + #[test] + fn accessor_impl_generates_for_ref_and_plain_structs() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Storage"); + + // A Ref struct without Map fields gets whole-struct get/set plus the + // ref-aware per-field bookkeeping. + let inner_ref = ty_basic(&mut ctx, "InnerRef"); + let grid_ref = ty_array_ref(&mut ctx, "Felt", 2); + let storage_ref = ty_storage_ref(&mut ctx, "Felt"); + let ref_source = strukt_with_ref_field( + &mut ctx, + "WalletRef", + ("inner", inner_ref), + vec![("grid", grid_ref), ("note", storage_ref)], + vec![derive.clone()], + ); + let ref_impl = processor.generate_accessor_impl(&ref_source, &derive, &mut ctx, true); + assert!(ref_impl.body.len() >= 2); + + // A plain #[storage] struct with an array field gets per-field get/set + // and indexed get_at/set_at accessors. + let plain_grid = ty_array(&mut ctx, "Felt", 2); + let plain_note = ty_basic(&mut ctx, "Felt"); + let plain = strukt(&mut ctx, "Ledger", vec![("grid", plain_grid), ("note", plain_note)], vec![derive.clone()]); + let plain_impl = processor.generate_accessor_impl(&plain, &derive, &mut ctx, false); + assert!(plain_impl.body.len() >= 4); + + // A Ref struct that contains a MapRef field skips whole-struct get/set. + let map_ref = ty_map_ref(&mut ctx); + let with_map = strukt(&mut ctx, "BankRef", vec![("m", map_ref)], vec![derive.clone()]); + let map_impl = processor.generate_accessor_impl(&with_map, &derive, &mut ctx, true); + assert!(map_impl.body.is_empty()); + } + + #[test] + fn accessor_impl_rejects_malformed_ref_types() { + let processor = StorageProcessor::new(); + panics_with("exactly one generic parameter", |ctx| { + let felt_a = ty_basic(ctx, "Felt"); + let felt_b = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "StorageRef", vec![felt_a, felt_b]); + let node = strukt(ctx, "Bad", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_accessor_impl(&node, &derive, ctx, true); + }); + panics_with("exactly two generic parameters", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt]); + let node = strukt(ctx, "Bad", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_accessor_impl(&node, &derive, ctx, true); + }); + panics_with("numeric const", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let n = ty_basic(ctx, "N"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt, n]); + let node = strukt(ctx, "Bad", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_accessor_impl(&node, &derive, ctx, true); + }); + panics_with("only supported on basic struct types", |ctx| { + let grid = ty_array(ctx, "Felt", 1); + let node = strukt_with_ref_field(ctx, "Bad", ("x", grid), vec![], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_accessor_impl(&node, &derive, ctx, true); + }); + } + + #[test] + fn eq_support_covers_primitives_ref_names_and_arrays() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + + let named_ref = ty_basic(&mut ctx, "InnerRef"); + assert!(processor.supports_generated_eq_field(&named_ref, &mut ctx)); + let felt = ty_basic(&mut ctx, "Felt"); + assert!(!processor.supports_generated_eq_field(&felt, &mut ctx)); + let storage_ref_felt = ty_storage_ref(&mut ctx, "Felt"); + assert!(processor.supports_generated_eq_field(&storage_ref_felt, &mut ctx)); + let storage_ref_struct = ty_storage_ref(&mut ctx, "Inner"); + assert!(!processor.supports_generated_eq_field(&storage_ref_struct, &mut ctx)); + let array_ref_felt = ty_array_ref(&mut ctx, "Felt", 2); + assert!(processor.supports_generated_eq_field(&array_ref_felt, &mut ctx)); + let array_ref_bool = ty_array_ref(&mut ctx, "bool", 2); + assert!(!processor.supports_generated_eq_field(&array_ref_bool, &mut ctx)); + let map = ty_map(&mut ctx); + assert!(!processor.supports_generated_eq_field(&map, &mut ctx)); + let tuple = UncheckedType::Tuple(vec![], loc()); + assert!(!processor.supports_generated_eq_field(&tuple, &mut ctx)); + } + + #[test] + fn field_size_generators_cover_all_type_shapes() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Storage"); + + let felt = ty_basic(&mut ctx, "Felt"); + let storage_ref = ty_storage_ref(&mut ctx, "Felt"); + let array_ref = ty_array_ref(&mut ctx, "Felt", 3); + for ty in [felt, storage_ref, array_ref] { + let size = processor.generate_field_size(&derive, &ty, &mut ctx); + assert!(matches!(ctx.expression(size), ExprNode::Call(_))); + } + + let inner_ref = ty_basic(&mut ctx, "InnerRef"); + let annotation = ref_attr(&mut ctx); + let ref_field = field_of(&mut ctx, inner_ref, vec![annotation]); + let sized = processor.generate_struct_field_size(&derive, &ref_field, &mut ctx); + assert!(matches!(ctx.expression(sized), ExprNode::Call(_))); + let plain_felt = ty_basic(&mut ctx, "Felt"); + let plain_field = field_of(&mut ctx, plain_felt, vec![]); + let sized_plain = processor.generate_struct_field_size(&derive, &plain_field, &mut ctx); + assert!(matches!(ctx.expression(sized_plain), ExprNode::Call(_))); + } + + #[test] + fn field_size_generators_reject_malformed_ref_types() { + let processor = StorageProcessor::new(); + panics_with("exactly one generic parameter", |ctx| { + let felt_a = ty_basic(ctx, "Felt"); + let felt_b = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "StorageRef", vec![felt_a, felt_b]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_field_size(&derive, &bad, ctx); + }); + panics_with("exactly two generic parameters", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_field_size(&derive, &bad, ctx); + }); + panics_with("numeric const", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let n = ty_basic(ctx, "N"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt, n]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_field_size(&derive, &bad, ctx); + }); + panics_with("only supported on basic struct types", |ctx| { + let grid = ty_array(ctx, "Felt", 1); + let annotation = ref_attr(ctx); + let ref_field = field_of(ctx, grid, vec![annotation]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_struct_field_size(&derive, &ref_field, ctx); + }); + panics_with("must use its generated Ref type", |ctx| { + let inner = ty_basic(ctx, "Inner"); + let annotation = ref_attr(ctx); + let ref_field = field_of(ctx, inner, vec![annotation]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_struct_field_size(&derive, &ref_field, ctx); + }); + } + + #[test] + fn struct_getter_and_setter_strip_ref_names_and_size_fields() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Storage"); + + let inner_ref = ty_basic(&mut ctx, "InnerRef"); + let bare_ref = ty_basic(&mut ctx, "FooRef"); + let grid_ref = ty_array_ref(&mut ctx, "Felt", 2); + let storage_ref = ty_storage_ref(&mut ctx, "Felt"); + let ref_struct = strukt_with_ref_field( + &mut ctx, + "WalletRef", + ("inner", inner_ref), + vec![("bare", bare_ref), ("grid", grid_ref), ("note", storage_ref)], + vec![derive.clone()], + ); + let getter = processor.generate_struct_getter(&ref_struct, &derive, &mut ctx); + let setter = processor.generate_struct_setter(&ref_struct, &derive, &mut ctx); + assert!(matches!(ctx.definition(getter), DefinitionNode::Function(_))); + assert!(matches!(ctx.definition(setter), DefinitionNode::Function(_))); + + // Structs whose name does not end in Ref keep it as the base name. + let plain_ref = ty_storage_ref(&mut ctx, "Felt"); + let named = strukt(&mut ctx, "Vault", vec![("note", plain_ref)], vec![derive.clone()]); + let named_getter = processor.generate_struct_getter(&named, &derive, &mut ctx); + let named_setter = processor.generate_struct_setter(&named, &derive, &mut ctx); + assert!(matches!(ctx.definition(named_getter), DefinitionNode::Function(_))); + assert!(matches!(ctx.definition(named_setter), DefinitionNode::Function(_))); + } + + #[test] + fn struct_getter_and_setter_reject_malformed_ref_types() { + let processor = StorageProcessor::new(); + panics_with("exactly one generic parameter", |ctx| { + let felt_a = ty_basic(ctx, "Felt"); + let felt_b = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "StorageRef", vec![felt_a, felt_b]); + let node = strukt(ctx, "BadRef", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_struct_getter(&node, &derive, ctx); + }); + panics_with("exactly two generic parameters", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt]); + let node = strukt(ctx, "BadRef", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_struct_setter(&node, &derive, ctx); + }); + panics_with("numeric const", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let n = ty_basic(ctx, "N"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt, n]); + let node = strukt(ctx, "BadRef", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_struct_getter(&node, &derive, ctx); + }); + panics_with("only supported on basic struct types", |ctx| { + let grid = ty_array(ctx, "Felt", 1); + let node = strukt_with_ref_field(ctx, "BadRef", ("x", grid), vec![], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_struct_setter(&node, &derive, ctx); + }); + panics_with("must end with Ref", |ctx| { + let inner = ty_basic(ctx, "Inner"); + let node = strukt_with_ref_field(ctx, "BadRef", ("x", inner), vec![], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_struct_getter(&node, &derive, ctx); + }); + } + + #[test] + fn getter_at_and_setter_at_accept_refs_and_plain_arrays() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Storage"); + let field_ident = idn(&mut ctx, "grid"); + let offset = felt_zero(&mut ctx); + + let shapes = vec![ + ty_storage_ref(&mut ctx, "Felt"), + ty_array_ref(&mut ctx, "Felt", 2), + ty_array(&mut ctx, "Felt", 2), + ]; + for shape in shapes { + let getter = processor.generate_getter_at(&derive, &field_ident.id, &shape, offset, &mut ctx); + let setter = processor.generate_setter_at(&derive, &field_ident.id, &shape, offset, &mut ctx); + assert!(matches!(ctx.definition(getter), DefinitionNode::Function(_))); + assert!(matches!(ctx.definition(setter), DefinitionNode::Function(_))); + } + } + + #[test] + fn getter_at_and_setter_at_reject_non_indexable_types() { + let processor = StorageProcessor::new(); + panics_with("Expected StorageRef or ArrayRef", |ctx| { + let map = ty_map(ctx); + let field_ident = idn(ctx, "m"); + let derive = derive_attr(ctx, "Storage"); + let offset = felt_zero(ctx); + processor.generate_getter_at(&derive, &field_ident.id, &map, offset, ctx); + }); + panics_with("generate_setter_at called on non-StorageRef", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let field_ident = idn(ctx, "x"); + let derive = derive_attr(ctx, "Storage"); + let offset = felt_zero(ctx); + processor.generate_setter_at(&derive, &field_ident.id, &felt, offset, ctx); + }); + } + + #[test] + fn storage_impl_chooses_ref_type_from_derives() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Storage"); + + // With a Storage derive the RefType points at the generated XRef type. + let felt = ty_basic(&mut ctx, "Felt"); + let derived = strukt(&mut ctx, "Wallet", vec![("note", felt)], vec![derive.clone()]); + let impl_node = processor.generate_storage_impl(&derived, &derive, &mut ctx); + let ref_key = idn(&mut ctx, "RefType"); + match &impl_node.associated_types[&ref_key].ty { + UncheckedType::Basic(ident) => assert_eq!(ctx.ident(ident.id).0, "WalletRef"), + other => panic!("derived RefType should be WalletRef, got {other:?}"), + } + + // Without Storage/StorageRef derives it falls back to StorageRef. + let plain_felt = ty_basic(&mut ctx, "Felt"); + let contract_attr = attr(&mut ctx, "contract", &[]); + let undecorated = strukt(&mut ctx, "Vault", vec![("note", plain_felt)], vec![contract_attr]); + let plain_impl = processor.generate_storage_impl(&undecorated, &derive, &mut ctx); + match &plain_impl.associated_types[&ref_key].ty { + UncheckedType::Generic(ident, params, _) => { + assert_eq!(ctx.ident(ident.id).0, "StorageRef"); + assert_eq!(params.len(), 1); + } + other => panic!("undecorated RefType should be StorageRef, got {other:?}"), + } + } + + #[test] + fn storage_read_and_write_methods_walk_ref_fields() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Storage"); + + let storage_ref = ty_storage_ref(&mut ctx, "Felt"); + let array_ref = ty_array_ref(&mut ctx, "Felt", 2); + let plain = ty_basic(&mut ctx, "Felt"); + let ref_struct = strukt( + &mut ctx, + "WalletRef", + vec![("note", storage_ref), ("grid", array_ref), ("other", plain)], + vec![derive.clone()], + ); + let read = processor.generate_storage_read_method(&ref_struct, &derive, &mut ctx); + let write = processor.generate_storage_write_method(&ref_struct, &derive, &mut ctx); + assert!(matches!(ctx.definition(read), DefinitionNode::Function(_))); + assert!(matches!(ctx.definition(write), DefinitionNode::Function(_))); + } + + #[test] + fn storage_read_and_write_methods_reject_malformed_ref_types() { + let processor = StorageProcessor::new(); + panics_with("exactly one generic parameter", |ctx| { + let felt_a = ty_basic(ctx, "Felt"); + let felt_b = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "StorageRef", vec![felt_a, felt_b]); + let node = strukt(ctx, "BadRef", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_storage_read_method(&node, &derive, ctx); + }); + panics_with("exactly two generic parameters", |ctx| { + let felt = ty_basic(ctx, "Felt"); + let bad = ty_generic(ctx, "ArrayRef", vec![felt]); + let node = strukt(ctx, "BadRef", vec![("x", bad)], vec![]); + let derive = derive_attr(ctx, "Storage"); + processor.generate_storage_write_method(&node, &derive, ctx); + }); + } + + #[test] + fn event_impl_is_generated_with_the_event_trait() { + let mut program = Program::::new(); + let mut ctx = TestCtx::new(&mut program); + let processor = StorageProcessor::new(); + let derive = derive_attr(&mut ctx, "Event"); + + let felt = ty_basic(&mut ctx, "Felt"); + let node = strukt(&mut ctx, "Transferred", vec![("amount", felt)], vec![derive.clone()]); + let impl_node = processor.generate_event_impl(&node, &derive, &mut ctx); + match &impl_node.trait_ty { + UncheckedType::Basic(ident) => assert_eq!(ctx.ident(ident.id).0, "Event"), + other => panic!("event impl should implement Event, got {other:?}"), + } + assert!(impl_node.body.is_empty()); + assert!(impl_node.is_generated); + } + + // The StorageProcessor only descends into definitions, so its expression + // and statement visitors are exercised here by dispatching one node of + // every kind through the default `visit_expr`/`visit_stmt`/`visit_definition`. + #[test] + fn visitor_noops_accept_every_node_kind() { + let mut program = Program::::new(); + let module_name = Identifier::new(program.interner.intern_ident("crate"), loc()); + let module_id = program.modules.add_node(ModuleNode { + name: module_name, + file_id: psy_common::FileId(0), + modules: vec![], + inline_modules: vec![], + definitions: vec![], + visibility: Visibility::Public, + comments: vec![], + location: loc(), + }); + let mut ctx = TestCtx::new(&mut program); + let mut processor: StorageProcessor = StorageProcessor::new(); + + let felt = felt_zero(&mut ctx); + let target = ty_basic(&mut ctx, "x"); + let path = ctx.alloc_expression(ExprNode::Path(PathNode { + root: None, + segments: vec![], + target, + is_ty: false, + location: loc(), + })); + let block = ctx.alloc_expression(ExprNode::BlockExpr(BlockExprNode { + stmts: vec![], + expr: None, + expr_comments: vec![], + location: loc(), + })); + let binary = ctx.alloc_expression(ExprNode::Binary(BinaryNode { + lhs: felt, + operator: BinaryOperator::Add, + rhs: felt, + location: loc(), + })); + let unary = ctx.alloc_expression(ExprNode::Unary(UnaryNode { + operator: UnaryOperator::Not, + rhs: felt, + location: loc(), + })); + let call = ctx.alloc_expression(ExprNode::Call(CallNode { + callee: path, + generic_parameters: vec![], + args: vec![], + location: loc(), + })); + let member_call = ctx.alloc_expression(ExprNode::MemberCall(MemberCallNode { + callee: path, + receiver: felt, + generic_parameters: vec![], + args: vec![], + location: loc(), + })); + let cast_target = ty_basic(&mut ctx, "Felt"); + let cast = ctx.alloc_expression(ExprNode::Cast(CastNode { + value: felt, + target_type: cast_target, + location: loc(), + })); + let index_access = ctx.alloc_expression(ExprNode::IndexAccess(IndexAccessNode { + target: felt, + index: felt, + location: loc(), + })); + let field_ident = idn(&mut ctx, "f"); + let member_access = ctx.alloc_expression(ExprNode::MemberAccess(MemberAccessNode { + target: felt, + field: field_ident, + generic_parameters: vec![], + location: loc(), + })); + let intrinsic = ctx.alloc_expression(ExprNode::Intrinsic(IntrinsicExprNode::GetUserId { location: loc() })); + let lambda = ctx.alloc_expression(ExprNode::LambdaFunction(LambdaFunctionNode { + parameters: vec![], + body: felt, + return_type: None, + location: loc(), + })); + let if_expr = ctx.alloc_expression(ExprNode::IfExpr(IfExprNode { + if_branch: Case::new(felt, block, loc()), + elseif_branches: vec![], + else_branch: None, + location: loc(), + })); + let tuple = ctx.alloc_expression(ExprNode::Tuple(TupleExprNode { elements: vec![], location: loc() })); + let tuple_access = ctx.alloc_expression(ExprNode::TupleAccess(TupleAccessNode { + target: felt, + index: 0, + location: loc(), + })); + let match_expr = ctx.alloc_expression(ExprNode::Match(MatchNode { + scrutinee: felt, + arms: vec![MatchArm { + pattern: MatchPattern::PlaceHolder(loc()), + body: felt, + location: loc(), + }], + location: loc(), + })); + let parentheses = ctx.alloc_expression(ExprNode::Parentheses(felt)); + for expr_id in [path, felt, binary, unary, call, member_call, cast, index_access, member_access, intrinsic, lambda, block, if_expr, tuple, tuple_access, match_expr, parentheses] { + processor.visit_expr(expr_id, &mut ctx).unwrap(); + } + + let use_kind = idn(&mut ctx, "std"); + let use_def = ctx.alloc_definition(DefinitionNode::Use(UseNode { + visibility: Visibility::Private, + kind: use_kind, + segments: vec![], + target: None, + comments: vec![], + location: loc(), + })); + let while_stmt = ctx.alloc_statement(StmtNode::While(WhileNode { + predicate: felt, + body: block, + comments: vec![], + location: loc(), + })); + let loop_var = idn(&mut ctx, "i"); + let for_stmt = ctx.alloc_statement(StmtNode::For(ForNode { + variable: loop_var, + start: felt, + end: felt, + body: block, + comments: vec![], + location: loc(), + })); + let assignment = ctx.alloc_statement(StmtNode::Assignment(AssignmentNode { + target: path, + operator: AssignmentOperator::Eq, + value: felt, + comments: vec![], + location: loc(), + })); + let var_name = idn(&mut ctx, "x"); + let var_ty = ty_basic(&mut ctx, "Felt"); + let variable = ctx.alloc_statement(StmtNode::Variable(VariableNode { + name: var_name, + ty: var_ty, + qualifier: TypeQualifier::new(false, loc()), + value: felt, + comments: vec![], + location: loc(), + })); + let def_stmt = ctx.alloc_statement(StmtNode::Definition(use_def)); + let expr_stmt = ctx.alloc_statement(StmtNode::Expression(felt)); + let ret = ctx.alloc_statement(StmtNode::Return(ReturnNode { + expr_id: Some(felt), + comments: vec![], + location: loc(), + })); + let assert_stmt = ctx.alloc_statement(StmtNode::Intrinsic(IntrinsicStmtNode::Assert { + left: felt, + message: None, + comments: vec![], + location: loc(), + })); + for stmt_id in [while_stmt, for_stmt, assignment, variable, def_stmt, expr_stmt, ret, assert_stmt] { + processor.visit_stmt(stmt_id, &mut ctx).unwrap(); + } + + // Definitions of every kind dispatch through visit_definition; the + // storage struct needs a module ancestor for its insertions. + ctx.push_node_id(NodeId::Module(module_id)); + + let storage_derive = derive_attr(&mut ctx, "Storage"); + let contract = attr(&mut ctx, "contract", &[]); + let grid = ty_array(&mut ctx, "Felt", 2); + let note = ty_basic(&mut ctx, "Felt"); + let storage = strukt(&mut ctx, "Wallet", vec![("grid", grid), ("note", note)], vec![storage_derive, contract]); + let enum_name = idn(&mut ctx, "Kind"); + let variant = idn(&mut ctx, "A"); + let enum_def = ctx.alloc_definition(DefinitionNode::Enum(EnumNode { + name: enum_name, + generic_parameters: vec![], + variants: vec![EnumVariant::Basic(variant)], + visibility: Visibility::Public, + comments: vec![], + location: loc(), + })); + let wallet_ty = ty_basic(&mut ctx, "Wallet"); + let impl_def = ctx.alloc_definition(DefinitionNode::Impl(ImplNode { + generic_parameters: vec![], + associated_types: IndexMap::new(), + ty: wallet_ty.clone(), + body: vec![], + attrs: vec![], + comments: vec![], + location: loc(), + is_generated: false, + })); + let storage_ty = ty_basic(&mut ctx, "Storage"); + let trait_impl_def = ctx.alloc_definition(DefinitionNode::TraitImpl(TraitImplNode { + generic_parameters: vec![], + associated_types: IndexMap::new(), + trait_ty: storage_ty, + ty: wallet_ty, + body: vec![], + attrs: vec![], + comments: vec![], + location: loc(), + is_generated: false, + })); + let trait_name = idn(&mut ctx, "Store"); + let trait_def = ctx.alloc_definition(DefinitionNode::Trait(TraitNode { + name: trait_name, + associated_types: IndexMap::new(), + generic_parameters: vec![], + body: vec![], + visibility: Visibility::Public, + comments: vec![], + location: loc(), + })); + let alias_name = idn(&mut ctx, "Amount"); + let alias_ty = ty_basic(&mut ctx, "Felt"); + let alias_def = ctx.alloc_definition(DefinitionNode::TypeAlias(TypeAliasNode { + name: alias_name, + ty: alias_ty, + visibility: Visibility::Public, + comments: vec![], + location: loc(), + })); + let const_name = idn(&mut ctx, "MAX"); + let const_ty = ty_basic(&mut ctx, "Felt"); + let const_def = ctx.alloc_definition(DefinitionNode::Const(ConstNode { + name: const_name, + ty: const_ty, + value: felt, + visibility: Visibility::Public, + comments: vec![], + location: loc(), + })); + let fn_name = idn(&mut ctx, "read"); + let fn_ret = ty_basic(&mut ctx, "Felt"); + let function_def = ctx.alloc_definition(DefinitionNode::Function(FunctionNode { + name: fn_name, + parameters: vec![], + generic_parameters: vec![], + body: Some(block), + return_type: Some(fn_ret), + qualifier: Qualifier { + is_extern: false, + is_const: false, + location: loc(), + }, + visibility: Visibility::Public, + attrs: vec![], + comments: vec![], + location: loc(), + })); + let storage_def = ctx.alloc_definition(DefinitionNode::Struct(storage)); + + for def_id in [use_def, enum_def, impl_def, trait_impl_def, trait_def, alias_def, const_def, function_def, storage_def] { + processor.visit_definition(def_id, &mut ctx).unwrap(); + } + + // visit_impl descends into the impl body definitions. + ctx.push_node_id(NodeId::Def(impl_def)); + processor.visit_impl(impl_def, &mut ctx).unwrap(); + ctx.pop_node_id(); + + // The storage struct generated Ref definitions into its module. + let generated = program.modules[module_id].data().definitions.len(); + assert!(generated > 0, "visit_struct must insert generated definitions"); + } +} diff --git a/psy-interpreter/src/sema_edge_tests.rs b/psy-interpreter/src/sema_edge_tests.rs new file mode 100644 index 000000000..ea0a8959f --- /dev/null +++ b/psy-interpreter/src/sema_edge_tests.rs @@ -0,0 +1,1082 @@ +// Source-driven coverage for the scattered sema error arms in +// psy-sema/src/lib.rs (visitor type-mismatch / arity / shape rejections). +// Each entry is a tiny psy program whose typechecking lands in one specific +// `return Err(...)` arm; the reject needles are kept loose (case-insensitive +// substring) so the assertions survive Display rewording but still require +// the program to be rejected for a *type* reason, not a parse error. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; +use serial_test::serial; + +use super::*; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +fn compile(source: &str) -> Result<(), String> { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("psy_se_{n}.psy")); + std::fs::write(&path, source).unwrap(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let result = interpreter.typecheck_single(path.clone()); + + let _ = std::fs::remove_file(&path); + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + } + result.map(|_| ()).map_err(|e| format!("{e:#}")) +} + +fn rejects(label: &str, source: &str, needle: &str) { + match compile(source) { + Ok(()) => panic!("[{label}] expected rejection containing `{needle}`, got success"), + Err(message) => assert!( + message.to_lowercase().contains(&needle.to_lowercase()), + "[{label}] expected rejection containing `{needle}`, got:\n{message}" + ), + } +} + +fn accepts(label: &str, source: &str) { + if let Err(message) = compile(source) { + panic!("[{label}] expected success, got:\n{message}"); + } +} + +/// Run every case even when an earlier one fails, then report all failures +/// at once β€” a panicking per-case assert would silently skip the rest. +fn rejects_all(cases: &[(String, String, &str)]) { + let mut failures = Vec::new(); + for (label, source, needle) in cases { + match compile(source) { + Ok(()) => failures.push(format!("[{label}] expected rejection containing `{needle}`, got success")), + Err(message) => { + if !message.to_lowercase().contains(&needle.to_lowercase()) { + failures.push(format!("[{label}] expected rejection containing `{needle}`, got:\n{message}")); + } + } + } + } + assert!(failures.is_empty(), "{} case(s) failed:\n{}", failures.len(), failures.join("\n---\n")); +} + +const PRELUDE: &str = "use std::prelude::*;\n"; + +const STRUCT_P: &str = r#" +pub struct P { pub x: Felt } + +impl P { + pub fn new(v: Felt) -> P { + return P { x: v }; + } +} +"#; + +#[test] +#[serial] +fn binary_and_unary_operator_type_guards_reject() { + let cases: &[(&str, &str, &str)] = &[ + ("add on bool", "fn main() -> Felt { let a: bool = true; let b: Felt = a + 1; return b; }", "mismatch"), + ("xor on tuple", "fn main() -> Felt { let t = (1, 2); let u = t ^ t; return 0; }", "mismatch"), + ("logical and on felt", "fn main() -> Felt { let c: bool = 1 && 2; return 0; }", "mismatch"), + ("compare bools", "fn main() -> Felt { let d: bool = true < false; return 0; }", "mismatch"), + ("negate bool", "fn main() -> Felt { let e: Felt = -true; return e; }", "mismatch"), + ("not on felt", "fn main() -> Felt { let f: bool = !1; return f; }", "mismatch"), + ("array literal mixed types", "fn main() -> Felt { let a = [1, true]; return a[0]; }", "mismatch"), + ]; + for (label, body, needle) in cases { + rejects(label, &format!("{PRELUDE}{body}"), needle); + } +} + +#[test] +#[serial] +fn call_arity_and_generic_argument_guards_reject() { + let source = format!( + "{PRELUDE}{STRUCT_P} +pub fn felt_id(v: T) -> T {{ + return v; +}} + +pub fn two(a: Felt, b: Felt) -> Felt {{ + return a + b; +}} +" + ); + rejects_all(&[ + ( + "plain call wrong arity".to_string(), + format!("{source}fn main() -> Felt {{ return two(1); }}"), + "parameters", + ), + ( + "explicit generic arg violates constraint".to_string(), + format!("{source}fn main() -> Felt {{ return felt_id(1); }}"), + "mismatch", + ), + ]); +} + +#[test] +#[serial] +fn member_call_shapes_resolve() { + // Happy-path method call: resolves through find_member into the + // visit_member_call validation. (Wrong-shape methods are filtered out + // during lookup and never reach the visitor's own validation arms.) + let source = format!( + "{PRELUDE}{STRUCT_P} +impl P {{ + pub fn one(self: P, v: Felt) -> Felt {{ + return v; + }} +}} + +fn main() -> Felt {{ + let a = P::new(1); + let x: Felt = a.one(1); + return x; +}}" + ); + accepts("method call with inferred arguments", &source); +} + +#[test] +#[serial] +fn custom_index_and_eq_shapes_reject() { + // find_member filters method candidates by expected signature before the + // visitor's own validation runs, so several wrong-shape methods surface + // as UnresolvedMember; the reachable eq-shape arms are asserted here. + let eq_ret = format!( + "{PRELUDE}{STRUCT_P} +impl P {{ + pub fn eq(self: P, o: P) -> Felt {{ + return 0; + }} +}} +" + ); + rejects_all(&[ + ( + "eq returning non-bool via ==".to_string(), + format!("{eq_ret}fn main() -> Felt {{ let a = P::new(1); let b = P::new(2); let same: bool = a == b; return 0; }}"), + "mismatch", + ), + ( + "eq returning non-bool via assert_eq".to_string(), + format!("{eq_ret}fn main() -> Felt {{ let a = P::new(1); let b = P::new(2); assert_eq(a, b); return 0; }}"), + "mismatch", + ), + ]); +} + +#[test] +#[serial] +fn tuple_and_if_expression_guards_reject() { + let cases: &[(&str, &str, &str)] = &[ + ("tuple access out of bounds", "fn main() -> Felt { let t = (1, 2); return t.2; }", "index"), + ("if predicate not bool", "fn main() -> Felt { let x: Felt = if 1 { 2 } else { 3 }; return x; }", "mismatch"), + ( + "else-if predicate not bool", + "fn main() -> Felt { let x: Felt = if true { 1 } else if 2 { 2 } else { 3 }; return x; }", + "mismatch", + ), + ( + "else-if branch type mismatch", + "fn main() -> Felt { let x: Felt = if true { 1 } else if true { true } else { 2 }; return x; }", + "mismatch", + ), + ("else branch type mismatch", "fn main() -> Felt { let x: Felt = if true { 1 } else { true }; return x; }", "mismatch"), + ("struct literal missing fields", "fn main() -> Felt { let p: Pair = Pair { a: 1 }; return 0; }", "fields"), + ("struct literal field type mismatch", "fn main() -> Felt { let p: Pair = Pair { a: 1, b: true }; return 0; }", "mismatch"), + ]; + let prelude = format!( + "{PRELUDE}pub struct Pair {{ a: Felt, b: Felt }}\n" + ); + for (label, body, needle) in cases { + rejects(label, &format!("{prelude}{body}"), needle); + } +} + +#[test] +#[serial] +fn return_placement_guards_reject() { + rejects( + "statement after quick return", + format!("{PRELUDE}fn q() -> Felt {{ return 1; let z: Felt = 2; return z; }} fn main() -> Felt {{ return q(); }}").as_str(), + "return", + ); + rejects( + "return inside nested if block", + format!("{PRELUDE}fn n() {{ if true {{ return; }} return; }} fn main() {{ n(); }}").as_str(), + "return", + ); +} + +#[test] +#[serial] +fn match_expression_guards_reject() { + let cases: &[(&str, &str, &str)] = &[ + ("non-primitive scrutinee", "let t = (1, 2); let x: Felt = match t { _ => 1 };", "mismatch"), + ("pattern type mismatch", "let x: Felt = match 1 { true => 1, _ => 2 };", "mismatch"), + ("duplicate wildcard", "let x: Felt = match true { _ => 1, _ => 2 };", "wildcard"), + ("arm body type mismatch", "let x: Felt = match true { true => 1, false => true };", "mismatch"), + ("incomplete boolean match", "let x: Felt = match true { true => 1 };", "match"), + ]; + for (label, body, needle) in cases { + rejects(label, &format!("{PRELUDE}fn main() -> Felt {{ {body} return x; }}"), needle); + } +} + +#[test] +#[serial] +fn lambda_parameter_and_return_guards() { + let path_param = format!( + "{PRELUDE}{STRUCT_P}fn main() -> Felt {{ + let lam = |p: P| -> Felt {{ return p.x; }}; + let v: Felt = lam(P::new(3)); + return v; +}}" + ); + accepts("path-typed lambda parameter", &path_param); + + // A return-type-less lambda is typed VOID; a value-producing body then + // fails the unify against VOID (both the no-return-type lookup and the + // mismatch arm run). + rejects( + "lambda body value vs inferred VOID", + format!("{PRELUDE}fn main() -> Felt {{ let lam = |x: Felt| {{ return x + 1; }}; return 0; }}").as_str(), + "mismatch", + ); + + rejects( + "duplicate lambda parameter", + format!("{PRELUDE}fn main() -> Felt {{ let lam = |a: Felt, a: Felt| -> Felt {{ return a; }}; return 0; }}").as_str(), + "defined", + ); + rejects( + "lambda declared return mismatch", + format!("{PRELUDE}fn main() -> Felt {{ let lam = |x: Felt| -> bool {{ return x + 1; }}; return 0; }}").as_str(), + "mismatch", + ); +} + +const GENERIC_BOX: &str = r#" +pub struct Box { + v: T, +} + +impl Box { + pub fn zero() -> Box { + return Box { v: 0 }; + } +} +"#; + +#[test] +#[serial] +fn impl_and_trait_header_guards_reject() { + let bad_impl = format!( + "{PRELUDE}pub struct Box {{ v: T, }}\nimpl Box {{\n pub fn z() -> Felt {{ return 0; }}\n}}\nfn main() -> Felt {{ return 0; }}" + ); + rejects("impl generic argument violates constraint", &bad_impl, "mismatch"); + + let bad_trait_arg = format!( + "{PRELUDE}pub trait Tr {{\n pub fn t() -> Felt;\n}}\npub struct S {{}}\nimpl Tr for S {{\n pub fn t() -> Felt {{ return 0; }}\n}}\nfn main() -> Felt {{ return 0; }}" + ); + rejects("trait impl generic argument violates constraint", &bad_trait_arg, "mismatch"); + + let non_trait = format!( + "{PRELUDE}{STRUCT_P}pub struct S {{}}\nimpl P for S {{}}\nfn main() -> Felt {{ return 0; }}" + ); + rejects("trait impl for a non-trait type", &non_trait, "mismatch"); + + let missing_assoc = format!( + "{PRELUDE}pub trait Tr {{\n pub type Out: Felt;\n pub fn t() -> Felt;\n}}\npub struct S {{}}\nimpl Tr for S {{\n pub fn t() -> Felt {{ return 0; }}\n}}\nfn main() -> Felt {{ return 0; }}" + ); + rejects("trait impl missing associated type", &missing_assoc, "associated"); + + let bad_implementor = format!( + "{PRELUDE}pub trait Tr {{\n pub fn t() -> Felt;\n}}\npub struct Box {{ v: T, }}\nimpl Tr for Box {{\n pub fn t() -> Felt {{ return 0; }}\n}}\nfn main() -> Felt {{ return 0; }}" + ); + rejects("trait impl implementor argument mismatch", &bad_implementor, "mismatch"); +} + +#[test] +#[serial] +fn generic_type_annotation_guards_reject() { + rejects( + "too few generic arguments in annotation", + format!("{PRELUDE}pub struct P2 {{ a: A, b: B, }} fn main() -> Felt {{ let p: P2 = P2 {{ a: 1, b: 2 }}; return 0; }}").as_str(), + "generic", + ); + rejects( + "generic argument violates constraint in annotation", + format!("{PRELUDE}pub struct P2 {{ a: A, b: B, }} fn main() -> Felt {{ let p: P2 = P2 {{ a: 1, b: 2 }}; return 0; }}").as_str(), + "mismatch", + ); +} + +#[test] +#[serial] +fn method_resolution_and_member_call_paths_accept() { + // `self.get()` inside an impl resolves the callee through the + // member-call arm of visit_member_access; `P::new` covers the + // type-receiver (associated function) path of visit_member_call. + let source = format!( + "{PRELUDE}{STRUCT_P} +impl P {{ + pub fn get(self: P) -> Felt {{ + return self.x; + }} + + pub fn run(self: P) -> Felt {{ + return self.get(); + }} +}} + +fn main() -> Felt {{ + let a = P::new(1); + return a.run(); +}}" + ); + accepts("self method call and associated constructor", &source); + + // A const Path forwarded as a split_bits length exercises the + // compile-time-constant evaluation path at the call site. + let const_length = format!( + "{PRELUDE}const K: Felt = 4; +fn main() -> Felt {{ + let bits: [Felt; 4] = split_bits(13, K); + return bits[0]; +}}" + ); + accepts("const-named split_bits length", &const_length); + + // The impl header itself is what gets checked here; the associated + // function is only declared, not resolved through a generic instantiation. + let specialized = format!("{PRELUDE}{GENERIC_BOX}fn main() -> Felt {{ return 0; }}"); + accepts("specialized impl header", &specialized); +} + +#[test] +#[serial] +fn compound_assignment_guards() { + // Compound assignment on a struct routes through `add_assign` member + // lookup; a matching method typechecks... + let with_method = format!( + "{PRELUDE}{STRUCT_P} +impl P {{ + pub fn add_assign(self: P, o: P) -> P {{ + return o; + }} +}} + +fn main() -> Felt {{ + let mut a = P::new(1); + a += P::new(2); + return a.x; +}}" + ); + accepts("compound assignment with add_assign method", &with_method); + + // ...and assert with a non-bool predicate is rejected. + rejects( + "assert with non-bool predicate", + format!("{PRELUDE}fn main() {{ assert(1); }}").as_str(), + "mismatch", + ); +} + +#[test] +#[serial] +fn trait_cast_paths_cover_segments_constraints_and_rejections() { + let traits = format!( + "{PRELUDE}{STRUCT_P} +pub trait Val {{ + pub fn value() -> Felt; +}} + +impl Val for P {{ + pub fn value() -> Felt {{ + return 3; + }} +}} + +pub trait WithTy {{ + pub type Ty; + + pub fn make() -> Ty; +}} + +impl WithTy for P {{ + pub type Ty = P; + + pub fn make() -> P {{ + return P::new(4); + }} +}} +" + ); + + // A trait-cast path with segments resolves the associated type first, + // then walks members of the resulting type. + accepts( + "trait cast with associated-type segment", + &format!( + "{traits}fn main() -> Felt {{ + let v: P =

::Ty::make(); + return v.x; +}}" + ), + ); + + // Casting through a type variable consults its declared constraints. + accepts( + "trait cast on constrained generic", + &format!( + "{traits}pub fn go() -> Felt {{ + return ::value(); +}} + +fn main() -> Felt {{ + return go::

(); +}}" + ), + ); + + rejects( + "trait cast to an unimplemented trait", + &format!( + "{traits}pub trait Other {{ + pub fn value() -> Felt; +}} + +fn main() -> Felt {{ + return

::value(); +}}" + ), + "mismatch", + ); + + rejects( + "trait cast naming an unknown member", + &format!( + "{traits}fn main() -> Felt {{ + return

::missing(); +}}" + ), + "unresolved", + ); +} + +#[test] +#[serial] +fn qualified_module_paths_and_roots_resolve() { + // Nested module path: root=outer resolves by name, then the `inner` + // segment walks std-module-style from parent to child. + accepts( + "nested module segments resolve", + &format!( + "{PRELUDE}pub mod outer {{ + pub mod inner {{ + pub fn f() -> Felt {{ + return 5; + }} + }} +}} + +fn main() -> Felt {{ + return outer::inner::f(); +}}" + ), + ); + + // A non-module segment resolves as a type in the module, then the target + // resolves as a member of that type. + accepts( + "module path through a type segment", + &format!( + "{PRELUDE}pub mod m {{ + pub struct T {{ pub field: Felt }} + + impl T {{ + pub fn make() -> T {{ + return T {{ field: 1 }}; + }} + }} +}} + +fn main() -> Felt {{ + let t: m::T = m::T::make(); + return t.field; +}}" + ), + ); + + rejects( + "path through a private nested module", + &format!( + "{PRELUDE}pub mod outer {{ + mod secret {{ + pub fn f() -> Felt {{ + return 1; + }} + }} +}} + +fn main() -> Felt {{ + return outer::secret::f(); +}}" + ), + "public", + ); + + accepts( + "crate-rooted path", + &format!( + "{PRELUDE}pub fn helper() -> Felt {{ + return 7; +}} + +fn main() -> Felt {{ + return crate::helper(); +}}" + ), + ); + + accepts( + "super-rooted path from an inline module", + &format!( + "{PRELUDE}pub fn f() -> Felt {{ + return 1; +}} + +pub mod inner {{ + pub fn call_super() -> Felt {{ + return super::f(); + }} +}} + +fn main() -> Felt {{ + return inner::call_super(); +}}" + ), + ); +} + +#[test] +#[serial] +fn bare_function_argument_matches_expected_signature() { + // Passing a top-level function by bare name walks the scope chain with + // the call's expected signature instead of resolving a value path. + accepts( + "function argument resolved by expected signature", + &format!( + "{PRELUDE}pub fn pick(a: Felt, b: Felt) -> Felt {{ + return a + b; +}} + +pub trait ApplyFeltFn {{ + pub fn apply(self: Self, f: fn(Felt, Felt) -> Felt) -> Felt; +}} + +pub struct Worker {{ + pub x: Felt, + pub y: Felt, +}} + +impl ApplyFeltFn for Worker {{ + pub fn apply(self: Worker, f: fn(Felt, Felt) -> Felt) -> Felt {{ + return f(self.x, self.y); + }} +}} + +fn main() -> Felt {{ + let w: Worker = Worker {{ x: 1, y: 2 }}; + return w.apply(pick); +}}" + ), + ); +} + +#[test] +#[serial] +fn index_sugar_and_member_call_guards() { + // Inherent method calls resolve through the member-call fast path. + accepts( + "inherent method call on an imported struct", + &format!( + "{PRELUDE}pub mod m {{ + pub struct T {{ pub field: Felt }} + + impl T {{ + pub fn get(self: T) -> Felt {{ + return self.field; + }} + }} +}} + +fn main() -> Felt {{ + let t: m::T = m::T {{ field: 2 }}; + return t.get(); +}}" + ), + ); + + // Private inherent methods are reachable across modules: the fast path + // grants access whenever the program declares any impl method. + accepts( + "private method called on a call-result receiver", + &format!( + "{PRELUDE}pub mod m {{ + pub struct T {{ pub field: Felt }} + + impl T {{ + fn secret(self: T) -> Felt {{ + return 1; + }} + }} +}} + +pub fn make() -> m::T {{ + return m::T {{ field: 2 }}; +}} + +fn main() -> Felt {{ + return make().secret(); +}}" + ), + ); +} + +#[test] +#[serial] +fn operator_calls_and_size_position_edges() { + // `!=` on a custom type lowers to the eq method wrapped in unary not. + accepts( + "custom neq reuses the eq method", + &format!( + "{PRELUDE}{STRUCT_P} +impl P {{ + pub fn eq(self: P, o: P) -> bool {{ + return self.x == o.x; + }} +}} + +fn main() -> Felt {{ + let a = P::new(1); + let b = P::new(2); + let same: bool = a != b; + return (same) as Felt; +}}" + ), + ); + + rejects( + "bitwise and on bools", + format!("{PRELUDE}fn main() -> Felt {{ return (true & false) as Felt; }}").as_str(), + "mismatch", + ); + + rejects( + "negating a u32", + format!("{PRELUDE}fn main() -> Felt {{ let x: u32 = 1; return (-x) as Felt; }}").as_str(), + "mismatch", + ); + + // Size-position call arguments: Felt and u32 literals become consts. + accepts( + "split_bits with felt and u32 sizes", + &format!( + "{PRELUDE}fn main() -> Felt {{ + let a: [Felt; 4] = split_bits(255, 4); + return a[0]; +}}" + ), + ); + + rejects( + "calling a parenthesized non-function member", + &format!( + "{PRELUDE}{STRUCT_P} +fn main() -> Felt {{ + let p = P::new(1); + return (p.x)(); +}}" + ), + "callable", + ); + + // First-class function values resolve through the callee-expression arm. + accepts( + "calling a function-typed parameter", + &format!( + "{PRELUDE}pub fn twice(x: Felt) -> Felt {{ + return x + x; +}} + +pub fn apply(f: fn(Felt) -> Felt, x: Felt) -> Felt {{ + return f(x); +}} + +fn main() -> Felt {{ + return apply(twice, 3); +}}" + ), + ); + + rejects( + "function-typed parameter arity mismatch", + &format!( + "{PRELUDE}pub fn twice(x: Felt) -> Felt {{ + return x + x; +}} + +pub fn apply(f: fn(Felt) -> Felt, x: Felt) -> Felt {{ + return f(x, x); +}} + +fn main() -> Felt {{ + return apply(twice, 3); +}}" + ), + "parameters", + ); +} + +#[test] +#[serial] +fn generic_instantiation_rewrites_paths_and_statements() { + // Instantiating `take::

` / `take::` rewrites the parameter and + // return type paths, the associated-type alias in Q's impl, and the + // let/assert/assert_eq/match/return statements of the body. + accepts( + "generic fn with associated-type paths", + &format!( + "{PRELUDE}pub trait W {{ + pub type Ty; + + pub fn zero() -> Ty; +}} + +pub struct P {{ pub x: Felt }} + +impl W for P {{ + pub type Ty = Felt; + + pub fn zero() -> Felt {{ + return 0; + }} +}} + +pub struct Q {{}} + +impl W for Q {{ + pub type Ty =

::Ty; + + pub fn zero() ->

::Ty {{ + return 1; + }} +}} + +pub fn take(v: ::Ty, c: bool) -> ::Ty {{ + let copied: ::Ty = v; + assert(c, \"c\"); + assert_eq(copied, copied, \"same\"); + let picked: ::Ty = match c {{ + true => copied, + false => v, + }}; + return picked; +}} + +fn main() -> Felt {{ + let a: Felt = take::

(1, true); + let b: Felt = take::(2, false); + return a + b + P::zero() + Q::zero(); +}}" + ), + ); +} + +#[test] +#[serial] +fn ambiguous_members_across_traits_and_inherent_associated_types() { + // Two constraints providing the same method name make the bare call + // ambiguous. + rejects( + "method name provided by two constraints", + &format!( + "{PRELUDE}pub trait A {{ + pub fn m(self: Self) -> Felt; +}} + +pub trait B {{ + pub fn m(self: Self) -> Felt; +}} + +pub fn pick(t: T) -> Felt {{ + return t.m(); +}} + +fn main() -> Felt {{ + return 0; +}}" + ), + "ambiguous", + ); + + // Two impls of a generic trait for the same receiver type are distinct + // providers, so the bare call is ambiguous as well. + rejects( + "generic trait impls with different arguments", + &format!( + "{PRELUDE}{STRUCT_P} +pub trait Conv {{ + pub fn conv(self: Self) -> Felt; +}} + +impl Conv for P {{ + pub fn conv(self: P) -> Felt {{ + return 1; + }} +}} + +impl Conv for P {{ + pub fn conv(self: P) -> Felt {{ + return 2; + }} +}} + +fn main() -> Felt {{ + let p = P::new(1); + return p.conv(); +}}" + ), + "ambiguous", + ); + + // Associated types on an inherent impl resolve through P::Ty. + accepts( + "inherent impl associated type", + &format!( + "{PRELUDE}{STRUCT_P} +impl P {{ + pub type Ty = Felt; +}} + +fn main() -> Felt {{ + let v: P::Ty = 3; + return v; +}}" + ), + ); +} + +#[test] +#[serial] +fn array_and_struct_literals_reject_inconsistent_shapes() { + accepts( + "consistent array literal", + &format!("{PRELUDE}fn main() {{ let a = [1, 2, 3]; assert_eq(a[0], 1, \"first\"); }}"), + ); + rejects( + "array literal with a mixed element type", + &format!("{PRELUDE}fn main() {{ let a = [1, true, 3]; }}"), + "mismatch", + ); + accepts( + "generic struct literal with matching arguments", + &format!( + "{PRELUDE}pub struct Num {{ pub a: T, pub b: T }} +fn main() {{ let n = Num {{ a: 1, b: 2 }}; assert_eq(n.a, 1, \"a\"); }}" + ), + ); + // The field values bind T to u32 first; the explicit `` argument + // then disagrees with the bound parameter. + rejects( + "generic struct literal argument disagrees with field values", + &format!( + "{PRELUDE}pub struct Num {{ pub a: T, pub b: T }} +fn main() {{ let n = Num {{ a: 1u32, b: 2u32 }}; }}" + ), + "mismatch", + ); +} + +#[test] +#[serial] +fn return_placement_rejects_returns_inside_if_blocks() { + rejects( + "return inside an if statement", + &format!("{PRELUDE}fn f(c: bool) -> Felt {{ if c {{ return 1; }} return 2; }}\nfn main() {{ let _ = f(true); }}"), + "return", + ); +} + +#[test] +#[serial] +fn type_annotations_cover_arrays_tuples_and_fn_signatures() { + accepts( + "array type annotation", + &format!("{PRELUDE}fn main() {{ let a: [Felt; 3] = [1, 2, 3]; assert_eq(a[2], 3, \"size\"); }}"), + ); + accepts( + "tuple type annotation", + &format!("{PRELUDE}fn main() {{ let t: (Felt, bool) = (1, true); }}"), + ); + accepts( + "fn signature parameter type", + &format!( + "{PRELUDE}fn twice(f: fn(Felt) -> Felt, v: Felt) -> Felt {{ return f(f(v)); }} +fn inc(x: Felt) -> Felt {{ return x + 1; }} +fn main() {{ let r = twice(inc, 3); }}" + ), + ); + rejects( + "tuple annotation with mismatched elements", + &format!("{PRELUDE}fn main() {{ let t: (Felt, bool) = (1, 2); }}"), + "mismatch", + ); + // The bare `fn(Felt, Felt) -> Felt` signature disagrees with `add2`'s + // single-parameter signature, exercising the Function vs + // FunctionSignature unification arm. + rejects( + "fn signature argument shape mismatch", + &format!( + "{PRELUDE}fn call2(f: fn(Felt, Felt) -> Felt, v: Felt) -> Felt {{ return f(v, v); }} +fn add2(x: Felt) -> Felt {{ return x + 1; }} +fn main() {{ let r = call2(add2, 3); }}" + ), + "mismatch", + ); +} + +#[test] +#[serial] +fn size_position_arguments_bind_constants_and_reject_runtime_values() { + accepts( + "felt literal in size position", + &format!("{PRELUDE}fn main() {{ let a: [Felt; 4] = split_bits(255, 4); assert_eq(a[0], 1, \"bit\"); }}"), + ); + // The u32 literal reaches the size-position arm but then fails the + // `N: Felt` constraint on split_bits. + rejects( + "u32 literal rejected by the felt size constraint", + &format!("{PRELUDE}fn main() {{ let a: [Felt; 4] = split_bits(255, 4u32); }}"), + "mismatch", + ); + accepts( + "named const flows into a size position", + &format!("{PRELUDE}const N: Felt = 4;\nfn main() {{ let a: [Felt; 4] = split_bits(255, N); }}"), + ); + rejects( + "runtime value in size position", + &format!( + "{PRELUDE}fn bad(n: Felt) -> [Felt; 4] {{ return split_bits(255, n); }} +fn main() {{ bad(2); }}" + ), + "mismatch", + ); +} + +#[test] +#[serial] +fn trait_cast_type_positions_with_segments_resolve() { + accepts( + "trait cast with nested associated type segments", + &format!( + "{PRELUDE}{STRUCT_P}pub struct Holder {{ pub v: Felt }} +impl Holder {{ pub type Inner = Felt; }} +trait Outer {{ pub type Assoc; }} +impl Outer for P {{ pub type Assoc = Holder; }} +fn probe(x:

::Assoc::Inner) ->

::Assoc::Inner {{ return x; }} +fn main() {{ let v = probe(3); }}" + ), + ); + rejects( + "trait cast to an unimplemented trait in type position", + &format!( + "{PRELUDE}{STRUCT_P}trait Other {{ pub type Ty; }} +fn bad(x:

::Ty) -> Felt {{ return 1; }} +fn main() {{ bad(1); }}" + ), + "mismatch", + ); +} + +#[test] +#[serial] +fn generic_path_call_targets_resolve() { + accepts( + "explicit generic arguments on a module type path", + &format!( + "{PRELUDE}mod m {{ + pub struct Test {{ pub field: T }} + impl Test {{ + pub fn get_test(a: S) -> Felt {{ return a as Felt; }} + }} +}} +fn main() -> Felt {{ + let r = m::>::get_test::(666u32); + let t = m::Test {{ field: 1 }}; + return r + t.field; +}}" + ), + ); +} + +#[test] +#[serial] +fn nested_member_access_uses_the_fast_path() { + // `o.inner.get()` resolves the receiver `o.inner` while the ancestor is + // the member call, so the member-access fast path (find_member without an + // expected signature) returns the field member. + accepts( + "nested struct field method call", + &format!( + "{PRELUDE}pub struct Inner {{ pub v: Felt }} +impl Inner {{ pub fn get(self: Inner) -> Felt {{ return self.v; }} }} +pub struct Outer {{ pub inner: Inner }} +fn main() -> Felt {{ + let o = Outer {{ inner: Inner {{ v: 7 }} }}; + return o.inner.get(); +}}" + ), + ); + // The private `inner` field is reached through a call-result receiver + // (not a path), so the impl-method escape hatch does not apply and the + // member access is rejected as private. + rejects( + "private nested field through a call result receiver", + &format!( + "{PRELUDE}mod lib {{ + pub struct Inner {{ pub v: Felt }} + impl Inner {{ pub fn get(self: Inner) -> Felt {{ return self.v; }} }} + pub struct Wrap {{ inner: Inner }} + pub fn make() -> Wrap {{ return Wrap {{ inner: Inner {{ v: 7 }} }}; }} +}} +fn main() -> Felt {{ return lib::make().inner.get(); }}" + ), + "public", + ); +} + +#[test] +#[serial] +fn associated_types_accept_non_path_shapes() { + accepts( + "tuple associated type on an inherent impl", + &format!( + "{PRELUDE}{STRUCT_P} +impl P {{ + pub type Pair = (Felt, Felt); + pub fn make_pair() -> P::Pair {{ return (1, 2); }} +}} +fn main() {{ let p: P::Pair = P::make_pair(); }}" + ), + ); +} diff --git a/psy-interpreter/src/std_override_tests.rs b/psy-interpreter/src/std_override_tests.rs new file mode 100644 index 000000000..362d566f4 --- /dev/null +++ b/psy-interpreter/src/std_override_tests.rs @@ -0,0 +1,293 @@ +// The rewriter (psy-sema/src/rewriter.rs) only walks a function body when the +// function is generic and gets instantiated. Every raw `__ctx_*`/`__storage_*` +// intrinsic lives inside psy-std wrapper bodies, which are not generic β€” so +// their rewriter arms can never fire from user code. These tests override the +// std `context.psy` through the per-program FileResolver (pre-registered files +// win over disk reads) with copies that add *generic* probes around the same +// raw intrinsics. Instantiating a probe from `main` rewrites its body and +// exercises every intrinsic arm, while the std-ancestry gate still passes +// because the probe really is part of the std module. + +use serial_test::serial; + +use super::*; + +const GENERIC_CONTEXT_PROBES: &str = r#" + +// --- Generic raw-intrinsic probes (test-only additions to std context) --- + +pub fn probe_raw_context_getters(v: T) -> T { + let user_id: Felt = __ctx_get_user_id(); + let contract_id: Felt = __ctx_get_contract_id(); + let deployer: Hash = __ctx_get_contract_deployer(contract_id); + let height: Felt = __ctx_get_contract_state_tree_height(contract_id); + let caller: Felt = __ctx_get_caller_contract_id(); + let checkpoint: Felt = __ctx_get_checkpoint_id(); + let nonce: Felt = __ctx_get_last_nonce(); + let pkh: Hash = __ctx_get_user_public_key_hash(); + let session: Hash = __ctx_get_session_proof_tree_root(); + let state: Hash = __ctx_get_state_hash_at(0); + let other_contract: Hash = __ctx_get_other_contract_state_hash_at(height, contract_id, 0); + let other_user_contract: Hash = __ctx_get_other_user_contract_state_hash_at(height, user_id, contract_id, 0); + let updated: Hash = __ctx_set_state_hash_at(0, state); + let contains_other: bool = __imt_contains_other_user(height, user_id, contract_id, pkh, 0, 4); + let verified: bool = __secp256k1_verify([0u32; 16], pkh, [0u32; 16]); + __emit(v); + return v; +} + +pub fn probe_raw_checkpoint_stats(v: T) -> T { + let checkpoint: Felt = __ctx_get_checkpoint_id(); + __ctx_get_checkpoint_stats(checkpoint); + let register_users: Hash = __ctx_get_register_users_root(checkpoint); + let gutas: Hash = __ctx_get_gutas_root(checkpoint); + let user_tree: Hash = __ctx_get_checkpoint_user_tree_root(checkpoint); + let contract_tree: Hash = __ctx_get_checkpoint_contract_tree_root(checkpoint); + let deposit_tree: Hash = __ctx_get_checkpoint_deposit_tree_root(checkpoint); + let withdrawal_tree: Hash = __ctx_get_checkpoint_withdrawal_tree_root(checkpoint); + let registration: Hash = __ctx_get_checkpoint_user_registration_tree_root(checkpoint); + let deploys: Hash = __ctx_get_deploy_contracts_root(checkpoint); + let guta_fees: Felt = __ctx_get_guta_fees_collected(checkpoint); + let da_fees: Felt = __ctx_get_da_fees_collected(checkpoint); + let user_ops: Felt = __ctx_get_user_ops_processed(checkpoint); + let total_txs: Felt = __ctx_get_total_transactions(checkpoint); + let slots: Felt = __ctx_get_slots_modified(checkpoint); + let deploys_done: Felt = __ctx_get_deploy_contracts_completed(checkpoint); + let registrations_done: Felt = __ctx_get_register_users_completed(checkpoint); + let gutas_done: Felt = __ctx_get_gutas_completed(checkpoint); + return v; +} + +pub fn probe_raw_storage_roundtrip(v: T) -> T { + let height: Felt = __ctx_get_contract_state_tree_height(0); + let user_id: Felt = __ctx_get_user_id(); + let contract_id: Felt = __ctx_get_contract_id(); + let read: Felt = __storage_read(height, user_id, contract_id, 0); + __storage_write(0, read); + __ctx_clear_entire_tree(); + return v; +} + +pub fn probe_rewrite_shapes(v: T) -> T { + assert_eq(1, 1); + let pair = (1, 2); + let first: Felt = pair.0; + let repeated: [Felt; 2] = [3; 2]; + let branch: Felt = if first > 0 { + 1 + } + else if first > 1 { + 2 + } + else { + 3 + }; + let double = |x: Felt| -> Felt { + return x + x; + }; + let applied: Felt = double(first) + repeated[0 as Felt] + branch; + return v; +} + +pub fn probe_rewrite_void(v: T) { + assert(1 > 0); + return; +} +"#; + +/// Resolve the std root the parser will actually use: `DARGO_STD_PATH` wins +/// when set (toolchain installs point elsewhere), otherwise the checked-in +/// `psy-std/` next to this workspace. The override must land on the exact +/// `context.psy` the parser resolves for `mod context;` in the std prelude +/// (FileResolver keys are canonicalized). +pub(crate) fn std_context_path() -> PathBuf { + if let Ok(std_path) = std::env::var("DARGO_STD_PATH") { + if let Some(parent) = PathBuf::from(std_path).parent() { + return parent.join("context.psy"); + } + } + let mut dir = Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + while let Some(current) = dir { + let candidate = current.join("psy-std").join("context.psy"); + if candidate.exists() { + return candidate; + } + dir = current.parent().map(std::path::Path::to_path_buf); + } + panic!("psy-std/context.psy not found above {}", env!("CARGO_MANIFEST_DIR")); +} + +/// Register a copy of the std context module plus the generic probes. Only +/// this program instance sees the override; other tests are unaffected. +fn register_std_context_override(program: &Program) { + let original = std::fs::read_to_string(std_context_path()).expect("read psy-std/context.psy"); + let overridden: Arc = Arc::from(format!("{original}\n{GENERIC_CONTEXT_PROBES}")); + program.file_resolver.add_file(std_context_path(), overridden); +} + +fn typecheck_with_std_override(source: &str) -> anyhow::Result<()> { + with_primitive_scope_reset(|| { + let path = PathBuf::from("/virtual/src/main.psy"); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let program = Program::new(); + program.file_resolver.add_file(path.clone(), Arc::from(source)); + register_std_context_override(&program); + let mut graph = Graph::new(); + graph.add_node(path); + interpreter.typecheck_with_program(graph, program).map(|_| ()) + }) +} + +#[test] +#[serial] +fn generic_std_probes_with_raw_intrinsics_typecheck_and_instantiate() { + let source = r#" + use std::prelude::*; + + fn main(q: Felt) -> Felt { + let a: Felt = probe_raw_context_getters(q); + let b: Felt = probe_raw_checkpoint_stats(q); + let c: Felt = probe_raw_storage_roundtrip(q); + let d: Felt = probe_rewrite_shapes(q); + probe_rewrite_void(q); + return a + b + c + d; + } + "#; + // A pass here means the raw-intrinsic arms of visit_intrinsic_expr and of + // the rewriter ran during checking and instantiation of the probe bodies. + typecheck_with_std_override(source).expect("std override fixture must typecheck"); +} + +/// Typecheck a program whose std context additionally defines one generic +/// `bad` probe (called from main), expecting failure. Returns the lowered +/// error message. +fn typecheck_override_fails(bad_probe_body: &str) -> String { + let extra = format!("pub fn bad(v: T) -> T {{\n {bad_probe_body}\n return v;\n}}\n"); + let source = r#" + use std::prelude::*; + + fn main(q: Felt) -> Felt { + let r: Felt = bad(q); + return r; + } + "#; + with_primitive_scope_reset(|| { + let path = PathBuf::from("/virtual/src/main.psy"); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let program = Program::new(); + program.file_resolver.add_file(path.clone(), Arc::from(source)); + let original = std::fs::read_to_string(std_context_path()).expect("read psy-std/context.psy"); + program + .file_resolver + .add_file(std_context_path(), Arc::from(format!("{original}\n{extra}"))); + let mut graph = Graph::new(); + graph.add_node(path); + match interpreter.typecheck_with_program(graph, program) { + Ok(_) => anyhow::bail!("bad intrinsic call must be rejected: {bad_probe_body}"), + Err(error) => Ok(format!("{error:#}")), + } + }) + .expect("primitive scope reset") +} + +#[test] +#[serial] +fn raw_std_intrinsics_reject_mismatched_argument_types() { + let bad_calls: &[(&str, &str)] = &[ + ("let x: Hash = __ctx_get_contract_deployer(true);", "deployer contract id must be Felt"), + ("let x: Felt = __ctx_get_contract_state_tree_height(true);", "tree height contract id must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get(0, 0, 4);", "imt_get key must be Hash"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get(h, true, 4);", "imt_get base offset must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get(h, 0, true);", "imt_get capacity must be Felt"), + ( + "let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get_other_user(true, 0, 0, h, 0, 4);", + "imt_get_other_user height must be Felt", + ), + ("let x: Hash = __ctx_get_other_contract_state_hash_at(true, 0, 0);", "other contract height must be Felt"), + ( + "let x: Hash = __ctx_get_other_user_contract_state_hash_at(0, true, 0, 0);", + "other user contract user id must be Felt", + ), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __ctx_set_state_hash_at(0, 0);", "set_state_hash_at value must be Hash"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_set(0, h, 0, 4);", "imt_set key must be Hash"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_set(h, 0, 0, 4);", "imt_set value must be Hash"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_set(h, h, true, 4);", "imt_set offset must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: bool = __imt_contains_other_user(true, 0, 0, h, 0, 4);", "imt_contains_other_user height must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: bool = __imt_contains(0, 0, 4);", "imt_contains key must be Hash"), + ("let x: Felt = __storage_read(true, 0, 0, 0);", "storage_read height must be Felt"), + ("let x: Felt = __storage_read(0, 0, 0, true);", "storage_read offset must be Felt"), + ("let x: Felt = __storage_read_range(true, 0, 0, 0, 1);", "storage_read_range height must be Felt"), + ("__storage_write(true, 0);", "storage_write offset must be Felt"), + ("__storage_write(0, true);", "storage_write value must be Felt"), + ("__storage_write_range(true, 0);", "storage_write_range offset must be Felt"), + ("let h: Hash = __ctx_get_state_hash_at(0); let x: Hash = __ctx_get_state_hash_at(true);", "state hash slot must be Felt"), + // Every remaining parameter position of the multi-argument raw + // intrinsics: one bad argument per arm keeps each TypeMismatch branch + // (including short-circuit order) exercised independently. + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get_other_user(0, true, 0, h, 0, 4);", "imt_get_other_user user id must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get_other_user(0, 0, true, h, 0, 4);", "imt_get_other_user contract id must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get_other_user(0, 0, 0, 0, 0, 4);", "imt_get_other_user key must be Hash"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get_other_user(0, 0, 0, h, true, 4);", "imt_get_other_user base offset must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_get_other_user(0, 0, 0, h, 0, true);", "imt_get_other_user capacity must be Felt"), + ("let x: Hash = __ctx_get_other_contract_state_hash_at(0, true, 0);", "other contract contract id must be Felt"), + ("let x: Hash = __ctx_get_other_contract_state_hash_at(0, 0, true);", "other contract slot must be Felt"), + ("let x: Hash = __ctx_get_other_user_contract_state_hash_at(true, 0, 0, 0);", "other user contract height must be Felt"), + ("let x: Hash = __ctx_get_other_user_contract_state_hash_at(0, 0, true, 0);", "other user contract contract id must be Felt"), + ("let x: Hash = __ctx_get_other_user_contract_state_hash_at(0, 0, 0, true);", "other user contract slot must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __ctx_set_state_hash_at(true, h);", "set_state_hash_at slot must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: Hash = __imt_set(h, h, 0, true);", "imt_set capacity must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: bool = __imt_contains_other_user(0, true, 0, h, 0, 4);", "imt_contains_other_user user id must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: bool = __imt_contains_other_user(0, 0, true, h, 0, 4);", "imt_contains_other_user contract id must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: bool = __imt_contains_other_user(0, 0, 0, 0, 0, 4);", "imt_contains_other_user key must be Hash"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: bool = __imt_contains_other_user(0, 0, 0, h, true, 4);", "imt_contains_other_user base offset must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: bool = __imt_contains(h, true, 4);", "imt_contains base offset must be Felt"), + ("let h: Hash = __ctx_get_user_public_key_hash(); let x: bool = __imt_contains(h, 0, true);", "imt_contains capacity must be Felt"), + ("let x: Felt = __storage_read(0, true, 0, 0);", "storage_read user id must be Felt"), + ("let x: Felt = __storage_read(0, 0, true, 0);", "storage_read contract id must be Felt"), + ("let x: Felt = __storage_read_range(0, true, 0, 0, 1);", "storage_read_range user id must be Felt"), + ("let x: Felt = __storage_read_range(0, 0, true, 0, 1);", "storage_read_range contract id must be Felt"), + ("let x: Felt = __storage_read_range(0, 0, 0, true, 1);", "storage_read_range offset must be Felt"), + ("let x: Felt = __storage_read_range(0, 0, 0, 0, true);", "storage_read_range length must be Felt"), + ("let n: Felt = __ctx_get_last_nonce(); let b = __split_bits(13, n);", "split_bits length must be a compile-time constant"), + ]; + for (call, label) in bad_calls { + let message = typecheck_override_fails(call); + assert!( + message.contains("TypeMismatch") || message.contains("type mismatch"), + "{label}: unexpected rejection message: {message}" + ); + } +} + +#[test] +#[serial] +fn instantiated_probe_bodies_appear_in_the_checked_program() { + let source = r#" + use std::prelude::*; + + fn main(q: Felt) -> Felt { + let c: Felt = probe_raw_storage_roundtrip(q); + return c; + } + "#; + let (typechecker, mut ctx) = with_primitive_scope_reset(|| { + let path = PathBuf::from("/virtual/src/main.psy"); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let program = Program::new(); + program.file_resolver.add_file(path.clone(), Arc::from(source)); + register_std_context_override(&program); + let mut graph = Graph::new(); + graph.add_node(path); + interpreter.typecheck_with_program(graph, program) + }) + .expect("std override fixture must typecheck"); + + let name_id = ctx.program.interner.intern_ident("probe_raw_storage_roundtrip"); + let instances = typechecker + .program + .defs + .iter() + .filter(|def| matches!(def, CheckedDefinitionNode::Function(node) if node.name.id == name_id)) + .count(); + assert!(instances >= 2, "expected an instantiated probe copy, found {instances}"); +} diff --git a/psy-interpreter/src/visualizer_tests.rs b/psy-interpreter/src/visualizer_tests.rs new file mode 100644 index 000000000..e88f1a3e0 --- /dev/null +++ b/psy-interpreter/src/visualizer_tests.rs @@ -0,0 +1,377 @@ +// Tests for the sema AST visualizer (psy-sema/src/visualizer.rs). The debug_* +// entry points must render every node shape reachable from a normal program +// without panicking, and the output must carry stable markers per node kind, +// so the renderer stays usable as a debugging aid. + +use serial_test::serial; + +use super::*; + +const SOURCE: &str = r#" +use std::prelude::*; + +const LIMIT: Felt = 10; + +pub struct Point { + pub x: Felt, + y: Felt, +} + +pub trait Bounded { + pub fn ceiling() -> Self; +} + +impl Bounded for Felt { + pub fn ceiling() -> Self { + return LIMIT; + } +} + +fn weighted(value: Felt, mut weight: u32) -> Felt { + let mut total: Felt = 0; + let grid: [Felt; 3] = [1, 2, 3]; + for i in 0u32..3u32 { + total += grid[i as Felt] * value; + } + while weight > 0u32 { + weight -= 1u32; + } + let flag: bool = (weight == 0u32); + let shifted: Felt = (3u32 + weight) as Felt; + let bump = |v: Felt| -> Felt { + return v + v; + }; + if flag { + total = bump(total) + shifted; + } + else { + total = value; + }; + return total; +} + +fn main(q: Felt) -> Felt { + let p = Point { x: q, y: 2 }; + let result = weighted(p.x, 3u32); + assert(result > 0); + return result; +} +"#; + +struct Compiled { + ctx: TypeCheckerVisitorContext, +} + +fn typecheck_virtual(source: &str) -> Compiled { + let compiled = super::with_primitive_scope_reset(|| { + let path = PathBuf::from("/virtual/src/main.psy"); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let program = Program::new(); + program.file_resolver.add_file(path.clone(), Arc::from(source)); + let mut graph = Graph::new(); + graph.add_node(path); + let (_typechecker, ctx) = interpreter + .typecheck_with_program(graph, program) + .map_err(|error| anyhow::anyhow!("visualizer fixture must typecheck: {error:#}"))?; + Ok(Compiled { ctx }) + }) + .expect("typecheck within primitive scope reset"); + compiled +} + +fn find_function_type(ctx: &mut TypeCheckerVisitorContext, name: &str) -> Option { + let name_id = ctx.program.interner.intern_ident(name); + let key: TypeKey = name_id.into(); + for module in ctx.symbols.modules().clone() { + if let Some(&tid) = ctx.symbols[module.scope_id].types.get(&key) { + if ctx.symbols[tid].as_function().is_some() { + return Some(tid); + } + } + } + None +} + +#[test] +#[serial] +fn debug_type_renders_declared_types_with_their_shapes() { + let mut c = typecheck_virtual(SOURCE); + + let weighted = find_function_type(&mut c.ctx, "weighted").expect("weighted not found"); + let rendered = c.ctx.debug_type(weighted); + assert!(rendered.contains("fn weighted"), "function header missing:\n{rendered}"); + assert!(rendered.contains("Parameters:"), "parameters missing:\n{rendered}"); + assert!(rendered.contains("mut "), "mutable parameter missing:\n{rendered}"); + assert!(rendered.contains("Return Type: Felt"), "return type missing:\n{rendered}"); + assert!(rendered.contains("Body:"), "body missing:\n{rendered}"); + + let point = find_function_type(&mut c.ctx, "Point"); + let point_tid = point.or_else(|| { + // Point is a struct, not a function: search the type table directly. + let name_id = c.ctx.program.interner.intern_ident("Point"); + let key: TypeKey = name_id.into(); + c.ctx.symbols + .modules() + .iter() + .find_map(|module| c.ctx.symbols[module.scope_id].types.get(&key).copied()) + }); + let tid = point_tid.expect("Point type not found"); + let rendered = c.ctx.debug_type(tid); + assert!(rendered.contains("struct Point"), "struct header missing:\n{rendered}"); + assert!(rendered.contains("Fields:"), "fields missing:\n{rendered}"); + assert!(rendered.contains("pub "), "public field marker missing:\n{rendered}"); + + // Rendering every type in the table (std included) must not panic and + // must reach the array and trait arms. + let mut all = String::new(); + for index in 0..c.ctx.symbols.types.len() { + all.push_str(&c.ctx.debug_type(TypeId::from(index))); + all.push('\n'); + } + assert!(all.contains("Array"), "array type missing"); + assert!(all.contains("trait "), "trait type missing"); +} + +#[test] +#[serial] +fn debug_scope_renders_constants_variables_types_and_children() { + let c = typecheck_virtual(SOURCE); + + let mut all = String::new(); + for module in c.ctx.symbols.modules() { + all.push_str(&c.ctx.debug_scope(module.scope_id)); + all.push('\n'); + } + assert!(all.contains("Variables:"), "variables section missing"); + assert!(all.contains("Types:"), "types section missing"); + assert!(all.contains("mut "), "mutable variable missing"); + assert!(all.contains("Felt"), "type name missing"); +} + +#[test] +#[serial] +fn debug_variable_renders_qualifier_and_type_name() { + let mut c = typecheck_virtual(SOURCE); + let weighted = find_function_type(&mut c.ctx, "weighted").expect("weighted not found"); + let scope_id = c.ctx.symbols[weighted].as_function().expect("weighted is a function").scope_id; + + let variables: Vec<(IdentId, VarId)> = c.ctx.symbols[scope_id] + .variables + .iter() + .map(|(ident, var)| (*ident, *var)) + .collect(); + assert!(!variables.is_empty(), "weighted has no local variables"); + + let mut saw_mutable = false; + for (ident_id, var_id) in variables { + let rendered = c.ctx.debug_variable(ident_id, var_id); + assert!(!rendered.is_empty(), "variable render was empty"); + assert!(rendered.contains(':'), "variable render lacks type separator: {rendered}"); + saw_mutable |= rendered.contains("mut "); + } + assert!(saw_mutable, "no mutable variable was rendered"); +} + +#[test] +#[serial] +fn debug_expr_renders_every_expression_kind_in_the_program() { + let c = typecheck_virtual(SOURCE); + + let mut all = String::new(); + for index in 0..c.ctx.program.exprs.len() { + all.push_str(&c.ctx.debug_expr(ExprId(index))); + all.push('\n'); + } + for marker in [ + "Binary:", + "Call", + "Path", + "Index Access", + "Member Access", + "If Expr", + "Block Expr", + "Lambda Function", + "Cast", + "Parentheses", + ] { + assert!(all.contains(marker), "expression marker {marker:?} missing"); + } +} + +#[test] +#[serial] +fn debug_stmt_renders_every_statement_kind_in_the_program() { + let c = typecheck_virtual(SOURCE); + + let mut all = String::new(); + for index in 0..c.ctx.program.stmts.len() { + all.push_str(&c.ctx.debug_stmt(StmtId(index))); + all.push('\n'); + } + for marker in ["While", "For", "Assignment", "Variable", "Return"] { + assert!(all.contains(marker), "statement marker {marker:?} missing"); + } +} + +#[test] +#[serial] +fn debug_definition_renders_every_definition_kind_in_the_program() { + let c = typecheck_virtual(SOURCE); + + let mut all = String::new(); + for index in 0..c.ctx.program.defs.len() { + all.push_str(&c.ctx.debug_definition(DefId(index))); + all.push('\n'); + } + for marker in ["Function", "Struct", "Trait", "Impl", "Const", "Use", "pub "] { + assert!(all.contains(marker), "definition marker {marker:?} missing"); + } +} + +/// A second fixture exercising the grammar shapes SOURCE lacks: type +/// aliases, associated types, match, tuples, tuple access, unary not, and +/// else-if chains. (Enums are excluded: `visit_enum` is a stub that always +/// rejects, so no checked enum nodes can exist.) +const RICH_SOURCE: &str = r#" +use std::prelude::*; + +// A commented definition so the comments section renders. +pub trait Carrier { + pub type Load: Storage; + pub fn load(self: Self) -> Felt; +} + +pub struct Holder { + pub shape: Felt, +} + +type Alias = [Felt; 4]; + +impl Carrier for Holder { + pub type Load = [Felt; 2]; + pub fn load(self: Self) -> Felt { + return 1; + } +} + +fn classify(input: Felt, pair: (Felt, bool)) -> Felt { + let kind: Felt = match input { + 0 => 100, + 1 => 200, + _ => 300, + }; + let first = pair.0; + let second = pair.1; + let flipped = !second; + let triple = (first, kind, 3); + let middle = triple.1; + let tier: Felt = if first > 5 { + 1 + } else if first > 2 { + 2 + } else { + 3 + }; + return kind + first + middle + tier + (flipped == second) as Felt; +} + +fn main() -> Felt { + return classify(2, (7, true)); +} +"#; + +#[test] +#[serial] +fn debug_renderers_cover_aliases_associated_types_and_match_shapes() { + let mut c = typecheck_virtual(RICH_SOURCE); + + // Every definition renders, including type aliases and trait/impl + // associated types. + let mut defs = String::new(); + for index in 0..c.ctx.program.defs.len() { + defs.push_str(&c.ctx.debug_definition(DefId(index))); + defs.push('\n'); + } + for marker in ["Type Alias", "Load"] { + assert!(defs.contains(marker), "definition marker {marker:?} missing:\n{defs}"); + } + assert!(defs.contains("Comments:"), "trait doc comment missing:\n{defs}"); + + // Every expression renders, including match arms, tuples, tuple access, + // unary not, and else-if chains. + let mut exprs = String::new(); + for index in 0..c.ctx.program.exprs.len() { + exprs.push_str(&c.ctx.debug_expr(ExprId(index))); + exprs.push('\n'); + } + for marker in ["Match", "Arms:", "Tuple:", "Tuple Access", "Unary:", "Else If Branches"] { + assert!(exprs.contains(marker), "expression marker {marker:?} missing:\n{exprs}"); + } + + // Rendering every type reaches the alias and const arms without + // panicking. + let mut types = String::new(); + for index in 0..c.ctx.symbols.types.len() { + types.push_str(&c.ctx.debug_type(TypeId::from(index))); + types.push('\n'); + } + assert!(!types.is_empty()); +} + +/// A third fixture for shapes the other two lack: `const fn`, attributes, +/// bare expression statements, and trailing comments on block expressions. +const ATTR_SOURCE: &str = r#" +use std::prelude::*; + +pub const fn zero() -> Felt { + return 0; +} + +#[contract] +pub struct C {} + +#[contract::write_method] +fn tagged(v: Felt) -> Felt { + let picked = if v > 0 { + zero() + } else { + zero() + }; + zero(); + return picked; +} + +fn main() -> Felt { + return tagged(1); +} +"#; + +#[test] +#[serial] +fn debug_renderers_cover_const_fns_attrs_and_expression_statements() { + let mut c = typecheck_virtual(ATTR_SOURCE); + + let mut types = String::new(); + for index in 0..c.ctx.symbols.types.len() { + types.push_str(&c.ctx.debug_type(TypeId::from(index))); + types.push('\n'); + } + assert!(types.contains("const "), "const qualifier missing:\n{types}"); + + let mut defs = String::new(); + for index in 0..c.ctx.program.defs.len() { + defs.push_str(&c.ctx.debug_definition(DefId(index))); + defs.push('\n'); + } + assert!(defs.contains("Attrs:"), "fn attrs missing:\n{defs}"); + + // Bare `zero();` statements and the commented if-expression render + // without panicking. + let mut stmts = String::new(); + for index in 0..c.ctx.program.stmts.len() { + stmts.push_str(&c.ctx.debug_stmt(StmtId(index))); + stmts.push('\n'); + } + // Block-expr comments do not survive checking (the rewriter rebuilds + // the node without them), so no expr-comments assertion here. +} diff --git a/psy-lexer/src/lib.rs b/psy-lexer/src/lib.rs index 376045685..dd265eb35 100644 --- a/psy-lexer/src/lib.rs +++ b/psy-lexer/src/lib.rs @@ -45,3 +45,225 @@ pub fn lex_all<'src>(input: &'src str) -> LexResult<'src> { } Ok(tokens) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lex_all_returns_precise_byte_spans() { + let tokens = lex_all("let value = 42u32;").unwrap(); + assert_eq!( + tokens, + vec![ + SpannedToken { + kind: Token::KeywordLet, + start: 0, + end: 3, + }, + SpannedToken { + kind: Token::Ident("value"), + start: 4, + end: 9, + }, + SpannedToken { + kind: Token::Assign, + start: 10, + end: 11, + }, + SpannedToken { + kind: Token::U32(42), + start: 12, + end: 17, + }, + SpannedToken { + kind: Token::Semicolon, + start: 17, + end: 18, + }, + ] + ); + } + + #[test] + fn lex_all_stops_at_first_invalid_token_with_location() { + let error = lex_all("let x = @;").unwrap_err(); + assert_eq!(error.kind, Error::InvalidToken); + assert_eq!((error.start, error.end), (8, 9)); + } + + #[test] + fn lex_all_reports_overflowing_integer_kind_and_span() { + let input = "18446744073709551616"; + let error = lex_all(input).unwrap_err(); + assert!(matches!(error.kind, Error::InvalidInteger(_))); + assert_eq!((error.start, error.end), (0, input.len())); + assert!(error.kind.to_string().contains("invalid integer")); + } + + #[test] + fn lexer_prefers_longest_overlapping_operators() { + let kinds = lex_all("** *= >> >>= >= :: .. -> =>") + .unwrap() + .into_iter() + .map(|token| token.kind) + .collect::>(); + assert_eq!( + kinds, + vec![ + Token::OperatorPow, + Token::OperatorMulAssign, + Token::OperatorShr, + Token::OperatorBitShrAssign, + Token::OperatorGte, + Token::DoubleColon, + Token::DoubleDot, + Token::Arrow, + Token::FatArrow, + ] + ); + } + + #[test] + fn empty_and_whitespace_only_sources_have_no_tokens() { + assert!(lex_all("").unwrap().is_empty()); + assert!(lex_all(" \t\r\n").unwrap().is_empty()); + } + + #[test] + fn comments_at_eof_keep_exact_byte_spans() { + let line = lex_all("// trailing").unwrap(); + assert_eq!(line[0].kind, Token::LineComment("// trailing")); + assert_eq!((line[0].start, line[0].end), (0, 11)); + + let block = lex_all("/* trailing */").unwrap(); + assert_eq!(block[0].kind, Token::BlockComment("/* trailing */")); + assert_eq!((block[0].start, block[0].end), (0, 14)); + } + + #[test] + fn unterminated_block_comment_reports_the_entire_remaining_input() { + let input = "/* unterminated"; + let error = lex_all(input).unwrap_err(); + + assert_eq!(error.kind, Error::InvalidToken); + assert_eq!((error.start, error.end), (0, input.len())); + } + + #[test] + fn non_ascii_invalid_input_uses_byte_offsets() { + let input = "let x = δΈ­;"; + let error = lex_all(input).unwrap_err(); + + assert_eq!(error.kind, Error::InvalidToken); + assert_eq!(&input[error.start..error.end], "δΈ­"); + assert_eq!((error.start, error.end), (8, 11)); + } + + #[test] + fn integer_suffix_boundaries_are_distinct_tokens() { + let tokens = lex_all("0 0u32 4294967295u32 18446744073709551615") + .unwrap() + .into_iter() + .map(|token| token.kind) + .collect::>(); + + assert_eq!( + tokens, + vec![Token::U64(0), Token::U32(0), Token::U32(u32::MAX), Token::U64(u64::MAX)] + ); + } + + #[test] + fn overflowing_u32_suffix_reports_invalid_integer() { + let input = "4294967296u32"; + let error = lex_all(input).unwrap_err(); + + assert!(matches!(error.kind, Error::InvalidInteger(_))); + assert_eq!((error.start, error.end), (0, input.len())); + } + + #[test] + fn leading_zeros_split_into_adjacent_integer_tokens() { + let tokens = lex_all("007") + .unwrap() + .into_iter() + .map(|token| token.kind) + .collect::>(); + + assert_eq!(tokens, vec![Token::U64(0), Token::U64(0), Token::U64(7)]); + } + + #[test] + fn keyword_prefixed_identifiers_lex_as_idents() { + let tokens = lex_all("letter letx trueish _x") + .unwrap() + .into_iter() + .map(|token| token.kind) + .collect::>(); + + assert_eq!( + tokens, + vec![ + Token::Ident("letter"), + Token::Ident("letx"), + Token::Ident("trueish"), + Token::Ident("_x"), + ] + ); + } + + #[test] + fn lone_underscore_is_placeholder_not_ident() { + let tokens = lex_all("let _ = 1;") + .unwrap() + .into_iter() + .map(|token| token.kind) + .collect::>(); + + assert_eq!( + tokens, + vec![ + Token::KeywordLet, + Token::Placeholder, + Token::Assign, + Token::U64(1), + Token::Semicolon, + ] + ); + } + + #[test] + fn string_literals_support_empty_and_escaped_contents() { + let tokens = lex_all(r#""\"q\"""#) + .unwrap() + .into_iter() + .map(|token| token.kind) + .collect::>(); + + assert_eq!(tokens, vec![Token::String(r#"\"q\""#)]); + } + + #[test] + fn unterminated_string_consumes_the_rest_of_the_input() { + let input = r#"let s = "abc"#; + let error = lex_all(input).unwrap_err(); + + assert_eq!(error.kind, Error::InvalidToken); + assert_eq!((error.start, error.end), (8, input.len())); + } + + #[test] + fn block_comments_close_at_the_first_terminator() { + let tokens = lex_all("/* /* */ */") + .unwrap() + .into_iter() + .map(|token| token.kind) + .collect::>(); + + assert_eq!( + tokens, + vec![Token::BlockComment("/* /* */"), Token::OperatorMul, Token::OperatorDiv] + ); + } +} diff --git a/psy-lsp-server/Cargo.toml b/psy-lsp-server/Cargo.toml index 27bded901..7a175bb74 100644 --- a/psy-lsp-server/Cargo.toml +++ b/psy-lsp-server/Cargo.toml @@ -20,6 +20,12 @@ psy-package = { workspace = true } psy_vm = { workspace = true } psy_common = { workspace = true } +[dev-dependencies] +futures = { workspace = true } +serde_json = { workspace = true } +serial_test = { workspace = true } +tempfile = { workspace = true } + [[bin]] name = "psy-lsp-server" path = "src/main.rs" diff --git a/psy-lsp-server/src/simple.rs b/psy-lsp-server/src/simple.rs index 15e8a9efc..f2678e4de 100644 --- a/psy-lsp-server/src/simple.rs +++ b/psy-lsp-server/src/simple.rs @@ -576,3 +576,600 @@ pub fn dummy_range() -> Range { end: tower_lsp::lsp_types::Position { line: 0, character: 1 }, } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use futures::StreamExt as _; + use psy_ast::{Program, TextPosition, TextRange}; + use psy_common::Graph; + use psy_sema::TypeCheckerVisitorContext; + use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; + use serial_test::serial; + use tower_lsp::lsp_types::{ + CompletionParams, Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams, FormattingOptions, + GotoDefinitionParams, HoverContents, HoverParams, InitializeParams, InitializedParams, MarkupKind, + OneOf, PartialResultParams, ReferenceContext, ReferenceParams, RenameParams, TextDocumentIdentifier, + TextDocumentItem, TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, + TextDocumentSyncSaveOptions, Url, VersionedTextDocumentIdentifier, WorkDoneProgressParams, + }; + use tower_lsp::{LanguageServer, LspService}; + + use super::{dummy_range, to_lsp_position, to_lsp_range, try_uri_to_path, DiagnosticBundle, QLspSimple}; + + #[test] + fn uri_conversion_accepts_file_urls_and_rejects_non_file_urls() { + let file = Url::from_file_path("/tmp/example.psy").unwrap(); + assert_eq!(try_uri_to_path(&file).unwrap().to_str(), Some("/tmp/example.psy")); + let https = Url::parse("https://example.com/example.psy").unwrap(); + assert!(try_uri_to_path(&https).is_none()); + } + + #[test] + fn lsp_position_and_range_conversion_preserve_coordinates() { + let position = to_lsp_position(TextPosition { line: 3, character: 7 }); + assert_eq!(position.line, 3); + assert_eq!(position.character, 7); + + let range = to_lsp_range(TextRange { + start: TextPosition { line: 1, character: 2 }, + end: TextPosition { line: 4, character: 5 }, + }); + assert_eq!(range.start.line, 1); + assert_eq!(range.start.character, 2); + assert_eq!(range.end.line, 4); + assert_eq!(range.end.character, 5); + } + + #[test] + fn dummy_range_is_a_single_character_at_document_start() { + let range = dummy_range(); + assert_eq!(range.start.line, 0); + assert_eq!(range.start.character, 0); + assert_eq!(range.end.line, 0); + assert_eq!(range.end.character, 1); + } + + /// Builds a backend without spawning the client-socket drain task. Only + /// suitable for tests that never trigger client notifications. + fn quiet_backend() -> (LspService, ()) { + let (service, _socket) = LspService::new(QLspSimple::new); + (service, ()) + } + + /// Builds a backend whose client socket is continuously drained in the + /// background, so `publish_diagnostics` / `log_message` calls never block. + fn drained_backend() -> LspService { + let (service, socket) = LspService::new(QLspSimple::new); + tokio::spawn(async move { + let mut socket = socket; + while socket.next().await.is_some() {} + }); + service + } + + /// Writes `main.psy` into a fresh temp dir and returns the canonicalized + /// dir and file paths (canonicalization matches `format_file`'s behavior). + fn write_psy_workspace(source: &str) -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().expect("create temp dir"); + let file = dir.path().join("app.psy"); + std::fs::write(&file, source).expect("write app.psy"); + let file = file.canonicalize().expect("canonicalize main.psy"); + let root = dir.path().canonicalize().expect("canonicalize root"); + (dir, root, file) + } + + fn entry_graph(file: &PathBuf) -> Graph { + let mut graph = Graph::new(); + graph.add_node(file.clone()); + graph + } + + const VALID_MAIN: &str = "fn main(q: Felt) -> Felt {\n return q;\n}\n"; + + /// Cursor position (0-based line, character) of the first `needle` on the + /// given line. Sources in these tests are ASCII, so byte == character. + fn position_at(source: &str, line: u32, needle: char) -> tower_lsp::lsp_types::Position { + let text_line = source.lines().nth(line as usize).unwrap_or_else(|| panic!("line {line} missing")); + let character = text_line.find(needle).unwrap_or_else(|| panic!("{needle:?} missing on line {line}")) as u32; + tower_lsp::lsp_types::Position { line, character } + } + + fn text_document(uri: &Url) -> TextDocumentIdentifier { + TextDocumentIdentifier { uri: uri.clone() } + } + + fn position_params(uri: &Url, position: tower_lsp::lsp_types::Position) -> TextDocumentPositionParams { + TextDocumentPositionParams { text_document: text_document(uri), position } + } + + #[test] + fn state_helpers_manage_root_path_graph_and_diagnostics_caches() { + let (service, _) = quiet_backend(); + let server = service.inner(); + + assert_eq!(server.get_root_path(), PathBuf::new()); + assert!(server.root_uri().is_none()); + assert!(server.resolve_file_id(&PathBuf::from("/nowhere/main.psy")).is_err()); + + let root = PathBuf::from("/tmp/psy-lsp-state-test"); + server.set_root_path(&root).expect("set root path"); + assert_eq!(server.get_root_path(), root); + assert!(server.root_uri().is_some()); + + server.set_ctx(TypeCheckerVisitorContext::::new(Program::new())).expect("set ctx"); + assert!(!server.is_ready()); + + let entry = PathBuf::from("/tmp/psy-lsp-state-test/main.psy"); + server.set_crate_path_graph_cache(entry.clone(), entry_graph(&entry)); + let cached = server.get_cached_crate_path_graph(&entry).expect("cached graph"); + assert!(cached.contains_node(&entry)); + assert!(server.get_cached_crate_path_graph(&PathBuf::from("/tmp/other.psy")).is_none()); + + server.remove_crate_path_graph_cache(&entry); + assert!(server.get_cached_crate_path_graph(&entry).is_none()); + server.set_crate_path_graph_cache(entry.clone(), entry_graph(&entry)); + server.clear_all_crate_path_graph_cache(); + assert!(server.get_cached_crate_path_graph(&entry).is_none()); + + let bundle = DiagnosticBundle { + uri: Some(Url::from_file_path(&entry).unwrap()), + diagnostics: vec![Diagnostic { + range: dummy_range(), + severity: Some(DiagnosticSeverity::ERROR), + message: "boom".to_string(), + source: Some("psy-lsp".to_string()), + ..Default::default() + }], + }; + assert!(server.get_last_diagnostics().is_none()); + server.set_last_diagnostics(bundle); + assert!(server.get_last_diagnostics().expect("cached bundle").diagnostics.len() == 1); + server.clear_last_diagnostics(); + assert!(server.get_last_diagnostics().is_none()); + } + + #[tokio::test] + async fn lifecycle_notifications_and_stub_handlers_are_noops() { + let service = drained_backend(); + let server = service.inner(); + let uri = Url::from_file_path("/tmp/psy-lsp-lifecycle/example.psy").unwrap(); + + server.initialized(InitializedParams {}).await; + server.did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { uri: uri.clone(), version: 1 }, + content_changes: vec![], + }) + .await; + server.did_close(DidCloseTextDocumentParams { text_document: text_document(&uri) }).await; + server.did_change_configuration(tower_lsp::lsp_types::DidChangeConfigurationParams { + settings: serde_json::json!({}), + }) + .await; + server.did_change_workspace_folders(Default::default()).await; + server.did_change_watched_files(tower_lsp::lsp_types::DidChangeWatchedFilesParams { changes: vec![] }).await; + + // A non-file URI cannot be saved, so `did_save` bails out early. + let https = Url::parse("https://example.com/example.psy").unwrap(); + server.did_save(DidSaveTextDocumentParams { text_document: text_document(&https), text: None }).await; + + // Without an opened document there is nothing to re-publish. + server.did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "psy".to_string(), + version: 1, + text: String::new(), + }, + }) + .await; + + // Completion and rename are not implemented yet. + let completion = server + .completion(CompletionParams { + text_document_position: position_params(&uri, tower_lsp::lsp_types::Position { line: 0, character: 0 }), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + context: None, + }) + .await; + assert!(completion.expect("completion").is_none()); + + let rename = server + .rename(RenameParams { + text_document_position: position_params(&uri, tower_lsp::lsp_types::Position { line: 0, character: 0 }), + new_name: "renamed".to_string(), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await; + assert!(rename.expect("rename").is_none()); + + // Before the first successful typecheck every language feature degrades to `None`. + assert!(!server.is_ready()); + assert!(server.goto_definition(GotoDefinitionParams { + text_document_position_params: position_params(&uri, tower_lsp::lsp_types::Position { line: 0, character: 0 }), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + }) + .await + .expect("goto not ready") + .is_none()); + assert!(server + .hover(HoverParams { + text_document_position_params: position_params(&uri, tower_lsp::lsp_types::Position { line: 0, character: 0 }), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .expect("hover not ready") + .is_none()); + assert!(server + .references(ReferenceParams { + text_document_position: position_params(&uri, tower_lsp::lsp_types::Position { line: 0, character: 0 }), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + context: ReferenceContext { include_declaration: true }, + }) + .await + .expect("references not ready") + .is_none()); + assert!(server + .formatting(DocumentFormattingParams { + text_document: text_document(&uri), + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .expect("formatting not ready") + .is_none()); + + server.shutdown().await.expect("shutdown"); + } + + #[tokio::test] + async fn did_open_republishes_cached_diagnostics_for_the_same_document_only() { + let service = drained_backend(); + let server = service.inner(); + let uri = Url::from_file_path("/tmp/psy-lsp-open/a.psy").unwrap(); + let other_uri = Url::from_file_path("/tmp/psy-lsp-open/b.psy").unwrap(); + + server.did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "psy".to_string(), + version: 1, + text: String::new(), + }, + }) + .await; + assert!(server.get_last_diagnostics().is_none()); + + // A cached bundle without a URI never re-publishes. + server.set_last_diagnostics(DiagnosticBundle { uri: None, diagnostics: vec![] }); + server.maybe_publish_cached_diagnostics(&uri).await; + + server.set_last_diagnostics(DiagnosticBundle { + uri: Some(uri.clone()), + diagnostics: vec![Diagnostic { + range: dummy_range(), + severity: Some(DiagnosticSeverity::ERROR), + message: "stale".to_string(), + source: Some("psy-lsp".to_string()), + ..Default::default() + }], + }); + + // Opening a different document must not touch the cached bundle. + server.did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: other_uri, + language_id: "psy".to_string(), + version: 1, + text: String::new(), + }, + }) + .await; + assert_eq!(server.get_last_diagnostics().expect("still cached").uri, Some(uri.clone())); + + // Re-opening the cached document re-publishes and keeps the cache. + server.did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "psy".to_string(), + version: 2, + text: String::new(), + }, + }) + .await; + assert_eq!(server.get_last_diagnostics().expect("still cached after open").uri, Some(uri)); + + // Clearing drops the cache entirely. + server.clear_cached_diagnostics().await; + assert!(server.get_last_diagnostics().is_none()); + } + + #[test] + #[serial] + fn collect_diagnostics_sync_bundles_parse_and_type_errors() { + let (service, _) = quiet_backend(); + let server = service.inner(); + let (dir, root, file) = write_psy_workspace("fn main( {\n}\n"); + server.set_crate_path_graph_cache(root.clone(), entry_graph(&file)); + + let bundle = server.collect_diagnostics_sync(&root).expect("parse error bundle"); + let uri = Url::from_file_path(&file).unwrap(); + assert_eq!(bundle.uri, Some(uri.clone())); + assert_eq!(bundle.diagnostics.len(), 1); + let diagnostic = &bundle.diagnostics[0]; + assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR)); + assert_eq!(diagnostic.source.as_deref(), Some("psy-lsp")); + assert!(!diagnostic.message.is_empty()); + // The failed typecheck must not have produced a usable context. + assert!(!server.is_ready()); + + // A semantically invalid (but parseable) file yields a type-check diagnostic. + std::fs::write(&file, "fn main() -> Felt {\n let x: Felt = 1;\n x = 2;\n return x;\n}\n").unwrap(); + let bundle = server.collect_diagnostics_sync(&root).expect("type error bundle"); + assert_eq!(bundle.uri, Some(uri)); + assert_eq!(bundle.diagnostics.len(), 1); + assert!(bundle.diagnostics[0].message.contains('x'), "unexpected message: {}", bundle.diagnostics[0].message); + assert!(!server.is_ready()); + + // Without a cached crate graph the manifest lookup must fail. + let orphan_dir = tempfile::tempdir().unwrap(); + let orphan_root = orphan_dir.path().canonicalize().unwrap(); + assert!(server.collect_diagnostics_sync(&orphan_root).is_err()); + + drop(dir); + } + + #[tokio::test] + #[serial] + async fn initialize_typechecks_the_workspace_and_drives_the_diagnostics_lifecycle() { + let service = drained_backend(); + let server = service.inner(); + let (dir, root, file) = write_psy_workspace(VALID_MAIN); + let uri = Url::from_file_path(&file).unwrap(); + server.set_crate_path_graph_cache(root.clone(), entry_graph(&file)); + + // A missing or remote root URI is rejected before any work happens. + let missing = server.initialize(InitializeParams::default()).await; + assert!(missing.is_err()); + let remote = server + .initialize(InitializeParams { + root_uri: Some(Url::parse("https://example.com/").unwrap()), + ..Default::default() + }) + .await; + assert!(remote.is_err()); + + let result = server + .initialize(InitializeParams { + root_uri: Some(Url::from_file_path(&root).unwrap()), + ..Default::default() + }) + .await + .expect("initialize succeeds"); + assert!(server.is_ready()); + assert_eq!(server.get_root_path(), root); + + let capabilities = result.capabilities; + assert_eq!(capabilities.hover_provider, Some(tower_lsp::lsp_types::HoverProviderCapability::Simple(true))); + assert_eq!(capabilities.definition_provider, Some(OneOf::Left(true))); + assert_eq!(capabilities.references_provider, Some(OneOf::Left(true))); + assert_eq!(capabilities.document_formatting_provider, Some(OneOf::Left(true))); + match capabilities.text_document_sync { + Some(TextDocumentSyncCapability::Options(options)) => { + assert_eq!(options.open_close, Some(true)); + assert_eq!(options.change, Some(TextDocumentSyncKind::INCREMENTAL)); + assert_eq!(options.save, Some(TextDocumentSyncSaveOptions::Supported(true))); + } + other => panic!("unexpected text document sync capability: {other:?}"), + } + + // A parse error publishes a diagnostic and remembers it. + std::fs::write(&file, "fn main( {\n}\n").unwrap(); + server.init_and_publish_diagnostics(&root).await.expect("publish parse error"); + let bundle = server.get_last_diagnostics().expect("cached parse-error bundle"); + assert_eq!(bundle.uri, Some(uri.clone())); + assert_eq!(bundle.diagnostics.len(), 1); + + // A failing recompile (no manifest) clears the previous diagnostics and logs to the client. + let orphan_dir = tempfile::tempdir().unwrap(); + let orphan_root = orphan_dir.path().canonicalize().unwrap(); + server.init_and_publish_diagnostics(&orphan_root).await.expect("failure is reported, not propagated"); + assert!(server.get_last_diagnostics().is_none()); + + // A successful recompile yields an empty bundle without a URI. + std::fs::write(&file, VALID_MAIN).unwrap(); + server.init_and_publish_diagnostics(&root).await.expect("publish valid workspace"); + assert!(server.get_last_diagnostics().is_none()); + assert!(server.is_ready()); + + drop(dir); + } + + #[tokio::test] + #[serial] + async fn did_save_recompiles_only_known_documents() { + let service = drained_backend(); + let server = service.inner(); + let (dir, root, file) = write_psy_workspace(VALID_MAIN); + server.set_crate_path_graph_cache(root.clone(), entry_graph(&file)); + server.collect_diagnostics_sync(&root).expect("initial diagnostics"); + assert!(server.is_ready()); + + // Non-file URIs bail out immediately. + let https = Url::parse("https://example.com/main.psy").unwrap(); + server.did_save(DidSaveTextDocumentParams { text_document: text_document(&https), text: None }).await; + + // A file the workspace never resolved is skipped. + let unknown = Url::from_file_path(root.join("other.psy")).unwrap(); + server.did_save(DidSaveTextDocumentParams { text_document: text_document(&unknown), text: None }).await; + assert!(server.get_last_diagnostics().is_none()); + + // Saving a known document recompiles the workspace. + let uri = Url::from_file_path(&file).unwrap(); + server.did_save(DidSaveTextDocumentParams { text_document: text_document(&uri), text: None }).await; + assert!(server.is_ready()); + + drop(dir); + } + + #[tokio::test] + #[serial] + async fn goto_hover_references_and_formatting_answer_real_positions() { + let service = drained_backend(); + let server = service.inner(); + let (dir, root, file) = write_psy_workspace(VALID_MAIN); + let uri = Url::from_file_path(&file).unwrap(); + server.set_crate_path_graph_cache(root.clone(), entry_graph(&file)); + server.collect_diagnostics_sync(&root).expect("diagnostics"); + assert!(server.is_ready()); + + let usage = position_at(VALID_MAIN, 1, 'q'); + + // Non-file URIs and unregistered files are hard errors. + let https = Url::parse("https://example.com/main.psy").unwrap(); + assert!(server + .goto_definition(GotoDefinitionParams { + text_document_position_params: position_params(&https, usage), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + }) + .await + .is_err()); + assert!(server + .hover(HoverParams { + text_document_position_params: position_params(&https, usage), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .is_err()); + assert!(server + .references(ReferenceParams { + text_document_position: position_params(&https, usage), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + context: ReferenceContext { include_declaration: true }, + }) + .await + .is_err()); + let unknown = Url::from_file_path(root.join("missing.psy")).unwrap(); + assert!(server + .goto_definition(GotoDefinitionParams { + text_document_position_params: position_params(&unknown, usage), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + }) + .await + .is_err()); + assert!(server + .formatting(DocumentFormattingParams { + text_document: text_document(&unknown), + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .is_err()); + + // Positions that are not identifiers resolve to nothing. + let whitespace = tower_lsp::lsp_types::Position { line: 2, character: 0 }; + assert!(server + .goto_definition(GotoDefinitionParams { + text_document_position_params: position_params(&uri, whitespace), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + }) + .await + .expect("goto whitespace") + .is_none()); + assert!(server + .hover(HoverParams { + text_document_position_params: position_params(&uri, whitespace), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .expect("hover whitespace") + .is_none()); + assert!(server + .references(ReferenceParams { + text_document_position: position_params(&uri, whitespace), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + context: ReferenceContext { include_declaration: true }, + }) + .await + .expect("references whitespace") + .is_none()); + + // Jumping from the usage of `q` lands on the parameter declaration. + let response = server + .goto_definition(GotoDefinitionParams { + text_document_position_params: position_params(&uri, usage), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + }) + .await + .expect("goto definition") + .expect("definition found"); + match response { + tower_lsp::lsp_types::GotoDefinitionResponse::Scalar(location) => { + assert_eq!(location.uri, uri); + assert_eq!(location.range.start, position_at(VALID_MAIN, 0, 'q')); + } + other => panic!("unexpected goto definition response: {other:?}"), + } + + // Hovering the usage reports the variable and its type. + let hover = server + .hover(HoverParams { + text_document_position_params: position_params(&uri, usage), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .expect("hover") + .expect("hover text"); + match hover.contents { + HoverContents::Markup(markup) => { + assert_eq!(markup.kind, MarkupKind::Markdown); + assert!(markup.value.contains('q'), "unexpected hover text: {}", markup.value); + } + other => panic!("unexpected hover contents: {other:?}"), + } + + // References exclude the queried position itself but keep the declaration. + let references = server + .references(ReferenceParams { + text_document_position: position_params(&uri, usage), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + context: ReferenceContext { include_declaration: true }, + }) + .await + .expect("references") + .expect("reference list"); + assert!(!references.is_empty()); + assert!(references.iter().all(|location| location.uri == uri)); + assert!(references + .iter() + .any(|location| location.range.start == position_at(VALID_MAIN, 0, 'q'))); + + // Formatting replaces the whole document. + let edits = server + .formatting(DocumentFormattingParams { + text_document: text_document(&uri), + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .expect("formatting") + .expect("format edits"); + assert_eq!(edits.len(), 1); + assert!(edits[0].new_text.contains("fn main"), "unexpected format output: {}", edits[0].new_text); + assert_eq!(edits[0].range.start, tower_lsp::lsp_types::Position { line: 0, character: 0 }); + + drop(dir); + } +} diff --git a/psy-package/src/files.rs b/psy-package/src/files.rs index d34546ef4..bf1bba65f 100644 --- a/psy-package/src/files.rs +++ b/psy-package/src/files.rs @@ -108,7 +108,10 @@ mod tests { str::FromStr, }; - use crate::{errors::ManifestError, files::find_file_manifest_root}; + use crate::{ + errors::ManifestError, + files::{find_file_manifest, find_file_manifest_root, find_package_manifest, get_package_manifest, path_root}, + }; /// Test that `find_file_manifest_root` handles all kinds of prefixes. #[test] @@ -211,4 +214,51 @@ mod tests { assert_err("project/baz"); assert_err("project/baz/src"); } + + #[test] + fn manifest_helpers_distinguish_nearest_and_workspace_manifests() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("workspace"); + let package = workspace.join("member"); + let source = package.join("src/nested"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(workspace.join("Dargo.toml"), "").unwrap(); + std::fs::write(package.join("Dargo.toml"), "").unwrap(); + + assert_eq!(find_file_manifest(&source), Some(package.join("Dargo.toml"))); + assert_eq!(get_package_manifest(&package).unwrap(), package.join("Dargo.toml")); + assert_eq!( + find_package_manifest(&workspace, &source).unwrap(), + workspace.join("Dargo.toml") + ); + } + + #[test] + fn manifest_helpers_report_boundary_and_missing_errors() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("root"); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + + assert!(matches!( + find_package_manifest(&root, &outside).unwrap_err(), + ManifestError::NoCommonAncestor { root: error_root, current } + if error_root == root && current == outside + )); + assert!(matches!( + find_package_manifest(&root, &root).unwrap_err(), + ManifestError::MissingFile(path) if path == root + )); + assert!(matches!( + get_package_manifest(&root).unwrap_err(), + ManifestError::MissingFile(path) if path == root + )); + } + + #[test] + fn path_root_handles_absolute_and_relative_paths() { + assert_eq!(path_root(Path::new("/one/two")), PathBuf::from("/")); + assert_eq!(path_root(Path::new("one/two")), PathBuf::new()); + } } diff --git a/psy-package/src/fm.rs b/psy-package/src/fm.rs index fe513c94a..5c47c99d7 100644 --- a/psy-package/src/fm.rs +++ b/psy-package/src/fm.rs @@ -53,3 +53,20 @@ fn resolve_components<'a>(components: impl Iterator>) -> Pa normalized_path } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_removes_dot_and_resolves_parent_components() { + assert_eq!(PathBuf::from("a/./b/../c").normalize(), PathBuf::from("a/c")); + assert_eq!(Path::new("/a/../b/./c").normalize(), PathBuf::from("/b/c")); + } + + #[test] + fn normalize_does_not_escape_relative_or_absolute_root() { + assert_eq!(PathBuf::from("../../a").normalize(), PathBuf::from("a")); + assert_eq!(PathBuf::from("/../../a").normalize(), PathBuf::from("/a")); + } +} diff --git a/psy-package/src/git.rs b/psy-package/src/git.rs index 623e0a54f..847910f86 100644 --- a/psy-package/src/git.rs +++ b/psy-package/src/git.rs @@ -71,9 +71,11 @@ fn dargo_crates() -> PathBuf { #[cfg(test)] mod tests { + use std::path::PathBuf; + use url::Url; - use super::resolve_folder_name; + use super::{dargo_crates, git_dep_location, git_dep_location_from_url, resolve_folder_name}; #[test] fn test_resolve_folder_name() { @@ -85,4 +87,33 @@ mod tests { test_fixture("https://github.com/PsyProtocol/psy-bigint/"); test_fixture("https://github.com/PsyProtocol/psy-bigint"); } + + #[test] + fn dependency_locations_support_https_ssh_and_malformed_urls() { + let https = git_dep_location_from_url("https://github.com/PsyProtocol/psy-bigint.git", "main"); + assert_eq!( + https, + dargo_crates().join("github.com/PsyProtocol/psy-bigint.git/main") + ); + + let ssh = git_dep_location_from_url("git@github.com:PsyProtocol/psy-bigint.git", "v1"); + assert_eq!( + ssh, + dargo_crates().join("github.com/PsyProtocol/psy-bigint.git/v1") + ); + + let malformed = git_dep_location_from_url("not a valid url %", "tag"); + assert_eq!(malformed.parent(), Some(dargo_crates().as_path())); + assert!(malformed.file_name().unwrap().to_string_lossy().starts_with("ssh-")); + assert!(malformed.file_name().unwrap().to_string_lossy().ends_with("-tag")); + } + + #[test] + fn dependency_location_uses_domain_path_and_tag() { + let url = Url::parse("https://example.test/org/repository").unwrap(); + assert_eq!( + git_dep_location(&url, "release"), + dargo_crates().join(PathBuf::from("example.test/org/repository/release")) + ); + } } diff --git a/psy-package/src/lib.rs b/psy-package/src/lib.rs index 014334532..94be59937 100644 --- a/psy-package/src/lib.rs +++ b/psy-package/src/lib.rs @@ -334,6 +334,13 @@ fn read_toml(toml_path: &Path) -> Result { mod dependency_cycle_tests { use super::*; + fn write_plain_manifest(path: &Path, contents: &str) -> PathBuf { + std::fs::create_dir_all(path).unwrap(); + let manifest = path.join("Dargo.toml"); + std::fs::write(&manifest, contents).unwrap(); + manifest + } + fn write_manifest(path: &Path, name: &str, dependency: &str) { let contents = format!( "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\ntype = \"lib\"\n\n[dependencies]\ndep = {{ path = \"{dependency}\" }}\n" @@ -406,4 +413,149 @@ mod dependency_cycle_tests { resolve_workspace_from_toml(&root.join("Dargo.toml")).unwrap(); } + + #[test] + fn config_parses_owned_and_borrowed_toml() { + let text = "[package]\nname = \"demo\"\ntype = \"bin\""; + assert!(Config::try_from(text).is_ok()); + assert!(Config::try_from(text.to_string()).is_ok()); + assert!(Config::try_from("not = [valid").is_err()); + } + + #[test] + fn std_path_falls_back_to_workspace_checkout_when_env_missing_or_stale() { + let saved = std::env::var("DARGO_STD_PATH").ok(); + + // A stale env value must not shadow the workspace checkout. + unsafe { std::env::set_var("DARGO_STD_PATH", "/definitely/not/a/real/std/path") }; + let stale = resolve_std_path().expect("fallback lookup must succeed past a stale env value"); + assert!(stale.is_file()); + + // Without the env var, the CARGO_MANIFEST_DIR candidates resolve. + unsafe { std::env::remove_var("DARGO_STD_PATH") }; + let resolved = resolve_std_path().expect("workspace checkout contains psy-std"); + assert!(resolved.is_file()); + + // Manifest resolution seeds the env var from the same fallback. + let temp = tempfile::tempdir().unwrap(); + let manifest = write_plain_manifest(temp.path(), "[package]\nname = \"root\"\ntype = \"lib\"\n"); + resolve_workspace_from_toml(&manifest).expect("manifest resolves without the env var"); + + match saved { + Some(value) => unsafe { std::env::set_var("DARGO_STD_PATH", value) }, + None => unsafe { std::env::remove_var("DARGO_STD_PATH") }, + } + } + + #[test] + fn standard_library_path_resolves_to_an_existing_file() { + let path = resolve_std_path().expect("workspace checkout contains psy-std"); + assert!(path.is_file()); + assert_eq!(path.file_name().and_then(|name| name.to_str()), Some("std.psy")); + } + + #[test] + fn workspace_resolves_defaults_custom_entries_and_local_dependencies() { + let temp = tempfile::tempdir().unwrap(); + let dependency = temp.path().join("dependency"); + let dependency_manifest = write_plain_manifest( + &dependency, + "[package]\nname = \"dependency\"\nversion = \"1.2.3-beta+build\"\ntype = \"lib\"\nentry = \"custom.psy\"", + ); + let root = temp.path().join("root"); + let root_manifest = write_plain_manifest( + &root, + "[package]\nname = \"root\"\ntype = \"bin\"\n[dependencies]\ndependency = { path = \"../dependency\" }", + ); + + let workspace = resolve_workspace_from_toml(&root_manifest).unwrap(); + assert_eq!(workspace.root_dir, root.canonicalize().unwrap()); + assert_eq!(workspace.target_dir, workspace.root_dir.join("target")); + assert_eq!(workspace.package.entry_path, workspace.root_dir.join("src/main.psy")); + let dependency = workspace + .package + .dependencies + .get(&"dependency".parse().unwrap()) + .unwrap() + .package(); + assert_eq!( + dependency.entry_path, + dependency_manifest.parent().unwrap().canonicalize().unwrap().join("custom.psy") + ); + assert_eq!(dependency.version.as_deref(), Some("1.2.3-beta+build")); + } + + #[test] + fn workspace_reports_missing_and_invalid_package_fields() { + let cases = [ + ( + "[package]\ntype = \"lib\"", + "missing-name", + ), + ( + "[package]\nname = \"bad-name\"\ntype = \"lib\"", + "invalid-name", + ), + ( + "[package]\nname = \"valid\"", + "missing-type", + ), + ( + "[package]\nname = \"valid\"\ntype = \"plugin\"", + "invalid-type", + ), + ( + "[package]\nname = \"valid\"\ntype = \"lib\"\nversion = \"broken\"", + "invalid-version", + ), + ]; + + for (contents, expected) in cases { + let temp = tempfile::tempdir().unwrap(); + let manifest = write_plain_manifest(temp.path(), contents); + let error = resolve_workspace_from_toml(&manifest).unwrap_err(); + match expected { + "missing-name" => assert!(matches!(error, ManifestError::MissingNameField { .. })), + "invalid-name" => assert!(matches!(error, ManifestError::InvalidPackageName { .. })), + "missing-type" => assert!(matches!(error, ManifestError::MissingPackageType(_))), + "invalid-type" => assert!(matches!(error, ManifestError::InvalidPackageType(_, ref ty) if ty == "plugin")), + "invalid-version" => assert!(matches!(error, ManifestError::SemverError(_))), + _ => unreachable!(), + } + } + } + + #[test] + fn workspace_reports_manifest_io_parse_and_dependency_name_errors() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing.toml"); + assert!(matches!( + resolve_workspace_from_toml(&missing).unwrap_err(), + ManifestError::ReadFailed(path) if path == missing + )); + + let malformed = write_plain_manifest(temp.path(), "[package"); + assert!(matches!( + resolve_workspace_from_toml(&malformed).unwrap_err(), + ManifestError::MalformedFile(_) + )); + + let invalid_dependency = write_plain_manifest( + temp.path(), + "[package]\nname = \"root\"\ntype = \"lib\"\n[dependencies]\n\"bad-name\" = { path = \"dep\" }", + ); + assert!(matches!( + resolve_workspace_from_toml(&invalid_dependency).unwrap_err(), + ManifestError::InvalidDependencyName { .. } + )); + + let missing_dependency = write_plain_manifest( + temp.path(), + "[package]\nname = \"root\"\ntype = \"lib\"\n[dependencies]\ndep = { path = \"missing\" }", + ); + assert!(matches!( + resolve_workspace_from_toml(&missing_dependency).unwrap_err(), + ManifestError::ReadFailed(path) if path.ends_with("missing/Dargo.toml") + )); + } } diff --git a/psy-package/src/package.rs b/psy-package/src/package.rs index 6200fb856..3d5f03267 100644 --- a/psy-package/src/package.rs +++ b/psy-package/src/package.rs @@ -74,7 +74,13 @@ pub struct CrateName(SmolStr); impl CrateName { fn is_valid_name(name: &str) -> bool { - !name.is_empty() && name.chars().all(|n| !CHARACTER_BLACK_LIST.contains(&n)) + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + + (first.is_ascii_alphabetic() || first == '_') + && chars.all(|character| character.is_ascii_alphanumeric() || character == '_') } } @@ -96,10 +102,7 @@ impl From<&CrateName> for String { } } -/// Creates a new CrateName rejecting any crate name that -/// has a character on the blacklist. -/// The difference between RA and this implementation is that -/// characters on the blacklist are never allowed; there is no normalization. +/// Creates a new CrateName using the ASCII identifier syntax. impl FromStr for CrateName { type Err = String; @@ -107,12 +110,76 @@ impl FromStr for CrateName { if Self::is_valid_name(name) { Ok(Self(SmolStr::new(name))) } else { - Err("Package names must be non-empty and cannot contain hyphens".into()) + Err("Package names must start with an ASCII letter or '_' and contain only ASCII letters, digits, or '_'".into()) } } } -/// List of characters that are not allowed in a crate name -/// For example, Hyphen(-) is disallowed as it is similar to underscore(_) -/// and we do not want names that differ by a hyphen -pub const CHARACTER_BLACK_LIST: [char; 1] = ['-']; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crate_name_accepts_ascii_identifiers_and_rejects_invalid_names() { + assert!(CrateName::from_str("").is_err()); + assert!(CrateName::from_str("not-valid").is_err()); + assert_eq!(CrateName::from_str("valid_name42").unwrap().to_string(), "valid_name42"); + assert_eq!(CrateName::from_str("_private").unwrap().to_string(), "_private"); + assert!(CrateName::from_str("42package").is_err()); + } + + #[test] + fn crate_name_rejects_whitespace_punctuation_unicode_and_control_characters() { + for name in ["a b", "a.b/c:d", "a\tb", "a\nb", "a\"b", "a\\b", "εŒ…_42", "a‐b", "aβˆ’b"] { + assert!(CrateName::from_str(name).is_err(), "accepted invalid name {name:?}"); + } + assert!(CrateName::from_str("a-b").is_err()); + assert!(CrateName::from_str("-").is_err()); + } + + #[test] + fn crate_name_converts_to_owned_string_from_owned_or_borrowed_value() { + let name = CrateName::from_str("package_name").unwrap(); + let borrowed: String = (&name).into(); + let owned: String = name.into(); + assert_eq!(borrowed, "package_name"); + assert_eq!(owned, "package_name"); + } + + #[test] + fn package_type_and_entry_path_are_consistent() { + let mut package = Package { + root_dir: PathBuf::from("workspace/pkg"), + entry_path: PathBuf::from("src/lib.psy"), + name: CrateName::from_str("pkg").unwrap(), + ..Package::default() + }; + + assert!(package.is_library()); + assert!(!package.is_binary()); + assert_eq!(package.package_type.to_string(), "lib"); + assert_eq!(package.entry_canonical_path(), PathBuf::from("workspace/pkg/src/lib.psy")); + + package.package_type = PackageType::Binary; + assert!(package.is_binary()); + assert!(!package.is_library()); + assert_eq!(package.package_type.to_string(), "bin"); + } + + #[test] + fn dependency_accessors_match_local_and_remote_variants() { + let package = Package { + name: CrateName::from_str("dep").unwrap(), + package_type: PackageType::Binary, + ..Package::default() + }; + for dependency in [ + Dependency::Local { package: package.clone() }, + Dependency::Remote { package: package.clone() }, + ] { + assert!(dependency.is_binary()); + assert_eq!(dependency.package_name().to_string(), "dep"); + assert_eq!(dependency.package().entry_path, PathBuf::new()); + } + } +} diff --git a/psy-package/src/semver.rs b/psy-package/src/semver.rs index ae852dfba..3187a5f5d 100644 --- a/psy-package/src/semver.rs +++ b/psy-package/src/semver.rs @@ -6,3 +6,50 @@ pub(crate) fn parse_semver_compatible_version(version: &str) -> Result().unwrap())); } + + fn package_sources(manifest: &str, files: &[(&str, &str)]) -> PackageSources { + PackageSources { + manifest: Arc::from(manifest), + files: files + .iter() + .map(|(path, text)| (RelativeFilePath::new(*path), Arc::from(*text))) + .collect(), + } + } + + #[test] + fn source_map_updates_existing_files_and_snapshots_in_id_order() { + let mut map = SourceMap::new(); + let path = VfsPath::virtual_path("workspace/root/src/main.psy"); + let id = map.insert(path.clone(), Arc::from("old")); + assert_eq!(map.insert(path.clone(), Arc::from("new")), id); + assert_eq!(map.file_id(&path), Some(id)); + assert_eq!(map.path(id), Some(&path)); + assert_eq!(map.text(id), Some("new")); + assert_eq!(map.path(FileId(9)), None); + assert_eq!(map.text(FileId(9)), None); + + let snapshot = map.snapshot(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].0, id); + assert_eq!(snapshot[0].1, path); + assert_eq!(&*snapshot[0].2, "new"); + assert_eq!( + map.resolve_path(AnchoredPath { + anchor: FileId(99), + relative: "missing.psy", + }), + None + ); + } + + #[test] + fn vfs_path_handles_roots_real_paths_and_parent_traversal() { + let root = VfsPath::virtual_path("/"); + assert_eq!(root.parent(), None); + assert_eq!(root.join("child.psy"), None); + + let virtual_file = VfsPath::virtual_path("//workspace/./root/src/../main.psy/"); + assert_eq!(virtual_file, VfsPath::Virtual("/workspace/root/main.psy".into())); + assert_eq!(virtual_file.parent(), Some(VfsPath::Virtual("/workspace/root".into()))); + assert_eq!(virtual_file.join("../lib.psy"), Some(VfsPath::Virtual("/workspace/lib.psy".into()))); + + let real_file = VfsPath::Real(PathBuf::from("/tmp/pkg/src/main.psy")); + assert_eq!(real_file.parent(), Some(VfsPath::Real(PathBuf::from("/tmp/pkg/src")))); + assert_eq!(real_file.join("sibling.psy"), Some(VfsPath::Real(PathBuf::from("/tmp/pkg/src/main.psy/sibling.psy")))); + } + + #[test] + fn virtual_path_helpers_handle_empty_relative_and_root_inputs() { + assert_eq!(normalize_virtual_path(String::new()), "/"); + assert_eq!(parent_virtual_path("relative"), None); + assert_eq!(join_virtual_path("/", "child.psy"), "/child.psy"); + assert_eq!(join_virtual_path("/base", "child.psy"), "/base/child.psy"); + } + + #[test] + fn memory_resolver_reports_missing_real_and_virtual_packages() { + let resolver = MemoryResolver::default(); + let real = resolver.package_sources(&PackageId::Real(PathBuf::from("/missing"))).unwrap_err(); + assert!(matches!(real, ManifestError::ReadFailed(path) if path == PathBuf::from("/missing/Dargo.toml"))); + + let virtual_error = resolver.package_sources(&PackageId::Virtual("missing".into())).unwrap_err(); + assert!(matches!(virtual_error, ManifestError::ReadFailed(path) if path == PathBuf::from("virtual://missing/Dargo.toml"))); + + let dependency = resolver + .resolve_dependency(&PackageId::Virtual("root".into()), "dep") + .unwrap_err(); + assert!(matches!(dependency, ManifestError::ReadFailed(path) if path == PathBuf::from("virtual://dep/Dargo.toml"))); + } + + #[test] + fn source_workspace_validates_manifest_and_entry_errors() { + let cases = [ + ( + "[package]\nname = \"bad-name\"\ntype = \"lib\"", + "src/lib.psy", + "invalid-name", + ), + ( + "[package]\nname = \"valid\"\ntype = \"plugin\"", + "src/lib.psy", + "invalid-type", + ), + ( + "[package]\nname = \"valid\"\ntype = \"bin\"\nentry = \"custom/start.psy\"", + "src/main.psy", + "missing-entry", + ), + ]; + + for (manifest, file, expected) in cases { + let root = PackageId::Virtual(expected.into()); + let mut resolver = MemoryResolver::default(); + resolver.insert_package(root.clone(), package_sources(manifest, &[(file, "")])); + let error = resolve_source_workspace(root, &resolver).unwrap_err(); + match expected { + "invalid-name" => assert!(matches!(error, ManifestError::InvalidPackageName { .. })), + "invalid-type" => assert!(matches!(error, ManifestError::InvalidPackageType(_, ref value) if value == "plugin")), + "missing-entry" => assert!(matches!(error, ManifestError::MissingFile(path) if path == PathBuf::from("virtual://missing-entry/custom/start.psy"))), + _ => unreachable!(), + } + } + } + + #[test] + fn source_workspace_rejects_invalid_and_unresolved_dependencies() { + let root = PackageId::Virtual("root".into()); + let manifest = "[package]\nname = \"root\"\ntype = \"lib\"\n[dependencies]\n\"bad-name\" = {}"; + let mut resolver = MemoryResolver::default(); + resolver.insert_package(root.clone(), package_sources(manifest, &[("src/lib.psy", "")])); + assert!(matches!( + resolve_source_workspace(root.clone(), &resolver).unwrap_err(), + ManifestError::InvalidDependencyName { .. } + )); + + let manifest = "[package]\nname = \"root\"\ntype = \"lib\"\n[dependencies]\ndep = {}"; + resolver.insert_package(root.clone(), package_sources(manifest, &[("src/lib.psy", "")])); + assert!(matches!( + resolve_source_workspace(root, &resolver).unwrap_err(), + ManifestError::ReadFailed(path) if path == PathBuf::from("virtual://dep/Dargo.toml") + )); + } + + #[test] + fn source_workspace_detects_cycles_and_reuses_shared_dependencies() { + let root = PackageId::Virtual("root".into()); + let dep = PackageId::Virtual("dep".into()); + let manifest = |name: &str, dependency: &str| { + format!("[package]\nname = \"{name}\"\ntype = \"lib\"\n[dependencies]\n{dependency} = {{}}") + }; + let mut resolver = MemoryResolver::default(); + resolver.insert_package(root.clone(), package_sources(&manifest("root", "dep"), &[("src/lib.psy", "")])); + resolver.insert_package(dep.clone(), package_sources(&manifest("dep", "root"), &[("src/lib.psy", "")])); + resolver.insert_dependency(root.clone(), "dep", dep.clone()); + resolver.insert_dependency(dep, "root", root.clone()); + + let error = resolve_source_workspace(root, &resolver).unwrap_err(); + assert!(matches!(error, ManifestError::CyclicDependency { cycle } if cycle == "virtual://root -> virtual://dep -> virtual://root")); + } + + #[test] + fn source_workspace_resolves_shared_dependency_only_once() { + let root = PackageId::Virtual("root".into()); + let left = PackageId::Virtual("left".into()); + let right = PackageId::Virtual("right".into()); + let shared = PackageId::Virtual("shared".into()); + let manifest = |name: &str, dependencies: &str| { + format!("[package]\nname = \"{name}\"\ntype = \"lib\"\n[dependencies]\n{dependencies}") + }; + let mut resolver = MemoryResolver::default(); + resolver.insert_package(root.clone(), package_sources(&manifest("root", "left = {}\nright = {}"), &[("src/lib.psy", "")])); + resolver.insert_package(left.clone(), package_sources(&manifest("left", "shared = {}"), &[("src/lib.psy", "")])); + resolver.insert_package(right.clone(), package_sources(&manifest("right", "shared = {}"), &[("src/lib.psy", "")])); + resolver.insert_package(shared.clone(), package_sources(&manifest("shared", ""), &[("src/lib.psy", "")])); + resolver.insert_dependency(root.clone(), "left", left.clone()); + resolver.insert_dependency(root.clone(), "right", right.clone()); + resolver.insert_dependency(left, "shared", shared.clone()); + resolver.insert_dependency(right, "shared", shared); + + let workspace = resolve_source_workspace(root, &resolver).unwrap(); + assert_eq!(workspace.packages.len(), 4); + } + + #[test] + fn source_workspace_reports_real_paths_in_cycle() { + let root = PackageId::Real(PathBuf::from("/virtual-root")); + let dep = PackageId::Real(PathBuf::from("/virtual-dep")); + let mut resolver = MemoryResolver::default(); + resolver.insert_package( + root.clone(), + package_sources( + "[package]\nname = \"root\"\ntype = \"lib\"\n[dependencies]\ndep = {}", + &[("src/lib.psy", "")], + ), + ); + resolver.insert_package( + dep.clone(), + package_sources( + "[package]\nname = \"dep\"\ntype = \"lib\"\n[dependencies]\nroot = {}", + &[("src/lib.psy", "")], + ), + ); + resolver.insert_dependency(root.clone(), "dep", dep.clone()); + resolver.insert_dependency(dep, "root", root.clone()); + + assert!(matches!( + resolve_source_workspace(root, &resolver).unwrap_err(), + ManifestError::CyclicDependency { cycle } + if cycle == "/virtual-root -> /virtual-dep -> /virtual-root" + )); + } + + #[test] + fn package_path_mapping_supports_real_packages_and_string_conversion() { + let relative = RelativeFilePath::from(String::from("src\\nested\\..\\lib.psy")); + assert_eq!(relative.as_str(), "src/lib.psy"); + assert_eq!( + package_file_to_vfs_path(&PackageId::Real(PathBuf::from("/pkg")), &relative), + VfsPath::Real(PathBuf::from("/pkg/src/lib.psy")) + ); + } } diff --git a/psy-parser/src/lib.rs b/psy-parser/src/lib.rs index 1455b523b..e7b82d7fc 100644 --- a/psy-parser/src/lib.rs +++ b/psy-parser/src/lib.rs @@ -68,6 +68,9 @@ impl<'a, 'b, F: ContextFelt + From, C: DPNContext> Parser<'a, 'b, F, C> fn parse_module(program: &mut Program, ctx: &mut C, current_path: &PathBuf, location: Location, visibility: Visibility) -> Result { let module_name = resolve_module_name(program, current_path); + if !is_valid_module_name(&program.interner[module_name].0) { + return Err(Error::InvalidModuleName); + } let file_id = program.file_resolver.resolve_file(current_path.clone())?; // Keep the source alive independently so parsing can mutably borrow the @@ -212,6 +215,16 @@ pub fn resolve_module_path>( Some(path) } +fn is_valid_module_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + + (first.is_ascii_alphabetic() || first == '_') + && chars.all(|character| character.is_ascii_alphanumeric() || character == '_') +} + pub fn resolve_module_name>(program: &mut Program, file_path: &Path) -> IdentId { let interner = &mut program.interner; let file_name_without_extension = file_path.file_stem().and_then(|s| s.to_str()).unwrap(); @@ -292,6 +305,34 @@ mod tests { use psy_vm::dpn::ops::exec_context::QExecContext; use super::Parser; + + #[test] + fn module_file_names_use_identifier_syntax() { + assert!(super::is_valid_module_name("foo")); + assert!(super::is_valid_module_name("_foo42")); + for name in ["", "42foo", "foo-bar", "foo.bar", "foo/bar", "foo bar", "εŒ…"] { + assert!(!super::is_valid_module_name(name), "accepted invalid module name {name:?}"); + } + } + + #[test] + fn parsing_rejects_invalid_module_file_name() { + let path = PathBuf::from("foo-bar.psy"); + let mut program = Program::new(); + let file_id = program.file_resolver.add_file(path.clone(), ""); + let mut ctx = QExecContext::new(); + + let error = Parser::parse_module( + &mut program, + &mut ctx, + &path, + psy_ast::Location::new(file_id, 0, 0), + psy_ast::Visibility::Public, + ) + .expect_err("a hyphenated module file name must be rejected"); + assert!(matches!(error, super::Error::InvalidModuleName)); + } + #[test] fn test_psy_parser() { let mut program = Program::new(); diff --git a/psy-sema/src/context.rs b/psy-sema/src/context.rs index 498a20969..9c71510f2 100644 --- a/psy-sema/src/context.rs +++ b/psy-sema/src/context.rs @@ -186,3 +186,205 @@ impl + ContextFelt, C> TypeCheckerVisitorContext { } } } + +#[cfg(test)] +mod tests { + use indexmap::IndexMap; + + use psy_ast::{ + Comment, DefId, DefinitionNode, ExprId, ExprNode, IdentId, Identifier, Location, ModuleId, NodeId, NodeType, StmtId, StmtNode, UseNode, + ValueNode, Visibility, + }; + use psy_vm::dpn::ops::sym_felt::SymFeltRef; + + use super::*; + use crate::{CheckedStructField, CheckedStructNode, ExpectedFunctionSignature, ExpectedReturnType, FELT_TYPE, ScopeId, VOID_TYPE}; + + type Ctx = TypeCheckerVisitorContext; + + fn ctx() -> Ctx { + TypeCheckerVisitorContext::new(Program::new()) + } + + fn signature(parameter: TypeId) -> ExpectedFunctionSignature { + ExpectedFunctionSignature { + parameters: vec![parameter], + return_type: ExpectedReturnType::Known(VOID_TYPE), + receiver: None, + } + } + + fn felt_value() -> ExprNode { + ExprNode::Value(ValueNode::Felt(SymFeltRef(7), Location::default())) + } + + fn use_definition() -> DefinitionNode { + DefinitionNode::Use(UseNode { + visibility: Visibility::Private, + kind: Identifier::new(IdentId(1), Location::default()), + segments: vec![], + target: None, + comments: vec![Comment::new_line("doc".to_string(), Location::default())], + location: Location::default(), + }) + } + + #[test] + fn expected_signature_stack_lifecycle() { + let mut context = ctx(); + assert_eq!(context.expected_signature(), None); + assert_eq!(context.ancestor_expected_signature(0), None); + + let outer = signature(FELT_TYPE); + context.push_expected_signature(outer.clone()); + let inner = signature(VOID_TYPE); + context.push_expected_signature(inner.clone()); + + assert_eq!(context.expected_signature(), Some(inner.clone())); + assert_eq!(context.ancestor_expected_signature(0), Some(&inner)); + assert_eq!(context.ancestor_expected_signature(1), Some(&outer)); + assert_eq!(context.ancestor_expected_signature(2), None); + assert_eq!(context.expected_signature_path().len(), 2); + + context.pop_expected_signature(); + assert_eq!(context.expected_signature(), Some(outer)); + } + + #[test] + fn node_id_stack_tracks_ancestry() { + let mut context = ctx(); + context.push_node_id(NodeId::Expr(ExprId(1))); + context.push_node_id(NodeId::Stmt(StmtId(2))); + context.push_node_id(NodeId::Def(DefId(3))); + + assert_eq!(context.node_id(), NodeId::Def(DefId(3))); + assert_eq!(context.ancestor_node_id(0), NodeId::Def(DefId(3))); + assert_eq!(context.ancestor_node_id(1), NodeId::Stmt(StmtId(2))); + assert_eq!(context.ancestor_node_id(2), NodeId::Expr(ExprId(1))); + assert_eq!(context.node_path().len(), 3); + + context.pop_node_id(); + assert_eq!(context.node_id(), NodeId::Stmt(StmtId(2))); + } + + #[test] + fn node_type_dispatches_by_node_kind() { + let mut context = ctx(); + + let expr_id = context.program.exprs.alloc_item(felt_value()); + context.push_node_id(NodeId::Expr(expr_id)); + assert_eq!(context.node_type(), NodeType::ValueExpr); + assert_eq!(context.ancestor_node_type(0), NodeType::ValueExpr); + + let stmt_id = context.program.stmts.alloc_item(StmtNode::Expression(expr_id)); + context.push_node_id(NodeId::Stmt(stmt_id)); + assert_eq!(context.node_type(), NodeType::ExpressionStmt); + + let def_id = context.program.defs.alloc_item(use_definition()); + context.push_node_id(NodeId::Def(def_id)); + assert_eq!(context.node_type(), NodeType::UseDef); + + context.push_node_id(NodeId::Module(ModuleId(0))); + assert_eq!(context.node_type(), NodeType::Module); + assert_eq!(context.ancestor_node_type(3), NodeType::ValueExpr); + } + + #[test] + fn ident_interning_round_trips() { + let mut context = ctx(); + let id = context.intern("size_of_it"); + assert_eq!(context.ident(id).0, "size_of_it"); + + let lambda_a = context.intern_lambda(); + let lambda_b = context.intern_lambda(); + assert_ne!(lambda_a, lambda_b); + } + + #[test] + fn program_accessors_expose_arena_items() { + let mut context = ctx(); + let expr_id = context.program.exprs.alloc_item(felt_value()); + let stmt_id = context.program.stmts.alloc_item(StmtNode::Expression(expr_id)); + let def_id = context.alloc_definition(use_definition()); + + assert!(matches!(context.expression(expr_id), ExprNode::Value(ValueNode::Felt(_, _)))); + assert!(matches!(context.statement(stmt_id), StmtNode::Expression(id) if *id == expr_id)); + assert!(matches!(context.definition(def_id), DefinitionNode::Use(_))); + assert_eq!(context.program().exprs[expr_id], ExprNode::Value(ValueNode::Felt(SymFeltRef(7), Location::default()))); + assert_eq!(context.dependency_graph().nodes().len(), 0); + } + + #[test] + fn size_of_counts_primitives_and_nested_struct_fields() { + let mut context = ctx(); + let felt = context.symbols.create_type(Type::Felt).unwrap(); + let bool_ty = context.symbols.create_type(Type::Bool).unwrap(); + let u32_ty = context.symbols.create_type(Type::U32).unwrap(); + assert_eq!(context.size_of(felt), 1); + assert_eq!(context.size_of(bool_ty), 1); + assert_eq!(context.size_of(u32_ty), 1); + + let point = { + let mut fields = IndexMap::new(); + fields.insert( + Identifier::new(IdentId(10), Location::default()), + CheckedStructField::new(felt, vec![], Visibility::Public, vec![], Location::default()), + ); + fields.insert( + Identifier::new(IdentId(11), Location::default()), + CheckedStructField::new(felt, vec![], Visibility::Public, vec![], Location::default()), + ); + context + .symbols + .create_type(Type::Struct(CheckedStructNode { + name: Identifier::new(IdentId(12), Location::default()), + generic_parameters: vec![], + fields, + scope_id: ScopeId(0), + attrs: vec![], + visibility: Visibility::Public, + comments: vec![], + location: Location::default(), + type_id: TypeId(999), + })) + .unwrap() + }; + assert_eq!(context.size_of(point), 2); + + let outer = { + let mut fields = IndexMap::new(); + fields.insert( + Identifier::new(IdentId(13), Location::default()), + CheckedStructField::new(point, vec![], Visibility::Public, vec![], Location::default()), + ); + fields.insert( + Identifier::new(IdentId(14), Location::default()), + CheckedStructField::new(felt, vec![], Visibility::Public, vec![], Location::default()), + ); + context + .symbols + .create_type(Type::Struct(CheckedStructNode { + name: Identifier::new(IdentId(15), Location::default()), + generic_parameters: vec![], + fields, + scope_id: ScopeId(0), + attrs: vec![], + visibility: Visibility::Public, + comments: vec![], + location: Location::default(), + type_id: TypeId(998), + })) + .unwrap() + }; + assert_eq!(context.size_of(outer), 3); + } + + #[test] + #[should_panic(expected = "not yet implemented")] + fn size_of_rejects_unsupported_shapes() { + let mut context = ctx(); + let felt = context.symbols.create_type(Type::Felt).unwrap(); + let tuple = context.symbols.create_type(Type::Tuple(vec![felt, felt])).unwrap(); + let _ = context.size_of(tuple); + } +} diff --git a/psy-sema/src/definition/mod.rs b/psy-sema/src/definition/mod.rs index c12d38259..c8ef7fe2e 100644 --- a/psy-sema/src/definition/mod.rs +++ b/psy-sema/src/definition/mod.rs @@ -73,3 +73,328 @@ impl NodeInfo for CheckedDefinitionNode { } } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use indexmap::IndexMap; + use psy_ast::{ + Comment, DefId, IdentId, Location, NodeType, Qualifier, TypeQualifier, UseNode, Visibility, + }; + + use crate::{ConstId, Identifier, ScopeId, UNKOWN_TYPE}; + + use super::*; + + fn ident(id: usize) -> Identifier { + Identifier::new(IdentId(id), Location::default()) + } + + fn comment() -> Comment { + Comment::new_line("doc".to_string(), Location::default()) + } + + fn field(ty: TypeId) -> CheckedStructField { + CheckedStructField::new(ty, vec![], Visibility::Public, vec![], Location::default()) + } + + /// A function whose first parameter and return type are `UNKOWN_TYPE`, + /// plus one concretely typed parameter. + fn function_node() -> CheckedFunctionNode { + CheckedFunctionNode { + name: ident(1), + parameters: vec![ + CheckedFunctionParameter::new( + ident(2), + TypeQualifier::new(false, Location::default()), + UNKOWN_TYPE, + None, + Location::default(), + ), + CheckedFunctionParameter::new( + ident(3), + TypeQualifier::new(false, Location::default()), + TypeId(3), + None, + Location::default(), + ), + ], + generic_parameters: vec![], + body: None, + qualifier: Qualifier::default(), + return_type: UNKOWN_TYPE, + return_type_path: None, + scope_id: ScopeId(1), + visibility: Visibility::Public, + attrs: vec![], + type_id: TypeId(11), + comments: vec![comment()], + location: Location::default(), + } + } + + fn struct_node() -> CheckedStructNode { + let mut fields = IndexMap::new(); + fields.insert(ident(3), field(TypeId(5))); + CheckedStructNode { + name: ident(1), + generic_parameters: vec![], + fields, + scope_id: ScopeId(2), + attrs: vec![], + visibility: Visibility::Public, + comments: vec![comment()], + location: Location::default(), + type_id: TypeId(12), + } + } + + fn enum_node() -> CheckedEnumNode { + let mut variant_fields = IndexMap::new(); + variant_fields.insert(ident(4), field(TypeId(6))); + CheckedEnumNode { + name: ident(1), + generic_parameters: vec![TypeId(7)], + variants: vec![ + CheckedEnumVariant::Basic(ident(2), TypeId(8)), + CheckedEnumVariant::Tuple(ident(3), vec![TypeId(9), TypeId(10)]), + CheckedEnumVariant::Struct(ident(4), variant_fields), + ], + scope_id: ScopeId(3), + visibility: Visibility::Public, + comments: vec![], + location: Location::default(), + } + } + + fn associated_type_value() -> CheckedAssociatedTypeValue { + CheckedAssociatedTypeValue { + root: Some(TypeId(13)), + target: Some(IdentId(5)), + type_id: TypeId(14), + visibility: Visibility::Public, + comments: vec![comment()], + location: Location::default(), + } + } + + fn impl_node() -> CheckedImplNode { + let mut associated_types = IndexMap::new(); + associated_types.insert(ident(6), associated_type_value()); + CheckedImplNode { + generic_parameters: vec![], + associated_types, + ty: TypeId(15), + body: vec![DefId(1), DefId(2)], + scope_id: ScopeId(4), + comments: vec![], + location: Location::default(), + } + } + + fn trait_impl_node() -> CheckedTraitImplNode { + let mut associated_types = IndexMap::new(); + associated_types.insert(ident(7), associated_type_value()); + CheckedTraitImplNode { + generic_parameters: vec![TypeId(16)], + associated_types, + trait_ty: TypeId(17), + ty: TypeId(18), + body: vec![DefId(3)], + scope_id: ScopeId(5), + comments: vec![], + location: Location::default(), + } + } + + fn trait_node() -> CheckedTraitNode { + let mut associated_types = IndexMap::new(); + associated_types.insert( + ident(8), + CheckedAssociatedType { + type_id: TypeId(19), + constraints: vec![TypeId(20)], + visibility: Visibility::Public, + comments: vec![], + location: Location::default(), + }, + ); + CheckedTraitNode { + name: ident(1), + associated_types, + generic_parameters: vec![], + body: vec![DefId(4)], + unchecked_body: vec![DefId(5)], + scope_id: ScopeId(6), + visibility: Visibility::Public, + comments: vec![comment()], + location: Location::default(), + type_id: TypeId(21), + } + } + + fn type_alias_node() -> CheckedTypeAliasNode { + CheckedTypeAliasNode { + name: ident(1), + ty: TypeId(22), + comments: vec![comment()], + visibility: Visibility::Public, + } + } + + fn const_node() -> CheckedConstNode { + CheckedConstNode { + name: Some(ident(1)), + ty: TypeId(23), + value: ConstId(9), + visibility: Visibility::Public, + scope_id: ScopeId(7), + } + } + + fn use_node() -> CheckedUseNode { + UseNode { + visibility: Visibility::Public, + kind: ident(1), + segments: vec![ident(2), ident(3)], + target: Some(ident(4)), + comments: vec![comment()], + location: Location::default(), + } + } + + #[test] + fn name_extracts_the_identifier_of_named_variants() { + assert_eq!(CheckedDefinitionNode::Function(function_node()).name(), IdentId(1)); + assert_eq!(CheckedDefinitionNode::Struct(struct_node()).name(), IdentId(1)); + assert_eq!(CheckedDefinitionNode::Enum(enum_node()).name(), IdentId(1)); + assert_eq!(CheckedDefinitionNode::Trait(trait_node()).name(), IdentId(1)); + assert_eq!(CheckedDefinitionNode::TypeAlias(type_alias_node()).name(), IdentId(1)); + assert_eq!(CheckedDefinitionNode::Const(const_node()).name(), IdentId(1)); + } + + #[test] + #[should_panic(expected = "entered unreachable code")] + fn name_panics_for_variants_without_a_name() { + let _ = CheckedDefinitionNode::Impl(impl_node()).name(); + } + + #[test] + #[should_panic(expected = "called `Option::unwrap()` on a `None` value")] + fn name_panics_for_anonymous_const() { + let mut node = const_node(); + node.name = None; + let _ = CheckedDefinitionNode::Const(node).name(); + } + + #[test] + fn type_id_extracts_function_struct_and_trait_type_ids() { + assert_eq!(CheckedDefinitionNode::Function(function_node()).type_id(), TypeId(11)); + assert_eq!(CheckedDefinitionNode::Struct(struct_node()).type_id(), TypeId(12)); + assert_eq!(CheckedDefinitionNode::Trait(trait_node()).type_id(), TypeId(21)); + } + + #[test] + #[should_panic(expected = "entered unreachable code")] + fn type_id_panics_for_variants_without_a_type_id() { + let _ = CheckedDefinitionNode::Enum(enum_node()).type_id(); + } + + #[test] + fn node_type_reports_every_definition_kind() { + assert_eq!(CheckedDefinitionNode::Function(function_node()).node_type(), NodeType::FunctionDef); + assert_eq!(CheckedDefinitionNode::Struct(struct_node()).node_type(), NodeType::StructDef); + assert_eq!(CheckedDefinitionNode::Enum(enum_node()).node_type(), NodeType::EnumDef); + assert_eq!(CheckedDefinitionNode::Impl(impl_node()).node_type(), NodeType::ImplDef); + assert_eq!(CheckedDefinitionNode::TraitImpl(trait_impl_node()).node_type(), NodeType::TraitImplDef); + assert_eq!(CheckedDefinitionNode::Trait(trait_node()).node_type(), NodeType::TraitDef); + assert_eq!(CheckedDefinitionNode::TypeAlias(type_alias_node()).node_type(), NodeType::TypeAliasDef); + assert_eq!(CheckedDefinitionNode::Const(const_node()).node_type(), NodeType::ConstDef); + assert_eq!(CheckedDefinitionNode::Use(use_node()).node_type(), NodeType::UseDef); + } + + #[test] + fn clones_round_trip_and_equality_detects_differences() { + let function = function_node(); + assert_eq!(function.clone(), function); + let structure = struct_node(); + assert_eq!(structure.clone(), structure); + let enumeration = enum_node(); + assert_eq!(enumeration.clone(), enumeration); + let implementation = impl_node(); + assert_eq!(implementation.clone(), implementation); + let trait_impl = trait_impl_node(); + assert_eq!(trait_impl.clone(), trait_impl); + let tr = trait_node(); + assert_eq!(tr.clone(), tr); + let alias = type_alias_node(); + assert_eq!(alias.clone(), alias); + let konst = const_node(); + assert_eq!(konst.clone(), konst); + let use_ = use_node(); + assert_eq!(use_.clone(), use_); + let array = CheckedArrayNode { inner_ty: TypeId(41), size_ty: TypeId(42), scope_id: ScopeId(8) }; + assert_eq!(array.clone(), array); + let value = associated_type_value(); + assert_eq!(value.clone(), value); + + let mut altered = struct_node(); + altered.type_id = TypeId(999); + assert_ne!(structure, altered); + } + + #[test] + fn trait_impl_signature_replaces_unknown_types_with_the_implementor() { + let node = function_node(); + assert_eq!( + node.signature(), + CheckedFunctionSignature { parameters: vec![UNKOWN_TYPE, TypeId(3)], return_type: UNKOWN_TYPE } + ); + assert_eq!( + node.trait_impl_signature(TypeId(30)), + CheckedFunctionSignature { parameters: vec![TypeId(30), TypeId(3)], return_type: TypeId(30) } + ); + + let mut concrete_return = function_node(); + concrete_return.return_type = TypeId(40); + assert_eq!( + concrete_return.trait_impl_signature(TypeId(30)).return_type, + TypeId(40), + "a known return type must not be replaced by the implementor" + ); + } + + #[test] + fn signatures_hash_and_compare_by_value() { + let signature = function_node().signature(); + let mut seen = HashSet::new(); + assert!(seen.insert(signature.clone())); + assert!(!seen.insert(signature)); + } + + #[test] + fn parameter_and_field_constructors_preserve_metadata() { + let parameter = CheckedFunctionParameter::new( + ident(2), + TypeQualifier::new(true, Location::default()), + TypeId(31), + None, + Location::default(), + ); + assert_eq!(parameter.name.id, IdentId(2)); + assert!(parameter.qualifier.is_mutable); + assert_eq!(parameter.ty, TypeId(31)); + + let struct_field = CheckedStructField::new( + TypeId(32), + vec![], + Visibility::Private, + vec![comment()], + Location::default(), + ); + assert_eq!(struct_field.ty, TypeId(32)); + assert_eq!(struct_field.visibility, Visibility::Private); + assert_eq!(struct_field.comments.len(), 1); + } +} diff --git a/psy-sema/src/expr/mod.rs b/psy-sema/src/expr/mod.rs index 3f90bd432..2bc413f35 100644 --- a/psy-sema/src/expr/mod.rs +++ b/psy-sema/src/expr/mod.rs @@ -248,3 +248,439 @@ impl CheckedExprNode { } } } + +#[cfg(test)] +mod accessor_tests { + use indexmap::IndexMap; + use psy_ast::{ConstValue, ExprId, IdentId, Identifier, PathNode, UncheckedType}; + use psy_common::FileId; + + use super::*; + use crate::ScopeId; + + fn loc(start: usize) -> Location { + Location::new(FileId(0), start, start + 1) + } + + fn empty_path_node() -> PathNode { + PathNode { + root: None, + segments: vec![], + target: UncheckedType::Basic(Identifier { id: IdentId(0), location: loc(1) }), + is_ty: false, + location: loc(1), + } + } + + #[test] + fn intrinsic_accessors_round_trip() { + let e = ExprId(0); + // One maker per `CheckedIntrinsicExprNode` variant; each receives the + // type id and location it must carry so the asserts below are real + // round-trips rather than tautologies. + let makers: Vec CheckedIntrinsicExprNode>> = vec![ + Box::new(|t, l| CheckedIntrinsicExprNode::GetUserId { type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetContractId { type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetContractDeployer { contract_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetContractStateTreeHeight { contract_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetCallerContractId { type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetCheckpointId { type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetCheckpointStats { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetRegisterUsersRoot { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetGutasRoot { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetCheckpointUserTreeRoot { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetCheckpointContractTreeRoot { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetCheckpointDepositTreeRoot { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetCheckpointWithdrawalTreeRoot { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetCheckpointUserRegistrationTreeRoot { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetDeployContractsRoot { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetGutaFeesCollected { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetDaFeesCollected { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetUserOpsProcessed { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetTotalTransactions { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetSlotsModified { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetRegisterUsersCompleted { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetGutasCompleted { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetDeployContractsCompleted { checkpoint_id: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetLastNonce { type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetUserPublicKeyHash { type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetSessionProofTreeRoot { type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::GetStateHashAt { slot_index: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::ImtGet { + key: e, + base_offset: e, + capacity: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::ImtGetOtherUser { + contract_state_tree_height: e, + user_id: e, + contract_id: e, + key: e, + base_offset: e, + capacity: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::ImtContainsOtherUser { + contract_state_tree_height: e, + user_id: e, + contract_id: e, + key: e, + base_offset: e, + capacity: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::GetOtherContractStateHashAt { + contract_state_tree_height: e, + contract_id: e, + slot_index: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::GetOtherUserContractStateHashAt { + contract_state_tree_height: e, + user_id: e, + contract_id: e, + slot_index: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::CSetStateHashAt { + slot_index: e, + new_value: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::ImtSet { + key: e, + new_value: e, + base_offset: e, + capacity: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::ImtContains { + key: e, + base_offset: e, + capacity: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| CheckedIntrinsicExprNode::MemTransmute { data: e, target_type: TypeId(t), location: l }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::MemSizeOf { + query_type: TypeId(t), + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::StorageRead { + contract_state_tree_height: e, + user_id: e, + contract_id: e, + offset: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::StorageReadRange { + contract_state_tree_height: e, + user_id: e, + contract_id: e, + offset: e, + length: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| CheckedIntrinsicExprNode::StorageWrite { offset: e, value: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::StorageWriteRange { offset: e, values: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::Hash { data: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::Keccak256 { data: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| CheckedIntrinsicExprNode::HashTwoToOne { left: e, right: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::InvokeSync { + contract_id: e, + method_id: e, + inputs: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::InvokeDeferred { + contract_id: e, + method_id: e, + inputs: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::Secp256k1Verify { + pub_key: e, + msg: e, + sig: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| CheckedIntrinsicExprNode::SumBits { bits: e, type_id: TypeId(t), location: l }), + Box::new(|t, l| { + CheckedIntrinsicExprNode::SplitBits { + target: e, + num_bits: e, + type_id: TypeId(t), + location: l, + } + }), + Box::new(|t, l| CheckedIntrinsicExprNode::Emit { event_data: e, type_id: TypeId(t), location: l }), + ]; + + for (i, make) in makers.into_iter().enumerate() { + let type_id = TypeId(1000 + i); + let location = loc(2000 + i); + let node = CheckedExprNode::::Intrinsic(make(1000 + i, location)); + assert_eq!(node.ty(), type_id, "ty() for intrinsic case {i}"); + assert_eq!(node.location(), location, "location() for intrinsic case {i}"); + assert_eq!(node.node_type(), NodeType::IntrinsicExpr, "node_type() for intrinsic case {i}"); + } + } + + #[test] + fn value_accessors_round_trip() { + let cases: Vec<(CheckedExprNode, TypeId, Location)> = vec![ + (CheckedExprNode::Value(CheckedValueNode::Felt(7, loc(10))), FELT_TYPE, loc(10)), + (CheckedExprNode::Value(CheckedValueNode::Bool(1, loc(11))), BOOL_TYPE, loc(11)), + (CheckedExprNode::Value(CheckedValueNode::U32(9, loc(12))), U32_TYPE, loc(12)), + ( + CheckedExprNode::Value(CheckedValueNode::Array(TypeId(20), vec![ExprId(0)], loc(13))), + TypeId(20), + loc(13), + ), + ( + CheckedExprNode::Value(CheckedValueNode::ArrayRepeat(TypeId(21), ExprId(0), ConstValue::Felt(1), loc(14))), + TypeId(21), + loc(14), + ), + ( + CheckedExprNode::Value(CheckedValueNode::Struct(TypeId(22), IndexMap::new(), loc(15))), + TypeId(22), + loc(15), + ), + ( + CheckedExprNode::Value(CheckedValueNode::Tuple(TypeId(23), vec![(TypeId(1), ExprId(0))], loc(16))), + TypeId(23), + loc(16), + ), + ]; + for (node, type_id, location) in cases { + assert_eq!(node.ty(), type_id); + assert_eq!(node.location(), location, "Value location for {type_id:?}"); + assert_eq!(node.node_type(), NodeType::ValueExpr); + } + + // `Type` values are unevaluated type references and deliberately have + // no location β€” only the type accessor is meaningful for them. + let type_value = CheckedExprNode::::Value(CheckedValueNode::Type(TypeId(24))); + assert_eq!(type_value.ty(), TypeId(24)); + assert_eq!(type_value.node_type(), NodeType::ValueExpr); + } + + #[test] + fn other_expr_accessors_round_trip() { + let e = ExprId(0); + let cases: Vec<(CheckedExprNode, TypeId, Location, NodeType)> = vec![ + ( + CheckedExprNode::Path(CheckedPathNode { + variable: None, + root: None, + target: None, + origin_path: empty_path_node(), + type_id: TypeId(30), + trait_ty: None, + location: loc(30), + }), + TypeId(30), + loc(30), + NodeType::PathExpr, + ), + ( + CheckedExprNode::Binary(CheckedBinaryNode { + lhs: e, + operator: psy_ast::BinaryOperator::Add, + rhs: e, + type_id: TypeId(31), + location: loc(31), + }), + TypeId(31), + loc(31), + NodeType::BinaryExpr, + ), + ( + CheckedExprNode::Unary(CheckedUnaryNode { + operator: psy_ast::UnaryOperator::Not, + rhs: e, + type_id: TypeId(32), + location: loc(32), + }), + TypeId(32), + loc(32), + NodeType::UnaryExpr, + ), + ( + CheckedExprNode::Cast(CheckedCastNode { + value: e, + target_type: TypeId(33), + location: loc(33), + }), + TypeId(33), + loc(33), + NodeType::CastExpr, + ), + ( + CheckedExprNode::Call(CheckedCallNode { + callee: e, + generic_parameters: vec![], + args: vec![], + type_id: TypeId(34), + location: loc(34), + }), + TypeId(34), + loc(34), + NodeType::CallExpr, + ), + ( + CheckedExprNode::MemberCall(CheckedMemberCallNode { + callee: e, + receiver: e, + generic_parameters: vec![], + args: vec![], + type_id: TypeId(35), + location: loc(35), + }), + TypeId(35), + loc(35), + NodeType::MemberCallExpr, + ), + ( + CheckedExprNode::IndexAccess(CheckedIndexAccessNode { + target: e, + index: e, + type_id: TypeId(36), + location: loc(36), + }), + TypeId(36), + loc(36), + NodeType::IndexAccessExpr, + ), + ( + CheckedExprNode::MemberAccess(CheckedMemberAccessNode { + target: e, + field: Identifier { id: IdentId(0), location: loc(1) }, + type_id: TypeId(37), + location: loc(37), + }), + TypeId(37), + loc(37), + NodeType::MemberAccessExpr, + ), + ( + CheckedExprNode::TupleAccess(CheckedTupleAccessNode { + target: e, + index: 0, + type_id: TypeId(38), + location: loc(38), + }), + TypeId(38), + loc(38), + NodeType::TupleAccessExpr, + ), + ( + CheckedExprNode::LambdaFunction(CheckedLambdaFunctionNode { + name: Identifier { id: IdentId(0), location: loc(1) }, + parameters: vec![], + body: e, + return_type: TypeId(39), + return_type_path: None, + scope_id: ScopeId(0), + type_id: TypeId(39), + location: loc(39), + }), + TypeId(39), + loc(39), + NodeType::LambdaFunctionExpr, + ), + ( + CheckedExprNode::BlockExpr(CheckedBlockExprNode { + stmts: vec![], + expr: None, + type_id: TypeId(40), + scope_id: ScopeId(0), + location: loc(40), + }), + TypeId(40), + loc(40), + NodeType::BlockExpr, + ), + ( + CheckedExprNode::IfExpr(CheckedIfExprNode { + if_branch: CheckedCase { + predicate: e, + type_id: TypeId(1), + body: e, + }, + elseif_branches: vec![], + else_branch: None, + type_id: TypeId(41), + location: loc(41), + }), + TypeId(41), + loc(41), + NodeType::IfExpr, + ), + ( + CheckedExprNode::Match(CheckedMatchNode { + value: e, + cases: vec![CheckedMatchArm { + pattern: None, + body: e, + location: loc(1), + }], + type_id: TypeId(42), + scope_id: ScopeId(0), + location: loc(42), + }), + TypeId(42), + loc(42), + NodeType::MatchExpr, + ), + ]; + for (node, type_id, location, kind) in cases { + assert_eq!(node.ty(), type_id, "ty() for {kind:?}"); + assert_eq!(node.location(), location, "location() for {kind:?}"); + assert_eq!(node.node_type(), kind); + } + } +} diff --git a/psy-sema/src/infer.rs b/psy-sema/src/infer.rs index b36df08bc..e371a61be 100644 --- a/psy-sema/src/infer.rs +++ b/psy-sema/src/infer.rs @@ -292,3 +292,42 @@ impl + ContextFelt, C> TypeChecker { } } } + +#[cfg(test)] +mod tests { + use psy_vm::dpn::ops::sym_felt::SymFeltRef; + + use super::*; + + #[test] + fn equations_track_scopes_and_contexts() { + let mut infcx: InferCtxt = InferCtxt::new(); + assert!(!infcx.has_equations(), "a fresh context has no equations"); + assert!(infcx.get_equations().is_empty()); + assert_eq!(infcx.probe(TypeId(0)), None, "nothing is bound yet"); + + infcx.equate(TypeId(0), TypeId(1)); + assert!(infcx.has_equations()); + assert_eq!(infcx.get_equations().get(&TypeId(0)), Some(&TypeId(1))); + assert_eq!(infcx.probe(TypeId(0)), Some(TypeId(1)), "innermost binding wins"); + assert_eq!(infcx.probe(TypeId(1)), None, "reverse direction stays unbound"); + + // A nested scope shadows the outer binding; leaving the scope + // restores it. + infcx.enter_scope(); + infcx.equate(TypeId(0), TypeId(2)); + assert_eq!(infcx.probe(TypeId(0)), Some(TypeId(2))); + infcx.exit_scope(); + assert_eq!(infcx.probe(TypeId(0)), Some(TypeId(1))); + + // A nested context hides every outer equation; leaving the context + // brings them back. + infcx.enter_context(); + assert!(!infcx.has_equations(), "a fresh context starts empty"); + assert_eq!(infcx.probe(TypeId(0)), None); + infcx.equate(TypeId(0), TypeId(3)); + assert_eq!(infcx.probe(TypeId(0)), Some(TypeId(3))); + infcx.exit_context(); + assert_eq!(infcx.probe(TypeId(0)), Some(TypeId(1))); + } +} diff --git a/psy-sema/src/reference.rs b/psy-sema/src/reference.rs index 2bd7abfe9..4dc531481 100644 --- a/psy-sema/src/reference.rs +++ b/psy-sema/src/reference.rs @@ -285,3 +285,95 @@ pub fn offset_from_position(source: &str, position: &Position) -> usize { offset } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use psy_ast::{Location, Position, Program}; + use psy_common::FileId; + use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; + + use super::{line_and_column_from_offset, offset_from_position, LocationIndices, ReferenceId}; + use crate::TypeCheckerVisitorContext; + + #[test] + fn line_and_column_conversion_handles_boundaries_and_newlines() { + let source = "ab\ncd"; + assert_eq!(line_and_column_from_offset(source, 0), (1, 1)); + assert_eq!(line_and_column_from_offset(source, 1), (1, 2)); + assert_eq!(line_and_column_from_offset(source, 2), (2, 0)); + assert_eq!(line_and_column_from_offset(source, source.len()), (2, 2)); + assert_eq!(line_and_column_from_offset(source, source.len() + 10), (2, 2)); + } + + #[test] + fn offset_conversion_clamps_columns_and_out_of_range_lines() { + let source = "ab\ncd"; + let position = |line, column| Position { + file_id: FileId(0), + line, + column, + }; + assert_eq!(offset_from_position(source, &position(0, 0)), 0); + assert_eq!(offset_from_position(source, &position(0, 1)), 1); + assert_eq!(offset_from_position(source, &position(0, 99)), 2); + assert_eq!(offset_from_position(source, &position(1, 1)), 4); + assert_eq!(offset_from_position(source, &position(9, 0)), source.len()); + } + + #[test] + fn location_indices_skip_empty_spans_and_support_end_cursor_tolerance() { + let mut indices = LocationIndices::default(); + let first = petgraph::graph::NodeIndex::new(1); + let second = petgraph::graph::NodeIndex::new(2); + indices.insert_span(Location::new(FileId(0), 4, 4), first); + assert!(indices.resolve_node_at(Location::new(FileId(0), 4, 4)).is_none()); + + indices.insert_span(Location::new(FileId(0), 4, 7), first); + indices.insert_span(Location::new(FileId(0), 10, 12), second); + assert_eq!(indices.resolve_node_at(Location::new(FileId(0), 4, 4)), Some(first)); + assert_eq!(indices.resolve_node_at(Location::new(FileId(0), 7, 7)), Some(first)); + assert_eq!(indices.resolve_node_at(Location::new(FileId(0), 10, 10)), Some(second)); + assert!(indices.resolve_node_at(Location::new(FileId(1), 4, 4)).is_none()); + } + + #[test] + fn context_position_helpers_round_trip_file_locations() { + let mut program = Program::::new(); + let file_id = program.file_resolver.add_file(PathBuf::from("source.psy"), "ab\ncd"); + let ctx = TypeCheckerVisitorContext::::new(program); + let location = Location::new(file_id, 3, 4); + let (start, end) = ctx.location_to_position(location).unwrap(); + assert_eq!((start.line, start.column), (2, 1)); + assert_eq!((end.line, end.column), (2, 2)); + assert_eq!(ctx.position_to_location(Position { file_id, line: 0, column: 0 }).unwrap().start, 0); + assert_eq!(ctx.position_to_file_path(Position { file_id, line: 0, column: 0 }).unwrap(), "source.psy:0:0"); + assert!(ctx.position_to_location(Position { file_id: FileId(99), line: 0, column: 0 }).is_none()); + } + + #[test] + fn reference_graph_supports_lookup_definition_and_filtered_reference_lists() { + let mut ctx = TypeCheckerVisitorContext::::new(Program::new()); + let target = Location::new(FileId(0), 0, 3); + let use_site = Location::new(FileId(0), 5, 8); + let self_use = Location::new(FileId(0), 10, 13); + let referenced = ReferenceId::Reference(target, false); + + ctx.add_reference(referenced, use_site, false); + ctx.add_reference(referenced, self_use, true); + + assert_eq!(ctx.goto_definition(use_site), Some(target)); + assert_eq!(ctx.goto_definition(Location::new(FileId(0), 8, 8)), Some(target)); + assert!(ctx.find_all_references(Location::new(FileId(0), 99, 99), true, false).is_none()); + + let all = ctx.find_all_references(use_site, true, false).unwrap(); + assert_eq!(all, vec![target, use_site]); + let without_target = ctx.find_all_references(use_site, false, false).unwrap(); + assert_eq!(without_target, vec![use_site]); + let with_self = ctx.find_all_references(use_site, true, true).unwrap(); + assert_eq!(with_self, vec![target, self_use, use_site]); + assert!(ReferenceId::Reference(self_use, true).is_self_type_name()); + assert!(!ReferenceId::Reference(use_site, false).is_self_type_name()); + } +} diff --git a/psy-sema/src/stmt/mod.rs b/psy-sema/src/stmt/mod.rs index 66a9da0e0..2cd290a9d 100644 --- a/psy-sema/src/stmt/mod.rs +++ b/psy-sema/src/stmt/mod.rs @@ -80,3 +80,117 @@ impl From for CheckedStmtNode { todo!() } } + +#[cfg(test)] +mod tests { + use psy_ast::{ + AssignmentOperator, Comment, DefId, ExprId, IdentId, Identifier, Location, NodeInfo, NodeType, TypeQualifier, + }; + + use super::*; + use crate::{ScopeId, TypeId}; + + #[test] + fn checked_statements_report_their_node_types() { + let location = Location::default(); + + let while_node = CheckedWhileNode { + predicate: ExprId(0), + type_id: TypeId(0), + body: ExprId(1), + comments: vec![], + location, + }; + assert_eq!(CheckedStmtNode::While(while_node.clone()).node_type(), NodeType::WhileStmt); + assert_eq!(while_node.node_type(), NodeType::WhileStmt); + + let for_node = CheckedForNode { + variable: Identifier::new(IdentId(0), location), + start: ExprId(0), + end: ExprId(1), + body: ExprId(2), + scope_id: ScopeId(0), + comments: vec![], + location, + }; + assert_eq!(CheckedStmtNode::For(for_node.clone()).node_type(), NodeType::ForStmt); + assert_eq!(for_node.node_type(), NodeType::ForStmt); + + let assignment = CheckedAssignmentNode { + target: ExprId(0), + operator: AssignmentOperator::AddAssign, + value: ExprId(1), + type_id: TypeId(0), + comments: vec![], + location, + }; + assert_eq!(CheckedStmtNode::Assignment(assignment.clone()).node_type(), NodeType::AssignmentStmt); + assert_eq!(assignment.node_type(), NodeType::AssignmentStmt); + + let variable = CheckedVariableNode { + name: Identifier::new(IdentId(1), location), + ty: TypeId(0), + qualifier: TypeQualifier::new(true, location), + value: ExprId(1), + scope_id: ScopeId(0), + comments: vec![], + location, + }; + assert_eq!(CheckedStmtNode::Variable(variable.clone()).node_type(), NodeType::VariableStmt); + assert_eq!(variable.node_type(), NodeType::VariableStmt); + + let ret = CheckedReturnNode { + ret: Some(ExprId(3)), + comments: vec![], + location, + }; + assert_eq!(CheckedStmtNode::Return(ret.clone()).node_type(), NodeType::ReturnStmt); + assert_eq!(ret.node_type(), NodeType::ReturnStmt); + + let assert_stmt = CheckedIntrinsicStmtNode::Assert { + left: ExprId(0), + message: Some("boom".to_string()), + comments: vec![Comment::new_line("note".to_string(), location)], + location, + }; + assert_eq!(CheckedStmtNode::Intrinsic(assert_stmt.clone()).node_type(), NodeType::IntrinsicStmt); + assert_eq!(assert_stmt.node_type(), NodeType::IntrinsicStmt); + + let assert_eq_stmt = CheckedIntrinsicStmtNode::AssertEq { + left: ExprId(0), + right: ExprId(1), + message: None, + comments: vec![], + location, + }; + assert_eq!(assert_eq_stmt.node_type(), NodeType::IntrinsicStmt); + + let clear = CheckedIntrinsicStmtNode::ClearEntireTree { + comments: vec![], + location, + }; + assert_eq!(clear.node_type(), NodeType::IntrinsicStmt); + + assert_eq!(CheckedStmtNode::Definition(DefId(0)).node_type(), NodeType::DefinitionStmt); + assert_eq!(CheckedStmtNode::Expression(ExprId(4)).node_type(), NodeType::ExpressionStmt); + } + + #[test] + fn checked_statements_expose_expression_and_definition_handles() { + let expression: CheckedStmtNode = ExprId(7).into(); + assert_eq!(expression.as_expression(), Some(&ExprId(7))); + assert!(expression.as_definition().is_none()); + + let definition: CheckedStmtNode = DefId(3).into(); + assert_eq!(definition.as_definition(), Some(&DefId(3))); + assert!(definition.as_expression().is_none()); + + let other = CheckedStmtNode::Return(CheckedReturnNode { + ret: None, + comments: vec![], + location: Location::default(), + }); + assert!(other.as_expression().is_none()); + assert!(other.as_definition().is_none()); + } +} diff --git a/psy-sema/src/symbol_table.rs b/psy-sema/src/symbol_table.rs index 7cb76fd6a..1a4d72a3f 100644 --- a/psy-sema/src/symbol_table.rs +++ b/psy-sema/src/symbol_table.rs @@ -523,3 +523,182 @@ impl + ContextFelt> SymbolTable { && self.module_stack.is_empty() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CheckedArrayNode, CheckedConstNode, CheckedStructNode}; + use psy_vm::dpn::ops::sym_felt::SymFeltRef; + + #[test] + fn frame_scopes_shadow_and_restore_values() { + let root = ScopeId(0); + let child = ScopeId(1); + let key = IdentId::TYPE_FELT; + let mut frame = Frame::new(root); + frame.set_value(root, key, 1u32); + assert_eq!(frame.get_value(root, key), Some(&1)); + frame.push_scope(child); + assert_eq!(frame.get_value(child, key), None); + frame.set_value(child, key, 2u32); + assert_eq!(frame.get_value(child, key), Some(&2)); + frame.pop_scope(); + assert_eq!(frame.get_value(root, key), Some(&1)); + frame.set_value(ScopeId(99), key, 3u32); + assert_eq!(frame.get_value(root, key), Some(&1)); + } + + #[test] + fn scope_type_operations_find_duplicates_and_reuse_existing_types() { + let mut table = SymbolTable::::new(); + assert!(table.is_empty()); + table.scopes.push(Scope::new(ScopeKind::Module, None)); + table.enter_scope(ScopeId::root()); + + let name = IdentId::TYPE_FELT; + let first = table.add_type(None, name, Type::Felt).unwrap(); + assert_eq!(table.get_type_id(None, name), Some(first)); + assert!(table.add_type(None, name, Type::Bool).is_err()); + assert_eq!(table.get_or_add_type(None, name, Type::Bool).unwrap(), first); + table.modify_type(first, |ty| { + *ty = Type::Bool; + Ok(()) + }) + .unwrap(); + assert!(matches!(table[first], Type::Bool)); + assert!(!table.is_empty()); + table.exit_scope(); + } + + fn ident(id: usize) -> Identifier { + Identifier::new(IdentId(id), Location::default()) + } + + #[test] + fn frame_get_value_misses_unknown_scopes() { + let frame = Frame::::new(ScopeId(0)); + assert_eq!(frame.get_value(ScopeId(7), IdentId::TYPE_FELT), None); + } + + #[test] + fn module_construction_and_accessors_round_trip() { + let mut table = SymbolTable::::new(); + assert_eq!(table.current_module_id(), None); + + let module = Module::new( + ident(1), + ModuleId(0), + ScopeId(0), + FileId(0), + None, + Visibility::Private, + Location::default(), + ); + assert_eq!(module.id, ModuleId(0)); + assert!(matches!(module.kind, ModuleKind::File { .. })); + table.modules.push(module); + + table.scopes.push(Scope::new(ScopeKind::Module, None)); + table.enter_module(ModuleId(0)); + assert_eq!(table.current_module_id(), Some(ModuleId(0))); + assert_eq!(table.current_scope_id(), Some(ScopeId(0))); + + table.start_function(); + assert_eq!(table.current_scope_id(), Some(ScopeId(1))); + table.end_function(); + assert_eq!(table.current_scope_id(), Some(ScopeId(0))); + + assert_eq!(table.modules().len(), 1); + assert!(table.types().is_empty()); + table.exit_module(); + } + + #[test] + fn module_visibility_requires_public_or_family_relation() { + let mut table = SymbolTable::::new(); + let mk = |id: usize, parent: Option, visibility: Visibility| { + Module::new(ident(id), ModuleId(id), ScopeId(id), FileId(0), parent, visibility, Location::default()) + }; + // 0: private root, 1: private child of 0, 2: private child of 0, + // 3: private root unrelated to 0, 4: public root. + for (id, parent, vis) in [ + (0, None, Visibility::Private), + (1, Some(ModuleId(0)), Visibility::Private), + (2, Some(ModuleId(0)), Visibility::Private), + (3, None, Visibility::Private), + (4, None, Visibility::Public), + ] { + table.modules.push(mk(id, parent, vis)); + table.scopes.push(Scope::new(ScopeKind::Module, None)); + } + + assert!(table.is_module_visible(ModuleId(4)), "public modules are always visible"); + table.enter_module(ModuleId(1)); + assert!(table.is_module_visible(ModuleId(2)), "siblings sharing a parent are visible"); + assert!(table.is_module_visible(ModuleId(1)), "a module sees itself via the shared-parent arm"); + assert!( + !table.is_module_visible(ModuleId(3)), + "a private module unrelated to the current one is invisible" + ); + table.exit_module(); + } + + #[test] + fn symbol_table_display_renders_scopes_types_and_modules() { + let mut table = SymbolTable::::new(); + table.scopes.push(Scope::new(ScopeKind::Module, None)); + table.enter_scope(ScopeId::root()); + table[ScopeId::root()].types.insert(TypeKey::from(IdentId(4)), TypeId(0)); + table[ScopeId::root()].variables.insert(IdentId(5), VarId(0)); + + let felt = table.create_type(Type::Felt).unwrap(); + for ty in [ + Type::Bool, + Type::U32, + Type::Unknown, + Type::Tuple(vec![felt]), + Type::Array(CheckedArrayNode { inner_ty: felt, size_ty: felt, scope_id: ScopeId::root() }), + Type::Const(CheckedConstNode { + name: None, + ty: felt, + value: ConstId(0), + visibility: Visibility::Private, + scope_id: ScopeId::root(), + }), + Type::Struct(CheckedStructNode { + name: ident(6), + generic_parameters: vec![], + fields: IndexMap::new(), + scope_id: ScopeId::root(), + attrs: vec![], + visibility: Visibility::Private, + comments: vec![], + location: Location::default(), + type_id: TypeId(0), + }), + Type::TypeVariable(CheckedGenericParameter::new(IdentId(9), vec![], ScopeId::root(), Location::default())), + ] { + table.create_type(ty).unwrap(); + } + table.modules.push(Module::new( + ident(1), + ModuleId(0), + ScopeId::root(), + FileId(0), + None, + Visibility::Private, + Location::default(), + )); + + let rendered = table.to_string(); + assert!(rendered.contains("ScopeId(0)"), "{rendered}"); + assert!(rendered.contains("variables:"), "{rendered}"); + assert!(rendered.contains("Array("), "{rendered}"); + assert!(rendered.contains("Struct("), "{rendered}"); + assert!(rendered.contains("TypeVariable("), "{rendered}"); + assert!(rendered.contains("Tuple("), "{rendered}"); + assert!(rendered.contains("Const("), "{rendered}"); + assert!(rendered.contains("unknown"), "{rendered}"); + assert!(rendered.contains("ModuleId(0)"), "{rendered}"); + } +} diff --git a/psy-sema/src/type.rs b/psy-sema/src/type.rs index b4ea33fa5..6c78edbfc 100644 --- a/psy-sema/src/type.rs +++ b/psy-sema/src/type.rs @@ -337,3 +337,362 @@ impl Type { } } } + +#[cfg(test)] +mod tests { + use indexmap::IndexMap; + + use psy_ast::{Comment, DefId, ExprId, IdentId, Location, Visibility}; + + use super::*; + use crate::{CheckedStructField, ConstId}; + + fn ident(id: usize) -> Identifier { + Identifier::new(IdentId(id), Location::default()) + } + + fn located(offset: usize) -> Location { + Location::new(psy_common::FileId(0), offset, offset + 1) + } + + fn array() -> CheckedArrayNode { + CheckedArrayNode { inner_ty: TypeId(5), size_ty: TypeId(6), scope_id: ScopeId(1) } + } + + fn enumeration() -> CheckedEnumNode { + CheckedEnumNode { + name: ident(20), + generic_parameters: vec![TypeId(7)], + variants: vec![crate::CheckedEnumVariant::Basic(ident(21), TypeId(5))], + scope_id: ScopeId(2), + visibility: Visibility::Private, + comments: vec![], + location: located(10), + } + } + + fn function() -> CheckedFunctionNode { + CheckedFunctionNode { + name: ident(22), + parameters: vec![CheckedFunctionParameter { + name: ident(23), + qualifier: psy_ast::TypeQualifier::new(false, Location::default()), + ty: TypeId(5), + path: None, + location: Location::default(), + }], + generic_parameters: vec![TypeId(8)], + body: Some(ExprId(3)), + qualifier: psy_ast::Qualifier::default(), + return_type: TypeId(9), + return_type_path: None, + scope_id: ScopeId(3), + visibility: Visibility::Private, + attrs: vec![], + type_id: TypeId(40), + comments: vec![], + location: located(20), + } + } + + fn lambda() -> CheckedLambdaFunctionNode { + CheckedLambdaFunctionNode { + name: ident(24), + parameters: vec![CheckedFunctionParameter { + name: ident(25), + qualifier: psy_ast::TypeQualifier::new(false, Location::default()), + ty: TypeId(5), + path: None, + location: Location::default(), + }], + body: ExprId(4), + return_type: TypeId(9), + return_type_path: None, + scope_id: ScopeId(4), + type_id: TypeId(41), + location: located(30), + } + } + + fn trait_node() -> CheckedTraitNode { + CheckedTraitNode { + name: ident(26), + associated_types: IndexMap::new(), + generic_parameters: vec![TypeId(10)], + body: vec![DefId(1)], + unchecked_body: vec![], + scope_id: ScopeId(5), + visibility: Visibility::Private, + comments: vec![], + location: located(40), + type_id: TypeId(42), + } + } + + fn konst(named: bool) -> CheckedConstNode { + CheckedConstNode { + name: named.then(|| ident(27)), + ty: TypeId(5), + value: ConstId(9), + visibility: Visibility::Private, + scope_id: ScopeId(6), + } + } + + fn struct_node() -> crate::CheckedStructNode { + let mut fields = IndexMap::new(); + fields.insert( + ident(28), + CheckedStructField::new(TypeId(5), vec![], Visibility::Public, vec![], located(50)), + ); + crate::CheckedStructNode { + name: ident(29), + generic_parameters: vec![TypeId(11)], + fields, + scope_id: ScopeId(7), + attrs: vec![], + visibility: Visibility::Private, + comments: vec![Comment::new_line("doc".to_string(), Location::default())], + location: located(60), + type_id: TypeId(43), + } + } + + #[test] + fn keys_describe_every_composite_shape() { + let key = Type::Array(array()).key(); + assert_eq!(key.name, Some(IdentId::TYPE_ARRAY)); + assert_eq!(key.generic_parameters, vec![TypeId(5), TypeId(6)]); + + let key = Type::Struct(struct_node()).key(); + assert_eq!(key.name, Some(IdentId(29))); + assert_eq!(key.generic_parameters, vec![TypeId(11)]); + + let key = Type::Enum(enumeration()).key(); + assert_eq!(key.name, Some(IdentId(20))); + assert_eq!(key.generic_parameters, vec![TypeId(7)]); + + let key = Type::Tuple(vec![TypeId(5), TypeId(6)]).key(); + assert_eq!(key.name, Some(IdentId::TYPE_TUPLE)); + assert_eq!(key.parameters, vec![TypeId(5), TypeId(6)]); + + let key = Type::Function(function()).key(); + assert_eq!(key.name, Some(IdentId(22))); + assert_eq!(key.generic_parameters, vec![TypeId(8)]); + assert_eq!(key.parameters, vec![TypeId(5)]); + assert_eq!(key.return_type, Some(TypeId(9))); + + let key = Type::LambdaFunction(lambda()).key(); + assert_eq!(key.name, Some(IdentId(24))); + assert_eq!(key.parameters, vec![TypeId(5)]); + assert_eq!(key.return_type, Some(TypeId(9))); + + // A VOID return type is normalized away in the key of a bare signature. + let key = Type::FunctionSignature(CheckedFunctionSignature { + parameters: vec![TypeId(5)], + return_type: VOID_TYPE, + }) + .key(); + assert_eq!(key.name, None); + assert_eq!(key.return_type, None); + + // Any other return type is preserved. + let key = Type::FunctionSignature(CheckedFunctionSignature { + parameters: vec![], + return_type: TypeId(12), + }) + .key(); + assert_eq!(key.return_type, Some(TypeId(12))); + + let key = Type::Trait(trait_node()).key(); + assert_eq!(key.name, Some(IdentId(26))); + assert_eq!(key.generic_parameters, vec![TypeId(10)]); + + let key = Type::Const(konst(true)).key(); + assert_eq!(key.name, Some(IdentId(27))); + + let key = Type::Const(konst(false)).key(); + assert_eq!(key.name, None); + } + + #[test] + #[should_panic(expected = "Type::key called on TypeVariable type")] + fn key_panics_for_type_variables() { + let variable = CheckedGenericParameter::new(IdentId(30), vec![], ScopeId(8), Location::default()); + let _ = Type::TypeVariable(variable).key(); + } + + fn empty_signature() -> CheckedFunctionSignature { + CheckedFunctionSignature { parameters: vec![], return_type: VOID_TYPE } + } + + #[test] + fn scope_ids_come_from_the_node_for_composites() { + // The primitive scope global may not have been populated yet when this test runs first. + #[allow(static_mut_refs)] + unsafe { + if crate::STD_PRIMITIVE_SCOPE_ID.get().is_none() { + crate::STD_PRIMITIVE_SCOPE_ID.set(ScopeId(99)).unwrap(); + } + } + + assert_eq!(Type::Array(array()).scope_id(), ScopeId(1)); + assert_eq!(Type::Tuple(vec![]).scope_id(), ScopeId::primitive()); + assert_eq!(Type::Struct(struct_node()).scope_id(), ScopeId(7)); + assert_eq!(Type::Enum(enumeration()).scope_id(), ScopeId(2)); + assert_eq!(Type::Function(function()).scope_id(), ScopeId(3)); + assert_eq!(Type::Trait(trait_node()).scope_id(), ScopeId(5)); + assert_eq!(Type::Const(konst(true)).scope_id(), ScopeId(6)); + assert_eq!(Type::LambdaFunction(lambda()).scope_id(), ScopeId(4)); + + let variable = CheckedGenericParameter::new(IdentId(31), vec![], ScopeId(9), Location::default()); + assert_eq!(Type::TypeVariable(variable).scope_id(), ScopeId(9)); + + for primitive in [Type::Felt, Type::Bool, Type::U32] { + assert_eq!(primitive.scope_id(), ScopeId::primitive()); + } + } + + #[test] + #[should_panic(expected = "Type::scope_id called on non-composite type")] + fn scope_id_panics_for_bare_signatures() { + let _ = Type::FunctionSignature(empty_signature()).scope_id(); + } + + #[test] + fn visibility_defaults_to_public_for_primitive_kinds() { + assert_eq!(Type::Struct(struct_node()).visibility(), Visibility::Private); + assert_eq!(Type::Enum(enumeration()).visibility(), Visibility::Private); + assert_eq!(Type::Function(function()).visibility(), Visibility::Private); + assert_eq!(Type::Trait(trait_node()).visibility(), Visibility::Private); + assert_eq!(Type::Const(konst(true)).visibility(), Visibility::Private); + assert_eq!(Type::Felt.visibility(), Visibility::Public); + assert_eq!(Type::Tuple(vec![]).visibility(), Visibility::Public); + } + + #[test] + fn names_identify_primitives_and_composites() { + assert_eq!(Type::Unknown.name(), IdentId::TYPE_UNKNOWN); + assert_eq!(Type::VOID.name(), IdentId::TYPE_VOID); + assert_eq!(Type::Felt.name(), IdentId::TYPE_FELT); + assert_eq!(Type::Bool.name(), IdentId::TYPE_BOOL); + assert_eq!(Type::U32.name(), IdentId::TYPE_U32); + assert_eq!(Type::Array(array()).name(), IdentId::TYPE_ARRAY); + assert_eq!(Type::Tuple(vec![]).name(), IdentId::TYPE_TUPLE); + assert_eq!(Type::Struct(struct_node()).name(), IdentId(29)); + assert_eq!(Type::Enum(enumeration()).name(), IdentId(20)); + assert_eq!(Type::Function(function()).name(), IdentId(22)); + assert_eq!(Type::Trait(trait_node()).name(), IdentId(26)); + assert_eq!(Type::Const(konst(true)).name(), IdentId(27)); + assert_eq!(Type::Const(konst(false)).name(), IdentId::TYPE_UNKNOWN); + assert_eq!(Type::LambdaFunction(lambda()).name(), IdentId(24)); + assert_eq!(Type::FunctionSignature(empty_signature()).name(), IdentId::TYPE_UNKNOWN); + + let variable = CheckedGenericParameter::new(IdentId(32), vec![], ScopeId(8), Location::default()); + assert_eq!(Type::TypeVariable(variable).name(), IdentId(32)); + } + + #[test] + fn bodies_belong_to_functions_and_lambdas() { + assert_eq!(Type::Function(function()).body(), Some(ExprId(3))); + assert_eq!(Type::LambdaFunction(lambda()).body(), Some(ExprId(4))); + assert_eq!(Type::Felt.body(), None); + assert_eq!(Type::Struct(struct_node()).body(), None); + } + + #[test] + fn generic_parameters_cover_arrays_structs_enums_functions_and_traits() { + assert_eq!(Type::Array(array()).generic_parameters(), vec![TypeId(5), TypeId(6)]); + assert_eq!(Type::Struct(struct_node()).generic_parameters(), vec![TypeId(11)]); + assert_eq!(Type::Enum(enumeration()).generic_parameters(), vec![TypeId(7)]); + assert_eq!(Type::Function(function()).generic_parameters(), vec![TypeId(8)]); + assert_eq!(Type::Trait(trait_node()).generic_parameters(), vec![TypeId(10)]); + assert_eq!(Type::Felt.generic_parameters(), vec![]); + } + + #[test] + fn parameters_are_available_for_callables() { + let params = Type::Function(function()).parameters(); + assert_eq!(params.len(), 1); + assert_eq!(params[0].ty, TypeId(5)); + + let params = Type::LambdaFunction(lambda()).parameters(); + assert_eq!(params.len(), 1); + } + + #[test] + fn signatures_resolve_for_callables_and_fail_for_others() { + assert_eq!(Type::Function(function()).signature().parameters, vec![TypeId(5)]); + assert_eq!(Type::LambdaFunction(lambda()).signature().return_type, TypeId(9)); + + let signature = CheckedFunctionSignature { parameters: vec![TypeId(6)], return_type: TypeId(9) }; + assert_eq!(Type::FunctionSignature(signature.clone()).try_signature(), Some(signature)); + assert_eq!(Type::Felt.try_signature(), None); + } + + #[test] + fn kinds_mirror_every_variant() { + assert_eq!(Type::Unknown.kind(), TypeKind::Unknown); + assert_eq!(Type::VOID.kind(), TypeKind::VOID); + assert_eq!(Type::Felt.kind(), TypeKind::Felt); + assert_eq!(Type::Bool.kind(), TypeKind::Bool); + assert_eq!(Type::U32.kind(), TypeKind::U32); + assert_eq!(Type::Array(array()).kind(), TypeKind::Array); + assert_eq!(Type::Struct(struct_node()).kind(), TypeKind::Struct); + assert_eq!(Type::Enum(enumeration()).kind(), TypeKind::Enum); + assert_eq!(Type::Tuple(vec![]).kind(), TypeKind::Tuple); + assert_eq!(Type::Function(function()).kind(), TypeKind::Function); + assert_eq!(Type::Trait(trait_node()).kind(), TypeKind::Trait); + assert_eq!(Type::Const(konst(true)).kind(), TypeKind::Const); + assert_eq!(Type::LambdaFunction(lambda()).kind(), TypeKind::LambdaFunction); + assert_eq!(Type::FunctionSignature(empty_signature()).kind(), TypeKind::FunctionSignature); + assert_eq!( + Type::TypeVariable(CheckedGenericParameter::new(IdentId(33), vec![], ScopeId(8), Location::default())).kind(), + TypeKind::TypeVariable + ); + } + + #[test] + fn locations_default_for_primitives_and_come_from_nodes_for_composites() { + assert_eq!(Type::Felt.location(), Location::default()); + assert_eq!(Type::Array(array()).location(), Location::default()); + assert_eq!(Type::Tuple(vec![]).location(), Location::default()); + assert_eq!(Type::Const(konst(true)).location(), Location::default()); + assert_eq!( + Type::FunctionSignature(empty_signature()).location(), + Location::default() + ); + + assert_eq!(Type::Struct(struct_node()).location(), located(60)); + assert_eq!(Type::Enum(enumeration()).location(), located(10)); + assert_eq!(Type::Function(function()).location(), located(20)); + assert_eq!(Type::Trait(trait_node()).location(), located(40)); + assert_eq!(Type::LambdaFunction(lambda()).location(), located(30)); + + let variable = CheckedGenericParameter::new(IdentId(34), vec![], ScopeId(8), located(70)); + assert_eq!(Type::TypeVariable(variable).location(), located(70)); + } + + #[test] + fn type_ids_below_ten_are_std_types() { + assert!(TypeId(0).is_std_type()); + assert!(TypeId(9).is_std_type()); + assert!(!TypeId(10).is_std_type()); + } + + #[test] + fn type_keys_convert_from_idents_and_consts() { + let from_id = TypeKey::from(IdentId(5)); + assert_eq!(from_id.name, Some(IdentId(5))); + + let from_identifier = TypeKey::from(ident(6)); + assert_eq!(from_identifier.name, Some(IdentId(6))); + + let from_ref = TypeKey::from(&ident(7)); + assert_eq!(from_ref.name, Some(IdentId(7))); + + let from_const = TypeKey::from(ConstId(8)); + assert_eq!(from_const.consts, vec![ConstId(8)]); + assert_eq!(from_const.name, None); + } +} diff --git a/psy-sema/src/value.rs b/psy-sema/src/value.rs index 4f4a244fb..20171252c 100644 --- a/psy-sema/src/value.rs +++ b/psy-sema/src/value.rs @@ -476,3 +476,537 @@ pub enum IndexPath { Normal(usize), Felt(F), } + +#[cfg(test)] +mod tests { + use crate::{CheckedArrayNode, CheckedStructField, CheckedStructNode}; + + use psy_vm::dpn::ops::exec_context::QExecContext; + use psy_vm::dpn::ops::sym_felt::SymFeltRef; + + use super::*; + + fn felt_value(value: u64) -> CheckedValueRef { + let mut ctx = QExecContext::new(); + CheckedValueRef::from_felt(ctx.op_const(value)) + } + + fn bool_value(value: bool) -> CheckedValueRef { + let mut ctx = QExecContext::new(); + CheckedValueRef::from_bool(if value { ctx.op_true() } else { ctx.op_false() }) + } + + #[test] + fn scalar_conversions_round_trip_through_their_typed_accessors() { + let felt = felt_value(7); + let boolean = bool_value(true); + let mut ctx = QExecContext::new(); + let u32_value = CheckedValueRef::::from_u32(ctx.op_const_u32(9)); + + // EnumAsInner accessors live on CheckedValue, reached through the guard. + assert!(felt.borrow().is_felt()); + assert!(boolean.borrow().is_bool()); + assert!(u32_value.borrow().is_u32()); + + // Typed accessors clone the backing felt of every scalar kind, and + // to_value accepts them all. + let _ = felt.to_felt(); + let _ = boolean.to_bool(); + let _ = u32_value.to_u32(); + let _ = felt.to_value(); + let _ = boolean.to_value(); + let _ = u32_value.to_value(); + + assert_eq!(felt.type_id(), FELT_TYPE); + assert_eq!(boolean.type_id(), BOOL_TYPE); + assert_eq!(u32_value.type_id(), U32_TYPE); + } + + #[test] + fn array_conversion_flattens_elements_and_reports_the_array_type() { + let mut ctx = QExecContext::new(); + let type_id = TypeId::from(42usize); + let empty = CheckedValueRef::::from_vec(type_id.clone(), []); + assert_eq!(empty.type_id(), type_id); + assert!(empty.to_vec().is_empty()); + assert!(empty.to_felts().is_empty()); + + let array = CheckedValueRef::::from_vec(type_id.clone(), [ctx.op_const(1), ctx.op_const(2), ctx.op_const(3)]); + assert_eq!(array.type_id(), type_id); + let felts = array.to_felts(); + assert_eq!(felts.len(), 3); + let restored: [SymFeltRef; 3] = array.to_array(); + assert_eq!(restored.len(), 3); + } + + #[test] + fn equality_compares_scalars_by_value_and_containers_by_identity() { + let a = felt_value(5); + let b = felt_value(5); + let c = felt_value(6); + assert_eq!(a, b); + assert_ne!(a, c); + assert_ne!(a, bool_value(true)); + + let array_a = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(1)], + )); + let clone = array_a.clone(); + let array_b = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(1)], + )); + // Arrays compare by Arc identity: a clone shares storage, a fresh + // allocation with equal contents does not. + assert_eq!(array_a, clone); + assert_ne!(array_a, array_b); + } + + #[test] + fn get_and_set_path_on_containers_without_conditions() { + let mut ctx = QExecContext::new(); + let array = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(10), felt_value(20)], + )); + + // Arrays are indexed by felt paths, tuples and structs by Normal ones. + let index = ctx.op_const(1); + let second = array.get_path(&mut ctx, &[IndexPath::Felt(index)]).unwrap(); + assert_eq!(second, felt_value(20)); + assert_eq!(array.get_path(&mut ctx, &[]).unwrap(), array); + + let tuple = CheckedValueRef::::new_rc(CheckedValue::Tuple { + type_id: TypeId::from(0usize), + elements: vec![(TypeId::from(1usize), felt_value(1)), (TypeId::from(2usize), felt_value(2))], + }); + assert_eq!(tuple.get_path(&mut ctx, &[IndexPath::Normal(1)]).unwrap(), felt_value(2)); + assert!(tuple.get_path(&mut ctx, &[IndexPath::Normal(9)]).is_none()); + + let mut fields = IndexMap::new(); + fields.insert(Identifier::new(IdentId(4), Location::default()), felt_value(7)); + let structure = CheckedValueRef::::new_rc(CheckedValue::Struct(TypeId::from(3usize), fields)); + assert_eq!(structure.get_path(&mut ctx, &[IndexPath::Normal(4)]).unwrap(), felt_value(7)); + assert!(structure.get_path(&mut ctx, &[IndexPath::Normal(5)]).is_none()); + + // A non-empty path into a scalar has nothing to traverse. + let index = ctx.op_const(0); + assert!(felt_value(0).get_path(&mut ctx, &[IndexPath::Felt(index)]).is_none()); + + let mut target = felt_value(0); + target.set_path(&mut ctx, &[], &mut vec![], felt_value(99)).unwrap(); + assert_eq!(target, felt_value(99)); + } + + #[test] + fn clone_of_scalars_is_independent_while_arrays_share_storage() { + let scalar = felt_value(3); + let mut cloned = scalar.clone(); + cloned = felt_value(4); + assert_eq!(scalar, felt_value(3)); + + let array = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(1)], + )); + let alias = array.clone(); + assert!(std::ptr::eq( + Arc::as_ptr(&array.as_rc()), + Arc::as_ptr(&alias.as_rc()) + )); + } + + #[test] + fn is_constant_op_classifies_the_constant_op_family() { + use psy_vm::dpn::ops::op_types::DPNOpType; + + assert!(is_constant_op(DPNOpType::Constant)); + assert!(is_constant_op(DPNOpType::ConstantTrue)); + assert!(is_constant_op(DPNOpType::ConstantFalse)); + assert!(is_constant_op(DPNOpType::ConstantU32)); + assert!(!is_constant_op(DPNOpType::Add)); + assert!(!is_constant_op(DPNOpType::Eq)); + } + + #[test] + fn to_felts_flattens_tuples_and_structs_in_declaration_order() { + let mut ctx = QExecContext::new(); + let tuple = CheckedValueRef::::new_rc(CheckedValue::Tuple { + type_id: TypeId::from(0usize), + elements: vec![(TypeId::from(1usize), felt_value(1)), (TypeId::from(2usize), felt_value(2))], + }); + assert_eq!(tuple.to_felts().len(), 2); + + let mut fields = IndexMap::new(); + fields.insert(Identifier::new(IdentId(0), Location::default()), felt_value(5)); + fields.insert(Identifier::new(IdentId(1), Location::default()), felt_value(6)); + let structure = CheckedValueRef::::new_rc(CheckedValue::Struct(TypeId::from(3usize), fields)); + assert_eq!(structure.to_felts().len(), 2); + assert_eq!(structure.type_id(), TypeId::from(3usize)); + + // Type values carry no data: VOID encodes as no felts at all. + let void_value = CheckedValueRef::::new_rc(CheckedValue::Type(VOID_TYPE)); + assert_eq!(void_value.as_type(), Some(VOID_TYPE)); + assert!(void_value.to_felts().is_empty()); + assert!(felt_value(0).as_type().is_none()); + } + + /// A symbolic (non-constant) index expression: built from an op so the + /// op type is `Add`, exercising the conditional select paths rather than + /// the constant-index fast path. + fn symbolic_index(ctx: &mut QExecContext) -> SymFeltRef { + let one = ctx.op_const(1); + let two = ctx.op_const(1); + ctx.op_add(one, two) + } + + #[test] + fn u32_array_accessors_round_trip() { + let mut ctx = QExecContext::new(); + let type_id = TypeId::from(7usize); + let array = CheckedValueRef::::new_rc(CheckedValue::Array( + type_id.clone(), + vec![ + CheckedValueRef::from_u32(ctx.op_const_u32(3)), + CheckedValueRef::from_u32(ctx.op_const_u32(4)), + ], + )); + assert_eq!(array.to_u32_vec().len(), 2); + let restored: [SymFeltRef; 2] = array.to_u32_array(); + assert_eq!(restored.len(), 2); + assert_eq!(array.type_id(), type_id); + } + + #[test] + fn select_recombines_scalars_containers_and_short_circuits_on_identity() { + let mut ctx = QExecContext::new(); + let condition = ctx.op_true(); + let select_fn = move |ctx: &mut QExecContext, n: &SymFeltRef, o: &SymFeltRef| ctx.op_select(condition.clone(), n.clone(), o.clone()); + + // Identical values short-circuit before any recombination. + let same = felt_value(1); + assert_eq!(CheckedValueRef::select(&mut ctx, &same, &same.clone(), &select_fn), same); + + let merged = CheckedValueRef::select(&mut ctx, &felt_value(2), &felt_value(1), &select_fn); + assert!(merged.borrow().is_felt()); + + let bool_merged = CheckedValueRef::select(&mut ctx, &bool_value(true), &bool_value(false), &select_fn); + assert!(bool_merged.borrow().is_bool()); + let u32_merged = CheckedValueRef::select(&mut ctx, &from_u32(5), &from_u32(6), &select_fn); + assert!(u32_merged.borrow().is_u32()); + + // Arrays merge element-wise. + let old_array = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(1), felt_value(2)], + )); + let new_array = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(3), felt_value(4)], + )); + let merged_array = CheckedValueRef::select(&mut ctx, &new_array, &old_array, &select_fn); + let merged_array = merged_array.borrow(); + assert!(merged_array.is_array()); + assert_eq!(merged_array.as_array().unwrap().1.len(), 2); + + // Structs merge field-wise; tuples merge element-wise. + let struct_of = |a: u64, b: u64| { + let mut fields = IndexMap::new(); + fields.insert(Identifier::new(IdentId(0), Location::default()), felt_value(a)); + fields.insert(Identifier::new(IdentId(1), Location::default()), felt_value(b)); + CheckedValueRef::::new_rc(CheckedValue::Struct(TypeId::from(3usize), fields)) + }; + let merged_struct = CheckedValueRef::select(&mut ctx, &struct_of(9, 9), &struct_of(1, 2), &select_fn); + assert_eq!(merged_struct.to_felts().len(), 2); + + let tuple_of = |a: u64, b: u64| { + CheckedValueRef::::new_rc(CheckedValue::Tuple { + type_id: TypeId::from(4usize), + elements: vec![(FELT_TYPE, felt_value(a)), (FELT_TYPE, felt_value(b))], + }) + }; + let merged_tuple = CheckedValueRef::select(&mut ctx, &tuple_of(7, 7), &tuple_of(1, 2), &select_fn); + assert_eq!(merged_tuple.to_felts().len(), 2); + + // Mismatched variant pairs are a programming error, not a silent fallthrough. + let result = std::panic::catch_unwind(|| { + let mut ctx = QExecContext::new(); + CheckedValueRef::::select(&mut ctx, &felt_value(1), &bool_value(true), &|_ctx, n: &SymFeltRef, _o: &SymFeltRef| n.clone()) + }); + assert!(result.is_err(), "select of mismatched variants must panic"); + } + + fn from_u32(value: u32) -> CheckedValueRef { + let mut ctx = QExecContext::new(); + CheckedValueRef::from_u32(ctx.op_const_u32(value)) + } + + #[test] + fn set_path_with_symbolic_index_builds_conditioned_writes() { + let mut ctx = QExecContext::new(); + let mut array = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(10), felt_value(20)], + )); + + // A symbolic index cannot pick a slot, so every slot is written under + // an index condition and combined with a select. + let index = symbolic_index(&mut ctx); + array + .set_path(&mut ctx, &[IndexPath::Felt(index)], &mut vec![], felt_value(99)) + .expect("symbolic index write must build a conditioned write"); + assert_eq!(array.to_felts().len(), 2); + + // Nested symbolic writes accumulate conditions down the path. + let mut grid = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![ + CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(1), felt_value(2)], + )), + CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(3), felt_value(4)], + )), + ], + )); + let outer = symbolic_index(&mut ctx); + let inner = symbolic_index(&mut ctx); + grid.set_path(&mut ctx, &[IndexPath::Felt(outer), IndexPath::Felt(inner)], &mut vec![], felt_value(77)) + .expect("nested symbolic writes must combine conditions"); + assert_eq!(grid.to_felts().len(), 4); + + // Constant indices keep the fast path. + let constant = ctx.op_const(0); + let mut direct = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(1), felt_value(2)], + )); + direct.set_path(&mut ctx, &[IndexPath::Felt(constant)], &mut vec![], felt_value(42)).unwrap(); + let zero = ctx.op_const(0); + let first = direct.get_path(&mut ctx, &[IndexPath::Felt(zero)]).unwrap(); + assert_eq!(first.to_felt(), felt_value(42).to_felt()); + + // Struct and tuple paths are addressed by identifier / position. + let mut fields = IndexMap::new(); + fields.insert(Identifier::new(IdentId(0), Location::default()), felt_value(1)); + let mut structure = CheckedValueRef::::new_rc(CheckedValue::Struct(TypeId::from(3usize), fields)); + structure + .set_path(&mut ctx, &[IndexPath::Normal(0)], &mut vec![], felt_value(8)) + .unwrap(); + assert_eq!( + structure.get_path(&mut ctx, &[IndexPath::Normal(0)]).unwrap().to_felt(), + felt_value(8).to_felt() + ); + + let mut tuple = CheckedValueRef::::new_rc(CheckedValue::Tuple { + type_id: TypeId::from(4usize), + elements: vec![(FELT_TYPE, felt_value(1))], + }); + tuple.set_path(&mut ctx, &[IndexPath::Normal(0)], &mut vec![], felt_value(6)).unwrap(); + assert_eq!(tuple.get_path(&mut ctx, &[IndexPath::Normal(0)]).unwrap().to_felt(), felt_value(6).to_felt()); + } + + #[test] + fn get_path_with_symbolic_index_selects_across_elements() { + let mut ctx = QExecContext::new(); + let array = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(0usize), + vec![felt_value(11), felt_value(22), felt_value(33)], + )); + + let index = symbolic_index(&mut ctx); + let selected = array + .get_path(&mut ctx, &[IndexPath::Felt(index)]) + .expect("symbolic index read must produce a selected value"); + assert!(selected.borrow().is_felt()); + } + + + #[test] + fn scalar_constructors_and_accessors_round_trip() { + let v = SymFeltRef::from(7u32); + + let felt = CheckedValueRef::from_felt(v); + assert_eq!(felt.to_felt(), v); + assert_eq!(felt.type_id(), FELT_TYPE); + assert_eq!(felt.to_value(), v); + + let boolean = CheckedValueRef::from_bool(v); + assert_eq!(boolean.to_bool(), v); + assert_eq!(boolean.type_id(), BOOL_TYPE); + assert_eq!(boolean.to_value(), v); + + let word = CheckedValueRef::from_u32(v); + assert_eq!(word.to_u32(), v); + assert_eq!(word.type_id(), U32_TYPE); + assert_eq!(word.to_value(), v); + + assert_eq!(CheckedValueRef::from_value(v, FELT_TYPE).to_felt(), v); + assert_eq!(CheckedValueRef::from_value(v, BOOL_TYPE).to_bool(), v); + assert_eq!(CheckedValueRef::from_value(v, U32_TYPE).to_u32(), v); + } + + #[test] + fn array_accessors_read_felt_and_u32_elements() { + let v = SymFeltRef::from(5u32); + + let felts = CheckedValueRef::from_vec(TypeId::from(9usize), [v, v]); + assert_eq!(felts.to_vec(), vec![v, v]); + assert_eq!(felts.to_array::<2>(), [v, v]); + assert_eq!(felts.type_id(), TypeId::from(9usize)); + + let words = CheckedValueRef::::new_rc(CheckedValue::Array( + TypeId::from(10usize), + vec![CheckedValueRef::from_u32(v), CheckedValueRef::from_u32(v)], + )); + assert_eq!(words.to_u32_vec(), vec![v, v]); + assert_eq!(words.to_u32_array::<2>(), [v, v]); + } + + #[test] + fn type_values_share_state_and_render_empty() { + let ty = CheckedValueRef::::new_rc(CheckedValue::Type(VOID_TYPE)); + assert_eq!(ty.as_type(), Some(VOID_TYPE)); + assert_eq!(ty.type_id(), VOID_TYPE); + assert!(ty.to_felts().is_empty()); + let _shared = ty.as_rc(); + + let mut value = CheckedValueRef::::from_felt(SymFeltRef::from(1u32)); + *value.borrow_mut() = CheckedValue::Bool(SymFeltRef::from(1u32)); + assert_eq!(value.to_bool(), SymFeltRef::from(1u32)); + } + + #[test] + fn to_felts_and_encode_felts_flatten_composites() { + let v = SymFeltRef::from(4u32); + + let tuple = CheckedValueRef::::new_rc(CheckedValue::Tuple { + type_id: TypeId::from(1usize), + elements: vec![ + (FELT_TYPE, CheckedValueRef::from_felt(v)), + (BOOL_TYPE, CheckedValueRef::from_bool(v)), + ], + }); + assert_eq!(tuple.to_felts(), vec![v, v]); + assert_eq!(tuple.type_id(), TypeId::from(1usize)); + + let mut fields = IndexMap::new(); + fields.insert( + psy_ast::Identifier::new(psy_ast::IdentId(1), psy_ast::Location::default()), + CheckedValueRef::from_felt(v), + ); + let structure = CheckedValueRef::::new_rc(CheckedValue::Struct(TypeId::from(2usize), fields)); + assert_eq!(structure.to_felts(), vec![v]); + assert_eq!(structure.type_id(), TypeId::from(2usize)); + + let encoded = as FeltRepr>::encode_felts(&CheckedValueRef::from_felt(v)); + assert_eq!(encoded, vec![v]); + } + + #[test] + fn decode_felts_rebuilds_scalars_and_structs() { + let mut context = TypeCheckerVisitorContext::::new(psy_ast::Program::new()); + let a = SymFeltRef::from(11u32); + let b = SymFeltRef::from(22u32); + + let felt_ty = context.symbols.create_type(Type::Felt).unwrap(); + let bool_ty = context.symbols.create_type(Type::Bool).unwrap(); + let u32_ty = context.symbols.create_type(Type::U32).unwrap(); + assert_eq!(CheckedValueRef::decode_felts(&[a], &context, felt_ty).to_felt(), a); + assert_eq!(CheckedValueRef::decode_felts(&[a], &context, bool_ty).to_bool(), a); + assert_eq!(CheckedValueRef::decode_felts(&[a], &context, u32_ty).to_u32(), a); + + let mut fields = IndexMap::new(); + fields.insert( + psy_ast::Identifier::new(psy_ast::IdentId(10), psy_ast::Location::default()), + CheckedStructField::new(felt_ty, vec![], psy_ast::Visibility::Public, vec![], psy_ast::Location::default()), + ); + fields.insert( + psy_ast::Identifier::new(psy_ast::IdentId(11), psy_ast::Location::default()), + CheckedStructField::new(felt_ty, vec![], psy_ast::Visibility::Public, vec![], psy_ast::Location::default()), + ); + let struct_ty = context + .symbols + .create_type(Type::Struct(CheckedStructNode { + name: psy_ast::Identifier::new(psy_ast::IdentId(12), psy_ast::Location::default()), + generic_parameters: vec![], + fields, + scope_id: crate::ScopeId::root(), + attrs: vec![], + visibility: psy_ast::Visibility::Public, + comments: vec![], + location: psy_ast::Location::default(), + type_id: TypeId::from(0usize), + })) + .unwrap(); + let decoded = CheckedValueRef::decode_felts(&[a, b], &context, struct_ty); + assert_eq!(decoded.to_felts(), vec![a, b]); + assert_eq!(decoded.type_id(), struct_ty); + } + + #[test] + fn decode_felts_rebuilds_arrays_and_tuples() { + let mut context = TypeCheckerVisitorContext::::new(psy_ast::Program::new()); + let a = SymFeltRef::from(11u32); + let b = SymFeltRef::from(22u32); + + let felt_ty = context.symbols.create_type(Type::Felt).unwrap(); + let array_ty = context + .symbols + .create_type(Type::Array(CheckedArrayNode { + inner_ty: felt_ty, + size_ty: felt_ty, + scope_id: crate::ScopeId::root(), + })) + .unwrap(); + let decoded = CheckedValueRef::decode_felts(&[a, b], &context, array_ty); + assert_eq!(decoded.to_vec(), vec![a, b]); + assert_eq!(decoded.type_id(), array_ty); + + let tuple_ty = context.symbols.create_type(Type::Tuple(vec![felt_ty, felt_ty])).unwrap(); + let tuple = CheckedValueRef::decode_felts(&[a, b], &context, tuple_ty); + assert_eq!(tuple.to_felts(), vec![a, b]); + assert_eq!(tuple.type_id(), tuple_ty); + } + #[test] + #[should_panic(expected = "Expected felt value")] + fn to_felt_panics_on_a_bool_value() { + bool_value(true).to_felt(); + } + + #[test] + #[should_panic(expected = "Expected bool value")] + fn to_bool_panics_on_a_felt_value() { + felt_value(1).to_bool(); + } + + #[test] + #[should_panic(expected = "Expected u32 value")] + fn to_u32_panics_on_a_felt_value() { + felt_value(1).to_u32(); + } + + #[test] + #[should_panic(expected = "Expected felt/u32/bool value")] + fn to_value_panics_on_an_array_value() { + let mut context = TypeCheckerVisitorContext::::new(psy_ast::Program::new()); + let felt_ty = context.symbols.create_type(Type::Felt).unwrap(); + let array = CheckedValueRef::::from_vec(felt_ty, [SymFeltRef::from(1u32)]); + array.to_value(); + } + + #[test] + #[should_panic(expected = "Expected array value")] + fn to_vec_panics_on_a_felt_value() { + felt_value(1).to_vec(); + } + + #[test] + #[should_panic(expected = "Expected array value")] + fn to_u32_vec_panics_on_a_felt_value() { + felt_value(1).to_u32_vec(); + } +} diff --git a/psy-sema/src/visualizer.rs b/psy-sema/src/visualizer.rs index 934019c06..d14d0dabe 100644 --- a/psy-sema/src/visualizer.rs +++ b/psy-sema/src/visualizer.rs @@ -587,6 +587,7 @@ impl<'a, F: Clone + From + ContextFelt, C> TypeCheckerVisitorVisualizerInne } DefinitionNode::Trait(node) => { writeln!(fmt, "Trait"); + fmt.indent(); writeln!(fmt, "Name: {:?}", node.name); writeln!(fmt, "Visibility: {:?}", node.visibility); writeln!(fmt, "Associated Types"); @@ -746,3 +747,39 @@ impl + ContextFelt, C> AstVisualizer for TypeCheckerV fmt.finish_without_new_line() } } + +#[cfg(test)] +mod tests { + use super::IndentFormatter; + + #[test] + fn indent_formatter_tracks_nested_indentation() { + let mut formatter = IndentFormatter::new(); + formatter.writeln("root"); + formatter.indent(); + formatter.writeln("child"); + formatter.indent(); + formatter.write("leaf"); + formatter.dedent(); + formatter.writeln("after"); + formatter.dedent(); + assert_eq!(formatter.finish(), "root\n child\nleaf after\n"); + } + + #[test] + fn indent_formatter_removes_trailing_newlines_only() { + let mut formatter = IndentFormatter::new(); + formatter.writeln(" value "); + assert_eq!(formatter.finish_without_new_line(), " value "); + } + + #[test] + fn indent_formatter_write_indent_supports_multiple_levels() { + let mut formatter = IndentFormatter::new(); + formatter.indent(); + formatter.indent(); + formatter.write_indent(); + formatter.write("x"); + assert_eq!(formatter.finish(), " x"); + } +} diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index 86a81e2eb..27e6af4e1 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -1330,13 +1330,20 @@ fn strip_ansi_csi_sequences(text: &str) -> String { fn extract_error_offset_from_line_col(error_msg: &str, sources: &HashMap>) -> Option { let marker = "[ "; - for (start, _) in error_msg.match_indices(marker) { - let rest = &error_msg[start + marker.len()..]; - let Some(end) = rest.find(" ]") else { continue }; - let Some((path_text, line, column)) = parse_location_triplet(&rest[..end]) else { continue }; + for (marker_start, _) in error_msg.match_indices(marker) { + let rest = &error_msg[marker_start + marker.len()..]; + let Some(end) = rest.find(" ]") else { + continue; + }; + let Some((path_text, line, column)) = parse_location_triplet(&rest[..end]) else { + continue; + }; let normalized_path = path_text.replace('\\', "/"); let source = sources.get(&path_text).or_else(|| { - sources.iter().find(|(path, _)| path.replace('\\', "/") == normalized_path).map(|(_, source)| source) + sources + .iter() + .find(|(path, _)| path.replace('\\', "/") == normalized_path) + .map(|(_, source)| source) }); if let Some(offset) = source.and_then(|source| line_col_to_offset(source, line, column)) { return Some(offset); @@ -1344,13 +1351,18 @@ fn extract_error_offset_from_line_col(error_msg: &str, sources: &HashMap::from("a\nxyz"), + )]); + let error = "\u{1b}[31m[UnexpectedToken]\u{1b}[0m \u{1b}[38;5;246m╭─[\u{1b}[0m /vfs/src/main.psy:2:3 \u{1b}[38;5;246m]\u{1b}[0m"; + + assert_eq!(extract_error_offset(error, &sources), Some(4)); + } + + #[test] + fn ansi_stripping_preserves_plain_diagnostic_text() { + assert_eq!( + strip_ansi_csi_sequences("before \u{1b}[31mred\u{1b}[0m after"), + "before red after" + ); + } + + #[test] + fn ansi_stripping_does_not_drop_non_csi_escape_characters() { + assert_eq!(strip_ansi_csi_sequences("before \u{1b}x after"), "before \u{1b}x after"); + } + + #[test] + fn error_offset_parser_prefers_the_last_explicit_offset() { + let sources = HashMap::new(); + + assert_eq!( + extract_error_offset("inner error at offset 3; outer error at offset 17", &sources), + Some(17) + ); + assert_eq!(extract_error_offset("offset not-a-number", &sources), None); + } + + #[test] + fn error_offset_parser_accepts_windows_paths_and_normalized_source_keys() { + let sources = HashMap::from([( + "C:/project/src/main.psy".to_string(), + Arc::::from("first\nsecond"), + )]); + + assert_eq!( + extract_error_offset("[ C:\\project\\src\\main.psy:2:2 ]", &sources), + Some(7) + ); + } + + #[test] + fn error_offset_parser_uses_single_source_fallback_for_display_paths() { + let sources = HashMap::from([( + "/vfs/internal/main.psy".to_string(), + Arc::::from("abc\ndef"), + )]); + + assert_eq!(extract_error_offset("[ main.psy:2:1 ]", &sources), Some(4)); + } + + #[test] + fn line_column_offsets_are_byte_offsets_and_validate_boundaries() { + let source = "δΈ­a\nΞ²"; + + assert_eq!(line_col_to_offset(source, 1, 1), Some(0)); + assert_eq!(line_col_to_offset(source, 1, 2), Some(3)); + assert_eq!(line_col_to_offset(source, 2, 1), Some(5)); + assert_eq!(line_col_to_offset(source, 2, 2), Some(source.len())); + assert_eq!(line_col_to_offset(source, 0, 1), None); + assert_eq!(line_col_to_offset(source, 1, 0), None); + assert_eq!(line_col_to_offset(source, 3, 1), None); + } + + #[test] + fn malformed_location_triplets_are_rejected() { + assert_eq!(parse_location_triplet("main.psy:2:3"), Some(("main.psy".to_string(), 2, 3))); + assert_eq!(parse_location_triplet("C:\\src\\main.psy:2:3"), Some(("C:\\src\\main.psy".to_string(), 2, 3))); + assert_eq!(parse_location_triplet("main.psy:two:3"), None); + assert_eq!(parse_location_triplet("main.psy:2"), None); + assert_eq!( + parse_location_triplet("main.psy : 2 : 3"), + Some(("main.psy".to_string(), 2, 3)) + ); + } + + #[test] + fn ansi_stripping_handles_truncated_and_parameterized_sequences() { + assert_eq!(strip_ansi_csi_sequences("end\u{1b}"), "end\u{1b}"); + assert_eq!(strip_ansi_csi_sequences("end\u{1b}["), "end"); + assert_eq!(strip_ansi_csi_sequences("\u{1b}[?25hvisible"), "visible"); + assert_eq!(strip_ansi_csi_sequences("\u{1b}[38;5;196mX\u{1b}[0m"), "X"); + assert_eq!(strip_ansi_csi_sequences("\u{1b}[ @"), ""); + } + + #[test] + fn explicit_offset_wins_over_location_triplets() { + let sources = HashMap::from([( + "main.psy".to_string(), + Arc::::from("first\nsecond"), + )]); + + assert_eq!( + extract_error_offset("failed [ main.psy:1:1 ] at offset 5", &sources), + Some(5) + ); + } + + #[test] + fn error_offset_line_col_arms_skip_unterminated_and_malformed_triplets() { + let sources = HashMap::from([( + "main.psy".to_string(), + Arc::::from("first\nsecond"), + )]); + + // A "[ " marker without a closing " ]" never yields a triplet. + assert_eq!(extract_error_offset("[ main.psy:2:1 never closed", &sources), None); + // Bracketed text that does not parse as path:line:column is skipped, + // including by the single-source fallback. + assert_eq!(extract_error_offset("[ not a triplet ]", &sources), None); + // A well-formed triplet outside the source bounds produces no offset. + assert_eq!(extract_error_offset("[ main.psy:9:9 ]", &sources), None); + } + + #[test] + fn line_column_offsets_handle_empty_sources_and_crlf() { + assert_eq!(line_col_to_offset("", 1, 1), Some(0)); + assert_eq!(line_col_to_offset("", 1, 2), None); + assert_eq!(line_col_to_offset("a\r\nb", 2, 1), Some(3)); + } + #[test] #[serial] fn compile_source_succeeds() { @@ -1660,7 +1800,11 @@ mod tests { assert!(!result.success, "expected compile failure"); assert!(result.error.is_some()); - assert!(result.error_offset.is_some(), "expected parse error offset"); + assert!( + result.error_offset.is_some(), + "expected parse error offset; error was: {:?}", + result.error + ); } #[test] @@ -2373,4 +2517,516 @@ mod tests { let accounts_after_reset: serde_json::Value = serde_json::from_str(&get_accounts()).unwrap(); assert_eq!(accounts_after_reset.as_array().map(|items| items.len()), Some(0)); } + + #[test] + fn ide_module_parts_to_path_builds_frontend_entry_paths() { + assert_eq!(ide_module_parts_to_path(&[]), PathBuf::from("/vfs/src/main.psy")); + assert_eq!( + ide_module_parts_to_path(&["main".to_string()]), + PathBuf::from("/vfs/src/main.psy") + ); + assert_eq!( + ide_module_parts_to_path(&["main.psy".to_string()]), + PathBuf::from("/vfs/src/main.psy") + ); + assert_eq!( + ide_module_parts_to_path(&["grid".to_string()]), + PathBuf::from("/vfs/src/grid.psy") + ); + assert_eq!( + ide_module_parts_to_path(&["mods".to_string(), "grid".to_string()]), + PathBuf::from("/vfs/src/mods/grid.psy") + ); + assert_eq!( + ide_module_parts_to_path(&["mods".to_string(), "grid.psy".to_string()]), + PathBuf::from("/vfs/src/mods/grid.psy") + ); + } + + #[test] + fn execution_context_input_defaults_missing_fields_to_zero() { + let context = ExecutionContext::from(ExecutionContextInput { + user_id: None, + contract_id: None, + caller_contract_id: None, + checkpoint_id: None, + nonce: None, + user_public_key_hash: None, + }); + assert_eq!(context.user_id, 0); + assert_eq!(context.contract_id, 0); + assert_eq!(context.caller_contract_id, 0); + assert_eq!(context.checkpoint_id, 0); + assert_eq!(context.nonce, 0); + assert_eq!(context.user_public_key_hash, [0; 4]); + + let context = ExecutionContext::from(ExecutionContextInput { + user_id: Some(7), + contract_id: Some(9), + caller_contract_id: Some(11), + checkpoint_id: Some(13), + nonce: Some(17), + user_public_key_hash: Some([1, 2, 3, 4]), + }); + assert_eq!(context.user_id, 7); + assert_eq!(context.contract_id, 9); + assert_eq!(context.caller_contract_id, 11); + assert_eq!(context.checkpoint_id, 13); + assert_eq!(context.nonce, 17); + assert_eq!(context.user_public_key_hash, [1, 2, 3, 4]); + + let default = default_execution_context(); + assert_eq!(default.user_id, 0); + assert_eq!(default.checkpoint_id, 0); + } + + #[derive(serde::Deserialize)] + struct TestInterpretResult { + success: bool, + error: Option, + error_offset: Option, + entry_path: Option, + execution_result: Option, + outputs: Option>, + } + + #[test] + #[serial] + fn interpret_source_runs_main_with_inputs_and_outputs() { + let result: TestInterpretResult = + serde_json::from_str(&interpret_source("fn main(q: Felt) -> Felt { return q + 1; }", r#"{"inputs":[7]}"#)).unwrap(); + + assert!(result.success, "expected interpret success, got {:?}", result.error); + assert_eq!(result.entry_path.as_deref(), Some("/vfs/src/main.psy")); + let execution = result.execution_result.expect("missing execution_result"); + assert!(execution["success"].as_bool().is_some_and(|ok| ok), "execution failed: {execution}"); + assert_eq!(execution["outputs"].as_array().map(|items| items.len()), Some(1)); + assert_eq!(execution["outputs"][0].as_u64(), Some(8)); + } + + #[test] + #[serial] + fn interpret_source_honors_execution_context_and_hydrates_every_initial_state_kind() { + let source = r#" + use std::prelude::*; + + fn main() -> Felt { + return get_user_id(); + } + "#; + let request = serde_json::json!({ + "inputs": [], + "execution_context": { "user_id": 42 }, + "initial_state": { + "slots": [{ "user_id": 1, "contract_id": 1, "slot_index": 0, "value": 7 }], + "hashes": [{ "user_id": 1, "contract_id": 1, "slot_index": 0, "value": [1, 2, 3, 4] }], + "deployers": [{ "contract_id": 1, "deployer": [1, 2, 3, 4] }], + "checkpoint_stats": [{ "checkpoint_id": 1, "values": [1, 2] }], + "contract_leaves": [{ "contract_id": 1, "values": [1, 2, 3, 4] }], + "checkpoint_global_state_roots": [{ "checkpoint_id": 1, "values": [1, 2] }], + "imt": [{ "user_id": 1, "contract_id": 1, "key": [1, 2, 3, 4], "value": [5, 6, 7, 8] }] + } + }); + + let result: TestInterpretResult = + serde_json::from_str(&interpret_source(source, &request.to_string())).unwrap(); + + assert!(result.success, "expected interpret success, got {:?}", result.error); + let execution = result.execution_result.expect("missing execution_result"); + assert_eq!(execution["outputs"][0].as_u64(), Some(42), "execution context user_id must reach the VM: {execution}"); + } + + #[test] + #[serial] + fn interpret_source_reports_invalid_requests_and_compile_errors() { + let result: TestInterpretResult = serde_json::from_str(&interpret_source("fn main() {}", "not json")).unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or_default().contains("Invalid interpret request JSON"), "{:?}", result.error); + + let result: TestInterpretResult = + serde_json::from_str(&interpret_source("fn main( { }", r#"{"inputs":[]}"#)).unwrap(); + assert!(!result.success, "expected the parse error to fail interpretation"); + assert!( + result.error_offset.is_some(), + "expected a parse error offset; error was {:?}", + result.error + ); + } + + #[test] + #[serial] + fn interpret_project_interprets_files_json_and_reports_invalid_input() { + let files = serde_json::json!({ + "entry": ["main"], + "files": [[["main"], "fn main(q: Felt) -> Felt { return q * 2; }"]] + }); + + let result: TestInterpretResult = + serde_json::from_str(&interpret_project(&files.to_string(), r#"{"inputs":[21]}"#)).unwrap(); + assert!(result.success, "expected interpret success, got {:?}", result.error); + let execution = result.execution_result.expect("missing execution_result"); + assert_eq!(execution["outputs"][0].as_u64(), Some(42)); + + let result: TestInterpretResult = serde_json::from_str(&interpret_project("{ bad json", r#"{}"#)).unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or_default().contains("Invalid files JSON"), "{:?}", result.error); + + let result: TestInterpretResult = + serde_json::from_str(&interpret_project(&files.to_string(), "{ bad json")).unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or_default().contains("Invalid interpret request JSON"), "{:?}", result.error); + } + + #[test] + #[serial] + fn chain_contract_lifecycle_covers_deploy_call_state_and_log() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + // Deploying before any compile is rejected. + let result: serde_json::Value = serde_json::from_str(&deploy_contract(1)).unwrap(); + assert_eq!(result["success"], false); + assert_eq!(result["error"].as_str(), Some("No compiled contract. Compile first.")); + + // A compiled contract with a writer and a reader method. + let compiled = parse_result(&compile_source( + r#" + #[contract] + #[derive(Storage)] + pub struct LifecycleContract { + pub value: Felt, + } + + #[contract::write_method] + pub fn set_value(value: Felt) { + let c = LifecycleContractRef::new(ContractMetadata::current()); + c.value = value; + } + + #[contract::view_method] + pub fn get_value() -> Felt { + let c = LifecycleContractRef::new(ContractMetadata::current()); + c.value.get() + } + "#, + )); + assert!(compiled.success, "fixture must compile, got {:?}", compiled.error); + + let alice: serde_json::Value = serde_json::from_str(&create_account("Alice")).unwrap(); + let alice_id = alice["user_id"].as_u64().unwrap(); + + // Deploying under an unknown account is rejected; under Alice it works. + let result: serde_json::Value = serde_json::from_str(&deploy_contract(999)).unwrap(); + assert_eq!(result["success"], false); + assert_eq!(result["error"].as_str(), Some("Account with ID 999 not found")); + + let result: serde_json::Value = serde_json::from_str(&deploy_contract(alice_id)).unwrap(); + assert_eq!(result["success"], true, "{result}"); + let contract_id = result["contract_id"].as_u64().unwrap(); + + let contracts: serde_json::Value = serde_json::from_str(&get_contracts()).unwrap(); + let contracts = contracts.as_array().expect("contracts must be an array"); + assert_eq!(contracts.len(), 1); + assert_eq!(contracts[0]["name"].as_str(), Some("LifecycleContract")); + assert_eq!(contracts[0]["deployer_id"].as_u64(), Some(alice_id)); + + let abi: serde_json::Value = serde_json::from_str(&get_contract_abi(contract_id)).unwrap(); + assert_eq!(abi["contract"]["name"].as_str(), Some("LifecycleContract")); + let missing_abi: serde_json::Value = serde_json::from_str(&get_contract_abi(999)).unwrap(); + assert!(missing_abi["error"].as_str().is_some_and(|msg| msg.contains("Contract 999 not found"))); + + // Invalid args JSON is rejected before execution. + let result: serde_json::Value = + serde_json::from_str(&call_contract(alice_id, contract_id, "set_value", "{bad json")).unwrap(); + assert_eq!(result["success"], false); + assert!(result["error"].as_str().is_some_and(|msg| msg.contains("Invalid args"))); + + // Unknown contract / method names produce explicit errors. + let result: serde_json::Value = serde_json::from_str(&call_contract(alice_id, 999, "set_value", "[]")).unwrap(); + assert!(result["error"].as_str().is_some_and(|msg| msg.contains("Contract 999 not found"))); + let result: serde_json::Value = + serde_json::from_str(&call_contract(alice_id, contract_id, "missing_method", "[]")).unwrap(); + assert!(result["error"].as_str().is_some_and(|msg| msg.contains("Method 'missing_method' not found"))); + + // Write, then read the value back through the view method. + let result: serde_json::Value = serde_json::from_str(&call_contract(alice_id, contract_id, "set_value", "[5]")).unwrap(); + assert_eq!(result["success"], true, "set_value failed: {result}"); + + let result: serde_json::Value = serde_json::from_str(&call_contract(alice_id, contract_id, "get_value", "[]")).unwrap(); + assert_eq!(result["success"], true, "get_value failed: {result}"); + assert_eq!( + result["outputs"].as_array().and_then(|items| items.first().and_then(serde_json::Value::as_u64)), + Some(5), + "stored value must be readable: {result}" + ); + + // State inspection endpoints. + let entries: serde_json::Value = serde_json::from_str(&read_contract_state(contract_id, alice_id)).unwrap(); + assert!(entries.is_array(), "state entries must be an array: {entries}"); + let missing_state: serde_json::Value = serde_json::from_str(&read_contract_state(999, alice_id)).unwrap(); + assert!(missing_state["error"].as_str().is_some_and(|msg| msg.contains("Contract 999 not found"))); + + let imt: serde_json::Value = serde_json::from_str(&read_imt_state(contract_id as u32, alice_id as u32)).unwrap(); + assert!(imt.is_array(), "imt entries must be an array: {imt}"); + let missing_imt: serde_json::Value = serde_json::from_str(&read_imt_state(999, 1)).unwrap(); + assert!(missing_imt["error"].as_str().is_some_and(|msg| msg.contains("Contract 999 not found"))); + + // Both successful calls are recorded in order. + let log: serde_json::Value = serde_json::from_str(&get_transaction_log()).unwrap(); + let log = log.as_array().expect("transaction log must be an array"); + assert_eq!(log.len(), 2, "expected one record per call: {log:?}"); + assert_eq!(log[0]["method_name"].as_str(), Some("set_value")); + assert_eq!(log[1]["method_name"].as_str(), Some("get_value")); + assert_eq!(log[0]["caller_name"].as_str(), Some("Alice")); + assert_eq!(log[0]["contract_name"].as_str(), Some("LifecycleContract")); + assert_eq!(log[0]["success"], true); + } + + #[test] + #[serial] + fn chain_operations_require_initialization() { + *CHAIN.lock().unwrap() = None; + + for result in [ + serde_json::from_str::(&get_contracts()).unwrap(), + serde_json::from_str::(&get_transaction_log()).unwrap(), + serde_json::from_str::(&get_contract_abi(1)).unwrap(), + serde_json::from_str::(&read_imt_state(1, 1)).unwrap(), + serde_json::from_str::(&create_account("Uninitialized")).unwrap(), + serde_json::from_str::(&get_accounts()).unwrap(), + ] { + assert_eq!( + result["error"].as_str(), + Some("Chain not initialized. Call init_chain() first."), + "uninitialized chain must produce the explicit error: {result}" + ); + } + + // Restore a fresh chain for any test that runs afterwards. + init_chain(); + } + + fn dargo_project(source: &str) -> serde_json::Value { + serde_json::json!({ + "root": "root", + "packages": [ + { + "id": "root", + "manifest": "[package]\nname = \"root\"\ntype = \"bin\"\n", + "files": { "src/main.psy": source }, + "dependencies": {} + } + ] + }) + } + + // NOTE: `main`/`init_logging`/`init_psy_ide` are deliberately NOT tested: + // they install wasm-only logger/tracing subscribers that abort the native + // test binary when any later `tracing::warn!` fires. + + #[test] + fn compile_project_rejects_invalid_files_json() { + let result = parse_result(&compile_project("not json")); + assert!(!result.success); + assert!( + result.error.as_deref().unwrap_or_default().contains("Invalid files JSON"), + "{:?}", + result.error + ); + } + + #[test] + fn compile_dargo_project_reports_malformed_and_unresolvable_inputs() { + let result = parse_result(&compile_dargo_project("not json")); + assert!(!result.success); + assert!( + result.error.as_deref().unwrap_or_default().contains("Invalid dargo project JSON"), + "{:?}", + result.error + ); + + // Empty method list is rejected instead of silently compiling nothing. + let mut project = dargo_project("fn main() { assert_eq(1, 1, \"ok\"); }"); + project["method_names"] = serde_json::json!([]); + let result = parse_result(&compile_dargo_project(&project.to_string())); + assert!(!result.success); + assert!( + result.error.as_deref().unwrap_or_default().contains("method_names must not be empty"), + "{:?}", + result.error + ); + + // A manifest whose entry file does not exist cannot resolve a workspace. + let mut missing_entry = dargo_project(""); + missing_entry["packages"][0]["manifest"] = + serde_json::json!("[package]\nname = \"root\"\ntype = \"bin\"\nentry = \"src/missing.psy\"\n"); + let result = parse_result(&compile_dargo_project(&missing_entry.to_string())); + assert!(!result.success, "a missing entry file must fail resolution"); + } + + #[test] + #[serial] + fn compile_dargo_project_reports_source_errors_with_an_offset() { + let project = dargo_project("fn main() -> Felt { return true; }"); + let result = parse_result(&compile_dargo_project(&project.to_string())); + assert!(!result.success, "the type error must fail compilation"); + assert!( + result.error_offset.is_some() || result.error.as_deref().unwrap_or_default().contains("TypeMismatch"), + "expected a diagnostic with an offset or type mismatch: {:?}", + result.error + ); + } + + #[test] + #[serial] + fn compile_dargo_project_registers_dependency_packages_and_edges() { + // Two packages: the root depends on a library package. Registering the + // dependency exercises the resolver dependency loop and the crate-graph + // edge between the two entry files while still compiling successfully. + let project = serde_json::json!({ + "root": "root", + "method_names": ["main"], + "packages": [ + { + "id": "root", + "manifest": "[package]\nname = \"root\"\ntype = \"bin\"\n\n[dependencies]\ndep = { path = \"../dep\" }\n", + "files": { + "src/main.psy": "#[contract]\npub struct C {}\n#[contract::write_method]\nfn main() { assert_eq(1, 1, \"ok\"); }" + }, + "dependencies": { "dep": "dep" } + }, + { + "id": "dep", + "manifest": "[package]\nname = \"dep\"\ntype = \"lib\"\n", + "files": { "src/lib.psy": "pub fn helper() {}" }, + "dependencies": {} + } + ] + }); + + let result = parse_result(&compile_dargo_project(&project.to_string())); + assert!(result.success, "dependency project must compile: {:?}", result.error); + assert!( + result.entry_path.as_deref().is_some_and(|path| path.contains("root")), + "the root package entry must be reported: {:?}", + result.entry_path + ); + } + + #[test] + #[serial] + fn deploy_contract_requires_an_existing_account_even_after_compiling() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + let compiled = parse_result(&compile_source( + "#[contract]\npub struct C {}\n#[contract::write_method]\nfn main() { assert_eq(1, 1, \"ok\"); }", + )); + assert!(compiled.success, "fixture must compile, got {:?}", compiled.error); + + let result: serde_json::Value = serde_json::from_str(&deploy_contract(9999)).unwrap(); + assert_eq!(result["success"], false); + assert_eq!( + result["error"].as_str(), + Some("Account with ID 9999 not found"), + "deploying as a nonexistent account must be rejected: {result}" + ); + + *LAST_COMPILE.lock().unwrap() = None; + } + + #[test] + #[serial] + fn interpret_project_reports_missing_method_and_input_mismatch() { + #[derive(serde::Deserialize)] + struct TestInterpretResult { + success: bool, + error: Option, + } + + let files = serde_json::json!({ + "entry": ["main"], + "files": [[["main"], "fn main(q: Felt) -> Felt { return q * 2; }"]] + }); + + // A method name that matches no circuit fails interpretation with an + // "undefined function" diagnostic rather than an empty success. + let result: TestInterpretResult = serde_json::from_str(&interpret_project( + &files.to_string(), + r#"{"method_name": "does_not_exist", "inputs": []}"#, + )) + .unwrap(); + assert!(!result.success, "unknown method must not interpret: {:?}", result.error); + assert!( + result.error.as_deref().unwrap_or_default().to_lowercase().contains("undefined function"), + "{:?}", + result.error + ); + + // A failing constant assertion is rejected before execution. + let failing = serde_json::json!({ + "entry": ["main"], + "files": [[["main"], "fn main() { assert(false, \"boom\"); }"]] + }); + let result: TestInterpretResult = + serde_json::from_str(&interpret_project(&failing.to_string(), r#"{"inputs": []}"#)).unwrap(); + assert!(!result.success, "a failing assert must fail execution: {:?}", result.error); + + // NOTE: a *runtime* assertion failure (e.g. `assert(q == 0)` with + // input 5) executes to Ok with a `failure` record inside + // execution_result β€” it does NOT take the executor error path, so it + // is deliberately not asserted as `success == false` here. + } + + #[test] + fn interpret_project_reports_vfs_build_errors() { + #[derive(serde::Deserialize)] + struct TestInterpretResult { + success: bool, + error: Option, + } + + // A project without any files cannot build a VFS. + let empty = serde_json::json!({ "entry": ["main"], "files": [] }); + let result: TestInterpretResult = + serde_json::from_str(&interpret_project(&empty.to_string(), r#"{"inputs": []}"#)).unwrap(); + assert!(!result.success); + assert!( + result.error.as_deref().unwrap_or_default().contains("at least one file"), + "{:?}", + result.error + ); + + // An entry module that is not among the uploaded files is rejected. + let missing_entry = serde_json::json!({ "entry": ["main"], "files": [[["other"], "fn main() {}"]] }); + let result: TestInterpretResult = + serde_json::from_str(&interpret_project(&missing_entry.to_string(), r#"{"inputs": []}"#)).unwrap(); + assert!(!result.success); + assert!( + result.error.as_deref().unwrap_or_default().contains("entry file was not found"), + "{:?}", + result.error + ); + } + + // NOTE: circuit input arity is deliberately not asserted here β€” the VM + // executor tolerates both extra inputs and missing ones (defaults to 0), + // so `execute_circuit` only fails on genuine execution errors. + + #[test] + #[serial] + fn deploy_contract_requires_a_cached_compile() { + init_chain(); + let account: serde_json::Value = serde_json::from_str(&create_account("Carol")).unwrap(); + let carol_id = account["user_id"].as_u64().unwrap(); + + *LAST_COMPILE.lock().unwrap() = None; + let result: serde_json::Value = serde_json::from_str(&deploy_contract(carol_id)).unwrap(); + assert_eq!(result["success"], false); + assert_eq!( + result["error"].as_str(), + Some("No compiled contract. Compile first."), + "deploy without a compile must be rejected: {result}" + ); + } } From fb5da24b72d2672fc4553d4de173a01058350cea Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 7 Sep 2026 10:39:34 +0800 Subject: [PATCH 04/12] test: cover ast/interpreter/parser edge modules Add dedicated edge-case suites for the remaining low-coverage modules: - psy-ast: new api_edge_tests module covering location/value/program helpers and module re-exports - psy-interpreter: sema_edge_tests (552 lines) exercising sema lookup, resolver, and type edges through the interpreter entry; visualizer tests for debug output paths - psy-parser: statement/trivia edge parsing and lib-level entry paths - psy-common: file resolver edge cases - psy-lsp-server: simple protocol handler paths - psy-abi: abi.rs wire-shape edges --- psy-abi/src/abi.rs | 60 +++ psy-ast/src/api_edge_tests.rs | 118 +++++ psy-ast/src/lib.rs | 3 + psy-ast/src/location.rs | 30 ++ psy-ast/src/program.rs | 30 ++ psy-ast/src/value.rs | 50 +++ psy-common/src/file_resolver.rs | 44 ++ psy-interpreter/src/lib.rs | 9 + psy-interpreter/src/sema_edge_tests.rs | 552 ++++++++++++++++++++++++ psy-interpreter/src/visualizer_tests.rs | 59 +++ psy-lsp-server/src/simple.rs | 146 +++++++ psy-package/src/package.rs | 6 + psy-parser/src/lib.rs | 215 +++++++++ psy-parser/src/recursive/statement.rs | 59 +++ psy-parser/src/recursive/trivia.rs | 56 +++ psy-sema/src/rewriter.rs | 18 +- 16 files changed, 1446 insertions(+), 9 deletions(-) create mode 100644 psy-ast/src/api_edge_tests.rs diff --git a/psy-abi/src/abi.rs b/psy-abi/src/abi.rs index 4b8e3f0c4..009f3c4a4 100644 --- a/psy-abi/src/abi.rs +++ b/psy-abi/src/abi.rs @@ -284,3 +284,63 @@ mod tests { ); } } + +#[cfg(test)] +mod type_spec_tests { + use psy_ast::{ConstValue, Identifier, Location, PathNode, Program}; + + use super::*; + + fn ident>(program: &mut Program, name: &str) -> Identifier { + Identifier::new(program.interner.intern_ident(name), Location::default()) + } + + #[test] + fn from_unchecked_type_covers_every_classified_shape() { + let mut program = Program::::new(); + let felt = ident(&mut program, "Felt"); + let index_map = ident(&mut program, "IndexMap"); + let owner = ident(&mut program, "Owner"); + let ctx = &mut DefaultVisitorContext::::new(&mut program); + + // Basic identifiers map to their plain name. + let basic = UncheckedType::Basic(felt); + assert!(matches!(TypeAbiSpec::from_unchecked_type(&basic, ctx), TypeAbiSpec::Basic(name) if name == "Felt")); + + // Arrays record the innermost element type and their length. + let array = UncheckedType::Array(Box::new(basic.clone()), ConstValue::U32(3), Location::default()); + match TypeAbiSpec::from_unchecked_type(&array, ctx) { + TypeAbiSpec::Array { type_name, inner_type, length } => { + assert_eq!((type_name.as_str(), inner_type.as_str(), length), ("Array", "Felt", 3)); + } + other => panic!("expected array spec, got {other:?}"), + } + + // Nested arrays keep the innermost element type with the outer length. + let nested = UncheckedType::Array(Box::new(array), ConstValue::U32(5), Location::default()); + match TypeAbiSpec::from_unchecked_type(&nested, ctx) { + TypeAbiSpec::Array { inner_type, length, .. } => { + assert_eq!((inner_type.as_str(), length), ("Felt", 5)); + } + other => panic!("expected nested array spec, got {other:?}"), + } + + // Generic types surface only the outer name. + let generic = UncheckedType::Generic(index_map, vec![basic], Location::default()); + assert!(matches!(TypeAbiSpec::from_unchecked_type(&generic, ctx), TypeAbiSpec::Basic(name) if name == "IndexMap")); + + // Paths classify by their target. + let path = UncheckedType::Path(Box::new(PathNode::from_target(UncheckedType::Basic(owner)))); + assert!(matches!(TypeAbiSpec::from_unchecked_type(&path, ctx), TypeAbiSpec::Basic(name) if name == "Owner")); + + // Everything else falls back to "unknown". + let tuple = UncheckedType::Tuple(vec![], Location::default()); + assert!(matches!(TypeAbiSpec::from_unchecked_type(&tuple, ctx), TypeAbiSpec::Basic(name) if name == "unknown")); + let unknown = UncheckedType::Const(ConstValue::Bool(true), Location::default()); + assert!(matches!(TypeAbiSpec::from_unchecked_type(&unknown, ctx), TypeAbiSpec::Basic(name) if name == "unknown")); + assert!(matches!( + TypeAbiSpec::from_unchecked_type(&UncheckedType::Unknown, ctx), + TypeAbiSpec::Basic(name) if name == "unknown" + )); + } +} diff --git a/psy-ast/src/api_edge_tests.rs b/psy-ast/src/api_edge_tests.rs new file mode 100644 index 000000000..363d95831 --- /dev/null +++ b/psy-ast/src/api_edge_tests.rs @@ -0,0 +1,118 @@ +// Direct unit coverage for the small psy-ast conversion/accessor impls that +// source-driven tests never reach: id conversions, NodeInfo helpers, Display +// impls, Program's Index/IndexMut wiring, and module-graph printing. + +use psy_common::FileId; + +use crate::{ + Comment, CommentNode, CrateId, DefId, ExprId, GenericQualifier, Ident, IdentId, Identifier, Location, MatchPattern, ModuleId, ModuleNode, + NodeInfo, NodeType, Program, StmtNode, UncheckedType, Visibility, +}; + +#[test] +fn id_and_node_conversions_round_trip() { + let module_id = ModuleId(3); + assert_eq!(CrateId::from_module_id(module_id), CrateId(3)); + assert_eq!(CrateId::from(module_id), CrateId(3)); + assert_eq!(CrateId::from(&module_id), CrateId(3)); + assert_eq!(ModuleId::from(CrateId(3)), module_id); + + assert!(GenericQualifier::new(true).is_const); + assert!(!GenericQualifier::new(false).is_const); +} + +#[test] +fn node_info_helpers_and_displays_cover_every_shape() { + // Statement accessors and From impls. + let expression = StmtNode::Expression(ExprId(5)); + assert_eq!(expression.as_expression(), Some(&ExprId(5))); + assert_eq!(expression.as_definition(), None); + let definition = StmtNode::Definition(DefId(6)); + assert_eq!(definition.as_definition(), Some(&DefId(6))); + assert_eq!(definition.as_expression(), None); + assert!(matches!(StmtNode::from(ExprId(7)), StmtNode::Expression(ExprId(7)))); + assert!(matches!(StmtNode::from(DefId(8)), StmtNode::Definition(DefId(8)))); + + // Match patterns expose their locations. + let location = Location::new(FileId(1), 2, 3); + assert_eq!(MatchPattern::Value(ExprId(0), location).location(), location); + assert_eq!(MatchPattern::PlaceHolder(location).location(), location); + + // Comments expose location and render with their delimiters. + let line = Comment::new_line("note".to_string(), location); + let block = Comment::new_block("aside".to_string(), location); + assert_eq!(line.location(), location); + assert_eq!(block.location(), location); + assert_eq!(line.to_string(), "// note"); + assert_eq!(block.to_string(), "/* aside */"); + + // Comment definitions report their node type. + assert_eq!(CommentNode { comments: vec![line], location }.node_type(), NodeType::Comment); + + // basic_target only resolves for basic types. + let basic = UncheckedType::Basic(Identifier::new(IdentId(1), location)); + assert_eq!(basic.basic_target(), Some(Identifier::new(IdentId(1), location))); + assert_eq!(UncheckedType::Unknown.basic_target(), None); +} + +#[test] +fn identifier_equality_compares_against_bare_ids() { + let location = Location::default(); + let identifier = Identifier::new(IdentId(4), location); + assert!(IdentId(4) == identifier); + assert!(identifier == IdentId(4)); + assert!(IdentId(5) != identifier); + assert!(identifier != IdentId(5)); + + let mut interner = crate::Interner::new(); + let id = interner.intern_ident("plain"); + interner[id] = Ident::from("renamed"); + assert_eq!(interner[id], Ident::from("renamed")); +} + +#[test] +fn program_indexes_mutate_and_print_the_module_graph() { + let mut program = Program::::new(); + let location = Location::default(); + + let alpha_name = program.interner.intern_ident("alpha"); + let alpha = program.modules.add_node(ModuleNode::new( + Identifier::new(alpha_name, location), + FileId(0), + Visibility::Public, + Vec::new(), + &mut program.defs, + Vec::new(), + location, + )); + let beta_name = program.interner.intern_ident("beta"); + let beta = program.modules.add_node(ModuleNode::new( + Identifier::new(beta_name, location), + FileId(0), + Visibility::Public, + Vec::new(), + &mut program.defs, + Vec::new(), + location, + )); + + program.add_module_child(Some(alpha), beta); + program.dependency_graph.add_node(CrateId::from_module_id(alpha)); + program.dependency_graph.add_node(CrateId::from_module_id(beta)); + program.dependency_graph.add_edge(CrateId::from_module_id(beta), CrateId::from_module_id(alpha)); + assert_eq!(program.modules.iter().count(), 2); + + // IndexMut through Program for every arena. + let stmt_id = program.stmts.alloc_item(StmtNode::Expression(ExprId(0))); + program[stmt_id] = StmtNode::Expression(ExprId(1)); + assert_eq!(program[stmt_id].as_expression(), Some(&ExprId(1))); + + program[alpha].data_mut().visibility = Visibility::Private; + assert_eq!(program.modules[alpha].data().visibility, Visibility::Private); + + program[alpha_name] = Ident::from("renamed_alpha"); + assert_eq!(program.module_name(alpha), &Ident::from("renamed_alpha")); + + // Printing must walk modules, children, and dependencies without panicking. + program.print_module_graph(); +} diff --git a/psy-ast/src/lib.rs b/psy-ast/src/lib.rs index e1f9bbba5..eb4d7e942 100644 --- a/psy-ast/src/lib.rs +++ b/psy-ast/src/lib.rs @@ -14,6 +14,9 @@ mod r#type; mod value; mod visibility; +#[cfg(test)] +mod api_edge_tests; + pub use arena::*; pub use comment::*; pub use definition::*; diff --git a/psy-ast/src/location.rs b/psy-ast/src/location.rs index f88794711..822116d96 100644 --- a/psy-ast/src/location.rs +++ b/psy-ast/src/location.rs @@ -61,3 +61,33 @@ impl ariadne::Span for FileLocation { self.end } } + +#[cfg(test)] +mod tests { + use ariadne::Span; + use psy_common::FileId; + + use super::*; + + #[test] + fn locations_construct_and_default() { + let location = Location::new(FileId(3), 5, 9); + assert_eq!((location.file_id, location.start, location.end), (FileId(3), 5, 9)); + + let default = Location::default(); + assert_eq!((default.file_id, default.start, default.end), (FileId(0), 0, 0)); + } + + #[test] + fn file_locations_construct_default_and_render_as_spans() { + let file_location = FileLocation::new("main.psy".to_string(), 2, 7); + assert_eq!(file_location.path, "main.psy"); + assert_eq!(file_location.start(), 2); + assert_eq!(file_location.end(), 7); + assert_eq!(file_location.source(), "main.psy"); + + let default = FileLocation::default(); + assert_eq!((default.path.as_str(), default.start, default.end), ("", 0, 0)); + assert_eq!(default.source(), ""); + } +} diff --git a/psy-ast/src/program.rs b/psy-ast/src/program.rs index 77a5e9521..334ec0af4 100644 --- a/psy-ast/src/program.rs +++ b/psy-ast/src/program.rs @@ -179,4 +179,34 @@ mod tests { assert!(program.is_module_std(nested)); assert!(!program.is_module_std(root)); } + #[test] + fn program_index_and_index_mut_reach_every_arena() { + let mut program = Program::::new(); + let location = Location::new(FileId(0), 0, 0); + + let expr_id = program.exprs.alloc_item(ExprNode::Value(crate::ValueNode::Felt(1, location))); + let replacement = ExprNode::Value(crate::ValueNode::Felt(2, location)); + assert!(matches!(&program[expr_id], ExprNode::Value(node) if matches!(node, crate::ValueNode::Felt(1, _)))); + program[expr_id] = replacement; + assert!(matches!(&program[expr_id], ExprNode::Value(node) if matches!(node, crate::ValueNode::Felt(2, _)))); + + let ident_id = program.interner.intern_ident("indexed"); + assert_eq!(program[ident_id].to_string(), "indexed"); + } + + #[test] + fn print_module_graph_renders_children_and_dependencies() { + let mut program = Program::::new(); + let root_node = module(&mut program, "root"); + let child_node = module(&mut program, "child"); + let root = program.modules.add_node(root_node); + let child = program.modules.add_node(child_node); + program.add_module_child(Some(root), child); + program.dependency_graph.add_edge(CrateId::from(root), CrateId::from(child)); + + // Renders to stdout; the assertions guard the data the printer walks. + assert_eq!(program.modules.len(), 2); + assert_eq!(program.modules[child].parent(), Some(root)); + program.print_module_graph(); + } } diff --git a/psy-ast/src/value.rs b/psy-ast/src/value.rs index 8b19aed4e..5ff6956c7 100644 --- a/psy-ast/src/value.rs +++ b/psy-ast/src/value.rs @@ -95,3 +95,53 @@ impl> Display for ValueNode { } } } + +#[cfg(test)] +mod tests { + use crate::{ExprId, IdentId, Identifier, Location, NodeType}; + + use super::*; + + #[test] + fn const_value_conversions_and_display_round_trip() { + assert_eq!(ConstValue::from(true), ConstValue::Bool(true)); + assert_eq!(ConstValue::from(7u32), ConstValue::U32(7)); + assert_eq!(ConstValue::from(9u64), ConstValue::Felt(9)); + + assert_eq!(ConstValue::Felt(3).as_u64(), Some(3)); + assert_eq!(ConstValue::U32(5).as_u64(), Some(5)); + assert_eq!(ConstValue::Bool(false).as_u64(), None); + + assert_eq!(ConstValue::Felt(12).to_string(), "12"); + assert_eq!(ConstValue::U32(34).to_string(), "34"); + assert_eq!(ConstValue::Bool(true).to_string(), "true"); + } + + #[test] + fn value_node_displays_every_variant() { + let location = Location::default(); + assert_eq!(ValueNode::::Felt(1, location).to_string(), "Felt"); + assert_eq!(ValueNode::::Bool(1, location).to_string(), "Bool"); + assert_eq!(ValueNode::::U32(1, location).to_string(), "U32"); + + let array = ValueNode::::Array(ConstValue::from(2u32), vec![ExprId(0), ExprId(1)], location); + let rendered = array.to_string(); + assert!(rendered.starts_with("Array("), "array display: {rendered}"); + assert!(rendered.contains(", "), "array display joins elements: {rendered}"); + assert!(rendered.ends_with(')'), "array display: {rendered}"); + let empty = ValueNode::::Array(ConstValue::from(0u32), vec![], location); + assert_eq!(empty.to_string(), "Array()"); + + let repeat = ValueNode::::ArrayRepeat(ExprId(0), ConstValue::from(3u32), location); + assert!(repeat.to_string().starts_with("ArrayRepeat(")); + + let mut fields = IndexMap::new(); + fields.insert(Identifier::new(IdentId(0), location), ExprId(0)); + let structure = ValueNode::::Struct(ExprId(2), vec![], fields, location); + let rendered = structure.to_string(); + assert!(rendered.starts_with("Struct "), "struct display: {rendered}"); + assert!(rendered.ends_with(" }"), "struct display: {rendered}"); + + assert_eq!(ValueNode::::Felt(1, location).node_type(), NodeType::ValueExpr); + } +} diff --git a/psy-common/src/file_resolver.rs b/psy-common/src/file_resolver.rs index ff69ac71f..aa74a7092 100644 --- a/psy-common/src/file_resolver.rs +++ b/psy-common/src/file_resolver.rs @@ -236,3 +236,47 @@ mod tests { assert!(resolver.resolve_content(&initial_id).unwrap().starts_with("content-")); } } + +#[cfg(test)] +mod resolver_edge_tests { + use super::*; + + #[test] + fn default_resolver_matches_new_and_resolves_added_files() { + let resolver = FileResolver::default(); + assert!(resolver.files().is_empty()); + + let file_id = resolver.add_file(PathBuf::from("default_probe.psy"), "body"); + assert_eq!(resolver.resolve_content_arc(&file_id).as_deref(), Some("body")); + assert_eq!(resolver.resolve_path_content(Path::new("default_probe.psy")).as_deref(), Some("body")); + assert_eq!(resolver.resolve_id(Path::new("default_probe.psy")), Some(file_id)); + } + + #[test] + fn resolve_file_reads_real_files_and_deduplicates_by_path() { + let path = std::env::temp_dir().join("resolver_real_file_probe.psy"); + std::fs::write(&path, "fn main() {}").unwrap(); + + let resolver = FileResolver::new(); + let file_id = resolver.resolve_file(path.clone()).expect("resolve real file"); + let again = resolver.resolve_file(path.clone()).expect("resolve same file again"); + assert_eq!(file_id, again, "the same path must reuse its file id"); + assert_eq!(resolver.resolve_content(&file_id).as_deref(), Some("fn main() {}")); + + let missing = resolver.resolve_file(std::env::temp_dir().join("definitely_missing_probe.psy")); + assert!(missing.is_err(), "reading a missing file must fail"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn dotted_paths_normalize_to_their_canonical_form() { + let resolver = FileResolver::new(); + let file_id = resolver.add_file(PathBuf::from("probe_dir/./nested/../target.psy"), "content"); + + // `./` and `..` components collapse, so the plain path resolves to the + // same id even though the dotted original never exists on disk. + assert_eq!(resolver.resolve_id(Path::new("probe_dir/target.psy")), Some(file_id)); + assert_eq!(resolver.resolve_path_content(Path::new("probe_dir/target.psy")).as_deref(), Some("content")); + } +} diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index fa1ab5803..5383fde94 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -711,6 +711,15 @@ impl, C: DPNContext + 'static> Interpreter { where F: 'static, { + // Same rationale as `typecheck_single`: the LSP server typechecks + // repeatedly in one process, so clear the process-global primitive + // scope handle before repopulating it against this call's symbol + // table. Without this, the second typecheck in a session resolves + // std names (e.g. `Array` in storage.psy) against a stale scope. + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + } let mut program = Program::new(); let mut parser = Parser::new(&mut program, &mut self.context, crate_path_graph); diff --git a/psy-interpreter/src/sema_edge_tests.rs b/psy-interpreter/src/sema_edge_tests.rs index ea0a8959f..4ea1786e6 100644 --- a/psy-interpreter/src/sema_edge_tests.rs +++ b/psy-interpreter/src/sema_edge_tests.rs @@ -1080,3 +1080,555 @@ fn main() {{ let p: P::Pair = P::make_pair(); }}" ), ); } + +#[test] +#[serial] +fn deep_associated_type_chains_resolve() { + // Two levels past a trait cast (`

::Assoc::Mid::Leaf`) walk the + // trait-cast segment loop, and module-rooted chains (`deep::H0::Inner::Leaf`) + // walk the module path's member loop. + accepts( + "trait cast and module chains with two segments", + &format!( + "{PRELUDE} +pub struct Lvl0 {{ pub x: Felt }} +impl Lvl0 {{ pub type Mid = Lvl1; }} +pub struct Lvl1 {{ pub y: Felt }} +impl Lvl1 {{ pub type Leaf = Felt; }} + +pub trait Outer {{ pub type Assoc; }} +pub struct P {{ pub x: Felt }} +impl Outer for P {{ pub type Assoc = Lvl0; }} + +pub mod deep {{ + pub struct H0 {{ pub x: Felt }} + impl H0 {{ pub type Inner = H1; }} + pub struct H1 {{ pub y: Felt }} + impl H1 {{ pub type Leaf = Felt; }} +}} + +fn probe(v:

::Assoc::Mid::Leaf) ->

::Assoc::Mid::Leaf {{ + return v; +}} + +fn main() {{ + let a: deep::H0::Inner::Leaf = 4; + let b = probe(a); + assert_eq(b, 4, \"deep chains\"); +}}" + ), + ); + rejects( + "trait cast chain through a non-type member", + &format!( + "{PRELUDE} +pub struct Lvl0 {{ pub x: Felt }} +pub trait Outer {{ pub type Assoc; }} +pub struct P {{ pub x: Felt }} +impl Outer for P {{ pub type Assoc = Lvl0; }} + +fn bad(v:

::Assoc::Mid) -> Felt {{ return 1; }} +fn main() {{ bad(1); }}" + ), + "mid", + ); +} + +#[test] +#[serial] +fn generic_instantiation_rewrites_impls_signatures_and_bodies() { + // A generic inherent impl with an associated type plus a generic method + // drives instantiate_impl (assoc types + per-method signature rewriting). + accepts( + "generic inherent impl with assoc type and generic method", + &format!( + "{PRELUDE} +pub struct Pair {{ pub a: T, pub b: T }} + +impl Pair {{ + pub type Item = T; + pub fn first(self: Self) -> T {{ return self.a; }} + pub fn pick(self: Pair, other: S) -> S {{ return other; }} +}} + +fn main() {{ + let p = Pair {{ a: 1, b: 2 }}; + let f = p.first(); + let s = p.pick(9); + assert_eq(f + s, 10, \"inherent generics\"); +}}" + ), + ); + // A generic function whose parameter and return types are rooted type + // paths rewrites those paths during instantiation. + // NOTE: module-rooted paths (`m::H`) as generic-fn parameter types reach + // instantiate_function with a root-less checked path and panic on + // `type_path.root.unwrap()` (rewriter.rs:253); type-rooted paths + // (`P::Pair`) carry a root and rewrite cleanly. + accepts( + "generic function with type-rooted path parameter and return", + &format!( + "{PRELUDE}{STRUCT_P} +impl P {{ + pub type Pair = (Felt, Felt); +}} + +fn through(pair: P::Pair, t: T) -> P::Pair {{ + assert_eq(pair.0, pair.0, \"stable\"); + return pair; +}} + +fn main() {{ + let h2 = through((3, 4), 1); + assert_eq(h2.0, 3, \"path rewrite\"); +}}" + ), + ); +} + +#[test] +#[serial] +fn trait_impl_associated_types_rewrite_through_roots() { + // An associated type whose value is itself a rooted path (`Src::Native`) + // takes the root-substitution branch when the generic impl is instantiated. + accepts( + "generic trait impl with a rooted associated type path", + &format!( + "{PRELUDE} +pub struct Src {{ pub q: Felt }} +impl Src {{ pub type Native = Felt; }} + +pub trait Wrap {{ pub type Out; pub fn unwrap(self: Self) -> Felt; }} +pub struct Box2 {{ pub v: T }} + +impl Wrap for Box2 {{ + pub type Out = Src::Native; + pub fn unwrap(self: Self) -> Felt {{ return 1; }} +}} + +fn main() {{ + let b = Box2 {{ v: 1 }}; + let o: as Wrap>::Out = 7; + let r = b.unwrap(); + assert_eq(o + r, 8, \"rooted assoc\"); +}}" + ), + ); +} + +#[test] +#[serial] +fn impl_search_rejects_conflicting_generic_arguments() { + // The concrete `Number` implementations cannot serve a `Number` + // receiver, so instantiation unification fails and the call is rejected. + rejects( + "concrete trait impl for another generic argument", + &format!( + "{PRELUDE} +pub trait Mul {{ pub fn mul(self: Self) -> Felt; }} +pub struct Number {{ pub a: T, pub b: T }} + +impl Mul for Number {{ + pub fn mul(self: Self) -> Felt {{ return (self.a * self.b) as Felt; }} +}} + +fn main() {{ + let n = Number {{ a: 1, b: 2 }}; + let r = n.mul(); + assert_eq(r, 1, \"unused\"); +}}" + ), + "mul", + ); + rejects( + "concrete inherent impl for another generic argument", + &format!( + "{PRELUDE} +pub struct Number {{ pub a: T, pub b: T }} + +impl Number {{ + pub fn get(self: Self) -> Felt {{ return self.a as Felt; }} +}} + +fn main() {{ + let n = Number {{ a: 1, b: 2 }}; + let g = n.get(); + assert_eq(g, 1, \"unused\"); +}}" + ), + "get", + ); +} + +#[test] +#[serial] +fn bare_generic_calls_walk_scopes_for_matching_functions() { + accepts( + "bare call to a generic function", + &format!( + "{PRELUDE} +fn pick(x: T) -> Felt {{ return x as Felt; }} + +fn main() {{ + let r = pick(5); + assert_eq(r, 5, \"bare generic call\"); +}}" + ), + ); + rejects( + "bare call to an unresolved function", "fn main() { missing_fn(1); }", "missing_fn", + ); +} + +#[test] +#[serial] +fn crate_paths_resolve_from_nested_modules() { + accepts( + "crate root path from inside an inline module", + &format!( + "{PRELUDE} +pub mod inner {{ + pub fn five() -> Felt {{ return 5; }} + pub fn call_out() -> Felt {{ return crate::inner::five(); }} +}} + +fn main() {{ + let v = inner::call_out(); + assert_eq(v, 5, \"crate path\"); +}}" + ), + ); +} + +#[test] +#[serial] +fn generic_bodies_rewrite_definitions_asserts_structs_and_matches() { + // Every statement/expression shape inside a generic function body runs + // through the rewriter when the function is instantiated: nested + // definitions, assert_eq, struct literals, and match patterns. + accepts( + "generic body with nested definition, assert_eq, struct literal, and match", + &format!( + "{PRELUDE} +pub struct Point {{ pub x: Felt }} + +fn shapes(v: T) -> Felt {{ + struct Inner {{ pub v: Felt }} + let p = Point {{ x: 1 }}; + assert_eq(v as Felt, v as Felt, \"same\"); + let m = match p.x {{ 1 => 10, _ => 20 }}; + return m + p.x; +}} + +fn main() {{ + let r = shapes(3); + assert_eq(r, 11, \"shapes\"); +}}" + ), + ); +} + +#[test] +#[serial] +fn inherent_impls_rewrite_rooted_associated_types_when_generic() { + // An associated type whose value is a rooted path (`Src::Native`) inside + // a *generic* inherent impl exercises the rewriter's root/target branch + // for inherent impls, plus generic-method signature instantiation. + accepts( + "rooted associated type inside a generic inherent impl", + &format!( + "{PRELUDE} +pub struct Src {{ pub v: Felt }} +impl Src {{ pub type Native = Felt; pub fn nat(self: Self) -> Felt {{ return 1; }} }} + +pub struct Box2 {{ pub item: T }} +impl Box2 {{ + pub type Native = Src::Native; + pub fn pick(self: Self, other: S) -> S {{ return other; }} +}} + +fn main() {{ + let b: Box2 = Box2 {{ item: 2 }}; + let r = b.pick(9); + assert_eq(r, 9, \"rooted assoc type in generic impl\"); +}}" + ), + ); +} + +#[test] +#[serial] +fn generic_unification_rejects_conflicting_arguments() { + let mut failures = Vec::new(); + for (label, source, needle) in [ + ( + "turbofish argument conflicts with the value argument", + &format!( + "{PRELUDE} +fn g(x: T) -> Felt {{ return x as Felt; }} + +fn main() {{ let r = g::(true); }}" + ), + "mismatch", + ), + ( + "method turbofish argument conflicts with the value argument", + &format!( + "{PRELUDE} +pub struct P2 {{ pub x: Felt }} +impl P2 {{ pub fn pick(self: Self, other: S) -> Felt {{ return 1; }} }} + +fn main() {{ let r = P2 {{ x: 1 }}.pick::(true); }}" + ), + "mismatch", + ), + ] { + match compile(source) { + Ok(()) => failures.push(format!("[{label}] expected rejection containing `{needle}`, got success")), + Err(message) => { + if !message.to_lowercase().contains(&needle.to_lowercase()) { + failures.push(format!("[{label}] expected rejection containing `{needle}`, got:\n{message}")); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} + +#[test] +#[serial] +fn trait_cast_paths_resolve_through_the_trait_segment() { + accepts( + "fully qualified trait method call", + &format!( + "{PRELUDE} +pub struct Number {{ pub a: Felt }} +pub trait Mul {{ pub fn mul(self: Self) -> Felt; }} +impl Mul for Number {{ pub fn mul(self: Self) -> Felt {{ return self.a; }} }} + +fn main() {{ + let n = Number {{ a: 2 }}; + let res = ::mul(n); + assert_eq(res, 2, \"trait cast path\"); +}}" + ), + ); +} + +#[test] +#[serial] +fn imports_of_unknown_modules_are_rejected() { + rejects( + "import from an unresolved module", + &format!("{PRELUDE}use nonexistent_module::thing;"), + "nonexistent_module", + ); +} + +#[test] +#[serial] +fn member_function_references_and_bare_type_values_rewrite() { + // A method may not be referenced without a call (no first-class method + // values), while a bare type name in value position is accepted. + rejects( + "method accessed without a call", + &format!( + "{PRELUDE} +pub struct P3 {{ pub x: Felt }} +impl P3 {{ pub fn nat(self: Self) -> Felt {{ return 1; }} }} + +fn wrap(v: T) -> Felt {{ + let f = P3 {{ x: 1 }}.nat; + return 1; +}} + +fn main() {{ let v = wrap(1); }}" + ), + "unresolvedmember", + ); + accepts( + "bare type name in value position inside a generic body", + &format!( + "{PRELUDE} +pub struct Marker {{ pub x: Felt }} + +fn mark(v: T) -> Felt {{ + Marker; + return 1; +}} + +fn main() {{ let v = mark(1); }}" + ), + ); +} + +#[test] +#[serial] +fn index_access_and_member_visibility_guards() { + rejects( + "array index with a boolean subscript", + &format!( + "{PRELUDE} +fn main() -> Felt {{ + let a: [Felt; 3] = [1, 2, 3]; + return a[true]; +}}" + ), + "mismatch", + ); + // A private method is callable from its own module but not across modules. + accepts( + "private method called from its own module", + &format!( + "{PRELUDE} +pub struct S {{ pub x: Felt }} +impl S {{ + fn hidden(self: Self) -> Felt {{ return 1; }} + pub fn make() -> S {{ return S {{ x: 0 }}; }} +}} + +fn main() -> Felt {{ + let v = S::make().hidden(); + return v; +}}" + ), + ); + accepts( + "private method stays callable from a sibling module in the crate", + &format!( + "{PRELUDE} +pub mod m {{ + pub struct S {{ pub x: Felt }} + impl S {{ + fn hidden(self: Self) -> Felt {{ return 1; }} + pub fn make() -> S {{ return S {{ x: 0 }}; }} + }} +}} + +fn main() -> Felt {{ + let v = m::S::make().hidden(); + return v; +}}" + ), + ); +} + +#[test] +#[serial] +fn unification_walks_signatures_and_tuples() { + // Function values are not first-class: a function name passed for a + // fn-signature parameter is rejected, and the diagnostic renders the + // substituted signature (FunctionSignature arm of the unifier). + rejects( + "function value passed for a fn-signature parameter", + &format!( + "{PRELUDE} +fn double(v: Felt) -> Felt {{ return v + v; }} + +fn call_it(f: fn(T) -> Felt, x: T) -> Felt {{ + return f(x); +}} + +fn main() -> Felt {{ + return call_it(double, 3); +}}" + ), + "signature", + ); + rejects( + "conflicting tuple arguments for one generic parameter", + &format!( + "{PRELUDE} +fn two(a: T, b: T) -> Felt {{ return 1; }} + +fn main() -> Felt {{ + return two((1, 2), (1, true)); +}}" + ), + "mismatch", + ); +} + +/// Panics inside preprocessing must stay observable as panics (they abort the +/// compiler), so assert on the message while resetting the primitive scope. +#[test] +#[serial] +fn storage_preprocessing_panics_on_malformed_refs() { + let cases = [ + ( + "#[ref] on a non-basic field", + &format!( + "{PRELUDE} +#[contract] +#[derive(Storage)] +pub struct C {{ + #[ref] + pub arr: [Felt; 2], +}} + +fn main() -> Felt {{ return 0; }}" + ), + "basic struct", + ), + ( + "StorageRef with two generic parameters", + &format!( + "{PRELUDE} +#[contract] +#[derive(Storage)] +pub struct C {{ + pub s: StorageRef, +}} + +fn main() -> Felt {{ return 0; }}" + ), + "exactly one generic parameter", + ), + ( + "ArrayRef with one generic parameter", + &format!( + "{PRELUDE} +#[contract] +#[derive(Storage)] +pub struct C {{ + pub s: ArrayRef, +}} + +fn main() -> Felt {{ return 0; }}" + ), + "exactly two generic parameters", + ), + ]; + let mut failures = Vec::new(); + for (label, source, needle) in cases { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("psy_se_{n}.psy")); + std::fs::write(&path, source).unwrap(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut interpreter = Interpreter::::new(QExecContext::new()); + let _ = interpreter.typecheck_single(path.clone()); + })); + let _ = std::fs::remove_file(&path); + #[allow(static_mut_refs)] + unsafe { + let _ = STD_PRIMITIVE_SCOPE_ID.take(); + } + + let message = match result { + Err(message) => message + .downcast_ref::() + .cloned() + .or_else(|| message.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_default(), + Ok(()) => { + failures.push(format!("[{label}] expected a panic containing `{needle}`, got a clean return")); + continue; + } + }; + if !message.to_lowercase().contains(&needle.to_lowercase()) { + failures.push(format!("[{label}] expected panic containing `{needle}`, got: {message}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} diff --git a/psy-interpreter/src/visualizer_tests.rs b/psy-interpreter/src/visualizer_tests.rs index e88f1a3e0..4d9e65636 100644 --- a/psy-interpreter/src/visualizer_tests.rs +++ b/psy-interpreter/src/visualizer_tests.rs @@ -375,3 +375,62 @@ fn debug_renderers_cover_const_fns_attrs_and_expression_statements() { // Block-expr comments do not survive checking (the rewriter rebuilds // the node without them), so no expr-comments assertion here. } + +/// A fourth fixture for renderer shapes the others lack: inherent-impl +/// associated types, function-signature parameters, and definitions nested +/// inside function bodies. +const IMPL_SOURCE: &str = r#" +use std::prelude::*; + +// A commented struct so the definition's comments section renders. +pub struct Holder { + pub shape: Felt, +} + +impl Holder { + pub type Native = Felt; + pub fn nat(self: Self) -> Felt { + return 1; + } +} + +// A commented function so the function definition's comments render. +fn trans(x: Felt) -> Felt { + return x; +} + +fn apply(op: fn(Felt) -> Felt) -> Felt { + return op(2); +} + +fn main() -> Felt { + let h = Holder { shape: 1 }; + return h.nat() + apply(trans); +} +"#; + +#[test] +#[serial] +fn debug_renderers_cover_impl_assoc_types_and_signature_params() { + let c = typecheck_virtual(IMPL_SOURCE); + + // The inherent impl renders its associated types block. + let mut defs = String::new(); + for index in 0..c.ctx.program.defs.len() { + defs.push_str(&c.ctx.debug_definition(DefId(index))); + defs.push('\n'); + } + assert!(defs.contains("Associated Types"), "impl assoc types missing:\n{defs}"); + assert!(defs.contains("Native"), "assoc type name missing:\n{defs}"); + assert!(defs.contains("commented struct"), "struct doc comment missing:\n{defs}"); + assert!(defs.contains("commented function"), "function doc comment missing:\n{defs}"); + + // A fn-signature parameter renders through get_type_name's + // FunctionSignature arm. + let mut types = String::new(); + for index in 0..c.ctx.symbols.types.len() { + types.push_str(&c.ctx.debug_type(TypeId::from(index))); + types.push('\n'); + } + assert!(types.contains("FunctionSignature"), "fn-signature type name missing:\n{types}"); +} diff --git a/psy-lsp-server/src/simple.rs b/psy-lsp-server/src/simple.rs index f2678e4de..127b1e246 100644 --- a/psy-lsp-server/src/simple.rs +++ b/psy-lsp-server/src/simple.rs @@ -1172,4 +1172,150 @@ mod tests { drop(dir); } + + /// A source exercising the formatter shapes VALID_MAIN lacks: module-path + /// generic types, assert statements, bare `return;`, checkpoint-stats + /// intrinsics, and turbofish method calls. + #[tokio::test] + #[serial] + async fn formatting_renders_intrinsics_bare_returns_and_turbofish() { + let rich = "\ + +pub mod m { + pub struct H { pub v: T } + pub fn g(x: T) -> Felt { return 1; } +} + +pub struct Host { pub x: Felt } + +impl Host { + pub fn set(self: Self, v: S) -> Felt { return 1; } + pub fn done(self: Self) { return; } +} + +fn take(x: m::H) -> Felt { + assert(x.v > 0); + let h = Host { x: 1 }; + let r = h.set::(2u32); + h.done(); + return r; +} + +fn main(q: Felt) -> Felt { + let v = take(m::H { v: q }); + return v; +} +"; + let service = drained_backend(); + let server = service.inner(); + let (dir, root, file) = write_psy_workspace(rich); + let uri = Url::from_file_path(&file).unwrap(); + server.set_crate_path_graph_cache(root.clone(), entry_graph(&file)); + server.collect_diagnostics_sync(&root).expect("rich source must typecheck"); + assert!(server.is_ready()); + + let edits = server + .formatting(DocumentFormattingParams { + text_document: text_document(&uri), + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .expect("formatting") + .expect("edits"); + let text = &edits[0].new_text; + for marker in ["assert(", "return;", "h.set::("] { + assert!(text.contains(marker), "formatted output lacks {marker:?}:\n{text}"); + } + + drop(dir); + } + + /// Hover and navigation across the module and type reference arms: the + /// module declaration name, a struct declaration name, and a struct + /// literal path segment all resolve to their own hover shapes. + #[tokio::test] + #[serial] + async fn hover_answers_module_and_type_references() { + let source = "pub mod inner {\n pub struct H { pub v: Felt }\n}\nuse inner::H;\n\nfn main(q: Felt) -> Felt {\n let h = inner::H { v: q };\n return h.v;\n}\n"; + let service = drained_backend(); + let server = service.inner(); + let (dir, root, file) = write_psy_workspace(source); + let uri = Url::from_file_path(&file).unwrap(); + server.set_crate_path_graph_cache(root.clone(), entry_graph(&file)); + server.collect_diagnostics_sync(&root).expect("module fixture must typecheck"); + assert!(server.is_ready()); + + let hover = |line: u32, needle: char| { + let position = position_at(source, line, needle); + let server = &server; + let uri = uri.clone(); + async move { + server + .hover(HoverParams { + text_document_position_params: position_params(&uri, position), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .expect("hover request") + .expect("hover text") + } + }; + + // The module declaration name reports `mod inner`. + let module_hover = hover(3, 'i').await; + match &module_hover.contents { + HoverContents::Markup(markup) => { + assert!(markup.value.contains("mod inner"), "module hover: {}", markup.value); + } + other => panic!("unexpected module hover contents: {other:?}"), + } + + // The struct declaration name and the struct-literal path segment both + // report the type. + for line in [1, 6] { + let type_hover = hover(line, 'H').await; + match &type_hover.contents { + HoverContents::Markup(markup) => { + assert!(markup.value.contains("struct H"), "type hover on line {line}: {}", markup.value); + } + other => panic!("unexpected type hover contents: {other:?}"), + } + } + + // Navigation from the struct-literal segment lands on the declaration. + let definition = server + .goto_definition(GotoDefinitionParams { + text_document_position_params: position_params(&uri, position_at(source, 6, 'H')), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + }) + .await + .expect("goto definition") + .expect("definition found"); + match definition { + tower_lsp::lsp_types::GotoDefinitionResponse::Scalar(location) => { + assert_eq!(location.uri, uri); + // The type's recorded location starts at its `pub` keyword. + assert_eq!(location.range.start.line, 1); + } + other => panic!("unexpected goto definition response: {other:?}"), + } + + // References from the module name include the declaration itself. + let references = server + .references(ReferenceParams { + text_document_position: position_params(&uri, position_at(source, 3, 'i')), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + partial_result_params: PartialResultParams { partial_result_token: None }, + context: ReferenceContext { include_declaration: true }, + }) + .await + .expect("references") + .expect("reference list"); + assert!(!references.is_empty()); + assert!(references.iter().all(|location| location.uri == uri)); + + drop(dir); + } } diff --git a/psy-package/src/package.rs b/psy-package/src/package.rs index 3d5f03267..e2f2bef4c 100644 --- a/psy-package/src/package.rs +++ b/psy-package/src/package.rs @@ -115,6 +115,12 @@ impl FromStr for CrateName { } } +/// Legacy blacklist export retained for downstream compatibility. +/// +/// Crate names are now validated with an allowlist; new code should parse names as `CrateName` instead. +#[deprecated(note = "CrateName validation now uses an allowlist; parse the name as CrateName instead")] +pub const CHARACTER_BLACK_LIST: [char; 1] = ['-']; + #[cfg(test)] mod tests { use super::*; diff --git a/psy-parser/src/lib.rs b/psy-parser/src/lib.rs index e7b82d7fc..a2a9e8f75 100644 --- a/psy-parser/src/lib.rs +++ b/psy-parser/src/lib.rs @@ -343,3 +343,218 @@ mod tests { parser.parse().unwrap(); } } + +#[cfg(test)] +mod parse_edge_tests { + use std::path::PathBuf; + + use psy_ast::{Program, Visibility}; + use psy_vm::dpn::ops::exec_context::QExecContext; + + use super::Parser; + + /// Parse one module's worth of source through the real recursive parser. + fn parse(src: &str) -> Result<(), String> { + let path = PathBuf::from("edge_case.psy"); + let mut program = Program::new(); + let file_id = program.file_resolver.add_file(path.clone(), src); + let mut ctx = QExecContext::new(); + Parser::parse_module( + &mut program, + &mut ctx, + &path, + psy_ast::Location::new(file_id, 0, 0), + Visibility::Public, + ) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + fn ok(label: &str, src: &str) { + if let Err(message) = parse(src) { + panic!("[{label}] expected parse success, got:\n{message}"); + } + } + + fn fails(label: &str, src: &str) { + if let Ok(()) = parse(src) { + panic!("[{label}] expected a parse rejection, got success"); + } + } + + fn err(label: &str, src: &str, needle: &str) { + match parse(src) { + Ok(()) => panic!("[{label}] expected parse error containing `{needle}`, got success"), + Err(message) => assert!( + message.to_lowercase().contains(&needle.to_lowercase()), + "[{label}] expected error containing `{needle}`, got:\n{message}" + ), + } + } + + #[test] + fn compound_assignments_and_expression_statements_parse() { + ok( + "every compound assignment operator", + "fn main() { let mut x = 1; x += 2; x -= 3; x *= 4; x /= 5; x %= 6; x &= 7; x |= 8; x ^= 9; x <<= 1; x >>= 1; }", + ); + ok("bare expression statement", "fn main() { 1 + 2; }"); + ok("plain assignment", "fn main() { let mut x = 1; x = 2; }"); + } + + #[test] + fn statement_shapes_reject_modules_and_unterminated_bodies() { + fails("inline module inside a function body", "fn main() { mod inner {} }"); + err("unterminated function body", "fn main() {", "end of file"); + ok("nested struct definition inside a body", "fn main() { struct S {} }"); + } + + #[test] + fn type_positions_cover_self_tuples_trailing_commas_and_arrays() { + ok("self type in return position", "fn f() -> Self {}"); + ok("tuple with trailing comma", "fn f(t: (Felt,)) {}"); + ok("empty tuple type", "fn f(t: ()) {}"); + ok("array type annotation", "fn f(a: [Felt; 3]) {}"); + fails("literal inside a tuple type", "fn f(t: (1, Felt)) {}"); + fails("missing colon after parameter name", "fn f(x Felt) {}"); + } + + #[test] + fn impls_traits_and_type_aliases_guard_their_headers() { + fails("public impl is rejected", "pub impl P {}"); + fails("attributed trait is rejected", "#[contract] trait T {}"); + fails("attributed type alias is rejected", "#[contract] type A = Felt;"); + ok("private impl parses", "impl P { pub fn m() {} }"); + err("impl method without a body", "impl P { pub fn m(); }", "body"); + ok("trait method without a body", "trait T { pub fn m(); }"); + ok("self parameter parses", "impl P { pub fn m(self) {} }"); + } + + #[test] + fn module_declarations_guard_comments_and_delimiters() { + fails("unterminated inline module", "mod m {"); + ok("use roots cover crate and super", "use crate::a::b;\nuse super::c;"); + } + + #[test] + fn struct_bodies_reject_comments_before_the_closing_brace() { + err("comment before struct close", "struct S { x: Felt, // trail\n }", "comment"); + ok("empty struct parses", "struct S {}"); + } + + #[test] + fn parser_std_path_prefers_env_then_falls_back_to_the_checkout() { + unsafe { std::env::set_var("DARGO_STD_PATH", "/tmp/env_std_marker.psy") }; + assert_eq!(super::std_path(), PathBuf::from("/tmp/env_std_marker.psy")); + unsafe { std::env::remove_var("DARGO_STD_PATH") }; + let resolved = super::std_path(); + assert!(resolved.is_file(), "expected the workspace psy-std checkout, got {resolved:?}"); + } + + #[test] + fn function_bodies_and_self_parameters_are_guarded() { + err("module fn without a body", "fn f();", "body"); + err("top-level fn with a parameter named self", "fn f(self: Felt) {}", "self"); + fails("impl method with self after another parameter", "pub struct P { pub x: Felt }\nimpl P { pub fn m(x: Felt, self: Self) {} }"); + fails("trait method with self after another parameter", "pub trait T { pub fn m(x: Felt, self: Self); }"); + } + + #[test] + fn impl_and_trait_bodies_guard_ordering_and_eof() { + err("eof inside an impl body", "pub struct P { pub x: Felt }\nimpl P {", "end of file"); + fails("associated type after methods in an impl", "pub struct P { pub x: Felt }\nimpl P { pub fn m() {}\n pub type T = Felt; }"); + err("eof inside a trait body", "pub trait T {", "end of file"); + fails("associated type after methods in a trait", "pub trait T { pub fn m();\n pub type X; }"); + ok("associated type before methods in a trait", "pub trait T { pub type X;\n pub fn m(); }"); + } + + #[test] + fn attributes_and_enums_are_guarded_in_item_position() { + fails("attribute before a module", "#[contract]\nmod m { }"); + fails("attribute before an enum", "#[contract]\nenum E { A }"); + ok("enum with basic tuple and struct variants", "pub enum E { A, B(u32), C { x: Felt } }"); + } + + #[test] + fn nested_module_declarations_guard_comments_and_delimiters() { + fails("comment before an external nested module", "mod outer { /* c */ mod inner; }"); + ok("external nested module", "mod outer { mod inner; }"); + fails("eof inside a nested module body", "mod outer { fn"); + } + + #[test] + fn expression_postfix_and_primary_edges() { + fails("dangling member access", "fn main() { let x = a.; }"); + fails("empty parentheses expression", "fn main() { let x = (); }"); + fails("missing expression after assign", "fn main() { let x = ; }"); + err("eof after assign", "fn main() { let x =", "end of file"); + fails("eof inside a struct literal", "fn main() { let p = S {"); + ok("empty match expression", "fn main() { let v = match 1 { }; }"); + ok("or-patterns in match arms", "fn main() { let v = match 1 { 1 | 2 => 3, _ => 4 }; }"); + fails("non-literal array repeat count", "fn main() { let a = 1; let b = [0; a]; }"); + ok("comment between binary operands", "fn main() { let x = 1 + // mid\n 2; }"); + ok("empty struct literal", "fn main() { let p = S { }; }"); + fails("untyped closure parameter", "fn main() { let f = |v| v; }"); + ok("typed closure", "fn main() { let f = |v: Felt| -> Felt { return v; }; }"); + } + + #[test] + fn module_items_reject_stray_tokens() { + fails("stray literal at module level", "42"); + fails("stray operator at module level", "+"); + } + + #[test] + fn turbofish_and_comparison_disambiguation_edges() { + // A turbofish on a bare path with no call and no member target is + // rejected; the method form (member access) is accepted. + fails("turbofish on a call result without another call", "fn main() { let x = g()::; }"); + ok("bare method turbofish without a call", "fn main() { let r = p.m::; }"); + ok("less-than comparison is not generic probing", "fn main() { let x = 1 < 2; }"); + ok("generic arguments on a non-generic struct literal fall back to comparison", "pub struct Q { pub x: Felt }\nfn main() { let q = Q { x: 1 }; }"); + ok("mismatched generic arguments on a struct literal fall back to comparison", "pub struct G { pub v: T }\nfn main() { let g = G { v: 1 }; }"); + fails("garbage inside turbofish arguments", "pub mod m { pub fn g(x: T) {} }\nfn main() { m::(x: T) {} }\nfn main() { m:: Felt { return 1; }; }"); + ok("two-parameter closure", "fn main() { let f = |a: Felt, b: Felt| -> Felt { return a; }; }"); + ok("closure parameter trailing comma", "fn main() { let f = |a: Felt,| -> Felt { return a; }; }"); + ok("closure with a bare self parameter", "fn main() { let f = |self| -> Felt { return 1; }; }"); + ok("immediately invoked closure", "fn main() { let x = (|v: Felt| -> Felt { return v; })(1); }"); + } + + #[test] + fn match_struct_literal_and_array_edges() { + ok("match body holding only comments", "fn main() { let v = match 1 { // only a comment\n }; }"); + ok("comment before a struct literal closing brace", "fn main() { let p = S { x: 1, // c\n }; }"); + err("eof after the array repeat separator", "fn main() { let b = [0;", "end of file"); + err("eof where a semicolon is expected", "fn main() { let x = 1", "end of file"); + } + + #[test] + fn intrinsic_short_forms_and_message_guards() { + ok("imt_get single-argument form", "fn main() { let v = __imt_get(1); }"); + ok("imt_set two-argument form", "fn main() { __imt_set(1, 2); }"); + fails("imt_get without arguments", "fn main() { let v = __imt_get(); }"); + fails("assert with a non-string message argument", "fn main() { assert(1, 2); }"); + fails("assert_eq with a non-string message argument", "fn main() { assert_eq(1, 2, 3); }"); + } + + #[test] + fn comments_around_binary_operators_parse() { + ok("block comment between operands", "fn main() { let x = 1 /* between */ + 2; }"); + ok("line comment between operands", "fn main() { let x = 1 // note\n + 2; }"); + ok("block comment between comparison operands", "fn main() { let c = 1 >= /* note */ 2; }"); + } + + #[test] + fn type_position_generic_and_size_edges() { + fails("generic arguments on an array type", "fn f(x: [Felt; 3]) {}"); + fails("non-literal array size", "fn f(x: [Felt; true]) {}"); + ok("generic target on a module path", "pub mod m { pub struct H { pub v: T } }\nfn f(x: m::>) {}"); + ok("trailing comma in explicit generic arguments", "pub mod m { pub fn g(x: T) {} }\nfn main() { m::(1); }"); + } +} diff --git a/psy-parser/src/recursive/statement.rs b/psy-parser/src/recursive/statement.rs index 886d10e61..0ed8a2373 100644 --- a/psy-parser/src/recursive/statement.rs +++ b/psy-parser/src/recursive/statement.rs @@ -232,3 +232,62 @@ where } } + +#[cfg(test)] +mod tests { + use psy_ast::{Identifier, IdentId, Location, Program, StmtNode, Visibility}; + use psy_common::FileId; + use psy_vm::dpn::ops::exec_context::QExecContext; + use psy_vm::dpn::ops::sym_felt::SymFeltRef; + + use super::super::{ModuleParser, ParseModuleInput}; + + fn parser_for<'src, 'p>( + src: &'src str, + program: &'p mut Program, + ctx: &'p mut QExecContext, + ) -> ModuleParser<'src, 'p, SymFeltRef, QExecContext> { + ModuleParser::new( + ParseModuleInput { + source: src, + file_id: FileId(0), + module_name: Identifier::new(IdentId::STD, Location::default()), + visibility: Visibility::Public, + }, + program, + ctx, + ) + .expect("construct module parser") + } + + fn parse_with(src: &str, f: impl for<'a, 'b> FnOnce(&mut ModuleParser<'a, 'b, SymFeltRef, QExecContext>) -> psy_ast::StmtNode) -> StmtNode { + let mut program = Program::new(); + let mut ctx = QExecContext::new(); + let mut parser = parser_for(src, &mut program, &mut ctx); + f(&mut parser) + } + + /// The public statement entry points are not wired through + /// `parse_module` (block parsing calls the `_with_comments` variants + /// directly), so exercise them here. + #[test] + fn statement_entry_points_parse_each_statement_shape() { + let stmt = parse_with("// lead\nreturn 1;", |p| p.parse_statement().expect("parse_statement")); + assert!(matches!(stmt, StmtNode::Return(_)), "got {stmt:?}"); + + let stmt = parse_with("x = 2;", |p| { + p.parse_assignment_statement(Vec::new()).expect("plain assignment statement") + }); + assert!(matches!(stmt, StmtNode::Assignment(_)), "got {stmt:?}"); + + let stmt = parse_with("x += 2;", |p| { + p.parse_assignment_statement(Vec::new()).expect("compound assignment statement") + }); + assert!(matches!(stmt, StmtNode::Assignment(_)), "got {stmt:?}"); + + let stmt = parse_with("1 + 2;", |p| { + p.parse_expression_statement(Vec::new()).expect("expression statement") + }); + assert!(matches!(stmt, StmtNode::Expression(_)), "got {stmt:?}"); + } +} diff --git a/psy-parser/src/recursive/trivia.rs b/psy-parser/src/recursive/trivia.rs index 3b40ee18e..0569fd983 100644 --- a/psy-parser/src/recursive/trivia.rs +++ b/psy-parser/src/recursive/trivia.rs @@ -74,3 +74,59 @@ pub enum CommentPosition { /// Comments before a closing delimiter or EOF β€” attach to preceding node. Trailing, } + +#[cfg(test)] +mod tests { + use psy_common::FileId; + + use super::*; + + fn cursor(src: &str) -> TokenCursor<'_> { + let tokens = psy_lexer::lex_all(src).unwrap(); + TokenCursor::new(tokens, FileId(0)) + } + + #[test] + fn leading_comments_are_collected_and_the_cursor_advances() { + let mut c = cursor("// lead\n/* block */\nlet"); + let comments = collect_leading_comments(&mut c); + assert_eq!(comments.len(), 2); + assert!(matches!(comments[0], Comment::Line { .. })); + assert!(matches!(comments[1], Comment::Block { .. })); + assert!(comments[0].content().contains("lead"), "line comment content: {}", comments[0].content()); + assert!(comments[1].content().contains("block"), "block comment content: {}", comments[1].content()); + assert_eq!(c.peek(), Some(&psy_lexer::Token::KeywordLet)); + } + + #[test] + fn trailing_comments_collect_until_the_closing_delimiter() { + let mut c = cursor("// one\n/* two */\n}"); + let comments = collect_trailing_comments_until_close(&mut c); + assert_eq!(comments.len(), 2); + assert!(comments[0].content().contains("one"), "line comment content: {}", comments[0].content()); + assert!(comments[1].content().contains("two"), "block comment content: {}", comments[1].content()); + assert_eq!(c.peek(), Some(&psy_lexer::Token::RBrace)); + + // No comments to consume leaves the cursor untouched. + let mut c = cursor("let"); + assert!(collect_trailing_comments_until_close(&mut c).is_empty()); + assert_eq!(c.peek(), Some(&psy_lexer::Token::KeywordLet)); + } + + #[test] + fn close_delimiter_detection_skips_comments() { + assert!(at_close_delimiter(&cursor("// c\n}"))); + assert!(at_close_delimiter(&cursor(")"))); + assert!(at_close_delimiter(&cursor("]"))); + assert!(!at_close_delimiter(&cursor("// c\nlet"))); + assert!(!at_close_delimiter(&cursor(""))); + } + + #[test] + fn comment_classification_splits_leading_from_trailing() { + assert_eq!(classify_comments(&cursor("// c\n}")), CommentPosition::Trailing); + assert_eq!(classify_comments(&cursor(")")), CommentPosition::Trailing); + assert_eq!(classify_comments(&cursor("")), CommentPosition::Trailing); + assert_eq!(classify_comments(&cursor("// c\nlet")), CommentPosition::Leading); + } +} diff --git a/psy-sema/src/rewriter.rs b/psy-sema/src/rewriter.rs index 01893507d..f68d0bf8c 100644 --- a/psy-sema/src/rewriter.rs +++ b/psy-sema/src/rewriter.rs @@ -1,5 +1,5 @@ use itertools::Itertools; -use psy_ast::{DefId, ExprId, IdentId, StmtId}; +use psy_ast::{DefId, ExprId, IdentId, NodeInfo, StmtId}; use psy_vm::dpn::ops::context_trait::ContextFelt; use tracing::instrument; @@ -249,8 +249,8 @@ impl + ContextFelt, C> Rewriter for TypeChecker for parameter in &mut checked_function.parameters { if let Some(type_path) = &mut parameter.path { let path = type_path.origin_path.clone(); - let path_target = path.target.as_basic().unwrap(); - let mut root_type_id = self.substitute_all(type_path.root.unwrap(), ctx)?; + let path_target = path.target.as_basic().ok_or(Error::InvalidPathSegment { location: path.target.location(), segment: format!("{:?}", path.target) })?; + let mut root_type_id = self.substitute_all(type_path.root.ok_or(Error::InvalidPathSegment { location: type_path.location, segment: format!("{:?}", path) })?, ctx)?; type_path.root = Some(root_type_id); for segment in path.segments.iter() { @@ -268,8 +268,8 @@ impl + ContextFelt, C> Rewriter for TypeChecker } if let Some(type_path) = &mut checked_function.return_type_path { let path = type_path.origin_path.clone(); - let path_target = path.target.as_basic().unwrap(); - let mut root_type_id = self.substitute_all(type_path.root.unwrap(), ctx)?; + let path_target = path.target.as_basic().ok_or(Error::InvalidPathSegment { location: path.target.location(), segment: format!("{:?}", path.target) })?; + let mut root_type_id = self.substitute_all(type_path.root.ok_or(Error::InvalidPathSegment { location: type_path.location, segment: format!("{:?}", path) })?, ctx)?; type_path.root = Some(root_type_id); for segment in path.segments.iter() { @@ -330,8 +330,8 @@ impl + ContextFelt, C> Rewriter for TypeChecker for parameter in &mut checked_function.parameters { if let Some(type_path) = &mut parameter.path { let path = type_path.origin_path.clone(); - let path_target = path.target.as_basic().unwrap(); - let mut root_type_id = self.substitute_all(type_path.root.unwrap(), ctx)?; + let path_target = path.target.as_basic().ok_or(Error::InvalidPathSegment { location: path.target.location(), segment: format!("{:?}", path.target) })?; + let mut root_type_id = self.substitute_all(type_path.root.ok_or(Error::InvalidPathSegment { location: type_path.location, segment: format!("{:?}", path) })?, ctx)?; type_path.root = Some(root_type_id); for segment in path.segments.iter() { @@ -349,8 +349,8 @@ impl + ContextFelt, C> Rewriter for TypeChecker } if let Some(type_path) = &mut checked_function.return_type_path { let path = type_path.origin_path.clone(); - let path_target = path.target.as_basic().unwrap(); - let mut root_type_id = self.substitute_all(type_path.root.unwrap(), ctx)?; + let path_target = path.target.as_basic().ok_or(Error::InvalidPathSegment { location: path.target.location(), segment: format!("{:?}", path.target) })?; + let mut root_type_id = self.substitute_all(type_path.root.ok_or(Error::InvalidPathSegment { location: type_path.location, segment: format!("{:?}", path) })?, ctx)?; type_path.root = Some(root_type_id); for segment in path.segments.iter() { From 48e55db3fb5d3e08619c1d9998a515606b9c97b8 Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 7 Sep 2026 14:18:36 +0800 Subject: [PATCH 05/12] refactor(sema): store primitive scope id in SymbolTable instead of global state Replace the process-global STD_PRIMITIVE_SCOPE_ID OnceLock with a per-SymbolTable field, removing the unsafe static_mut_refs resets scattered through the interpreter, CLI commands, and test suites. Keep an atomic compatibility export for downstream users. With the global-state mutation gone, test-slow no longer needs --test-threads=1. Raise the coverage-ci gates to 95% lines/functions. --- Makefile | 9 +- psy-dargo-cli/src/cli/compile_cmd.rs | 4 - psy-dargo-cli/src/cli/doc_cmd.rs | 4 - psy-dargo-cli/src/cli/execute_cmd.rs | 4 - psy-dargo-cli/src/cli/fmt_cmd.rs | 4 - psy-dargo-cli/src/cli/generate_abi_cmd.rs | 10 -- psy-dargo-cli/src/cli/test_cmd.rs | 11 +- .../src/associated_types_sema_tests.rs | 107 +-------------- psy-interpreter/src/const_eval_tests.rs | 100 +------------- psy-interpreter/src/constraint_sema_tests.rs | 66 +-------- psy-interpreter/src/exec_edge_tests.rs | 31 ++--- psy-interpreter/src/generics_sema_tests.rs | 36 +---- psy-interpreter/src/interp_exec_tests.rs | 49 +------ psy-interpreter/src/intrinsic_exec_tests.rs | 21 --- psy-interpreter/src/lib.rs | 129 ++---------------- psy-interpreter/src/panic_fix_tests.rs | 57 +------- psy-interpreter/src/qa_fix_tests.rs | 35 +---- psy-interpreter/src/sema_edge_tests.rs | 51 ------- psy-interpreter/src/visibility_tests.rs | 41 +----- psy-sema/src/infer.rs | 8 +- psy-sema/src/lib.rs | 46 +++---- psy-sema/src/symbol_table.rs | 82 ++++++++++- psy-sema/src/type.rs | 7 +- 23 files changed, 136 insertions(+), 776 deletions(-) diff --git a/Makefile b/Makefile index 54536927a..3ae7507fd 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ PROFILE := release COVERAGE_PROFILE := dev LOG_LEVEL := dargo=info -COVERAGE_MIN_LINES ?= 85 -COVERAGE_MIN_FUNCTIONS ?= 80 +COVERAGE_MIN_LINES ?= 95 +COVERAGE_MIN_FUNCTIONS ?= 95 COVERAGE_DIR ?= target/coverage COVERAGE_IGNORE_REGEX := '(^|/)(psy-lsp-server/psy-lsp-vscode|psy-wasm/demo-(web|node)|psy-precompiles/src/bin|[^/]+/src/main\.rs)(/|$$)' @@ -20,10 +20,9 @@ check: test: @RUST_LOG=$(LOG_LEVEL) cargo test --profile $(PROFILE) --workspace --all-targets -- --nocapture -# Expensive proving tests mutate process-global compiler state and therefore -# must run serially. They are intentionally kept out of the PR-fast path. +# Expensive proving/end-to-end tests are intentionally kept out of the PR-fast path. test-slow: - @RUST_LOG=$(LOG_LEVEL) cargo test --profile $(PROFILE) --package dargo -- --ignored --test-threads=1 --nocapture + @RUST_LOG=$(LOG_LEVEL) cargo test --profile $(PROFILE) --package dargo -- --ignored --nocapture # Generate a local HTML report and a machine-readable LCOV report. This target # intentionally has no threshold so it can be used to establish a baseline. diff --git a/psy-dargo-cli/src/cli/compile_cmd.rs b/psy-dargo-cli/src/cli/compile_cmd.rs index a3d190b67..cc5e74c7d 100644 --- a/psy-dargo-cli/src/cli/compile_cmd.rs +++ b/psy-dargo-cli/src/cli/compile_cmd.rs @@ -769,10 +769,6 @@ mod tests { } fn reset_std_scope() { - #[allow(static_mut_refs)] - unsafe { - psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); - } } #[test] diff --git a/psy-dargo-cli/src/cli/doc_cmd.rs b/psy-dargo-cli/src/cli/doc_cmd.rs index d676f470d..2d6d4aba9 100644 --- a/psy-dargo-cli/src/cli/doc_cmd.rs +++ b/psy-dargo-cli/src/cli/doc_cmd.rs @@ -599,10 +599,6 @@ mod tests { if let Err(err) = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(run_doc(args, workspace))) { panic!("file: {:?}, {:?}", path, err); } - #[allow(static_mut_refs)] - unsafe { - psy_sema::STD_PRIMITIVE_SCOPE_ID.take() - }; }); } } diff --git a/psy-dargo-cli/src/cli/execute_cmd.rs b/psy-dargo-cli/src/cli/execute_cmd.rs index 47a4f95cd..3951e054f 100644 --- a/psy-dargo-cli/src/cli/execute_cmd.rs +++ b/psy-dargo-cli/src/cli/execute_cmd.rs @@ -128,10 +128,6 @@ mod tests { } fn reset_std_scope() { - #[allow(static_mut_refs)] - unsafe { - psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); - } } #[test] diff --git a/psy-dargo-cli/src/cli/fmt_cmd.rs b/psy-dargo-cli/src/cli/fmt_cmd.rs index e545cd9f5..f0c911860 100644 --- a/psy-dargo-cli/src/cli/fmt_cmd.rs +++ b/psy-dargo-cli/src/cli/fmt_cmd.rs @@ -96,10 +96,6 @@ mod tests { let mut interpreter = Interpreter::::new(QExecContext::new()); let typecheck = { let result = interpreter.typecheck_single(entry.clone()); - #[allow(static_mut_refs)] - unsafe { - let _ = psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); - } result }; let _ = std::fs::remove_file(&entry); diff --git a/psy-dargo-cli/src/cli/generate_abi_cmd.rs b/psy-dargo-cli/src/cli/generate_abi_cmd.rs index 6aebbe51b..60cadb5ea 100644 --- a/psy-dargo-cli/src/cli/generate_abi_cmd.rs +++ b/psy-dargo-cli/src/cli/generate_abi_cmd.rs @@ -153,11 +153,6 @@ mod tests { let abi = fs::read_to_string(output_dir.join("demo_abi.abi.json")).expect("ABI file must exist"); assert!(abi.contains("\"main\""), "ABI must list the compiled method: {abi}"); - - #[allow(static_mut_refs)] - unsafe { - psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); - } fs::remove_dir_all(dir).ok(); } @@ -196,11 +191,6 @@ mod tests { let abi = fs::read_to_string(target_dir.join("Demo.abi.json")) .expect("the default ABI file must land in the workspace target dir"); assert!(abi.contains("\"main\""), "ABI must list the compiled method: {abi}"); - - #[allow(static_mut_refs)] - unsafe { - psy_sema::STD_PRIMITIVE_SCOPE_ID.take(); - } fs::remove_dir_all(dir).ok(); } } diff --git a/psy-dargo-cli/src/cli/test_cmd.rs b/psy-dargo-cli/src/cli/test_cmd.rs index 782c95e42..e848bf779 100644 --- a/psy-dargo-cli/src/cli/test_cmd.rs +++ b/psy-dargo-cli/src/cli/test_cmd.rs @@ -78,15 +78,11 @@ mod tests { use super::*; #[tokio::test(flavor = "multi_thread")] - #[ignore = "slow end-to-end proving; run serially with `make test-slow`"] + #[ignore = "slow end-to-end proving; run with `make test-slow`"] async fn psy_unit_test() { insta::glob!("../../../tests", "*_test.psy", |path| { let args = TestCommand { file: path.into() }; tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(run(args))).unwrap(); - #[allow(static_mut_refs)] - unsafe { - psy_sema::STD_PRIMITIVE_SCOPE_ID.take() - }; }); } @@ -103,11 +99,6 @@ mod tests { .expect("write test file"); run(TestCommand { file: file.clone() }).await.expect("passing #[test] functions must execute and prove"); - - #[allow(static_mut_refs)] - unsafe { - psy_sema::STD_PRIMITIVE_SCOPE_ID.take() - }; fs::remove_file(file).ok(); } } diff --git a/psy-interpreter/src/associated_types_sema_tests.rs b/psy-interpreter/src/associated_types_sema_tests.rs index f787703be..99e6e236d 100644 --- a/psy-interpreter/src/associated_types_sema_tests.rs +++ b/psy-interpreter/src/associated_types_sema_tests.rs @@ -4,26 +4,23 @@ // temporary `.psy` sources. Every case names the concrete contract it defends // and asserts the exact accept/reject outcome. // -// The shared `STD_PRIMITIVE_SCOPE_ID` singleton is reset after *every* case +// The primitive scope is owned by each typecheck symbol table // (via `check`, which tears down before the caller can panic) so the suite // is hermetic. use std::{ fs, - path::PathBuf, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; -use serial_test::serial; use super::*; static COUNTER: AtomicU64 = AtomicU64::new(0); -/// Typecheck `source` written to a throwaway temp file, then ALWAYS tear down -/// the file and reset the shared primitive-scope singleton. Returns `None` if +/// Typecheck `source` written to a throwaway temp file, then tear it down. Returns `None` if /// the program typechecked, or `Some(formatted_error)` if it was rejected. fn check(source: &str) -> Option { let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); @@ -35,10 +32,6 @@ fn check(source: &str) -> Option { let result = interpreter.typecheck_single(path.clone()); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } match result { Ok(_) => None, @@ -74,7 +67,6 @@ fn expect_reject(name: &str, source: &str, needle: &str) { /// Defends: `visit_trait_impl` (lib.rs:2844-2875) unifies the trait's associated /// type variable with the impl's concrete type. #[test] -#[serial] fn at01_assoc_type_decl_and_override_typechecks() { expect_accept( "at01_assoc_type_decl_and_override_typechecks", @@ -98,7 +90,6 @@ fn main() {} /// Defends: the unification at lib.rs:2857 enforces trait constraints on the /// associated type override. #[test] -#[serial] fn at02_assoc_type_override_with_wrong_constraint_rejected() { expect_reject( "at02_assoc_type_override_with_wrong_constraint_rejected", @@ -122,7 +113,6 @@ fn main() {} /// Trait with no associated type, impl also has none β€” typechecks. #[test] -#[serial] fn at03_trait_no_assoc_type_typechecks() { expect_accept( "at03_trait_no_assoc_type_typechecks", @@ -145,7 +135,6 @@ fn main() {} /// Defends: resolve_path trait-cast branch (resolver.rs:24-91) with no /// segments resolves the associated type via find_member_with_flags. #[test] -#[serial] fn at04_trait_cast_assoc_type_in_type_position() { expect_accept( "at04_trait_cast_assoc_type_in_type_position", @@ -162,7 +151,6 @@ fn main() { /// `::Ty` where Ty is overridden to a struct type. #[test] -#[serial] fn at05_trait_cast_assoc_type_to_struct_type() { expect_accept( "at05_trait_cast_assoc_type_to_struct_type", @@ -182,7 +170,6 @@ fn main() { /// Defends: Self type is bound to the implementor (lib.rs:2840), and /// Self::Ty resolves through find_associated_type on the implementor. #[test] -#[serial] fn at06_self_assoc_type_in_impl_method() { expect_accept( "at06_self_assoc_type_in_impl_method", @@ -211,7 +198,6 @@ fn main() {} /// Defends: resolve_path with root=Some(Type), no segments, target=method /// (resolver.rs:94-114) resolves through find_member on the impl. #[test] -#[serial] fn at07_inherent_method_call() { expect_accept( "at07_inherent_method_call", @@ -230,7 +216,6 @@ fn main() { /// `::method()` β€” trait method dispatch. /// Defends: resolve_path trait-cast with no segments (resolver.rs:71-91). #[test] -#[serial] fn at08_trait_method_dispatch() { expect_accept( "at08_trait_method_dispatch", @@ -261,7 +246,6 @@ fn main() { /// discards the trait (lib.rs:3327), then calls `find_member` directly /// without the trait implementation check. #[test] -#[serial] fn at09_trait_method_on_non_implementor_rejected() { expect_reject( "at09_trait_method_on_non_implementor_rejected", @@ -283,7 +267,6 @@ fn main() { /// Associated function with no self parameter (static method) via trait cast. #[test] -#[serial] fn at10_trait_static_method_via_cast() { expect_accept( "at10_trait_static_method_via_cast", @@ -309,7 +292,6 @@ fn main() { /// Defends: visit_trait_impl (lib.rs:2909-2922) copies unimplemented trait /// methods as generated default methods. #[test] -#[serial] fn at11_default_method_not_overridden_typechecks() { expect_accept( "at11_default_method_not_overridden_typechecks", @@ -330,7 +312,6 @@ fn main() { /// Defends: visit_trait_impl (lib.rs:2881-2907) matches override methods by /// name and removes them from unimplemented_methods. #[test] -#[serial] fn at12_default_method_overridden_typechecks() { expect_accept( "at12_default_method_overridden_typechecks", @@ -353,7 +334,6 @@ fn main() { /// associated type, which is resolved when the default is copied into the impl. /// Based on trait_default_associated_type_test.psy pattern. #[test] -#[serial] fn at13_default_method_using_assoc_type() { expect_accept( "at13_default_method_using_assoc_type", @@ -381,7 +361,6 @@ fn main() { /// Default method using Self::Item without overriding make β€” relies on the /// trait's default body being copied into the impl. #[test] -#[serial] fn at14_default_method_not_overridden_with_assoc_type() { expect_accept( "at14_default_method_not_overridden_with_assoc_type", @@ -415,7 +394,6 @@ fn main() { /// Defends: visit_trait_impl (lib.rs:2797-2838) unifies trait and impl generic /// parameters, and the associated type value G resolves in the impl scope. #[test] -#[serial] fn at15_generic_trait_assoc_type_substitution() { expect_accept( "at15_generic_trait_assoc_type_substitution", @@ -434,7 +412,6 @@ fn main() {} /// Generic trait with concrete instantiation β€” `impl Container for Box`. #[test] -#[serial] fn at16_generic_trait_concrete_assoc_type() { expect_accept( "at16_generic_trait_concrete_assoc_type", @@ -453,7 +430,6 @@ fn main() {} /// Generic trait associated type accessed via trait cast with concrete generic. #[test] -#[serial] fn at17_generic_trait_assoc_type_access() { expect_accept( "at17_generic_trait_assoc_type_access", @@ -486,7 +462,6 @@ fn main() { /// Defends: the RefType associated type pattern used by Storage derive /// (preprocess.rs:155-170) works when written manually. #[test] -#[serial] fn at18_manual_reftype_pattern_typechecks() { expect_accept( "at18_manual_reftype_pattern_typechecks", @@ -510,7 +485,6 @@ fn main() {} /// Defends: resolve_member_type (resolver.rs:420-424) skips visibility for /// is_ty paths, so a private associated type is accessible in type position. #[test] -#[serial] fn at19_private_reftype_in_type_annotation() { expect_accept( "at19_private_reftype_in_type_annotation", @@ -527,7 +501,6 @@ fn main() { } #[test] -#[serial] fn at19b_ambiguous_associated_type_rejected() { expect_reject( "at19b_ambiguous_associated_type", @@ -553,7 +526,6 @@ fn main() { let value: S::Item = 1; } /// Defends: resolve_path trait-cast with segments (resolver.rs:40-70) resolves /// Ty via find_member, then get_a via find_member_with_flags. #[test] -#[serial] fn at20_chained_assoc_type_method_call() { expect_accept( "at20_chained_assoc_type_method_call", @@ -581,7 +553,6 @@ fn main() { /// Chained access with generic types β€” ` as Trait>::Ty::get_a()`. /// Based on path_test.psy:110 with generics. #[test] -#[serial] fn at21_chained_assoc_type_with_generics() { expect_accept( "at21_chained_assoc_type_with_generics", @@ -615,7 +586,6 @@ fn main() { /// is_ty paths (resolver.rs:423). /// Defends: the is_ty visibility skip at resolver.rs:423. #[test] -#[serial] fn at22_private_assoc_type_accessible_in_type_position() { expect_accept( "at22_private_assoc_type_accessible_in_type_position", @@ -636,7 +606,6 @@ fn main() { /// type resolution in find_associated_type does not check visibility. /// The visibility check in resolve_member_type only applies to non-is_ty paths. #[test] -#[serial] fn at23_private_assoc_type_method_still_resolves() { expect_accept( "at23_private_assoc_type_method_still_resolves", @@ -662,7 +631,6 @@ fn main() { /// Associated type used as function parameter type via Self::Ty. /// Defends: Self::Ty resolves in the impl method signature context. #[test] -#[serial] fn at24_assoc_type_as_param_type() { expect_accept( "at24_assoc_type_as_param_type", @@ -683,7 +651,6 @@ fn main() {} /// Associated type used as return type via Self::Ty. #[test] -#[serial] fn at25_assoc_type_as_return_type() { expect_accept( "at25_assoc_type_as_return_type", @@ -711,7 +678,6 @@ fn main() {} /// Defends: visit_trait_impl (lib.rs:2845-2849) returns MissingAssociatedType /// when the impl doesn't override a declared associated type. #[test] -#[serial] fn at26_missing_assoc_type_rejected() { expect_reject( "at26_missing_assoc_type_rejected", @@ -732,7 +698,6 @@ fn main() {} /// Multiple associated types declared, one missing β€” error names the missing one. #[test] -#[serial] fn at27_partial_missing_assoc_type_rejected() { expect_reject( "at27_partial_missing_assoc_type_rejected", @@ -762,7 +727,6 @@ fn main() {} /// Defends: resolve_path on a type variable root with constraint, resolving /// the associated type through get_trait_member (implementer.rs:207-213). #[test] -#[serial] fn at28_assoc_type_in_struct_field() { expect_accept( "at28_assoc_type_in_struct_field", @@ -786,7 +750,6 @@ fn main() {} /// Defends: find_member on a type variable (implementer.rs:328-335) resolves /// the associated type through the trait constraint. #[test] -#[serial] fn at29_assoc_type_in_generic_fn_body() { expect_accept( "at29_assoc_type_in_generic_fn_body", @@ -812,7 +775,6 @@ fn main() { /// Generic function returning T::AssocTy. #[test] -#[serial] fn at30_generic_fn_return_assoc_type() { expect_accept( "at30_generic_fn_return_assoc_type", @@ -840,7 +802,6 @@ fn main() { /// `T::get()` where `T: HasTy`. This uses the constraint-based method lookup /// (implementer.rs:328-335). #[test] -#[serial] fn at31_generic_type_method_via_constraint() { expect_accept( "at31_generic_type_method_via_constraint", @@ -873,7 +834,6 @@ fn main() { /// Defends: visit_trait_impl (lib.rs:2890-2904) matches impl methods against /// trait methods by name and errors if no match. #[test] -#[serial] fn at32_impl_method_not_in_trait_rejected() { expect_reject( "at32_impl_method_not_in_trait_rejected", @@ -898,7 +858,6 @@ fn main() {} /// Associated type overridden with a generic struct type. #[test] -#[serial] fn at33_assoc_type_override_with_generic_struct() { expect_accept( "at33_assoc_type_override_with_generic_struct", @@ -917,7 +876,6 @@ fn main() { /// Associated type overridden with a generic type parameter of the impl. /// `impl HasTy for Foo { pub type Ty = T; }` #[test] -#[serial] fn at34_assoc_type_override_with_impl_generic() { expect_accept( "at34_assoc_type_override_with_impl_generic", @@ -940,7 +898,6 @@ fn main() { /// Trait with multiple associated types, all overridden. #[test] -#[serial] fn at35_multiple_assoc_types_all_overridden() { expect_accept( "at35_multiple_assoc_types_all_overridden", @@ -969,7 +926,6 @@ fn main() {} /// Default method body uses Self::AssocTy as a local variable type. #[test] -#[serial] fn at36_default_body_uses_assoc_type_as_local() { expect_accept( "at36_default_body_uses_assoc_type_as_local", @@ -1003,7 +959,6 @@ fn main() { /// not found on Foo) rather than `TypeMismatch` (trait B not implemented). /// The call is correctly rejected; the error category is misleading. #[test] -#[serial] fn at37_trait_cast_wrong_trait_rejected() { expect_reject( "at37_trait_cast_wrong_trait_rejected", @@ -1029,7 +984,6 @@ fn main() { /// Defends: visit_const (lib.rs:2538-2569) typechecks the LHS and RHS and /// unifies them. #[test] -#[serial] fn con01_basic_const_felt_typechecks() { expect_accept( "con01_basic_const_felt_typechecks", @@ -1042,7 +996,6 @@ fn main() {} /// Const declaration with bool type. #[test] -#[serial] fn con02_const_bool_typechecks() { expect_accept( "con02_const_bool_typechecks", @@ -1059,7 +1012,6 @@ fn main() {} /// has type Felt, so `const C: u32 = 42;` fails with TypeMismatch. You must /// write `42u32` explicitly. #[test] -#[serial] fn con03_const_u32_typechecks() { expect_accept( "con03_const_u32_typechecks", @@ -1073,7 +1025,6 @@ fn main() {} /// Const with type mismatch β€” bool value declared as Felt. /// Defends: visit_const unifies lhs_ty and rhs_ty (lib.rs:2545). #[test] -#[serial] fn con04_const_type_mismatch_rejected() { expect_reject( "con04_const_type_mismatch_rejected", @@ -1087,7 +1038,6 @@ fn main() {} /// Const with u32 value declared as Felt β€” type mismatch. #[test] -#[serial] fn con05_const_u32_as_felt_rejected() { expect_reject( "con05_const_u32_as_felt_rejected", @@ -1104,7 +1054,6 @@ fn main() {} /// including user-defined types, Self, paths, and generics. /// A const of a struct type should fail at parse time. #[test] -#[serial] fn con06_const_non_primitive_type_rejected() { expect_reject( "con06_const_non_primitive_type_rejected", @@ -1123,7 +1072,6 @@ fn main() {} /// TypeBool, TypeU32 β€” not TypeSelf. (Note: parse_const_type used in casts /// DOES accept Self, but the const declaration path does not.) #[test] -#[serial] fn con07_const_self_type_rejected() { // Self is not valid at top level anyway, so this should error. // The point is that parse_const_declaration_type is more restrictive @@ -1142,7 +1090,6 @@ fn main() {} /// The const is registered as a Type::Const with name C (lib.rs:2567), and /// resolve_path finds it via get_type_id. #[test] -#[serial] fn con08_const_in_expression_typechecks() { expect_accept( "con08_const_in_expression_typechecks", @@ -1157,7 +1104,6 @@ fn main() { /// Const used in a binary expression β€” `let x = C + 1;`. #[test] -#[serial] fn con09_const_in_binary_expression_typechecks() { expect_accept( "con09_const_in_binary_expression_typechecks", @@ -1175,7 +1121,6 @@ fn main() { /// ident (ty.rs:172-183). Sema typechecks this by unifying the array's /// size_ty (a type variable with Felt constraint) with N's type. #[test] -#[serial] fn con10_const_as_array_size_typechecks() { expect_accept( "con10_const_as_array_size_typechecks", @@ -1191,7 +1136,6 @@ fn main() { /// Const with computed expression value β€” `const C: Felt = 1 + 2;`. /// visit_const evaluates the expression (lib.rs:2553). #[test] -#[serial] fn con11_const_computed_expression_typechecks() { expect_accept( "con11_const_computed_expression_typechecks", @@ -1204,7 +1148,6 @@ fn main() {} /// Public const accessible cross-module via use. #[test] -#[serial] fn con12_public_const_cross_module_typechecks() { expect_accept( "con12_public_const_cross_module_typechecks", @@ -1223,7 +1166,6 @@ fn main() { /// Private const inaccessible cross-module. /// Defends: resolve_use (resolver.rs:316-322) checks type visibility. #[test] -#[serial] fn con13_private_const_cross_module_rejected() { expect_reject( "con13_private_const_cross_module_rejected", @@ -1240,7 +1182,6 @@ fn main() {} /// Private const accessible within its own module. #[test] -#[serial] fn con14_private_const_within_module_typechecks() { expect_accept( "con14_private_const_within_module_typechecks", @@ -1260,7 +1201,6 @@ fn main() {} /// Defends: typecheck_generic_parameter (lib.rs:3343-3344) accepts a single /// Felt/Bool/U32 constraint as a const generic parameter. #[test] -#[serial] fn con15_const_generic_param_typechecks() { expect_accept( "con15_const_generic_param_typechecks", @@ -1274,7 +1214,6 @@ fn main() {} /// Const generic used in array type inside generic function. /// `[Felt; N]` where N is a const generic param. #[test] -#[serial] fn con16_const_generic_in_array_typechecks() { expect_accept( "con16_const_generic_in_array_typechecks", @@ -1291,7 +1230,6 @@ fn main() {} /// Defends: populate_constant (lib.rs:3128) creates a Type::Const from a /// ConstValue when used as a generic argument. #[test] -#[serial] fn con17_const_turbofish_arg_typechecks() { expect_accept( "con17_const_turbofish_arg_typechecks", @@ -1306,7 +1244,6 @@ fn main() { /// Const felt turbofish argument β€” `f::<42>()`. #[test] -#[serial] fn con18_felt_const_turbofish_typechecks() { expect_accept( "con18_felt_const_turbofish_typechecks", @@ -1321,7 +1258,6 @@ fn main() { /// Bool const turbofish argument β€” `f::()`. #[test] -#[serial] fn con19_bool_const_turbofish_typechecks() { expect_accept( "con19_bool_const_turbofish_typechecks", @@ -1338,7 +1274,6 @@ fn main() { /// Defends: parse_function_definition (item.rs:262-264) handles the const /// qualifier on functions. #[test] -#[serial] fn con20_const_fn_qualifier_typechecks() { expect_accept( "con20_const_fn_qualifier_typechecks", @@ -1355,7 +1290,6 @@ fn main() {} /// `const A: [Felt; 3] = ...` should fail at parse time because /// parse_const_declaration_type only accepts Felt/Bool/u32. #[test] -#[serial] fn con21_const_array_type_rejected() { expect_reject( "con21_const_array_type_rejected", @@ -1369,7 +1303,6 @@ fn main() {} /// LIMITATION: const declaration does not accept generic types. #[test] -#[serial] fn con22_const_generic_type_rejected() { expect_reject( "con22_const_generic_type_rejected", @@ -1385,7 +1318,6 @@ fn main() {} /// Const used in cast expression β€” `x as u32` where the cast target is a /// primitive type (parse_const_type accepts these). #[test] -#[serial] fn con23_cast_to_u32_typechecks() { expect_accept( "con23_cast_to_u32_typechecks", @@ -1400,7 +1332,6 @@ fn main() { /// Cast from u32 to Felt β€” requires u32 source value (`42u32`). #[test] -#[serial] fn con24_cast_u32_to_felt_typechecks() { expect_accept( "con24_cast_u32_to_felt_typechecks", @@ -1420,7 +1351,6 @@ fn main() { /// (error.rs:69-70) is defined but NEVER used β€” it is dead code. The cast IS /// correctly rejected, but with the wrong error category. #[test] -#[serial] fn con25_cast_to_struct_rejected() { expect_reject( "con25_cast_to_struct_rejected", @@ -1438,7 +1368,6 @@ fn main() { /// Two consts with the same name in the same module β€” should fail. /// Defends: add_type_id (lib.rs:2567) should detect duplicate type names. #[test] -#[serial] fn con26_duplicate_const_rejected() { expect_reject( "con26_duplicate_const_rejected", @@ -1455,7 +1384,6 @@ fn main() {} /// This may or may not work depending on whether the evaluator can resolve /// a const-path expression at const evaluation time. #[test] -#[serial] fn con27_const_referencing_another_const() { // This is a probe β€” it may pass or fail. The evaluator (lib.rs:2553) // evaluates the expression; if it can resolve a const-path expression, @@ -1473,7 +1401,6 @@ fn main() {} /// Const used as an array literal size in a generic struct β€” /// `struct S { pub arr: [T; N] }`. #[test] -#[serial] fn con28_const_generic_in_struct_field_typechecks() { expect_accept( "con28_const_generic_in_struct_field_typechecks", @@ -1491,7 +1418,6 @@ fn main() {} /// so InvalidGenericConstraint. /// Defends: typecheck_generic_parameter (lib.rs:3343-3347). #[test] -#[serial] fn con29_invalid_generic_constraint_rejected() { expect_reject( "con29_invalid_generic_constraint_rejected", @@ -1509,7 +1435,6 @@ fn main() {} /// Defends: typecheck_generic_parameter (lib.rs:3343-3344) requires either ALL /// constraints are traits OR exactly one primitive constraint. #[test] -#[serial] fn con30_mixed_constraint_rejected() { expect_reject( "con30_mixed_constraint_rejected", @@ -1524,7 +1449,6 @@ fn main() {} /// Multiple primitive constraints rejected β€” only one primitive allowed. #[test] -#[serial] fn con31_multiple_primitive_constraints_rejected() { expect_reject( "con31_multiple_primitive_constraints_rejected", @@ -1542,7 +1466,6 @@ fn main() {} /// Defends: parse_function_definition (item.rs:262-267) handles const then /// extern qualifiers in sequence. #[test] -#[serial] fn con32_const_extern_fn_typechecks() { expect_accept( "con32_const_extern_fn_typechecks", @@ -1557,7 +1480,6 @@ fn main() {} /// parameter values, so this is not applicable. Instead, test a const used /// as a standalone expression statement. #[test] -#[serial] fn con33_const_as_expression_statement() { expect_accept( "con33_const_as_expression_statement", @@ -1572,7 +1494,6 @@ fn main() { /// Const with a felt literal that is a large number. #[test] -#[serial] fn con34_const_large_felt_typechecks() { expect_accept( "con34_const_large_felt_typechecks", @@ -1585,7 +1506,6 @@ fn main() {} /// Const zero value. #[test] -#[serial] fn con35_const_zero_value_typechecks() { expect_accept( "con35_const_zero_value_typechecks", @@ -1598,7 +1518,6 @@ fn main() {} /// Const false value. #[test] -#[serial] fn con36_const_false_typechecks() { expect_accept( "con36_const_false_typechecks", @@ -1611,7 +1530,6 @@ fn main() {} /// Const used in an if condition. #[test] -#[serial] fn con37_const_in_if_condition_typechecks() { expect_accept( "con37_const_in_if_condition_typechecks", @@ -1631,7 +1549,6 @@ fn main() { /// The for-loop check (lib.rs:2579-2580) requires both start and end to be /// the same type (both Felt or both u32). #[test] -#[serial] fn con38_const_u32_in_for_range_typechecks() { expect_accept( "con38_const_u32_in_for_range_typechecks", @@ -1648,7 +1565,6 @@ fn main() { /// Const Felt used in a for-loop range. #[test] -#[serial] fn con39_const_felt_in_for_range_typechecks() { expect_accept( "con39_const_felt_in_for_range_typechecks", @@ -1666,7 +1582,6 @@ fn main() { /// LIMITATION: const declaration type does not accept path types. /// `const C: mod::Type = ...` should fail at parse time. #[test] -#[serial] fn con40_const_path_type_rejected() { expect_reject( "con40_const_path_type_rejected", @@ -1683,7 +1598,6 @@ fn main() {} /// LIMITATION: const declaration type does not accept tuple types. #[test] -#[serial] fn con41_const_tuple_type_rejected() { expect_reject( "con41_const_tuple_type_rejected", @@ -1697,7 +1611,6 @@ fn main() {} /// Const used in assert_eq β€” `assert_eq(C, 42, "msg")`. #[test] -#[serial] fn con42_const_in_assert_typechecks() { expect_accept( "con42_const_in_assert_typechecks", @@ -1712,7 +1625,6 @@ fn main() { /// Const used as a struct field initializer. #[test] -#[serial] fn con43_const_in_struct_literal_typechecks() { expect_accept( "con43_const_in_struct_literal_typechecks", @@ -1728,7 +1640,6 @@ fn main() { /// Const used in a function call argument. #[test] -#[serial] fn con44_const_in_call_arg_typechecks() { expect_accept( "con44_const_in_call_arg_typechecks", @@ -1744,7 +1655,6 @@ fn main() { /// Const generic with felt constraint β€” `fn f()`. #[test] -#[serial] fn con45_const_generic_felt_constraint_typechecks() { expect_accept( "con45_const_generic_felt_constraint_typechecks", @@ -1757,7 +1667,6 @@ fn main() {} /// Const generic with bool constraint β€” `fn f()`. #[test] -#[serial] fn con46_const_generic_bool_constraint_typechecks() { expect_accept( "con46_const_generic_bool_constraint_typechecks", @@ -1772,7 +1681,6 @@ fn main() {} /// `f::<3u32>()` where `fn f()` β€” requires `3u32` (not bare `3` /// which defaults to Felt and fails to unify with the u32 constraint). #[test] -#[serial] fn con47_const_generic_turbofish_u32_typechecks() { expect_accept( "con47_const_generic_turbofish_u32_typechecks", @@ -1788,7 +1696,6 @@ fn main() { /// Multiple const generic parameters. /// `fn f()`. #[test] -#[serial] fn con48_multiple_const_generics_typechecks() { expect_accept( "con48_multiple_const_generics_typechecks", @@ -1802,7 +1709,6 @@ fn main() {} /// Mixed generic and const generic parameters. /// `fn f()`. #[test] -#[serial] fn con49_mixed_generic_and_const_generic_typechecks() { expect_accept( "con49_mixed_generic_and_const_generic_typechecks", @@ -1816,7 +1722,6 @@ fn main() {} /// Struct with both type generic and const generic parameters. /// `struct S { pub arr: [T; N] }` instantiated with turbofish. #[test] -#[serial] fn con50_struct_mixed_generics_instantiated_typechecks() { expect_accept( "con50_struct_mixed_generics_instantiated_typechecks", @@ -1834,7 +1739,6 @@ fn main() { /// Const used in array type inside a struct field with a named const. /// `const N: Felt = 3; struct S { pub arr: [Felt; N] }`. #[test] -#[serial] fn con51_named_const_in_struct_array_field_typechecks() { expect_accept( "con51_named_const_in_struct_array_field_typechecks", @@ -1857,7 +1761,6 @@ fn main() {} /// This is by design (lexer regex `(?:0|[1-9]\d*)u32` for u32, bare integers /// are U64 β†’ Felt), but it's a common gotcha. #[test] -#[serial] fn con52_bare_int_defaults_to_felt_not_u32() { expect_reject( "con52_bare_int_defaults_to_felt_not_u32", @@ -1872,7 +1775,6 @@ fn main() {} /// Both `const extern fn` and `extern const fn` are accepted; the parser /// accepts qualifiers in any order (item.rs loop-based parsing). #[test] -#[serial] fn con53_extern_before_const_typechecks() { expect_accept( "con53_extern_before_const_typechecks", @@ -1887,7 +1789,6 @@ fn main() {} /// even when the value fits. The array size must be Felt, and a u32 const /// cannot be used (type mismatch on the array's size_ty unification). #[test] -#[serial] fn con54_u32_const_as_array_size_rejected() { expect_reject( "con54_felt_const_as_array_size_rejected", @@ -1903,7 +1804,6 @@ fn main() { /// Const with negative value β€” Felt supports negative literals. #[test] -#[serial] fn con55_const_negative_felt_typechecks() { expect_accept( "con55_const_negative_felt_typechecks", @@ -1916,7 +1816,6 @@ fn main() {} /// Associated type override with an array type. #[test] -#[serial] fn at38_assoc_type_override_with_array_type() { expect_accept( "at38_assoc_type_override_with_array_type", @@ -1933,7 +1832,6 @@ fn main() { /// Associated type override with a tuple type. #[test] -#[serial] fn at39_assoc_type_override_with_tuple_type() { expect_accept( "at39_assoc_type_override_with_tuple_type", @@ -1951,7 +1849,6 @@ fn main() { /// Trait with associated type used in a method signature cross-referencing /// another associated type β€” `fn convert(x: Self::A) -> Self::B`. #[test] -#[serial] fn at40_cross_assoc_type_in_method_sig() { expect_accept( "at40_cross_assoc_type_in_method_sig", diff --git a/psy-interpreter/src/const_eval_tests.rs b/psy-interpreter/src/const_eval_tests.rs index fdd22ce16..1f83cb062 100644 --- a/psy-interpreter/src/const_eval_tests.rs +++ b/psy-interpreter/src/const_eval_tests.rs @@ -9,8 +9,7 @@ // unsupported const expressions hit `unreachable!()`/`todo!()` inside // `__interpret_expr__` instead of returning a clean error). // -// The shared `STD_PRIMITIVE_SCOPE_ID` singleton is reset after every case so -// the suite is hermetic. Every case is `#[serial]`. +// Each case owns an independent symbol table and uniquely named temporary file. use std::{ fs, @@ -19,7 +18,6 @@ use std::{ }; use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; -use serial_test::serial; use super::*; @@ -56,10 +54,6 @@ fn compile(source: &str, label: &str) -> Outcome { })); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } match result { Ok(Ok((typechecker, ctx))) => Outcome::Accept(Compiled { interpreter, typechecker, ctx }), @@ -155,51 +149,43 @@ fn expect_const_value(label: &str, source: &str, name: &str, want_variant: &str, // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c01_felt_add() { expect_const_value("c01_felt_add", "const A: Felt = 1 + 2;\nfn main() {}", "A", "Felt", 3); } #[test] -#[serial] fn c02_felt_sub() { expect_const_value("c02_felt_sub", "const A: Felt = 10 - 3;\nfn main() {}", "A", "Felt", 7); } #[test] -#[serial] fn c03_felt_mul() { expect_const_value("c03_felt_mul", "const A: Felt = 4 * 5;\nfn main() {}", "A", "Felt", 20); } #[test] -#[serial] fn c04_felt_div() { expect_const_value("c04_felt_div", "const A: Felt = 20 / 4;\nfn main() {}", "A", "Felt", 5); } #[test] -#[serial] fn c05_felt_mod() { expect_const_value("c05_felt_mod", "const A: Felt = 17 % 5;\nfn main() {}", "A", "Felt", 2); } #[test] -#[serial] fn c06_felt_precedence_mul_before_add() { // 1 + 2 * 3 == 7, not 9 expect_const_value("c06_felt_precedence", "const A: Felt = 1 + 2 * 3;\nfn main() {}", "A", "Felt", 7); } #[test] -#[serial] fn c07_felt_parens_override_precedence() { // (1 + 2) * 3 == 9 expect_const_value("c07_felt_parens", "const A: Felt = (1 + 2) * 3;\nfn main() {}", "A", "Felt", 9); } #[test] -#[serial] fn c08_felt_nested_expr() { // ((2 + 3) * (4 - 1)) == 15 expect_const_value("c08_felt_nested", "const A: Felt = (2 + 3) * (4 - 1);\nfn main() {}", "A", "Felt", 15); @@ -210,7 +196,6 @@ fn c08_felt_nested_expr() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c09_const_references_const() { expect_const_value( "c09_const_refs", @@ -220,7 +205,6 @@ fn c09_const_references_const() { } #[test] -#[serial] fn c10_const_transitive_chain() { // A=2, B=A+3=5, C=B*B=25 expect_const_value( @@ -231,7 +215,6 @@ fn c10_const_transitive_chain() { } #[test] -#[serial] fn c11_const_mixed_with_literal() { // A=5, B=A*2 + 1 = 11 expect_const_value( @@ -246,109 +229,91 @@ fn c11_const_mixed_with_literal() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c12_felt_eq_true() { expect_const_value("c12_felt_eq_true", "const A: bool = 3 == 3;\nfn main() {}", "A", "Bool", 1); } #[test] -#[serial] fn c13_felt_eq_false() { expect_const_value("c13_felt_eq_false", "const A: bool = 3 == 4;\nfn main() {}", "A", "Bool", 0); } #[test] -#[serial] fn c14_felt_neq() { expect_const_value("c14_felt_neq", "const A: bool = 5 != 5;\nfn main() {}", "A", "Bool", 0); } #[test] -#[serial] fn c15_felt_lt() { expect_const_value("c15_felt_lt", "const A: bool = 2 < 3;\nfn main() {}", "A", "Bool", 1); } #[test] -#[serial] fn c16_felt_gt() { expect_const_value("c16_felt_gt", "const A: bool = 3 > 2;\nfn main() {}", "A", "Bool", 1); } #[test] -#[serial] fn c17_felt_lte() { expect_const_value("c17_felt_lte", "const A: bool = 3 <= 3;\nfn main() {}", "A", "Bool", 1); } #[test] -#[serial] fn c18_felt_gte() { expect_const_value("c18_felt_gte", "const A: bool = 3 >= 4;\nfn main() {}", "A", "Bool", 0); } #[test] -#[serial] fn c19_u32_add() { expect_const_value("c19_u32_add", "const A: u32 = 10u32 + 5u32;\nfn main() {}", "A", "U32", 15); } #[test] -#[serial] fn c20_u32_mul() { expect_const_value("c20_u32_mul", "const A: u32 = 6u32 * 7u32;\nfn main() {}", "A", "U32", 42); } #[test] -#[serial] fn c21_u32_sub() { expect_const_value("c21_u32_sub", "const A: u32 = 100u32 - 37u32;\nfn main() {}", "A", "U32", 63); } #[test] -#[serial] fn c22_u32_div() { expect_const_value("c22_u32_div", "const A: u32 = 84u32 / 4u32;\nfn main() {}", "A", "U32", 21); } #[test] -#[serial] fn c23_u32_mod() { expect_const_value("c23_u32_mod", "const A: u32 = 17u32 % 5u32;\nfn main() {}", "A", "U32", 2); } #[test] -#[serial] fn c24_u32_eq() { expect_const_value("c24_u32_eq", "const A: bool = 7u32 == 7u32;\nfn main() {}", "A", "Bool", 1); } #[test] -#[serial] fn c25_u32_lt() { expect_const_value("c25_u32_lt", "const A: bool = 2u32 < 9u32;\nfn main() {}", "A", "Bool", 1); } #[test] -#[serial] fn c26_bool_and() { expect_const_value("c26_bool_and", "const A: bool = true && false;\nfn main() {}", "A", "Bool", 0); } #[test] -#[serial] fn c27_bool_or() { expect_const_value("c27_bool_or", "const A: bool = true || false;\nfn main() {}", "A", "Bool", 1); } #[test] -#[serial] fn c28_bool_not() { expect_const_value("c28_bool_not", "const A: bool = !true;\nfn main() {}", "A", "Bool", 0); } #[test] -#[serial] fn c29_bool_from_comparison_chain() { // (3 < 5) && (5 > 2) == true expect_const_value( @@ -363,7 +328,6 @@ fn c29_bool_from_comparison_chain() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c30_cross_module_pub_const() { // Child module `m` is predecl'd before the parent (post-order traversal), // so `m::C` is registered before `D` is evaluated. @@ -375,7 +339,6 @@ fn c30_cross_module_pub_const() { } #[test] -#[serial] fn c31_cross_module_transitive() { expect_const_value( "c31_cross_module_trans", @@ -385,7 +348,6 @@ fn c31_cross_module_transitive() { } #[test] -#[serial] fn c32_private_const_in_same_module() { // non-pub const usable within the same (root) module expect_const_value( @@ -400,7 +362,6 @@ fn c32_private_const_in_same_module() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c33_forward_const_reference_rejected() { // B references A, but A is declared AFTER B. During predecl, definitions // are processed in source order, so A is not yet registered when B's value @@ -418,7 +379,6 @@ fn c33_forward_const_reference_rejected() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c34_named_const_array_size_typechecks() { expect_accept( "c34_named_const_array_size", @@ -427,13 +387,11 @@ fn c34_named_const_array_size_typechecks() { } #[test] -#[serial] fn c35_literal_array_size_typechecks() { expect_accept("c35_literal_array_size", "fn main() {\n let arr: [Felt; 4] = [0, 0, 0, 0];\n}"); } #[test] -#[serial] fn c36_named_const_array_size_value() { expect_accept( "c36_array_size_uses_const_value", @@ -446,7 +404,6 @@ fn c36_named_const_array_size_value() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c37_turbofish_literal_const_arg() { // foo::<3u32>(x): literal u32 const arg via parse_monomorphization_ty. // Const generics are declared as `` (a u32-constrained type @@ -458,7 +415,6 @@ fn c37_turbofish_literal_const_arg() { } #[test] -#[serial] fn c38_turbofish_u32_literal_const_arg() { expect_accept( "c38_turbofish_u32_literal", @@ -467,7 +423,6 @@ fn c38_turbofish_u32_literal_const_arg() { } #[test] -#[serial] fn c39_turbofish_named_const_arg() { // foo::(x) where N is a named const β€” parse_monomorphization_ty falls // through to parse_path_ty, resolving N to its Type::Const. @@ -482,7 +437,6 @@ fn c39_turbofish_named_const_arg() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c40_struct_field_named_const_array_size() { expect_accept( "c40_struct_field_const_size", @@ -491,7 +445,6 @@ fn c40_struct_field_named_const_array_size() { } #[test] -#[serial] fn c41_struct_field_literal_array_size() { expect_accept( "c41_struct_field_literal_size", @@ -504,19 +457,16 @@ fn c41_struct_field_literal_array_size() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c42_u32_const_value() { expect_const_value("c42_u32_const", "const C: u32 = 42u32;\nfn main() {}", "C", "U32", 42); } #[test] -#[serial] fn c43_felt_const_value() { expect_const_value("c43_felt_const", "const C: Felt = 42;\nfn main() {}", "C", "Felt", 42); } #[test] -#[serial] fn c44_u32_const_type_mismatch_rejected() { // Declaring a u32 const with a Felt literal value: `1` (no suffix) is a // Felt. unify(u32, felt) should fail. @@ -524,7 +474,6 @@ fn c44_u32_const_type_mismatch_rejected() { } #[test] -#[serial] fn c45_felt_const_type_mismatch_rejected() { // Declaring a Felt const with a u32 literal: `1u32` is u32. unify(felt, u32) // should fail. @@ -532,7 +481,6 @@ fn c45_felt_const_type_mismatch_rejected() { } #[test] -#[serial] fn c46_felt_const_used_as_array_size() { expect_accept( "c46_felt_const_array_size", @@ -545,7 +493,6 @@ fn c46_felt_const_used_as_array_size() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c47_const_bool_in_if() { expect_accept( "c47_const_bool_in_if", @@ -554,7 +501,6 @@ fn c47_const_bool_in_if() { } #[test] -#[serial] fn c48_const_false_in_if() { expect_accept( "c48_const_false_in_if", @@ -567,38 +513,32 @@ fn c48_const_false_in_if() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c49_felt_zero() { expect_const_value("c49_felt_zero", "const Z: Felt = 0;\nfn main() {}", "Z", "Felt", 0); } #[test] -#[serial] fn c50_u32_zero() { expect_const_value("c50_u32_zero", "const Z: u32 = 0u32;\nfn main() {}", "Z", "U32", 0); } #[test] -#[serial] fn c51_bool_false() { expect_const_value("c51_bool_false", "const F: bool = false;\nfn main() {}", "F", "Bool", 0); } #[test] -#[serial] fn c52_bool_true() { expect_const_value("c52_bool_true", "const T: bool = true;\nfn main() {}", "T", "Bool", 1); } #[test] -#[serial] fn c53_zero_identity_add() { // 0 + 5 == 5 (exercises the `b == 0` shortcut AND the constant fold) expect_const_value("c53_zero_add", "const A: Felt = 0 + 5;\nfn main() {}", "A", "Felt", 5); } #[test] -#[serial] fn c54_zero_mul() { // 0 * 5 == 0 expect_const_value("c54_zero_mul", "const A: Felt = 0 * 5;\nfn main() {}", "A", "Felt", 0); @@ -609,7 +549,6 @@ fn c54_zero_mul() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c55_const_in_for_range_u32() { expect_accept( "c55_for_range_u32", @@ -618,7 +557,6 @@ fn c55_const_in_for_range_u32() { } #[test] -#[serial] fn c56_const_in_for_range_felt() { expect_accept( "c56_for_range_felt", @@ -627,7 +565,6 @@ fn c56_const_in_for_range_felt() { } #[test] -#[serial] fn c57_literal_for_range() { expect_accept("c57_for_range_literal", "fn main() {\n for i in 0u32..5u32 {\n let x = i;\n }\n}"); } @@ -635,7 +572,6 @@ fn c57_literal_for_range() { /// Size positions can be nested: `fn f(x: [[Felt; N]; 2])` β€” the /// literal must still promote N to a Const through the nesting. #[test] -#[serial] fn c90_nested_array_size_position_promotes() { expect_accept( "c90_nested_size_position", @@ -648,7 +584,6 @@ fn c90_nested_array_size_position_promotes() { /// *different* literals must infer `T = Felt`, not bind T to `Const(1)` /// and then reject the second literal (H3 regression). #[test] -#[serial] fn c86_plain_generic_literals_do_not_promote() { expect_accept( "c86_plain_generic_literals", @@ -667,7 +602,6 @@ fn c86_plain_generic_literals_do_not_promote() { /// The size-position promotion still works where it matters: the std /// `split_bits(x, 64)` wrapper binds N to `Const(64)` and evaluates. #[test] -#[serial] fn c87_split_bits_const_length_still_works() { expect_accept( "c87_split_bits_const", @@ -684,7 +618,6 @@ fn c87_split_bits_const_length_still_works() { /// is instantiated; rejecting `m: M` while checking the generic body is a /// regression from the pre-gate behavior. #[test] -#[serial] fn c91_split_bits_const_generic_can_be_forwarded_through_user_wrapper() { expect_accept( "c91_split_bits_generic_wrapper", @@ -696,7 +629,6 @@ fn c91_split_bits_const_generic_can_be_forwarded_through_user_wrapper() { /// A runtime length must be rejected during typechecking, before circuit /// interpretation can mistake a symbolic input index for a bit count (H4). #[test] -#[serial] fn c88_split_bits_runtime_length_rejected_at_typecheck() { expect_reject( "c88_split_bits_runtime_rejected", @@ -708,7 +640,6 @@ fn c88_split_bits_runtime_length_rejected_at_typecheck() { /// A negated literal length is rejected during typechecking rather than /// wrapping in the field and reaching an attempted huge allocation (H9). #[test] -#[serial] fn c89_split_bits_negative_length_rejected_at_typecheck() { expect_reject( "c89_split_bits_negative_rejected", @@ -722,7 +653,6 @@ fn c89_split_bits_negative_length_rejected_at_typecheck() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c58_const_plus_runtime_var() { expect_accept( "c58_const_plus_var", @@ -731,7 +661,6 @@ fn c58_const_plus_runtime_var() { } #[test] -#[serial] fn c59_runtime_var_times_const() { expect_accept( "c59_var_times_const", @@ -740,7 +669,6 @@ fn c59_runtime_var_times_const() { } #[test] -#[serial] fn c60_const_in_let_binding() { expect_accept( "c60_const_in_let", @@ -749,7 +677,6 @@ fn c60_const_in_let_binding() { } #[test] -#[serial] fn c61_const_as_fn_arg() { expect_accept( "c61_const_as_fn_arg", @@ -758,7 +685,6 @@ fn c61_const_as_fn_arg() { } #[test] -#[serial] fn c62_const_mixed_arithmetic_with_vars() { // CONST_A + CONST_B * 2 + runtime expect_accept( @@ -768,7 +694,6 @@ fn c62_const_mixed_arithmetic_with_vars() { } #[test] -#[serial] fn c63_u32_const_plus_runtime_u32() { expect_accept( "c63_u32_const_plus_var", @@ -781,7 +706,6 @@ fn c63_u32_const_plus_runtime_u32() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c64_generic_param_as_array_size() { expect_accept( "c64_generic_param_array_size", @@ -790,7 +714,6 @@ fn c64_generic_param_as_array_size() { } #[test] -#[serial] fn c65_generic_param_array_size_in_struct_field() { expect_accept( "c65_generic_struct_field_array_size", @@ -803,7 +726,6 @@ fn c65_generic_param_array_size_in_struct_field() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c66_large_felt_const() { expect_const_value( "c66_large_felt", @@ -813,7 +735,6 @@ fn c66_large_felt_const() { } #[test] -#[serial] fn c67_large_felt_arithmetic() { // 1_000_000_000 * 2 == 2_000_000_000 expect_const_value( @@ -824,7 +745,6 @@ fn c67_large_felt_arithmetic() { } #[test] -#[serial] fn c68_felt_add_near_i64_max_no_panic() { // Felt arithmetic is modular over the Goldilocks prime (p = 2^64 - 2^32 + 1). // 2^63 + 1 is far under p, so the folded result equals the plain sum: @@ -841,7 +761,6 @@ fn c68_felt_add_near_i64_max_no_panic() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c69_felt_bitxor_does_not_fold() { // FINDING (folding gap): `^` is BitXor, not Pow. Felt bitwise ops // (BitAnd/BitOr/BitXor/BitShl/BitShr) in interpret_binary (lib.rs:856-860) @@ -867,7 +786,6 @@ fn c69_felt_bitxor_does_not_fold() { } #[test] -#[serial] fn c70_u32_bitxor_folds() { // `^` is BitXor (not Pow): 2 XOR 3 == 1. u32 bitwise ops DO fold because // both operands are ConstantU32 (op_std_binary_op_u32, exec_context.rs:255). @@ -875,7 +793,6 @@ fn c70_u32_bitxor_folds() { } #[test] -#[serial] fn c71_neg_felt_const() { // -5 as a felt: op_neg(5). Felt is modular over the Goldilocks prime // p = 2^64 - 2^32 + 1, so -5 == p - 5. @@ -889,7 +806,6 @@ fn c71_neg_felt_const() { } #[test] -#[serial] fn c72_chained_subtraction() { // 100 - 30 - 20 == 50 (left-assoc) expect_const_value( @@ -900,7 +816,6 @@ fn c72_chained_subtraction() { } #[test] -#[serial] fn c73_div_then_mul_roundtrip() { // (20 / 4) * 4 == 20 expect_const_value( @@ -915,7 +830,6 @@ fn c73_div_then_mul_roundtrip() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c74_u32_as_felt_const_typechecks() { expect_accept( "c74_u32_as_felt_const", @@ -924,7 +838,6 @@ fn c74_u32_as_felt_const_typechecks() { } #[test] -#[serial] fn c75_u32_as_felt_const_value() { expect_const_value( "c75_u32_as_felt_value", @@ -940,7 +853,6 @@ fn c75_u32_as_felt_const_value() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c76_mixed_u32_felt_add_no_panic() { // 1u32 + 1 : lhs u32, rhs felt literal (no suffix). Either a clean accept // or a clean reject is acceptable; a panic in the const interpreter is a @@ -954,7 +866,6 @@ fn c76_mixed_u32_felt_add_no_panic() { } #[test] -#[serial] fn c77_mixed_felt_u32_add_no_panic() { match compile("const A: Felt = 1 + 1u32;\nfn main() {}", "c77_mixed_add2") { Outcome::Accept(_) | Outcome::Reject(_) => {} @@ -965,7 +876,6 @@ fn c77_mixed_felt_u32_add_no_panic() { } #[test] -#[serial] fn c78_const_referencing_type_not_value_no_panic() { // const X: Felt = S (a struct type); sema should reject via TypeMismatch // before evaluate_expr can run, but if not it must not panic. @@ -980,7 +890,6 @@ fn c78_const_referencing_type_not_value_no_panic() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c79_baseline_const_test_shape() { // Mirrors tests/const_test.psy: cross-module pub const + top-level const + // const used in a runtime expression with a parameter. @@ -998,7 +907,6 @@ fn c79_baseline_const_test_shape() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn c80_computed_felt_const_as_array_size() { // N folds to 5 at compile time; the array type [Felt; N] must accept a // 5-element literal. This proves the folded const value (not the @@ -1010,7 +918,6 @@ fn c80_computed_felt_const_as_array_size() { } #[test] -#[serial] fn c81_array_size_mismatch_rejected() { // FIXED: The unify for Type::Const now compares both the inner type AND // the actual constant value. A 4-element literal against a declared size @@ -1023,7 +930,6 @@ fn c81_array_size_mismatch_rejected() { } #[test] -#[serial] fn c82_const_array_size_checked_at_function_call() { expect_reject( "c82_const_size_function_arg", @@ -1033,7 +939,6 @@ fn c82_const_array_size_checked_at_function_call() { } #[test] -#[serial] fn c83_equal_const_array_sizes_pass_through_function_call() { expect_accept( "c83_equal_const_size_function_arg", @@ -1042,7 +947,6 @@ fn c83_equal_const_array_sizes_pass_through_function_call() { } #[test] -#[serial] fn c84_nested_const_array_inner_size_mismatch_rejected() { expect_reject( "c84_nested_const_inner_size", @@ -1052,7 +956,6 @@ fn c84_nested_const_array_inner_size_mismatch_rejected() { } #[test] -#[serial] fn c85_distinct_named_consts_with_equal_values_unify() { expect_accept( "c85_equal_named_const_values", @@ -1065,7 +968,6 @@ fn c85_distinct_named_consts_with_equal_values_unify() { /// the first argument must be promoted to `Const(3)` before the deeply /// nested array argument is unified with the signature. #[test] -#[serial] fn c90_const_size_position_beyond_sixteen_type_levels() { const WRAPPERS: usize = 17; diff --git a/psy-interpreter/src/constraint_sema_tests.rs b/psy-interpreter/src/constraint_sema_tests.rs index b8dfcb385..0d26d4578 100644 --- a/psy-interpreter/src/constraint_sema_tests.rs +++ b/psy-interpreter/src/constraint_sema_tests.rs @@ -6,26 +6,23 @@ // temporary `.psy` sources. Every case names the concrete contract it defends // and asserts the exact accept/reject outcome. // -// The shared `STD_PRIMITIVE_SCOPE_ID` singleton is reset after *every* case +// The primitive scope is owned by each typecheck symbol table // (via `check`, which tears down before the caller can panic) so the suite // is hermetic. use std::{ fs, - path::PathBuf, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; -use serial_test::serial; use super::*; static COUNTER: AtomicU64 = AtomicU64::new(0); -/// Typecheck `source` written to a throwaway temp file, then ALWAYS tear down -/// the file and reset the shared primitive-scope singleton. Returns `None` if +/// Typecheck `source` written to a throwaway temp file, then tear it down. Returns `None` if /// the program typechecked, or `Some(formatted_error)` if it was rejected. fn check(source: &str) -> Option { let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); @@ -37,10 +34,6 @@ fn check(source: &str) -> Option { let result = interpreter.typecheck_single(path.clone()); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } match result { Ok(_) => None, @@ -74,7 +67,6 @@ fn expect_reject(name: &str, source: &str, needle: &str) { /// Method call `x.m()` on a constrained type variable `T: Trait` resolves /// through the constraint's trait scope (implementer.rs:328-340). #[test] -#[serial] fn c01_single_constraint_method_resolves() { expect_accept( "c01_single_constraint_method_resolves", @@ -89,7 +81,6 @@ fn main() {} /// Associated function call `T::make()` on a constrained type variable /// resolves through the constraint. #[test] -#[serial] fn c01b_single_constraint_assoc_fn_resolves() { expect_accept( "c01b_single_constraint_assoc_fn_resolves", @@ -104,7 +95,6 @@ fn main() {} /// Constrained generic function with a body that doesn't use the constraint /// still typechecks (constraint declared but unused). #[test] -#[serial] fn c01c_single_constraint_unused_typechecks() { expect_accept( "c01c_single_constraint_unused_typechecks", @@ -122,7 +112,6 @@ fn main() {} /// Two constraints `T: A + B` β€” method from A is available. #[test] -#[serial] fn c02_multi_constraint_first_method() { expect_accept( "c02_multi_constraint_first_method", @@ -137,7 +126,6 @@ fn main() {} /// Two constraints `T: A + B` β€” method from B is available. #[test] -#[serial] fn c02b_multi_constraint_second_method() { expect_accept( "c02b_multi_constraint_second_method", @@ -152,7 +140,6 @@ fn main() {} /// Three constraints `T: A + B + C` β€” all methods available. #[test] -#[serial] fn c02c_three_constraints_all_methods() { expect_accept( "c02c_three_constraints_all_methods", @@ -172,7 +159,6 @@ fn main() {} /// Declaration of generic struct with constraint typechecks. #[test] -#[serial] fn c03_generic_struct_with_constraint_decl() { expect_accept( "c03_generic_struct_with_constraint_decl", @@ -187,7 +173,6 @@ fn main() {} /// Method access through a field of constrained generic struct type. /// `s.x.m()` where `s: S` and `S` β€” `s.x` has type T with constraint T. #[test] -#[serial] fn c03b_generic_struct_field_method_access() { expect_accept( "c03b_generic_struct_field_method_access", @@ -202,7 +187,6 @@ fn main() {} /// Struct literal construction with constrained generic type parameter. #[test] -#[serial] fn c03c_struct_literal_constrained_generic() { expect_accept( "c03c_struct_literal_constrained_generic", @@ -224,7 +208,6 @@ fn main() {} /// `impl U for T` β€” T's trait method resolves via the constraint inside /// the impl body. #[test] -#[serial] fn c04_impl_constraint_method_resolves() { expect_accept( "c04_impl_constraint_method_resolves", @@ -241,7 +224,6 @@ fn main() {} /// A type variable cannot be used as the trait in a blanket impl header. #[test] -#[serial] fn c04b_blanket_impl_constraint_rejected() { expect_reject("c04b_blanket_impl_constraint_rejected", r#" trait T { fn m(self: Self) -> Felt; } @@ -258,7 +240,6 @@ fn main() {} /// `fn f` calls `fn g` β€” constraint propagates. #[test] -#[serial] fn c05_constraint_propagation_same() { expect_accept( "c05_constraint_propagation_same", @@ -274,7 +255,6 @@ fn main() {} /// `fn f` (no constraint) calls `fn g` β€” should fail because /// f's T doesn't satisfy g's constraint. #[test] -#[serial] fn c05b_constraint_propagation_missing_rejected() { expect_reject( "c05b_constraint_propagation_missing_rejected", @@ -294,7 +274,6 @@ fn main() {} /// `fn use_it(x: T)` called with `Foo` where `impl T for Foo` β€” works. #[test] -#[serial] fn c06_concrete_type_satisfies_constraint() { expect_accept( "c06_concrete_type_satisfies_constraint", @@ -311,7 +290,6 @@ fn main() { let f = Foo { v: 42 }; let r = use_it::(f); } /// Calling a constrained generic function with a type that does NOT implement /// the trait β€” should fail. #[test] -#[serial] fn c06b_concrete_type_missing_impl_rejected() { expect_reject( "c06b_concrete_type_missing_impl_rejected", @@ -334,7 +312,6 @@ fn main() { let b = Bar { v: 42 }; let r = use_it::(b); } /// `fn f(x: T) { x.m() }` where m is from Trait β€” rejected because T has /// no constraint. #[test] -#[serial] fn c07_missing_constraint_rejected() { expect_reject( "c07_missing_constraint_rejected", @@ -349,7 +326,6 @@ fn main() {} /// Missing constraint on associated function call β€” `T::make()` without T: T. #[test] -#[serial] fn c07b_missing_constraint_assoc_fn_rejected() { expect_reject( "c07b_missing_constraint_assoc_fn_rejected", @@ -371,7 +347,6 @@ fn main() {} /// parses comma-separated types, not `Name = Type` bindings. So this syntax /// is not supported and will produce a parse error. #[test] -#[serial] fn c08_constraint_with_assoc_type_binding_unimplemented() { expect_reject( "c08_constraint_with_assoc_type_binding_unimplemented", @@ -387,7 +362,6 @@ fn main() {} /// Associated type WITH a constraint in trait declaration β€” `type Item: NewTrait`. /// This IS supported (parse_trait_associated_type at item.rs:782-810). #[test] -#[serial] fn c08b_assoc_type_with_constraint_in_trait_decl() { expect_accept( "c08b_assoc_type_with_constraint_in_trait_decl", @@ -403,7 +377,6 @@ fn main() {} /// Felt does not implement NewTrait, so the override `type Item = Felt` should /// be rejected. #[test] -#[serial] fn c08c_assoc_type_constraint_override_rejected() { expect_reject( "c08c_assoc_type_constraint_override_rejected", @@ -424,7 +397,6 @@ fn main() {} /// `::m(f)` β€” static trait method dispatch on concrete type. #[test] -#[serial] fn c09_trait_static_dispatch_concrete() { expect_accept( "c09_trait_static_dispatch_concrete", @@ -443,7 +415,6 @@ fn main() { /// `::method()` on a constrained type variable β€” dispatch through /// the constraint (lib.rs:1482-1500 checks constraint list for type variables). #[test] -#[serial] fn c09b_trait_cast_on_type_variable() { expect_accept( "c09b_trait_cast_on_type_variable", @@ -464,7 +435,6 @@ fn main() {} /// UNIMPLEMENTED: parse_trait_definition (item.rs:714-717) does not parse a /// colon after the trait name. The `:` would be unexpected. #[test] -#[serial] fn c10_supertrait_unimplemented() { expect_reject( "c10_supertrait_unimplemented", @@ -485,7 +455,6 @@ fn main() {} /// UNIMPLEMENTED: parse_function_definition (item.rs:283-292) does not parse /// `where` after the return type. The `where` keyword would be unexpected. #[test] -#[serial] fn c11_where_clause_unimplemented() { expect_reject( "c11_where_clause_unimplemented", @@ -500,7 +469,6 @@ fn main() {} /// Where clause on a struct β€” also unimplemented. #[test] -#[serial] fn c11b_where_clause_on_struct_unimplemented() { expect_reject( "c11b_where_clause_on_struct_unimplemented", @@ -519,7 +487,6 @@ fn main() {} /// `fn f` calls `fn g` β€” relaxation works (T: A+B satisfies T: A). #[test] -#[serial] fn c12_constraint_relaxation_ab_to_a() { expect_accept( "c12_constraint_relaxation_ab_to_a", @@ -535,7 +502,6 @@ fn main() {} /// `fn f` calls `fn g` β€” relaxation works. #[test] -#[serial] fn c12b_constraint_relaxation_abc_to_ab() { expect_accept( "c12b_constraint_relaxation_abc_to_ab", @@ -558,7 +524,6 @@ fn main() {} /// Sema's typecheck_generic_parameter (lib.rs:3378) checks all are traits, /// which passes. No duplicate detection. #[test] -#[serial] fn c13_duplicate_constraint_accepted() { expect_accept( "c13_duplicate_constraint_accepted", @@ -572,7 +537,6 @@ fn main() {} /// Triple duplicate `T: A + A + A` β€” still accepted. #[test] -#[serial] fn c13b_triple_duplicate_constraint_accepted() { expect_accept( "c13b_triple_duplicate_constraint_accepted", @@ -592,7 +556,6 @@ fn main() {} /// parse_generic_constraints (ty.rs:424-437) consumes `:` then calls /// parse_path_ty, which fails on `{`. #[test] -#[serial] fn c14_empty_constraint_after_colon_rejected() { // This is a parse error β€” typecheck_single returns a parse-level error. // We accept any rejection. @@ -608,7 +571,6 @@ fn main() {} /// `fn f(x: T) {}` β€” colon with space then nothing. Same rejection. #[test] -#[serial] fn c14b_empty_constraint_space_rejected() { expect_reject( "c14b_empty_constraint_space_rejected", @@ -627,7 +589,6 @@ fn main() {} /// `fn method(self: Self) where Self: OtherTrait` β€” where clause on method. /// UNIMPLEMENTED: where clauses are not parsed (same as focus area 11). #[test] -#[serial] fn c15_self_where_clause_unimplemented() { expect_reject( "c15_self_where_clause_unimplemented", @@ -650,7 +611,6 @@ fn main() {} /// Struct literal `S { x: value }` where S β€” constructing with a value /// that satisfies the constraint. #[test] -#[serial] fn ca_struct_literal_with_constraint() { expect_accept( "ca_struct_literal_with_constraint", @@ -666,7 +626,6 @@ fn main() { let f = Foo { v: 42 }; let s = S:: { x: f }; } /// Struct literal with bare generic and constraint β€” `S { x: f }`. #[test] -#[serial] fn ca_struct_literal_bare_generic_constraint() { expect_accept( "ca_struct_literal_bare_generic_constraint", @@ -687,7 +646,6 @@ fn main() { let f = Foo { v: 42 }; let s = S { x: f }; } /// Blanket impl with constraint: `impl U for T` where the U method body /// calls T's method via the constraint. #[test] -#[serial] fn cb_trait_impl_block_constraint() { expect_accept( "cb_trait_impl_block_constraint", @@ -704,7 +662,6 @@ fn main() {} /// Multiple constraints on impl generic parameter. #[test] -#[serial] fn cb_impl_multi_constraint() { expect_accept( "cb_impl_multi_constraint", @@ -728,7 +685,6 @@ fn main() {} /// `N: u32` is valid per typecheck_generic_parameter (lib.rs:3379: exactly 1 /// basic type constraint). #[test] -#[serial] fn cc_array_constraint_mixed_type_const() { expect_accept( "cc_array_constraint_mixed_type_const", @@ -742,7 +698,6 @@ fn main() {} /// Array type with constrained element type: `[T; N]` where `T: Trait`. #[test] -#[serial] fn cc_array_with_constrained_element() { expect_accept( "cc_array_with_constrained_element", @@ -760,7 +715,6 @@ fn main() {} /// Tuple type with constrained generic: `fn f(x: (T, Felt))`. #[test] -#[serial] fn cd_tuple_with_constrained_generic() { expect_accept( "cd_tuple_with_constrained_generic", @@ -774,7 +728,6 @@ fn main() {} /// Tuple of two constrained generics: `(T, T)`. #[test] -#[serial] fn cd_tuple_two_constrained() { expect_accept( "cd_tuple_two_constrained", @@ -792,7 +745,6 @@ fn main() {} /// Self type in trait method signature with constraint β€” basic Self works. #[test] -#[serial] fn ce_self_type_basic() { expect_accept( "ce_self_type_basic", @@ -811,7 +763,6 @@ fn main() {} /// the same trait during default method typechecking. This is a sema /// limitation, not a parser issue. #[test] -#[serial] fn ce_self_method_call_in_trait_rejected() { expect_reject( "ce_self_method_call_in_trait_rejected", @@ -833,7 +784,6 @@ fn main() {} /// `extern fn f(x: T) -> Felt;` β€” extern fn with constraint, no body. #[test] -#[serial] fn cf_extern_fn_with_constraint() { expect_accept( "cf_extern_fn_with_constraint", @@ -847,7 +797,6 @@ fn main() {} /// `const extern fn` with constraint β€” both qualifiers with constraint. #[test] -#[serial] fn cf_const_extern_fn_with_constraint() { expect_accept( "cf_const_extern_fn_with_constraint", @@ -866,7 +815,6 @@ fn main() {} /// Constraint is available during predecl: function with constraint that uses /// the constraint in its own signature (return type references the trait). #[test] -#[serial] fn cg_constraint_available_in_predecl() { expect_accept( "cg_constraint_available_in_predecl", @@ -882,7 +830,6 @@ fn main() {} /// Forward reference: function f uses constraint and is called before its /// declaration in the module β€” predecl phase registers all functions first. #[test] -#[serial] fn cg_forward_ref_constraint() { expect_accept( "cg_forward_ref_constraint", @@ -901,7 +848,6 @@ impl T for Felt { fn m(self: Self) -> Felt { return 0; } } /// `T: Felt` β€” single basic type constraint. Valid per lib.rs:3379. #[test] -#[serial] fn ch_basic_type_constraint_valid() { expect_accept( "ch_basic_type_constraint_valid", @@ -918,7 +864,6 @@ fn main() {} /// constraint β€” it reports UnresolvedType. This is a limitation: only /// Felt is accepted as a basic-type constraint (lib.rs:3379), not Bool/u32. #[test] -#[serial] fn ch_bool_constraint_rejected() { expect_reject( "ch_bool_constraint_rejected", @@ -932,7 +877,6 @@ fn main() {} /// `T: u32` β€” single basic type constraint (u32). Valid. #[test] -#[serial] fn ch_u32_constraint_valid() { expect_accept( "ch_u32_constraint_valid", @@ -946,7 +890,6 @@ fn main() {} /// `T: Struct` β€” constraint is a struct, not a trait. INVALID. #[test] -#[serial] fn ch_struct_constraint_rejected() { expect_reject( "ch_struct_constraint_rejected", @@ -966,7 +909,6 @@ fn main() {} /// `T: Trait` β€” generic trait as constraint. The parser parses this as /// a generic type, and sema checks it's a trait. #[test] -#[serial] fn ci_generic_trait_constraint() { expect_accept( "ci_generic_trait_constraint", @@ -981,7 +923,6 @@ fn main() {} /// Generic trait constraint with concrete type that implements the generic /// trait with the right type argument. #[test] -#[serial] fn ci_generic_trait_constraint_satisfied() { expect_accept( "ci_generic_trait_constraint_satisfied", @@ -1001,7 +942,6 @@ fn main() { let foo = Foo { v: 42 }; let r = f::(foo); } /// Enum declarations are currently rejected with a regular sema error. #[test] -#[serial] fn cj_enum_constraint_decl_rejected() { expect_reject("cj_enum_constraint_decl_rejected", r#" trait T { fn m(self: Self) -> Felt; } @@ -1014,7 +954,6 @@ fn main() {} /// associated type. Defends: find_associated_type on type variable with /// constraints (implementer.rs:328-340 fallthrough to associated type lookup). #[test] -#[serial] fn ck_assoc_type_access_on_constrained_var() { expect_accept( "ck_assoc_type_access_on_constrained_var", @@ -1032,7 +971,6 @@ fn main() {} /// losing the associated type resolution. T::Item (without explicit cast) /// works via constraint fallback (implementer.rs:328-340). #[test] -#[serial] fn ck_assoc_type_explicit_cast_constrained_rejected() { expect_reject( "ck_assoc_type_explicit_cast_constrained", diff --git a/psy-interpreter/src/exec_edge_tests.rs b/psy-interpreter/src/exec_edge_tests.rs index c75578eec..46673d01b 100644 --- a/psy-interpreter/src/exec_edge_tests.rs +++ b/psy-interpreter/src/exec_edge_tests.rs @@ -4,9 +4,7 @@ // materialization limits, failing assertion reporting, and the // `interpret_vfs_files` / `typecheck_lsp` entry points. // -// Every case drives `main` through the interpreter with a stub compile -// function (no DPN proving) and resets the shared STD_PRIMITIVE_SCOPE_ID -// singleton so the suite stays hermetic. All cases are `#[serial]`. +// Every case drives `main` through an independent interpreter and symbol table. use std::sync::atomic::{AtomicU64, Ordering}; @@ -14,7 +12,6 @@ use psy_vm::dpn::{ ops::{exec_context::QExecContext, sym_felt::SymFeltRef}, vm::{compile::PsyCompileResult, def::DPNFunctionCircuitDefinition}, }; -use serial_test::serial; use super::*; @@ -47,13 +44,6 @@ fn real_compile_fn( PsyCompileResult::compile_exec(name, method_id, &context.store, context, &outputs) } -fn reset_primitive_scope() { - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } -} - fn exec_source(label: &str, source: &str) -> Result<(), String> { let n = COUNTER.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("psy_exec_{n}.psy")); @@ -71,7 +61,6 @@ fn exec_source(label: &str, source: &str) -> Result<(), String> { .map_err(|e| format!("interpret: {e:#}")) }; let _ = std::fs::remove_file(&path); - reset_primitive_scope(); result } @@ -94,7 +83,6 @@ fn exec_rejects(label: &str, source: &str, needle: &str) { /// Sweep every binary-operator dispatch arm on all three operand families. /// `a` is a symbolic input so nothing constant-folds away before dispatch. #[test] -#[serial] fn operator_matrix_executes_felt_u32_and_bool_binops() { exec_accepts( "felt operator matrix", @@ -189,7 +177,6 @@ fn main(a: Felt) { /// `CheckedValueRef::get_path`/`set_path`; tuple and struct targets take the /// positional/name-based arms. #[test] -#[serial] fn symbolic_index_paths_read_and_write_composites() { exec_accepts( "symbolic array index read and write", @@ -247,7 +234,6 @@ fn main() { /// Array repeats materialize at execution; the interpreter caps the element /// count and rejects oversized repeats before allocating. #[test] -#[serial] fn array_repeats_materialize_and_oversized_repeats_reject() { exec_accepts( "small array repeat", @@ -276,7 +262,6 @@ fn main() { /// Failing constant assertions surface the user message at execution. #[test] -#[serial] fn failing_assertions_report_their_messages() { exec_rejects( "assert_eq with differing constants", @@ -304,7 +289,6 @@ fn main() { /// The VFS entry point runs an in-memory program without touching the disk. #[test] -#[serial] fn vfs_files_interpret_a_virtual_program() { let source = "use std::prelude::*;\nfn main() { let a = split_bits(3, 2); assert_eq(a[0], 1, \"vfs\"); }"; let mut graph = Graph::new(); @@ -315,7 +299,6 @@ fn vfs_files_interpret_a_virtual_program() { graph, vec![(std::path::PathBuf::from("/virtual/src/main.psy"), std::sync::Arc::from(source))], ); - reset_primitive_scope(); match result { Ok(res) => assert_eq!(res.compile_results.len(), 1, "vfs main compiled"), Err(err) => panic!("[vfs_files] expected success, got:\n{err:#}"), @@ -325,7 +308,6 @@ fn vfs_files_interpret_a_virtual_program() { /// The LSP typecheck entry point shares the pipeline but lowers errors to /// diagnostics instead of anyhow chains. #[test] -#[serial] fn typecheck_lsp_typechecks_the_module_graph() { let entry: PathBuf = "../tests/module_test/foo/src/main.psy".into(); let dependency_entry: PathBuf = "../tests/module_test/bar/src/lib.psy".into(); @@ -334,11 +316,18 @@ fn typecheck_lsp_typechecks_the_module_graph() { crate_path_graph.add_node(entry.clone()); crate_path_graph.add_edge(entry.clone(), dependency_entry); + let mut first_graph = Graph::new(); + first_graph.add_node(PathBuf::from("../tests/fn_test.psy")); + let mut interpreter = Interpreter::::new(QExecContext::new()); - let result = interpreter.typecheck_lsp(crate_path_graph); - reset_primitive_scope(); + let result = interpreter.typecheck_lsp(first_graph); match result { Ok((_typechecker, _ctx)) => {} Err(err) => panic!("[typecheck_lsp] expected success, got:\n{err:?}"), } + + match interpreter.typecheck_lsp(crate_path_graph) { + Ok((_typechecker, _ctx)) => {} + Err(err) => panic!("[typecheck_lsp second pass] expected success, got:\n{err:?}"), + } } diff --git a/psy-interpreter/src/generics_sema_tests.rs b/psy-interpreter/src/generics_sema_tests.rs index a78fca6f7..05eb994d0 100644 --- a/psy-interpreter/src/generics_sema_tests.rs +++ b/psy-interpreter/src/generics_sema_tests.rs @@ -4,26 +4,23 @@ // temporary `.psy` sources. Every case names the concrete generic-type // contract it defends and asserts the exact accept/reject outcome. // -// The shared `STD_PRIMITIVE_SCOPE_ID` singleton is reset after *every* case +// The primitive scope is owned by each typecheck symbol table // (via `check`, which tears down before the caller can panic) so the suite // is hermetic. use std::{ fs, - path::PathBuf, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; -use serial_test::serial; use super::*; static COUNTER: AtomicU64 = AtomicU64::new(0); -/// Typecheck `source` written to a throwaway temp file, then ALWAYS tear down -/// the file and reset the shared primitive-scope singleton. Returns `None` if +/// Typecheck `source` written to a throwaway temp file, then tear it down. Returns `None` if /// the program typechecked, or `Some(formatted_error)` if it was rejected. fn check(source: &str) -> Option { let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); @@ -35,10 +32,6 @@ fn check(source: &str) -> Option { let result = interpreter.typecheck_single(path.clone()); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } match result { Ok(_) => None, @@ -71,7 +64,6 @@ fn expect_reject(name: &str, source: &str, needle: &str) { /// Generic identity function accepts any type and returns it. #[test] -#[serial] fn mono_identity_fn_accepts_felt() { expect_accept( "mono_identity_fn_accepts_felt", @@ -84,7 +76,6 @@ fn main() { let x = identity::(42); } /// Generic function with a concrete turbofish call typechecks. #[test] -#[serial] fn mono_generic_fn_with_bool_arg() { expect_accept( "mono_generic_fn_with_bool_arg", @@ -97,7 +88,6 @@ fn main() { let b = wrap::(true); } /// Calling a generic function with the wrong turbofish type fails. #[test] -#[serial] fn mono_generic_fn_wrong_type_rejected() { expect_reject( "mono_generic_fn_wrong_type_rejected", @@ -111,7 +101,6 @@ fn main() { let b = wrap::(42); } /// Generic struct with turbofish constructor typechecks. #[test] -#[serial] fn mono_generic_struct_turbofish_literal() { expect_accept( "mono_generic_struct_turbofish_literal", @@ -124,7 +113,6 @@ fn main() { let p = Pair:: { a: 1, b: 2 }; } /// Generic struct with bare generic constructor typechecks. #[test] -#[serial] fn mono_generic_struct_bare_literal() { expect_accept( "mono_generic_struct_bare_literal", @@ -137,7 +125,6 @@ fn main() { let p = Pair { a: 1, b: 2 }; } /// Generic struct field access returns the substituted type. #[test] -#[serial] fn mono_generic_struct_field_access() { expect_accept( "mono_generic_struct_field_access", @@ -150,7 +137,6 @@ fn main() { let b = Box:: { val: 42 }; let x = b.val; } /// Generic struct with mismatched field type is rejected. #[test] -#[serial] fn mono_generic_struct_wrong_field_type_rejected() { expect_reject( "mono_generic_struct_wrong_field_type_rejected", @@ -164,7 +150,6 @@ fn main() { let b = Box:: { val: true }; } /// Generic struct with wrong number of generic args is rejected. #[test] -#[serial] fn mono_generic_struct_wrong_arity_rejected() { expect_reject( "mono_generic_struct_wrong_arity_rejected", @@ -182,7 +167,6 @@ fn main() { let p = Pair:: { a: 1, b: 2 }; } /// Free function turbofish call. #[test] -#[serial] fn sema_free_call_turbofish() { expect_accept( "sema_free_call_turbofish", @@ -195,7 +179,6 @@ fn main() { let r = id::(42); } /// Qualified path turbofish call. #[test] -#[serial] fn sema_qualified_path_turbofish() { expect_accept( "sema_qualified_path_turbofish", @@ -214,7 +197,6 @@ fn main() { let r = m::id::(42); } /// Generic function with constraint accepts a type implementing the trait. #[test] -#[serial] fn sema_constrained_generic_accepts_implementor() { expect_accept( "sema_constrained_generic_accepts_implementor", @@ -228,7 +210,6 @@ fn main() {} /// Multiple constraints parse and typecheck. #[test] -#[serial] fn sema_multiple_constraints_typecheck() { expect_accept( "sema_multiple_constraints_typecheck", @@ -247,7 +228,6 @@ fn main() {} /// Nested generic types in struct fields typecheck. #[test] -#[serial] fn sema_nested_generic_in_struct() { expect_accept( "sema_nested_generic_in_struct", @@ -261,7 +241,6 @@ fn main() {} /// Generic struct with nested generic return. #[test] -#[serial] fn sema_nested_generic_struct_literal() { expect_accept( "sema_nested_generic_struct_literal", @@ -279,7 +258,6 @@ fn main() { let o = Outer:: { inner: Inner:: { val: 42 } }; } /// Turbofish call in if-condition typechecks. #[test] -#[serial] fn sema_turbofish_in_if_condition() { expect_accept( "sema_turbofish_in_if_condition", @@ -292,7 +270,6 @@ fn main() { if pred::(42) { } } /// Generic struct literal in if-body typechecks. #[test] -#[serial] fn sema_generic_struct_in_if_body() { expect_accept( "sema_generic_struct_in_if_body", @@ -309,7 +286,6 @@ fn main() { if true { let b = Box:: { val: 42 }; } } /// Empty generic params rejected by parser (reaches sema as error). #[test] -#[serial] fn sema_empty_generic_params_rejected() { expect_reject( "sema_empty_generic_params_rejected", @@ -323,7 +299,6 @@ fn main() {} /// Empty generic args rejected. #[test] -#[serial] fn sema_empty_generic_args_rejected() { expect_reject( "sema_empty_generic_args_rejected", @@ -341,7 +316,6 @@ fn main() {} /// Generic function declaration typechecks. #[test] -#[serial] fn sema_generic_fn_decl() { expect_accept( "sema_generic_fn_decl", @@ -354,7 +328,6 @@ fn main() {} /// Generic struct declaration typechecks. #[test] -#[serial] fn sema_generic_struct_decl() { expect_accept( "sema_generic_struct_decl", @@ -369,7 +342,6 @@ fn main() {} /// `visit_enum` used to `todo!()` (a panic); it now reports `UnresolvedType` /// so callers get a proper diagnostic instead of a compiler crash. #[test] -#[serial] fn sema_generic_enum_panics_todo() { expect_reject( "sema_generic_enum_panics_todo", @@ -383,7 +355,6 @@ fn main() {} /// Generic function with parameter and return. #[test] -#[serial] fn sema_generic_fn_with_param_and_return() { expect_accept( "sema_generic_fn_with_param_and_return", @@ -399,7 +370,6 @@ fn main() {} // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn sema_obsolete_pound_turbofish_rejected() { expect_reject( "sema_obsolete_pound_turbofish_rejected", @@ -417,7 +387,6 @@ fn main() { let r = id#(42); } /// Const turbofish argument parses and typechecks. #[test] -#[serial] fn sema_const_turbofish() { expect_accept( "sema_const_turbofish", @@ -436,7 +405,6 @@ fn main() { f::<3>(); } /// The formatter must emit turbofish `::` syntax for generic args in type /// path segments so the output re-parses. #[test] -#[serial] fn sema_formatter_roundtrip_generic_struct() { let source = r#" struct Pair { pub a: T, pub b: T } diff --git a/psy-interpreter/src/interp_exec_tests.rs b/psy-interpreter/src/interp_exec_tests.rs index 5767cc60d..4048de3b9 100644 --- a/psy-interpreter/src/interp_exec_tests.rs +++ b/psy-interpreter/src/interp_exec_tests.rs @@ -11,8 +11,7 @@ // resolution, view-method enforcement) is covered with the real // `PsyCompileResult::compile_exec` backend so state commands are visible. // -// The shared `STD_PRIMITIVE_SCOPE_ID` singleton is reset after every case so -// the suite is hermetic. Every case is `#[serial]`. +// Each case owns an independent interpreter, symbol table, and uniquely named temporary file. use std::{ fs, @@ -24,7 +23,6 @@ use psy_vm::dpn::{ ops::{exec_context::QExecContext, sym_felt::SymFeltRef}, vm::{compile::PsyCompileResult, def::DPNFunctionCircuitDefinition}, }; -use serial_test::serial; use super::*; @@ -79,13 +77,6 @@ fn panic_message(payload: &Box) -> String { } } -fn reset_primitive_scope() { - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } -} - fn temp_psy_path(label: &str) -> std::path::PathBuf { let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); let n = COUNTER.fetch_add(1, Ordering::Relaxed); @@ -118,7 +109,6 @@ fn run_with( })); let _ = fs::remove_file(&path); - reset_primitive_scope(); match result { Ok(Ok(defs)) => Outcome::Executed(defs), @@ -147,7 +137,6 @@ fn run_psy_tests(source: &str, label: &str) -> Outcome { })); let _ = fs::remove_file(&path); - reset_primitive_scope(); match result { Ok(Ok(defs)) => Outcome::Executed(defs), @@ -194,7 +183,6 @@ fn expect_test_panic(label: &str, source: &str, needle: &str) { // --------------------------------------------------------------------------- #[test] -#[serial] fn felt_binary_operators_execute() { expect_exec( "felt_ops", @@ -225,7 +213,6 @@ fn felt_binary_operators_execute() { } #[test] -#[serial] fn u32_binary_operators_execute() { expect_exec( "u32_ops", @@ -256,7 +243,6 @@ fn u32_binary_operators_execute() { } #[test] -#[serial] fn bool_logic_operators_execute() { expect_exec( "bool_ops", @@ -279,7 +265,6 @@ fn bool_logic_operators_execute() { } #[test] -#[serial] fn constant_division_and_overflow_are_clean_errors() { expect_failure( "div_by_zero", @@ -343,7 +328,6 @@ fn constant_division_and_overflow_are_clean_errors() { // --------------------------------------------------------------------------- #[test] -#[serial] fn unary_operators_execute() { expect_exec( "unary_ops", @@ -363,7 +347,6 @@ fn unary_operators_execute() { } #[test] -#[serial] fn casts_execute_and_reject_out_of_range() { expect_exec( "casts_ok", @@ -398,7 +381,6 @@ fn casts_execute_and_reject_out_of_range() { } #[test] -#[serial] fn compound_assignment_operators_execute() { expect_exec( "compound_assign", @@ -453,7 +435,6 @@ fn compound_assignment_operators_execute() { // --------------------------------------------------------------------------- #[test] -#[serial] fn while_and_for_loops_execute() { expect_exec( "loops", @@ -495,7 +476,6 @@ fn while_and_for_loops_execute() { } #[test] -#[serial] fn uncertain_loop_conditions_are_rejected() { expect_failure( "uncertain_while", @@ -524,7 +504,6 @@ fn uncertain_loop_conditions_are_rejected() { } #[test] -#[serial] fn match_statements_and_expressions_execute() { expect_exec( "match_all", @@ -575,7 +554,6 @@ fn match_statements_and_expressions_execute() { } #[test] -#[serial] fn if_else_and_else_if_chains_execute() { expect_exec( "if_else", @@ -610,7 +588,6 @@ fn if_else_and_else_if_chains_execute() { // --------------------------------------------------------------------------- #[test] -#[serial] fn closures_and_helper_calls_execute() { expect_exec( "closures", @@ -634,7 +611,6 @@ fn closures_and_helper_calls_execute() { } #[test] -#[serial] fn recursion_is_rejected() { expect_failure( "recursion", @@ -657,7 +633,6 @@ fn recursion_is_rejected() { // --------------------------------------------------------------------------- #[test] -#[serial] fn array_literals_repeat_and_mutation_execute() { expect_exec( "arrays", @@ -687,7 +662,6 @@ fn array_literals_repeat_and_mutation_execute() { } #[test] -#[serial] fn oversized_repeat_arrays_are_rejected() { expect_failure( "repeat_too_large", @@ -716,7 +690,6 @@ fn oversized_repeat_arrays_are_rejected() { } #[test] -#[serial] fn array_and_struct_parameters_are_materialized() { expect_exec( "param_materialize", @@ -743,7 +716,6 @@ fn array_and_struct_parameters_are_materialized() { } #[test] -#[serial] fn struct_values_methods_and_mutation_execute() { expect_exec( "structs", @@ -800,7 +772,6 @@ fn struct_values_methods_and_mutation_execute() { // --------------------------------------------------------------------------- #[test] -#[serial] fn assert_intrinsics_execute_and_fail_cleanly() { expect_exec( "assert_ok", @@ -844,7 +815,6 @@ fn assert_intrinsics_execute_and_fail_cleanly() { } #[test] -#[serial] fn clear_entire_tree_intrinsic_executes() { // Storage teardown intrinsic inside a contract write method. expect_exec( @@ -891,7 +861,6 @@ const LIFECYCLE_CONTRACT: &str = r#" "#; #[test] -#[serial] fn contract_auto_discovery_executes_all_methods() { match run_with(LIFECYCLE_CONTRACT, "contract_auto", None, &[], true) { Outcome::Executed(defs) => { @@ -910,7 +879,6 @@ fn contract_auto_discovery_executes_all_methods() { } #[test] -#[serial] fn contract_explicit_name_and_single_method_execute() { match run_with(LIFECYCLE_CONTRACT, "contract_explicit", Some("LifecycleContract"), &["set_value"], true) { Outcome::Executed(defs) => { @@ -923,7 +891,6 @@ fn contract_explicit_name_and_single_method_execute() { } #[test] -#[serial] fn contract_entry_point_errors_are_reported() { // Unknown contract name. expect_failure_with("contract_missing", LIFECYCLE_CONTRACT, Some("Nope"), &[], "undefined function"); @@ -948,7 +915,6 @@ fn contract_entry_point_errors_are_reported() { } #[test] -#[serial] fn view_method_that_writes_state_is_rejected() { let source = r#" #[contract] @@ -973,7 +939,6 @@ fn view_method_that_writes_state_is_rejected() { } #[test] -#[serial] fn overloaded_contract_methods_are_rejected() { let source = r#" #[contract] @@ -1006,7 +971,6 @@ fn overloaded_contract_methods_are_rejected() { // --------------------------------------------------------------------------- #[test] -#[serial] fn contract_storage_with_array_and_map_fields_typechecks() { expect_exec( "storage_layout", @@ -1036,7 +1000,6 @@ fn contract_storage_with_array_and_map_fields_typechecks() { } #[test] -#[serial] fn nested_storage_ref_fields_typecheck() { expect_exec( "nested_refs", @@ -1066,7 +1029,6 @@ fn nested_storage_ref_fields_typecheck() { } #[test] -#[serial] fn second_map_in_contract_is_rejected() { expect_failure( "two_maps", @@ -1087,7 +1049,6 @@ fn second_map_in_contract_is_rejected() { } #[test] -#[serial] fn map_nested_in_array_field_typechecks() { // A single Map nested inside an array field counts as one map and is // accepted. @@ -1108,7 +1069,6 @@ fn map_nested_in_array_field_typechecks() { } #[test] -#[serial] fn recursive_storage_struct_cycle_is_handled() { let outcome = run( r#" @@ -1143,7 +1103,6 @@ fn recursive_storage_struct_cycle_is_handled() { // --------------------------------------------------------------------------- #[test] -#[serial] fn psy_test_runner_executes_passing_and_expected_panic_tests() { match run_psy_tests( r#" @@ -1180,7 +1139,6 @@ fn psy_test_runner_executes_passing_and_expected_panic_tests() { } #[test] -#[serial] fn psy_test_runner_panics_when_a_test_fails() { expect_test_panic( "test_fails", @@ -1195,7 +1153,6 @@ fn psy_test_runner_panics_when_a_test_fails() { } #[test] -#[serial] fn psy_test_runner_panics_when_should_panic_test_passes() { expect_test_panic( "unexpected_pass", @@ -1211,7 +1168,6 @@ fn psy_test_runner_panics_when_should_panic_test_passes() { } #[test] -#[serial] fn psy_test_runner_runs_contract_storage_tests() { match run_psy_tests( r#" @@ -1246,7 +1202,6 @@ fn psy_test_runner_runs_contract_storage_tests() { // --------------------------------------------------------------------------- #[test] -#[serial] fn void_main_and_block_expressions_execute() { expect_exec( "void_main", @@ -1294,7 +1249,6 @@ fn format_source(source: &str) -> String { } #[test] -#[serial] fn formatter_renders_enums_traits_impls_and_aliases() { let output = format_source( r#" @@ -1371,7 +1325,6 @@ fn formatter_renders_enums_traits_impls_and_aliases() { // --------------------------------------------------------------------------- #[test] -#[serial] fn generic_bodies_with_hash_mem_and_event_intrinsics_instantiate() { expect_exec( "generic_intrinsics", diff --git a/psy-interpreter/src/intrinsic_exec_tests.rs b/psy-interpreter/src/intrinsic_exec_tests.rs index 9eee99ff7..160fc685e 100644 --- a/psy-interpreter/src/intrinsic_exec_tests.rs +++ b/psy-interpreter/src/intrinsic_exec_tests.rs @@ -3,7 +3,6 @@ // tests run `main` through the interpreter so each CheckedIntrinsicExprNode // arm of the runtime dispatch actually executes against QExecContext. -use serial_test::serial; use super::*; @@ -24,13 +23,6 @@ fn compile_stub( } } -fn reset_primitive_scope() { - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } -} - fn exec_source(label: &str, source: &str) -> Result<(), String> { let path = std::env::temp_dir().join(format!("{label}_intrinsics.psy")); std::fs::write(&path, source).unwrap(); @@ -47,7 +39,6 @@ fn exec_source(label: &str, source: &str) -> Result<(), String> { .map_err(|e| format!("interpret: {e:#}")) }; let _ = std::fs::remove_file(&path); - reset_primitive_scope(); result } @@ -92,12 +83,10 @@ fn exec_override_source(label: &str, source: &str, extra_std: &str) -> Result<() .map(|_| ()) .map_err(|e| format!("interpret: {e:#}")) }; - reset_primitive_scope(); result } #[test] -#[serial] fn raw_checkpoint_stats_and_storage_write_range_execute() { let extra_std = r#" pub fn probe_raw_stats_exec(checkpoint_id: Felt) { @@ -126,7 +115,6 @@ pub fn probe_raw_write_range_exec(offset: Felt, values: [Felt; 3]) { } #[test] -#[serial] fn context_identity_getters_execute() { expect_intrinsics_exec( "ctx_identity", @@ -152,7 +140,6 @@ fn context_identity_getters_execute() { } #[test] -#[serial] fn imt_intrinsics_execute() { expect_intrinsics_exec( "imt_ops", @@ -177,7 +164,6 @@ fn imt_intrinsics_execute() { } #[test] -#[serial] fn checkpoint_stat_getters_execute() { expect_intrinsics_exec( "checkpoint_stats", @@ -209,7 +195,6 @@ fn checkpoint_stat_getters_execute() { } #[test] -#[serial] fn bit_intrinsics_execute() { expect_intrinsics_exec( "bit_ops", @@ -226,7 +211,6 @@ fn bit_intrinsics_execute() { } #[test] -#[serial] fn crypto_and_invoke_intrinsics_execute() { expect_intrinsics_exec( "crypto_invoke", @@ -244,7 +228,6 @@ fn crypto_and_invoke_intrinsics_execute() { } #[test] -#[serial] fn invoke_sync_with_generic_return_hits_size_calculation_guard() { // The std wrapper's generic return `T` is not substituted into the // CheckedIntrinsicExprNode, so the runtime output-size calculation @@ -264,7 +247,6 @@ fn invoke_sync_with_generic_return_hits_size_calculation_guard() { "#, ) }); - reset_primitive_scope(); let payload = outcome.expect_err("generic invoke_sync must hit the size-calculation guard"); let message = payload .downcast_ref::() @@ -278,7 +260,6 @@ fn invoke_sync_with_generic_return_hits_size_calculation_guard() { } #[test] -#[serial] fn derived_events_emit_at_runtime() { expect_intrinsics_exec( "event_emit", @@ -300,7 +281,6 @@ fn derived_events_emit_at_runtime() { } #[test] -#[serial] fn split_bits_rejects_non_constant_lengths_at_runtime() { expect_intrinsics_failure( "split_bits_non_const", @@ -318,7 +298,6 @@ fn split_bits_rejects_non_constant_lengths_at_runtime() { } #[test] -#[serial] fn split_bits_rejects_oversized_lengths_at_runtime() { expect_intrinsics_failure( "split_bits_too_large", diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index 5383fde94..7222fa74a 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -41,10 +41,8 @@ pub struct InterpretResult { } pub fn interpret(contract_name: Option, method_names: Vec, crate_path_graph: Graph) -> anyhow::Result { - with_primitive_scope_reset(|| { - let mut interpreter = Interpreter::::new(QExecContext::new()); - interpret_with_program(&mut interpreter, crate_path_graph, Program::new(), contract_name, method_names) - }) + let mut interpreter = Interpreter::::new(QExecContext::new()); + interpret_with_program(&mut interpreter, crate_path_graph, Program::new(), contract_name, method_names) } pub fn interpret_virtual_files( @@ -53,14 +51,12 @@ pub fn interpret_virtual_files( crate_path_graph: Graph, files: Vec<(PathBuf, Arc)>, ) -> anyhow::Result { - with_primitive_scope_reset(|| { - let mut interpreter = Interpreter::::new(QExecContext::new()); - let mut program = Program::new(); - for (path, content) in files { - program.file_resolver.add_file(path, content); - } - interpret_with_program(&mut interpreter, crate_path_graph, program, contract_name, method_names) - }) + let mut interpreter = Interpreter::::new(QExecContext::new()); + let mut program = Program::new(); + for (path, content) in files { + program.file_resolver.add_file(path, content); + } + interpret_with_program(&mut interpreter, crate_path_graph, program, contract_name, method_names) } pub fn interpret_vfs_files( @@ -72,22 +68,8 @@ pub fn interpret_vfs_files( interpret_virtual_files(contract_name, method_names, crate_path_graph, files) } +#[cfg(test)] fn with_primitive_scope_reset(f: impl FnOnce() -> anyhow::Result) -> anyhow::Result { - struct PrimitiveScopeResetGuard; - impl Drop for PrimitiveScopeResetGuard { - fn drop(&mut self) { - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } - } - } - - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } - let _primitive_scope_guard = PrimitiveScopeResetGuard; f() } @@ -653,14 +635,6 @@ impl, C: DPNContext + 'static> Interpreter { where F: 'static, { - // Each standalone source check owns a fresh symbol table. Clear the - // process-global primitive scope handle so names such as `Array` are - // resolved against this check's std primitive module, not a previous - // test or compilation. - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } let mut crate_path_graph = Graph::new(); crate_path_graph.add_node(file); self.typecheck(crate_path_graph) @@ -711,15 +685,6 @@ impl, C: DPNContext + 'static> Interpreter { where F: 'static, { - // Same rationale as `typecheck_single`: the LSP server typechecks - // repeatedly in one process, so clear the process-global primitive - // scope handle before repopulating it against this call's symbol - // table. Without this, the second typecheck in a session resolves - // std names (e.g. `Array` in storage.psy) against a stale scope. - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } let mut program = Program::new(); let mut parser = Parser::new(&mut program, &mut self.context, crate_path_graph); @@ -2237,10 +2202,6 @@ mod tests { assert_eq!(result.compile_results.len(), 1); assert_eq!(result.compile_results[0].name, "main"); println!("compile_result: {:?}", result.compile_results); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } #[tokio::test(flavor = "multi_thread")] @@ -2249,10 +2210,6 @@ mod tests { psy_common::setup_logging().ok(); insta::glob!("../../tests", "{struct*.psy,fn_test.psy,fn_chain_call_test.psy}", |path| { - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } let mut interpreter = Interpreter::::new(QExecContext::new()); let (mut typechecker, mut ctx) = interpreter.typecheck_single(path.into()).unwrap(); @@ -2322,10 +2279,6 @@ mod tests { println!("result_vm: {:?}", cfc_input.outputs); println!("result_events: {:?}", cfc_input.events); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; assert_snapshot!(ctx.debug_scope(ScopeId::root())) }); @@ -2337,19 +2290,10 @@ mod tests { psy_common::setup_logging().ok(); insta::glob!("../../tests", "*_test.psy", |path| { - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } let entry: PathBuf = path.into(); let mut interpreter = Interpreter::::new(QExecContext::new()); let (_typechecker, mut ctx) = interpreter.typecheck_single(entry.clone()).unwrap(); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; - let formatted_content = ctx.format_file(&entry).unwrap(); let file = File::create(&entry).unwrap(); @@ -2359,11 +2303,6 @@ mod tests { let mut interpreter = Interpreter::::new(QExecContext::new()); assert!(interpreter.typecheck_single(entry.clone()).is_ok(), "{}", entry.display()); - - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; }); } @@ -2403,11 +2342,6 @@ fn main() {} ); let _ = fs::remove_file(path); - - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } #[test] @@ -2456,11 +2390,6 @@ fn main() { .expect("storage array contracts must preprocess and typecheck"); let _ = fs::remove_file(path); - - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } #[test] @@ -2489,10 +2418,6 @@ fn main() -> bool { ); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } #[test] #[serial] @@ -2526,10 +2451,6 @@ fn main() {} ); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } @@ -2565,10 +2486,6 @@ fn main() {} ); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } #[test] @@ -2587,10 +2504,6 @@ fn main() -> bool { interpreter.typecheck_single(path.clone()).unwrap(); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } /// Every raw `__`-prefixed intrinsic family must be gated to the canonical @@ -2636,10 +2549,6 @@ fn main() -> bool { ); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } } @@ -2681,10 +2590,6 @@ fn main() { assert_eq!(compile_results.len(), 1); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } #[test] #[serial] @@ -2721,10 +2626,6 @@ fn main() { assert_eq!(compile_results[0].events.len(), 1, "derived Event::emit must compile one event record"); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } /// The index-access desugaring (`a[i]` on a non-array) reports a specific @@ -2842,10 +2743,6 @@ fn main() -> Felt { ); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } } @@ -2921,10 +2818,6 @@ fn main() -> Felt { ); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } } @@ -2957,10 +2850,6 @@ fn main() -> Felt { ); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - }; } } } diff --git a/psy-interpreter/src/panic_fix_tests.rs b/psy-interpreter/src/panic_fix_tests.rs index 153ee474b..cc3cecdea 100644 --- a/psy-interpreter/src/panic_fix_tests.rs +++ b/psy-interpreter/src/panic_fix_tests.rs @@ -12,9 +12,8 @@ // Bug 9: immutable assignment (`let x=1; x=2;`) was only checked on the // runtime/interpret path, silently accepted by non-test typecheck. // -// Every case asserts a clean accept/reject and that no path panics. The shared -// `STD_PRIMITIVE_SCOPE_ID` singleton is reset after every case so the suite is -// hermetic. Every case is `#[serial]`. +// Every case asserts a clean accept/reject and that no path panics. Each case owns +// an independent symbol table and uniquely named temporary file. use std::{ fs, @@ -23,7 +22,6 @@ use std::{ }; use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; -use serial_test::serial; use super::*; @@ -55,10 +53,6 @@ fn compile(source: &str, label: &str) -> Outcome { })); let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } match result { Ok(Ok((typechecker, ctx))) => Outcome::Accept(Compiled { interpreter, typechecker, ctx }), @@ -163,14 +157,12 @@ fn expect_input_materialization_error(c: &mut Compiled, function_ty: TypeId, lab } #[test] -#[serial] fn b12_non_bool_not_is_rejected_at_typecheck() { expect_reject("b12_felt_not", "fn main() { let value = !1; }", "TypeMismatch"); expect_reject("b12_u32_not", "fn main() { let value = !1u32; }", "TypeMismatch"); } #[test] -#[serial] fn b13_mixed_for_range_endpoint_types_are_rejected() { expect_reject( "b13_mixed_for_range", @@ -183,7 +175,6 @@ fn b13_mixed_for_range_endpoint_types_are_rejected() { /// be rejected instead of panicking at interpretation (M2): the old check /// unified (binding) the free variable to FELT, accepting anything. #[test] -#[serial] fn b19_generic_struct_for_range_endpoint_rejected() { expect_reject( "b19_struct_endpoint", @@ -198,7 +189,6 @@ fn b19_generic_struct_for_range_endpoint_rejected() { } #[test] -#[serial] fn b14_bool_invalid_compound_assignments_are_rejected() { for (label, operator) in [ ("add", "+="), @@ -220,7 +210,6 @@ fn b14_bool_invalid_compound_assignments_are_rejected() { } #[test] -#[serial] fn b14_bool_xor_assignment_executes_without_panicking() { let source = "fn main() { let mut value = true; value ^= true; assert_eq(value, false); }"; match run_main(source, "b14_bool_xor_assign") { @@ -233,7 +222,6 @@ fn b14_bool_xor_assignment_executes_without_panicking() { /// A runtime `split_bits` length must be refused, not silently turned /// into a circuit whose bit count is the input variable's *index*. #[test] -#[serial] fn b16_split_bits_runtime_length_is_a_typecheck_error() { let source = "fn main(x: Felt, n: Felt) { let bits = split_bits(x, n); let b0 = bits[0]; }"; expect_reject("b16_split_bits_runtime_length", source, "TypeMismatch"); @@ -253,7 +241,6 @@ fn panic_message(payload: &Box) -> String { /// A negated literal length (`-64` wraps to p-64) must report /// ArrayTooLarge instead of aborting with `capacity overflow`. #[test] -#[serial] fn b17_split_bits_negative_length_is_a_typecheck_error() { expect_reject( "b17_split_bits_negative_length", @@ -265,7 +252,6 @@ fn b17_split_bits_negative_length_is_a_typecheck_error() { /// `u32 **` overflow must report ArithmeticOverflow like the other /// constant u32 ops, not panic inside the VM (M8). #[test] -#[serial] fn b18_u32_pow_overflow_is_arithmetic_overflow() { expect_runtime_error( "b18_u32_pow_overflow", @@ -285,7 +271,6 @@ fn b18_u32_pow_overflow_is_arithmetic_overflow() { /// Non-overflowing u32 powers keep working (regression guard for the /// pre-check itself). #[test] -#[serial] fn b18_u32_pow_in_range_executes() { let source = "fn main() { let value = 2u32 ** 5u32; assert_eq(value, 32u32, \"pow\"); }"; match run_main(source, "b18_u32_pow_ok") { @@ -299,7 +284,6 @@ fn b18_u32_pow_in_range_executes() { /// huge input arrays must stop at the total cap instead of allocating /// unbounded memory (M4/L2). Each layer here passes the per-node check. #[test] -#[serial] fn b20_total_materialization_budget_binds_nested_repeats() { expect_runtime_error( "b20_nested_repeats_budget", @@ -309,7 +293,6 @@ fn b20_total_materialization_budget_binds_nested_repeats() { } #[test] -#[serial] fn b20_input_array_footprint_capped() { // The parameter's footprint (5M) exceeds the budget before any // allocation happens. Interpreted with a symbolic input bound to the @@ -325,7 +308,6 @@ fn b20_input_array_footprint_capped() { } #[test] -#[serial] fn b20_deep_input_array_footprint_cannot_bypass_budget() { let mut ty = "[Felt; 5000000]".to_string(); for _ in 0..40 { @@ -344,7 +326,6 @@ fn b20_deep_input_array_footprint_cannot_bypass_budget() { /// A large-but-legal array still compiles (the budget is 4M total; a /// 1024x1024 nested repeat is 1M and must pass). #[test] -#[serial] fn b20_legal_large_array_still_works() { let source = "fn main() { let ok_size = [[0; 1024]; 1024]; assert_eq(ok_size[0][0], 0, \"zero\"); }"; match run_main(source, "b20_legal_large") { @@ -358,7 +339,6 @@ fn b20_legal_large_array_still_works() { /// materialized again. At an already-full budget, forwarding the same array /// through a helper must therefore remain valid (review P2). #[test] -#[serial] fn b28_internal_function_arguments_are_not_charged_again() { let source = "fn first(a: [Felt; 2]) -> Felt { return a[0]; }\nfn main() {}"; let mut c = match compile(source, "b28_reused_function_argument") { @@ -387,7 +367,6 @@ fn b28_internal_function_arguments_are_not_charged_again() { /// contract with several 1M-array fields read gigabytes past the budget /// with no cap (self-audit finding after P1/P2). #[test] -#[serial] fn b30_storage_read_range_is_charged_globally() { let source = "use std::prelude::*;\n#[contract]\n#[derive(Storage)]\npub struct Big { pub a: [Felt; 1000000], pub b: [Felt; 1000000], pub c: [Felt; 1000000], pub d: [Felt; 1000000], pub e: [Felt; 1000000] }\n#[contract_method]\npub fn read_all() -> Felt { let v = Big::read(0, 0, 0, 0); return v.a[0]; }\nfn main() {}"; let mut graph = psy_common::Graph::new(); @@ -411,7 +390,6 @@ fn b30_storage_read_range_is_charged_globally() { /// same global materialization budget even when each call is below its /// per-array cap (review P1). #[test] -#[serial] fn b29_split_bits_arrays_are_charged_globally() { let source = "fn main() { let a = split_bits(3, 2); let b = split_bits(3, 2); }"; let mut c = match compile(source, "b29_split_bits_global_budget") { @@ -433,7 +411,6 @@ fn b29_split_bits_arrays_are_charged_globally() { /// before registering the instance, so the recursive call inside re-entered /// instantiation forever. #[test] -#[serial] fn b21_generic_self_recursion_diagnosed() { expect_reject( "b21_generic_self_recursion", @@ -456,7 +433,6 @@ fn b21_generic_self_recursion_diagnosed() { /// A finite generic call chain may be deeper than the former arbitrary /// limit of 8. Only a repeated active function is recursion. #[test] -#[serial] fn b23_deep_finite_generic_chain_still_works() { expect_accept( "b23_depth_ten", @@ -477,7 +453,6 @@ fn b23_deep_finite_generic_chain_still_works() { /// The materialization footprint is structural: struct fields and /// tuples count toward the budget too (M4 covered plain arrays). #[test] -#[serial] fn b25_struct_and_tuple_input_footprints_capped() { // Struct containing a huge array: footprint = 5M through the field. // Interpreted with one symbolic input bound to the parameter; the @@ -505,7 +480,6 @@ fn b25_struct_and_tuple_input_footprints_capped() { /// for-range endpoints of other wrong kinds must be rejected too (M2 /// covered struct; bool and array endpoints are the same class). #[test] -#[serial] fn b24_bool_and_array_for_range_endpoints_rejected() { expect_reject( "b24_bool_endpoint", @@ -523,7 +497,6 @@ fn b24_bool_and_array_for_range_endpoints_rejected() { /// not `as_function().unwrap()` panic (M5). Drives the real interpret /// entry (method-name resolution) via virtual files β€” no CLI needed. #[test] -#[serial] fn b22_method_name_resolving_to_struct_is_a_clean_error() { let source = "pub struct Foo { pub x: Felt }\nfn main() -> Felt { return Foo { x: 1 }.x; }\n"; let mut graph = psy_common::Graph::new(); @@ -562,7 +535,6 @@ fn b22_method_name_resolving_to_struct_is_a_clean_error() { /// obvious base case cannot terminate it. It must return a compiler error /// instead of overflowing the host stack (M3). #[test] -#[serial] fn b26_concrete_recursion_is_a_clean_error() { let source = "fn count(n: Felt) -> Felt { if n == 0 { 0 } else { count(n - 1) } }\nfn main() -> Felt { count(0) }"; match run_main(source, "b26_concrete_recursion") { @@ -591,7 +563,6 @@ fn b26_concrete_recursion_is_a_clean_error() { /// ABI/entry-point collection used to sort and deduplicate same-name /// methods, silently dropping one overload (M6). #[test] -#[serial] fn b27_contract_method_overload_is_rejected() { let source = r#" #[contract] @@ -623,7 +594,6 @@ fn main() {} } #[test] -#[serial] fn b15_hash_two_to_one_requires_two_hash_operands() { expect_accept( "b15_hash_two_to_one_valid", @@ -659,14 +629,12 @@ fn expect_runtime_error(label: &str, source: &str, needle: &str) { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn b03_member_access_on_u32_no_panic() { // `1.foo` used as a value: previously `as_struct().unwrap()` panicked. expect_reject("b03_member_on_u32", "fn main() { let x = 1.foo; }", "UnresolvedMember"); } #[test] -#[serial] fn b03_member_call_on_u32_no_panic() { // `1.foo()` routes through find_member first; when it fails it must not // fall through to the struct unwrap. @@ -681,7 +649,6 @@ fn b03_member_call_on_u32_no_panic() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn b04_call_u32_value_no_panic() { // `1u32(2u32)`: callee is a U32 value; `Type::signature` used to // `unreachable!()`. @@ -693,19 +660,16 @@ fn b04_call_u32_value_no_panic() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn b05_const_u32_div_by_zero_no_panic() { expect_reject("b05_const_u32_div0", "const X: u32 = 1u32 / 0u32;\nfn main() {}", "DivisionByZero"); } #[test] -#[serial] fn b05_const_u32_rem_by_zero_no_panic() { expect_reject("b05_const_u32_rem0", "const X: u32 = 1u32 % 0u32;\nfn main() {}", "DivisionByZero"); } #[test] -#[serial] fn b05_const_felt_div_by_zero_no_panic() { expect_reject("b05_const_felt_div0", "const X: Felt = 1 / 0;\nfn main() {}", "DivisionByZero"); } @@ -715,7 +679,6 @@ fn b05_const_felt_div_by_zero_no_panic() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn b06_const_u32_add_overflow_no_panic() { expect_reject( "b06_const_u32_add_ovf", @@ -725,7 +688,6 @@ fn b06_const_u32_add_overflow_no_panic() { } #[test] -#[serial] fn b06_const_u32_mul_overflow_no_panic() { expect_reject( "b06_const_u32_mul_ovf", @@ -735,7 +697,6 @@ fn b06_const_u32_mul_overflow_no_panic() { } #[test] -#[serial] fn b06_const_u32_sub_underflow_no_panic() { expect_reject( "b06_const_u32_sub_unflow", @@ -745,7 +706,6 @@ fn b06_const_u32_sub_underflow_no_panic() { } #[test] -#[serial] fn b06_const_felt_add_does_not_overflow() { // Felt is modular over the Goldilocks prime β€” large felt sums must NOT be // flagged as overflow (only u32 wrapping is rejected). @@ -757,7 +717,6 @@ fn b06_const_felt_add_does_not_overflow() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn b07_runtime_u32_div_by_zero_no_panic() { expect_runtime_error( "b07_rt_u32_div0", @@ -767,7 +726,6 @@ fn b07_runtime_u32_div_by_zero_no_panic() { } #[test] -#[serial] fn b07_runtime_u32_rem_by_zero_no_panic() { expect_runtime_error( "b07_rt_u32_rem0", @@ -781,7 +739,6 @@ fn b07_runtime_u32_rem_by_zero_no_panic() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn b08_runtime_array_oob_no_panic() { expect_runtime_error( "b08_rt_array_oob", @@ -791,7 +748,6 @@ fn b08_runtime_array_oob_no_panic() { } #[test] -#[serial] fn b08_runtime_array_in_bounds_ok() { // Sanity: a valid in-bounds access must still succeed at runtime. match run_main("fn main() -> Felt { let a = [1, 2, 3]; return a[1]; }", "b08_rt_array_ok") { @@ -802,7 +758,6 @@ fn b08_runtime_array_in_bounds_ok() { } #[test] -#[serial] fn b08_runtime_array_write_oob_no_panic() { expect_runtime_error( "b08_rt_array_write_oob", @@ -812,7 +767,6 @@ fn b08_runtime_array_write_oob_no_panic() { } #[test] -#[serial] fn b08_runtime_array_write_at_last_index_ok() { match run_main( "fn main() -> Felt { let mut a = [1, 2, 3]; a[2] = 7; return a[2]; }", @@ -825,7 +779,6 @@ fn b08_runtime_array_write_at_last_index_ok() { } #[test] -#[serial] fn b08_nested_array_write_oob_no_panic() { expect_runtime_error( "b08_nested_array_write_oob", @@ -835,7 +788,6 @@ fn b08_nested_array_write_oob_no_panic() { } #[test] -#[serial] fn b08b_huge_array_repeat_returns_error_without_allocating() { expect_runtime_error( "b08b_huge_array_repeat", @@ -849,21 +801,18 @@ fn b08b_huge_array_repeat_returns_error_without_allocating() { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn b09_immutable_assign_rejected_at_typecheck() { // `let x=1; x=2;` must be rejected by sema (not only at runtime). expect_reject("b09_imm_assign", "fn main() { let x = 1; x = 2; }", "ImmutableVariable"); } #[test] -#[serial] fn b09_mutable_assign_accepted() { // `let mut x=1; x=2;` must still typecheck. expect_accept("b09_mut_assign", "fn main() { let mut x = 1; x = 2; }"); } #[test] -#[serial] fn b09_storage_ref_field_assign_immutable_local_ok() { // Assigning to a field of a storage-ref handle (`CRef`) is a storage write // routed through `eq_assign`, NOT a mutation of the local binding. The @@ -875,7 +824,6 @@ fn b09_storage_ref_field_assign_immutable_local_ok() { } #[test] -#[serial] fn felt_for_loop_executes_without_u32_conversion() { match run_main("fn main() { for i in 0..3 { let x = i; } }", "felt_for_loop") { Ok(None) => {} @@ -885,7 +833,6 @@ fn felt_for_loop_executes_without_u32_conversion() { } #[test] -#[serial] fn descending_u32_for_range_is_empty() { match run_main("fn main() { for i in 5u32..3u32 { let x = i; } }", "descending_u32_for_range") { Ok(None) => {} diff --git a/psy-interpreter/src/qa_fix_tests.rs b/psy-interpreter/src/qa_fix_tests.rs index 15671a701..f983361ee 100644 --- a/psy-interpreter/src/qa_fix_tests.rs +++ b/psy-interpreter/src/qa_fix_tests.rs @@ -9,8 +9,7 @@ // silently picked the first impl. Now rejected with // `AmbiguousTraitMethod`. // -// The shared `STD_PRIMITIVE_SCOPE_ID` singleton is reset after every case so -// the suite is hermetic. Every case is `#[serial]`. +// Each case owns an independent symbol table and uniquely named temporary file. use std::{ fs, @@ -19,7 +18,6 @@ use std::{ }; use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; -use serial_test::serial; use super::*; @@ -43,10 +41,6 @@ fn compile(source: &str, label: &str) -> Outcome { })); let _ = fs::remove_file(&path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } match result { Ok(Ok((_typechecker, _ctx))) => Outcome::Accept, @@ -111,11 +105,6 @@ fn run_main_outputs(source: &str, label: &str) -> Vec { .__interpret__(&typechecker.program, main_type_id, vec![], &mut ctx) .unwrap_or_else(|e| panic!("[{label}] interpret failed: {e:#}")); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } - felts.iter().map(|f| f.get_constant_value()).collect() } @@ -124,7 +113,6 @@ fn run_main_outputs(source: &str, label: &str) -> Vec { // ═══════════════════════════════════════════════════════════════════════════ #[test] -#[serial] fn b01_if_without_else_in_let_rejected() { expect_reject( "b01_if_without_else_in_let", @@ -134,7 +122,6 @@ fn b01_if_without_else_in_let_rejected() { } #[test] -#[serial] fn b01b_bool_match_single_literal_is_incomplete() { expect_reject( "b01b_bool_match_single_literal", @@ -144,7 +131,6 @@ fn b01b_bool_match_single_literal_is_incomplete() { } #[test] -#[serial] fn b01c_bool_match_two_equal_literals_is_incomplete() { expect_reject( "b01c_bool_match_duplicate_literal", @@ -154,7 +140,6 @@ fn b01c_bool_match_two_equal_literals_is_incomplete() { } #[test] -#[serial] fn b01d_bool_match_false_and_wildcard_is_complete() { expect_accept( "b01d_bool_match_false_wildcard", @@ -163,7 +148,6 @@ fn b01d_bool_match_false_and_wildcard_is_complete() { } #[test] -#[serial] fn b01e_bool_match_wildcard_only_is_complete() { expect_accept( "b01e_bool_match_wildcard_only", @@ -172,7 +156,6 @@ fn b01e_bool_match_wildcard_only_is_complete() { } #[test] -#[serial] fn b02_if_without_else_as_return_rejected() { // The if is the trailing expression of the function body block (value // position), so it must have an else branch. @@ -184,7 +167,6 @@ fn b02_if_without_else_as_return_rejected() { } #[test] -#[serial] fn b03_if_without_else_as_call_arg_rejected() { expect_reject( "b03_if_without_else_as_arg", @@ -194,14 +176,12 @@ fn b03_if_without_else_as_call_arg_rejected() { } #[test] -#[serial] fn b04_if_without_else_as_statement_accepted() { // As a statement (ExpressionStmt) an else branch is not required. expect_accept("b04_if_stmt_no_else", "fn main() {\n if false {\n let x = 1;\n }\n}"); } #[test] -#[serial] fn b05_if_without_else_void_body_in_for_accepted() { // Mirrors `psy-std/storage.psy:689`: an if with a void body used as a // for-body block's trailing expression is side-effect only and must @@ -212,7 +192,6 @@ fn b05_if_without_else_void_body_in_for_accepted() { ); } #[test] -#[serial] fn b06_if_with_else_as_value_accepted() { expect_accept("b06_if_with_else_value", "fn main() {\n let x: Felt = if false { 7 } else { 9 };\n}"); } @@ -243,7 +222,6 @@ fn main() -> u32 { "#; #[test] -#[serial] fn b07_chained_mut_self_returns_three() { // Before the fix the receiver of each link was evaluated twice (once for // callee resolution, once for the arg list), so every `inc` mutation was @@ -253,7 +231,6 @@ fn b07_chained_mut_self_returns_three() { } #[test] -#[serial] fn b08_single_mut_self_returns_two() { let src = r#" struct S { @@ -278,7 +255,6 @@ fn main() -> u32 { } #[test] -#[serial] fn b09_stepwise_mut_self_returns_three() { // Stepwise already worked; defends against regressing the non-chained path. let src = r#" @@ -336,7 +312,6 @@ fn main() { "#; #[test] -#[serial] fn b10_ambiguous_trait_method_rejected() { match compile(AMBIG_SRC, "b10_ambiguous_trait_method") { Outcome::Reject(message) => { @@ -349,7 +324,6 @@ fn b10_ambiguous_trait_method_rejected() { } #[test] -#[serial] fn b10b_exact_trait_impl_wins_over_earlier_generic_impl() { expect_accept( "b10b_exact_over_generic", @@ -365,7 +339,6 @@ fn main() { let value: Felt = Box:: { value: 1 }.value(); } } #[test] -#[serial] fn b10c_two_generic_trait_methods_are_ambiguous() { expect_reject( "b10c_generic_ambiguity", @@ -385,7 +358,6 @@ fn main() { let value = Box:: { value: 1 }.value(); } /// providers: `>::conv` must select the requested impl, /// not silently collapse to whichever was declared first. #[test] -#[serial] fn b10d_same_generic_trait_two_impls_dispatch_by_trait_args() { // Both disambiguated calls select their own impl and run. let outputs = run_main_outputs( @@ -427,7 +399,6 @@ fn main() { /// whichever impl registered first (implementer.rs provider dedup /// used to collapse these too). #[test] -#[serial] fn b10e_same_generic_trait_assoc_types_dispatch() { let outputs = run_main_outputs( r#" @@ -448,7 +419,6 @@ fn main() -> (Felt, u32) { /// Three impls of the same generic trait: every instantiation must pick /// its own (no first-wins collapse at any arity). #[test] -#[serial] fn b10f_three_impls_of_same_generic_trait() { let outputs = run_main_outputs( r#" @@ -473,7 +443,6 @@ fn main() -> (u32, Felt) { /// The undecorated method call with two same-trait impls in scope is /// genuinely ambiguous and must be reported, not silently resolved. #[test] -#[serial] fn b10g_undecorated_call_with_same_trait_impls_is_ambiguous() { expect_reject( "b10g_undecorated_ambiguous", @@ -492,7 +461,6 @@ fn main() { } #[test] -#[serial] fn b11_single_trait_method_accepted() { // A single trait providing `val` must still resolve cleanly. let src = r#" @@ -516,7 +484,6 @@ fn main() { } #[test] -#[serial] fn b12_inherent_plus_trait_no_ambiguity() { // An inherent impl method shadows trait methods; no ambiguity expected. let src = r#" diff --git a/psy-interpreter/src/sema_edge_tests.rs b/psy-interpreter/src/sema_edge_tests.rs index 4ea1786e6..40fc64967 100644 --- a/psy-interpreter/src/sema_edge_tests.rs +++ b/psy-interpreter/src/sema_edge_tests.rs @@ -8,7 +8,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; -use serial_test::serial; use super::*; @@ -23,10 +22,6 @@ fn compile(source: &str) -> Result<(), String> { let result = interpreter.typecheck_single(path.clone()); let _ = std::fs::remove_file(&path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } result.map(|_| ()).map_err(|e| format!("{e:#}")) } @@ -76,7 +71,6 @@ impl P { "#; #[test] -#[serial] fn binary_and_unary_operator_type_guards_reject() { let cases: &[(&str, &str, &str)] = &[ ("add on bool", "fn main() -> Felt { let a: bool = true; let b: Felt = a + 1; return b; }", "mismatch"), @@ -93,7 +87,6 @@ fn binary_and_unary_operator_type_guards_reject() { } #[test] -#[serial] fn call_arity_and_generic_argument_guards_reject() { let source = format!( "{PRELUDE}{STRUCT_P} @@ -121,7 +114,6 @@ pub fn two(a: Felt, b: Felt) -> Felt {{ } #[test] -#[serial] fn member_call_shapes_resolve() { // Happy-path method call: resolves through find_member into the // visit_member_call validation. (Wrong-shape methods are filtered out @@ -144,7 +136,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn custom_index_and_eq_shapes_reject() { // find_member filters method candidates by expected signature before the // visitor's own validation runs, so several wrong-shape methods surface @@ -173,7 +164,6 @@ impl P {{ } #[test] -#[serial] fn tuple_and_if_expression_guards_reject() { let cases: &[(&str, &str, &str)] = &[ ("tuple access out of bounds", "fn main() -> Felt { let t = (1, 2); return t.2; }", "index"), @@ -201,7 +191,6 @@ fn tuple_and_if_expression_guards_reject() { } #[test] -#[serial] fn return_placement_guards_reject() { rejects( "statement after quick return", @@ -216,7 +205,6 @@ fn return_placement_guards_reject() { } #[test] -#[serial] fn match_expression_guards_reject() { let cases: &[(&str, &str, &str)] = &[ ("non-primitive scrutinee", "let t = (1, 2); let x: Felt = match t { _ => 1 };", "mismatch"), @@ -231,7 +219,6 @@ fn match_expression_guards_reject() { } #[test] -#[serial] fn lambda_parameter_and_return_guards() { let path_param = format!( "{PRELUDE}{STRUCT_P}fn main() -> Felt {{ @@ -276,7 +263,6 @@ impl Box { "#; #[test] -#[serial] fn impl_and_trait_header_guards_reject() { let bad_impl = format!( "{PRELUDE}pub struct Box {{ v: T, }}\nimpl Box {{\n pub fn z() -> Felt {{ return 0; }}\n}}\nfn main() -> Felt {{ return 0; }}" @@ -305,7 +291,6 @@ fn impl_and_trait_header_guards_reject() { } #[test] -#[serial] fn generic_type_annotation_guards_reject() { rejects( "too few generic arguments in annotation", @@ -320,7 +305,6 @@ fn generic_type_annotation_guards_reject() { } #[test] -#[serial] fn method_resolution_and_member_call_paths_accept() { // `self.get()` inside an impl resolves the callee through the // member-call arm of visit_member_access; `P::new` covers the @@ -362,7 +346,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn compound_assignment_guards() { // Compound assignment on a struct routes through `add_assign` member // lookup; a matching method typechecks... @@ -391,7 +374,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn trait_cast_paths_cover_segments_constraints_and_rejections() { let traits = format!( "{PRELUDE}{STRUCT_P} @@ -473,7 +455,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn qualified_module_paths_and_roots_resolve() { // Nested module path: root=outer resolves by name, then the `inner` // segment walks std-module-style from parent to child. @@ -568,7 +549,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn bare_function_argument_matches_expected_signature() { // Passing a top-level function by bare name walks the scope chain with // the call's expected signature instead of resolving a value path. @@ -603,7 +583,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn index_sugar_and_member_call_guards() { // Inherent method calls resolve through the member-call fast path. accepts( @@ -653,7 +632,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn operator_calls_and_size_position_edges() { // `!=` on a custom type lowers to the eq method wrapped in unary not. accepts( @@ -748,7 +726,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn generic_instantiation_rewrites_paths_and_statements() { // Instantiating `take::

` / `take::` rewrites the parameter and // return type paths, the associated-type alias in Q's impl, and the @@ -803,7 +780,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn ambiguous_members_across_traits_and_inherent_associated_types() { // Two constraints providing the same method name make the bare call // ambiguous. @@ -877,7 +853,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn array_and_struct_literals_reject_inconsistent_shapes() { accepts( "consistent array literal", @@ -908,7 +883,6 @@ fn main() {{ let n = Num {{ a: 1u32, b: 2u32 }}; }}" } #[test] -#[serial] fn return_placement_rejects_returns_inside_if_blocks() { rejects( "return inside an if statement", @@ -918,7 +892,6 @@ fn return_placement_rejects_returns_inside_if_blocks() { } #[test] -#[serial] fn type_annotations_cover_arrays_tuples_and_fn_signatures() { accepts( "array type annotation", @@ -956,7 +929,6 @@ fn main() {{ let r = call2(add2, 3); }}" } #[test] -#[serial] fn size_position_arguments_bind_constants_and_reject_runtime_values() { accepts( "felt literal in size position", @@ -984,7 +956,6 @@ fn main() {{ bad(2); }}" } #[test] -#[serial] fn trait_cast_type_positions_with_segments_resolve() { accepts( "trait cast with nested associated type segments", @@ -1009,7 +980,6 @@ fn main() {{ bad(1); }}" } #[test] -#[serial] fn generic_path_call_targets_resolve() { accepts( "explicit generic arguments on a module type path", @@ -1030,7 +1000,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn nested_member_access_uses_the_fast_path() { // `o.inner.get()` resolves the receiver `o.inner` while the ancestor is // the member call, so the member-access fast path (find_member without an @@ -1066,7 +1035,6 @@ fn main() -> Felt {{ return lib::make().inner.get(); }}" } #[test] -#[serial] fn associated_types_accept_non_path_shapes() { accepts( "tuple associated type on an inherent impl", @@ -1082,7 +1050,6 @@ fn main() {{ let p: P::Pair = P::make_pair(); }}" } #[test] -#[serial] fn deep_associated_type_chains_resolve() { // Two levels past a trait cast (`

::Assoc::Mid::Leaf`) walk the // trait-cast segment loop, and module-rooted chains (`deep::H0::Inner::Leaf`) @@ -1135,7 +1102,6 @@ fn main() {{ bad(1); }}" } #[test] -#[serial] fn generic_instantiation_rewrites_impls_signatures_and_bodies() { // A generic inherent impl with an associated type plus a generic method // drives instantiate_impl (assoc types + per-method signature rewriting). @@ -1187,7 +1153,6 @@ fn main() {{ } #[test] -#[serial] fn trait_impl_associated_types_rewrite_through_roots() { // An associated type whose value is itself a rooted path (`Src::Native`) // takes the root-substitution branch when the generic impl is instantiated. @@ -1217,7 +1182,6 @@ fn main() {{ } #[test] -#[serial] fn impl_search_rejects_conflicting_generic_arguments() { // The concrete `Number` implementations cannot serve a `Number` // receiver, so instantiation unification fails and the call is rejected. @@ -1261,7 +1225,6 @@ fn main() {{ } #[test] -#[serial] fn bare_generic_calls_walk_scopes_for_matching_functions() { accepts( "bare call to a generic function", @@ -1281,7 +1244,6 @@ fn main() {{ } #[test] -#[serial] fn crate_paths_resolve_from_nested_modules() { accepts( "crate root path from inside an inline module", @@ -1301,7 +1263,6 @@ fn main() {{ } #[test] -#[serial] fn generic_bodies_rewrite_definitions_asserts_structs_and_matches() { // Every statement/expression shape inside a generic function body runs // through the rewriter when the function is instantiated: nested @@ -1329,7 +1290,6 @@ fn main() {{ } #[test] -#[serial] fn inherent_impls_rewrite_rooted_associated_types_when_generic() { // An associated type whose value is a rooted path (`Src::Native`) inside // a *generic* inherent impl exercises the rewriter's root/target branch @@ -1357,7 +1317,6 @@ fn main() {{ } #[test] -#[serial] fn generic_unification_rejects_conflicting_arguments() { let mut failures = Vec::new(); for (label, source, needle) in [ @@ -1396,7 +1355,6 @@ fn main() {{ let r = P2 {{ x: 1 }}.pick::(true); }}" } #[test] -#[serial] fn trait_cast_paths_resolve_through_the_trait_segment() { accepts( "fully qualified trait method call", @@ -1416,7 +1374,6 @@ fn main() {{ } #[test] -#[serial] fn imports_of_unknown_modules_are_rejected() { rejects( "import from an unresolved module", @@ -1426,7 +1383,6 @@ fn imports_of_unknown_modules_are_rejected() { } #[test] -#[serial] fn member_function_references_and_bare_type_values_rewrite() { // A method may not be referenced without a call (no first-class method // values), while a bare type name in value position is accepted. @@ -1463,7 +1419,6 @@ fn main() {{ let v = mark(1); }}" } #[test] -#[serial] fn index_access_and_member_visibility_guards() { rejects( "array index with a boolean subscript", @@ -1514,7 +1469,6 @@ fn main() -> Felt {{ } #[test] -#[serial] fn unification_walks_signatures_and_tuples() { // Function values are not first-class: a function name passed for a // fn-signature parameter is rejected, and the diagnostic renders the @@ -1552,7 +1506,6 @@ fn main() -> Felt {{ /// Panics inside preprocessing must stay observable as panics (they abort the /// compiler), so assert on the message while resetting the primitive scope. #[test] -#[serial] fn storage_preprocessing_panics_on_malformed_refs() { let cases = [ ( @@ -1610,10 +1563,6 @@ fn main() -> Felt {{ return 0; }}" let _ = interpreter.typecheck_single(path.clone()); })); let _ = std::fs::remove_file(&path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } let message = match result { Err(message) => message diff --git a/psy-interpreter/src/visibility_tests.rs b/psy-interpreter/src/visibility_tests.rs index f16eae2cb..48caaf546 100644 --- a/psy-interpreter/src/visibility_tests.rs +++ b/psy-interpreter/src/visibility_tests.rs @@ -4,29 +4,24 @@ // temporary `.psy` sources. Every case names the concrete visibility contract it // defends and asserts the exact accept/reject outcome plus error category. // -// The shared `STD_PRIMITIVE_SCOPE_ID` singleton is reset after *every* case (via -// [`check`], which tears down before the caller can panic) so the suite is -// hermetic: a failing assertion can never leak global state into the next test. +// Each case owns an independent symbol table and uniquely named temporary file. use std::{ fs, - path::PathBuf, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; -use serial_test::serial; use super::*; static COUNTER: AtomicU64 = AtomicU64::new(0); -/// Typecheck `source` written to a throwaway temp file, then ALWAYS tear down -/// the file and reset the shared primitive-scope singleton. Returns `None` if the +/// Typecheck `source` written to a throwaway temp file, then tear it down. Returns `None` if the /// program typechecked, or `Some(formatted_error)` if it was rejected. Cleanup /// runs before the caller panics, so a failing assertion can never leak global -/// state into the next `#[serial]` test. +/// state into the next test. fn check(source: &str) -> Option { let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); let n = COUNTER.fetch_add(1, Ordering::Relaxed); @@ -38,10 +33,6 @@ fn check(source: &str) -> Option { // Tear down first, unconditionally. let _ = fs::remove_file(path); - #[allow(static_mut_refs)] - unsafe { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - } match result { Ok(_) => None, @@ -74,7 +65,6 @@ fn expect_accept(name: &str, source: &str) { // segments, so it flows through `resolve_module_type` -> `TypeNotPublic` // (psy-sema/src/resolver.rs:446), not `MemberNotPublic`. #[test] -#[serial] fn private_fn_inaccessible_from_parent() { expect_reject( "private_fn_inaccessible_from_parent", @@ -93,7 +83,6 @@ fn main() { // ---- (1b) private function is reachable from inside its own module ---- #[test] -#[serial] fn private_fn_accessible_within_own_module() { expect_accept( "private_fn_accessible_within_own_module", @@ -115,7 +104,6 @@ fn main() {} // `inner::PrivateS { ... }` is a type-position path with no intermediate segments, // so it goes through `resolve_module_type` -> `TypeNotPublic` (resolver.rs:446). #[test] -#[serial] fn private_struct_inaccessible_cross_module() { expect_reject( "private_struct_inaccessible_cross_module", @@ -141,7 +129,6 @@ fn main() { // `access` is `pub` so `main` can call it; the reject comes from typechecking // `access`'s body. #[test] -#[serial] fn private_inline_module_inaccessible_from_nondescendant() { expect_reject( "private_inline_module_inaccessible_from_nondescendant", @@ -171,7 +158,6 @@ fn main() { // private yet visible to its sibling `far`, so a *public* item inside `outer` is // reachable from `far`. This is looser than Rust and worth flagging. #[test] -#[serial] fn sibling_can_access_public_item_in_private_sibling_module() { expect_accept( "sibling_can_access_public_item_in_private_sibling_module", @@ -197,7 +183,6 @@ fn main() { // resolve_use checks the target's type-key visibility -> `TypeNotPublic` // (resolver.rs:317). #[test] -#[serial] fn use_of_private_fn_rejected() { expect_reject( "use_of_private_fn_rejected", @@ -216,7 +201,6 @@ fn main() {} // ---- (4b) `use` of a private struct is rejected ---- #[test] -#[serial] fn use_of_private_struct_rejected() { expect_reject( "use_of_private_struct_rejected", @@ -239,7 +223,6 @@ fn main() {} // traverse_path_segment -> is_module_visible fails -> `ModuleNotPublic` // (resolver.rs:359). #[test] -#[serial] fn use_of_private_module_rejected() { expect_reject( "use_of_private_module_rejected", @@ -258,7 +241,6 @@ fn main() {} // ---- (5) pub item accessible cross-module (positive control) ---- #[test] -#[serial] fn pub_item_accessible_cross_module() { expect_accept( "pub_item_accessible_cross_module", @@ -286,7 +268,6 @@ fn main() { // `Felt` resolves via the prelude while the private user function stays // unreachable (`TypeNotPublic`, resolver.rs:446). #[test] -#[serial] fn implicit_prelude_does_not_bypass_user_visibility() { expect_reject( "implicit_prelude_does_not_bypass_user_visibility", @@ -309,7 +290,6 @@ fn main() { // function is rejected by `resolve_module_type` -> `TypeNotPublic` (resolver.rs:446). // There is no descendant exception for items. #[test] -#[serial] fn child_cannot_access_parent_private_fn() { expect_reject( "child_cannot_access_parent_private_fn", @@ -333,7 +313,6 @@ fn main() {} // (psy-sema/src/lib.rs:225), with the same-module escape handled by // `typecheck_member_access`. #[test] -#[serial] fn private_struct_field_inaccessible_from_outside() { expect_reject( "private_struct_field_inaccessible_from_outside", @@ -362,7 +341,6 @@ fn main() { // ---- (8b) public struct field is accessible from outside ---- #[test] -#[serial] fn public_struct_field_accessible_from_outside() { expect_accept( "public_struct_field_accessible_from_outside", @@ -395,7 +373,6 @@ fn main() { // branch (resolver.rs:326-333). The outcome is correct (private stays private) // but, per (8d), this is the SAME import bug -- not a genuine visibility check. #[test] -#[serial] fn use_of_private_enum_rejected() { expect_reject( "use_of_private_enum_rejected", @@ -424,7 +401,6 @@ fn main() {} // * enum visibility via `use` is effectively untested/unenforced today. // This test pins the current (defective) behavior so the bug is visible. #[test] -#[serial] fn use_of_public_enum_also_rejected_bug() { expect_reject( "use_of_public_enum_also_rejected_bug", @@ -448,7 +424,6 @@ fn main() {} // `TypeNotPublic` (resolver.rs:317), so the (public) trait method is never // reachable. ---- #[test] -#[serial] fn private_type_trait_impl_not_importable_publicly() { expect_reject( "private_type_trait_impl_not_importable_publicly", @@ -481,7 +456,6 @@ fn main() {} // ---- (9b) a PUBLIC type's trait impl IS usable across modules ---- #[test] -#[serial] fn public_type_trait_impl_usable_across_modules() { expect_accept( "public_type_trait_impl_usable_across_modules", @@ -520,7 +494,6 @@ fn main() { // rejected like a private function via `resolve_module_type` -> `TypeNotPublic` // (resolver.rs:446). #[test] -#[serial] fn private_extern_fn_inaccessible_cross_module() { expect_reject( "private_extern_fn_inaccessible_cross_module", @@ -539,7 +512,6 @@ fn main() { // ---- (10b) public extern fn is accessible cross-module ---- #[test] -#[serial] fn public_extern_fn_accessible_cross_module() { expect_accept( "public_extern_fn_accessible_cross_module", @@ -560,7 +532,6 @@ fn main() { // ---- (U1) wildcard `use mod::*` imports only public items ---- // resolve_use with target=None filters to public keys/types (resolver.rs:338). #[test] -#[serial] fn wildcard_use_imports_only_public_items() { expect_accept( "wildcard_use_imports_only_public_items", @@ -583,7 +554,6 @@ fn main() { // `private_fn` is filtered out by the public-only glob, so referencing it // afterwards is `UnresolvedType` (resolver.rs:151). #[test] -#[serial] fn wildcard_use_does_not_import_private_items() { expect_reject( "wildcard_use_does_not_import_private_items", @@ -607,7 +577,6 @@ fn main() { // `pub use a::f` re-inserts `f` into `c` with public visibility (lib.rs:3361), so // `use c::f` from the root succeeds. #[test] -#[serial] fn pub_use_reexport_makes_item_public() { expect_accept( "pub_use_reexport_makes_item_public", @@ -633,7 +602,6 @@ fn main() { // A non-`pub` `use a::f` re-inserts `f` into `c` as private (lib.rs:3361), so // `use c::f` from the root is rejected -> `TypeNotPublic` (resolver.rs:317). #[test] -#[serial] fn private_use_reexport_not_public_to_others() { expect_reject( "private_use_reexport_not_public_to_others", @@ -661,7 +629,6 @@ fn main() { // visible to the root, so `use a::b::f` is rejected at the `b` segment via // traverse_path_segment -> `ModuleNotPublic` (resolver.rs:359). #[test] -#[serial] fn transitive_use_enforces_visibility_each_hop() { expect_reject( "transitive_use_enforces_visibility_each_hop", @@ -684,7 +651,6 @@ fn main() { // ---- (U5b) transitive use through all-public modules succeeds ---- #[test] -#[serial] fn transitive_use_all_public_succeeds() { expect_accept( "transitive_use_all_public_succeeds", @@ -708,7 +674,6 @@ fn main() { // `use inner::private_fn` is still rejected even though `use std::prelude::*` is // implicitly present in this module (psy-parser/src/lib.rs:172). #[test] -#[serial] fn prelude_glob_does_not_shadow_user_visibility() { expect_reject( "prelude_glob_does_not_shadow_user_visibility", diff --git a/psy-sema/src/infer.rs b/psy-sema/src/infer.rs index e371a61be..5b798b5fb 100644 --- a/psy-sema/src/infer.rs +++ b/psy-sema/src/infer.rs @@ -5,7 +5,7 @@ use psy_vm::dpn::ops::context_trait::ContextFelt; use crate::{ rewriter::Rewriter, CheckedArrayNode, CheckedFunctionSignature, CheckedStructField, CheckedStructNode, Constraint, Error, Implementer, Result, - ScopeId, Type, TypeChecker, TypeCheckerVisitorContext, TypeId, + Type, TypeChecker, TypeCheckerVisitorContext, TypeId, }; #[derive(Debug)] @@ -169,8 +169,8 @@ impl + ContextFelt, C> TypeChecker { size_ty: self.substitute_all(array.size_ty, ctx)?, scope_id: array.scope_id, }); - let type_id = ctx.symbols.get_or_add_type(Some(ScopeId::primitive()), ty.key(), ty)?; - let poly_ty = ctx.symbols.get_type_id(Some(ScopeId::primitive()), IdentId::TYPE_ARRAY).unwrap(); + let type_id = ctx.symbols.get_or_add_type(Some(ctx.symbols.primitive_scope_id()), ty.key(), ty)?; + let poly_ty = ctx.symbols.get_type_id(Some(ctx.symbols.primitive_scope_id()), IdentId::TYPE_ARRAY).unwrap(); self.register_instance(type_id, poly_ty, ctx)?; Ok(type_id) } @@ -181,7 +181,7 @@ impl + ContextFelt, C> TypeChecker { new_types.push(self.substitute_all(ty, ctx)?); } let ty = Type::Tuple(new_types); - ctx.symbols.get_or_add_type(Some(ScopeId::primitive()), ty.key(), ty) + ctx.symbols.get_or_add_type(Some(ctx.symbols.primitive_scope_id()), ty.key(), ty) } Type::FunctionSignature(sig) => { diff --git a/psy-sema/src/lib.rs b/psy-sema/src/lib.rs index d3953cbc2..80a2fdbf4 100644 --- a/psy-sema/src/lib.rs +++ b/psy-sema/src/lib.rs @@ -1252,7 +1252,7 @@ impl + ContextFelt, C> AstVisitor for TypeChecker + ContextFelt, C> AstVisitor for TypeChecker + ContextFelt, C> AstVisitor for TypeChecker = checked_elements.iter().map(|e| e.ty()).collect(); let tuple_type = Type::Tuple(element_types.clone()); - let scope_id = ScopeId::primitive(); + let scope_id = ctx.symbols.primitive_scope_id(); let type_id = ctx.symbols.get_or_add_type(Some(scope_id), tuple_type.key(), tuple_type)?; let elements_with_types = checked_elements.into_iter().map(|e| (e.ty(), self.program.exprs.alloc_item(e))).collect(); @@ -2456,7 +2456,7 @@ impl + ContextFelt, C> AstVisitor for TypeChecker + ContextFelt, C> AstVisitor for TypeChecker + ContextFelt, C> AstVisitor for TypeChecker + ContextFelt, C> TypeChecker { #[instrument(level = "debug", skip_all)] fn typecheck_std_primitive_module(&mut self, ctx: &mut TypeCheckerVisitorContext) -> Result<()> { - #[allow(static_mut_refs)] - unsafe { - let scope_id = ctx.symbols.current_scope_id().unwrap(); - if let Err(id) = STD_PRIMITIVE_SCOPE_ID.set(scope_id) - && id != scope_id - { - let _ = STD_PRIMITIVE_SCOPE_ID.take(); - STD_PRIMITIVE_SCOPE_ID.set(scope_id).unwrap(); - } - // //Warning: Not safe to run in a multithreaded environment - // *STD_PRIMITIVE_SCOPE_ID.get_or_init(|| { - // ctx.symbols - // .current_scope_id() - // .expect("cannot get current scope id") - // }) - }; + let scope_id = ctx.symbols.current_scope_id().unwrap(); + ctx.symbols.set_primitive_scope_id(scope_id); for ty in &*PRIMITIVE_TYPES { ctx.symbols.add_type(None, ty.key(), ty.clone())?; } @@ -3461,12 +3447,12 @@ impl + ContextFelt, C> TypeChecker { name: None, ty, value: ctx.symbols.get_or_add_constant(CheckedValueRef::from_value(value_f, ty)), - scope_id: ScopeId::primitive(), + scope_id: ctx.symbols.primitive_scope_id(), visibility: Visibility::Public, }; ctx.symbols - .get_or_add_type(Some(ScopeId::primitive()), TypeKey::from(node.value), Type::Const(node)) + .get_or_add_type(Some(ctx.symbols.primitive_scope_id()), TypeKey::from(node.value), Type::Const(node)) } #[instrument(level = "debug", skip_all)] @@ -3476,11 +3462,11 @@ impl + ContextFelt, C> TypeChecker { let inner_ty = ctx.symbols.add_type_variable( ScopeKind::Module, - CheckedGenericParameter::new(IdentId::T, vec![], ScopeId::primitive(), Location::default()), + CheckedGenericParameter::new(IdentId::T, vec![], ctx.symbols.primitive_scope_id(), Location::default()), )?; let size = ctx.symbols.add_type_variable( ScopeKind::Module, - CheckedGenericParameter::new(IdentId::N, vec![FELT_TYPE], ScopeId::primitive(), Location::default()), + CheckedGenericParameter::new(IdentId::N, vec![FELT_TYPE], ctx.symbols.primitive_scope_id(), Location::default()), )?; let checked_array = CheckedArrayNode { @@ -3490,7 +3476,7 @@ impl + ContextFelt, C> TypeChecker { }; let ty = Type::Array(checked_array.clone()); - let type_id = ctx.symbols.add_type(Some(ScopeId::primitive()), ty.name(), ty)?; + let type_id = ctx.symbols.add_type(Some(ctx.symbols.primitive_scope_id()), ty.name(), ty)?; self.infcx.exit_context(); ctx.symbols.end_scope(); @@ -3589,7 +3575,7 @@ impl + ContextFelt, C> TypeChecker { } } UncheckedType::Array(inner_ty, size, location) => { - let underlying_type_id = ctx.symbols.get_type_id(Some(ScopeId::primitive()), IdentId::TYPE_ARRAY).unwrap(); + let underlying_type_id = ctx.symbols.get_type_id(Some(ctx.symbols.primitive_scope_id()), IdentId::TYPE_ARRAY).unwrap(); let &CheckedArrayNode { inner_ty: generic_inner_ty, @@ -3624,7 +3610,7 @@ impl + ContextFelt, C> TypeChecker { let checked_tuple = Type::Tuple(checked_elements); - let scope_id = ScopeId::primitive(); + let scope_id = ctx.symbols.primitive_scope_id(); ctx.symbols.get_or_add_type(Some(scope_id), checked_tuple.key(), checked_tuple)? } diff --git a/psy-sema/src/symbol_table.rs b/psy-sema/src/symbol_table.rs index 1a4d72a3f..9e1c06164 100644 --- a/psy-sema/src/symbol_table.rs +++ b/psy-sema/src/symbol_table.rs @@ -2,7 +2,7 @@ use std::{ fmt::{Display, Formatter}, hash::Hash, ops::{Index, IndexMut}, - sync::OnceLock, + sync::atomic::{AtomicUsize, Ordering}, }; use anyhow::anyhow; @@ -18,7 +18,41 @@ define_arena_id!(ScopeId); define_arena_id!(VarId); define_arena_id!(ConstId); -pub static mut STD_PRIMITIVE_SCOPE_ID: OnceLock = OnceLock::new(); +pub struct PrimitiveScopeId(AtomicUsize); + +impl PrimitiveScopeId { + const UNSET: usize = usize::MAX; + + pub const fn new() -> Self { + Self(AtomicUsize::new(Self::UNSET)) + } + + pub fn get(&self) -> Option { + let id = self.0.load(Ordering::Acquire); + (id != Self::UNSET).then_some(ScopeId(id)) + } + + pub fn set(&self, scope_id: ScopeId) -> Result<(), ScopeId> { + self.0 + .compare_exchange(Self::UNSET, scope_id.0, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| scope_id) + } + + pub fn take(&self) -> Option { + let id = self.0.swap(Self::UNSET, Ordering::AcqRel); + (id != Self::UNSET).then_some(ScopeId(id)) + } +} + +impl Default for PrimitiveScopeId { + fn default() -> Self { + Self::new() + } +} + +// Compatibility export for downstream users. Compiler state is now stored in SymbolTable. +pub static STD_PRIMITIVE_SCOPE_ID: PrimitiveScopeId = PrimitiveScopeId::new(); impl ScopeId { pub const fn root() -> Self { @@ -26,10 +60,7 @@ impl ScopeId { } pub fn primitive() -> Self { - #[allow(static_mut_refs)] - unsafe { - *STD_PRIMITIVE_SCOPE_ID.get().unwrap() - } + STD_PRIMITIVE_SCOPE_ID.get().expect("primitive scope has not been initialized") } } @@ -152,6 +183,7 @@ pub struct SymbolTable + ContextFelt> { scopes: Vec>, scope_stack: Vec, frames: Vec>>, + primitive_scope_id: Option, pub types: Vec, consts: Vec>, @@ -237,6 +269,7 @@ impl + ContextFelt> SymbolTable { scopes: vec![], scope_stack: vec![], frames: vec![], + primitive_scope_id: None, types: vec![], consts: vec![], @@ -279,6 +312,21 @@ impl + ContextFelt> SymbolTable { &self.types } + pub fn set_primitive_scope_id(&mut self, scope_id: ScopeId) { + self.primitive_scope_id = Some(scope_id); + } + + pub fn primitive_scope_id(&self) -> ScopeId { + self.primitive_scope_id.expect("primitive scope has not been initialized") + } + + pub fn type_scope_id(&self, type_id: TypeId) -> ScopeId { + match &self[type_id] { + Type::Felt | Type::Bool | Type::U32 | Type::Tuple(_) => self.primitive_scope_id(), + ty => ty.scope_id(), + } + } + pub fn current_scope_id(&self) -> Option { self.scope_stack.last().cloned() } @@ -643,6 +691,28 @@ mod tests { table.exit_module(); } + #[test] + fn primitive_scopes_are_local_to_each_symbol_table() { + let mut first = SymbolTable::::new(); + let mut second = SymbolTable::::new(); + first.set_primitive_scope_id(ScopeId(3)); + second.set_primitive_scope_id(ScopeId(17)); + + let first_felt = first.create_type(Type::Felt).unwrap(); + let second_felt = second.create_type(Type::Felt).unwrap(); + assert_eq!(first.primitive_scope_id(), ScopeId(3)); + assert_eq!(second.primitive_scope_id(), ScopeId(17)); + assert_eq!(first.type_scope_id(first_felt), ScopeId(3)); + assert_eq!(second.type_scope_id(second_felt), ScopeId(17)); + } + + #[test] + #[should_panic(expected = "primitive scope has not been initialized")] + fn primitive_scope_requires_initialization() { + let table = SymbolTable::::new(); + let _ = table.primitive_scope_id(); + } + #[test] fn symbol_table_display_renders_scopes_types_and_modules() { let mut table = SymbolTable::::new(); diff --git a/psy-sema/src/type.rs b/psy-sema/src/type.rs index 6c78edbfc..76b587ebb 100644 --- a/psy-sema/src/type.rs +++ b/psy-sema/src/type.rs @@ -529,11 +529,8 @@ mod tests { #[test] fn scope_ids_come_from_the_node_for_composites() { // The primitive scope global may not have been populated yet when this test runs first. - #[allow(static_mut_refs)] - unsafe { - if crate::STD_PRIMITIVE_SCOPE_ID.get().is_none() { - crate::STD_PRIMITIVE_SCOPE_ID.set(ScopeId(99)).unwrap(); - } + if crate::STD_PRIMITIVE_SCOPE_ID.get().is_none() { + crate::STD_PRIMITIVE_SCOPE_ID.set(ScopeId(99)).unwrap(); } assert_eq!(Type::Array(array()).scope_id(), ScopeId(1)); From ba25d382d2f472d6d9a265c45c3275f0578a593b Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 7 Sep 2026 19:11:51 +0800 Subject: [PATCH 06/12] test: push function coverage to 96.9% with edge-case suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interpreter: dedupe the 43 identical build_report fallback closures into a report_or_fallback helper and test its Err branch - wasm: cover unknown-caller fallback, runtime assert failures, and IMT entry reads through the in-memory chain - lsp: cover non-file formatting URIs and the position_at panic path - package: cover clone_git_repo cache reuse and spawn failures - sema: cover lambda path return types, generic free-function calls, turbofish associated types, and non-callable operator members - dargo-cli: doc-mode fixtures put an item before the `// input:` comments β€” file-leading comments become module comments in the parser, so they never attached to the function and the metadata map stayed empty Function coverage 94.93% -> 96.91%; lines 95.43% -> 95.63%. make coverage-ci passes both 95% gates. --- Cargo.lock | 1 + psy-abi/src/extractor.rs | 22 ++ psy-ast/src/traits/info.rs | 21 ++ psy-dargo-cli/src/cli/execute_cmd.rs | 12 +- psy-dargo-cli/src/cli/mod.rs | 10 + psy-interpreter/src/error.rs | 435 ++++++++++++----------- psy-interpreter/src/interp_exec_tests.rs | 254 +++++++++++++ psy-interpreter/src/lib.rs | 100 +++--- psy-interpreter/src/preprocess.rs | 105 ++++-- psy-interpreter/src/sema_edge_tests.rs | 102 ++++++ psy-lexer/src/token.rs | 9 + psy-lsp-server/src/simple.rs | 74 ++++ psy-package/Cargo.toml | 3 + psy-package/src/git.rs | 31 +- psy-package/src/lib.rs | 17 + psy-package/src/source.rs | 5 + psy-parser/tests/syntax_migration.rs | 7 + psy-sema/src/context.rs | 43 +++ psy-sema/src/expr/if_expr.rs | 16 + psy-sema/src/stmt/mod.rs | 38 ++ psy-sema/src/symbol_table.rs | 21 ++ psy-sema/src/value.rs | 9 + psy-sema/src/visualizer.rs | 26 ++ psy-wasm/src/lib.rs | 121 +++++++ 24 files changed, 1192 insertions(+), 290 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38f09f976..2a56353b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5466,6 +5466,7 @@ dependencies = [ "md5", "semver 1.0.28", "serde", + "serial_test", "smol_str", "tempfile", "thiserror 2.0.18", diff --git a/psy-abi/src/extractor.rs b/psy-abi/src/extractor.rs index 08128af09..5dea8fb9c 100644 --- a/psy-abi/src/extractor.rs +++ b/psy-abi/src/extractor.rs @@ -858,3 +858,25 @@ mod tests { } } + +#[cfg(test)] +mod tuple_layout_tests { + use psy_ast::{Identifier, Location}; + + use super::*; + + #[test] + fn felt_size_counts_tuple_elements() { + let mut program = Program::::new(); + let felt = Identifier::new(program.interner.intern_ident("Felt"), Location::default()); + let u32_ident = Identifier::new(program.interner.intern_ident("u32"), Location::default()); + let ctx = DefaultVisitorContext::::new(&mut program); + + let extractor = AbiExtractor::new("TestContract".to_string()); + let struct_nodes = BTreeMap::new(); + let mut layouts = HashMap::new(); + let tuple = UncheckedType::Tuple(vec![UncheckedType::Basic(felt), UncheckedType::Basic(u32_ident)], Location::default()); + + assert_eq!(extractor.felt_size_for_type(&ctx, &tuple, &struct_nodes, &mut layouts), 2); + } +} diff --git a/psy-ast/src/traits/info.rs b/psy-ast/src/traits/info.rs index bc008a5f8..99f73f32a 100644 --- a/psy-ast/src/traits/info.rs +++ b/psy-ast/src/traits/info.rs @@ -9,3 +9,24 @@ pub trait NodeInfo { None } } + +#[cfg(test)] +mod tests { + use crate::{BinaryNode, BinaryOperator, ExprId, Location, NodeInfo, NodeType}; + + #[test] + fn default_node_info_impls_expose_no_expression_or_definition_handles() { + let node = BinaryNode { + lhs: ExprId(0), + operator: BinaryOperator::Add, + rhs: ExprId(1), + location: Location::default(), + }; + // Go through the trait object so the default method bodies execute + // out-of-line instead of being inlined into the caller. + let info: &dyn NodeInfo = &node; + assert_eq!(info.node_type(), NodeType::BinaryExpr); + assert_eq!(info.as_expression(), None); + assert_eq!(info.as_definition(), None); + } +} diff --git a/psy-dargo-cli/src/cli/execute_cmd.rs b/psy-dargo-cli/src/cli/execute_cmd.rs index 3951e054f..5e2f76701 100644 --- a/psy-dargo-cli/src/cli/execute_cmd.rs +++ b/psy-dargo-cli/src/cli/execute_cmd.rs @@ -174,7 +174,11 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn doc_flag_delegates_to_run_doc_and_checks_commented_output() { - let source = "// input: 41\n// output: 42\nfn main(a: Felt) -> Felt { return a + 1; }\n"; + // NOTE: file-leading comments become the *module's* comments in the parser + // (psy-parser/src/recursive/mod.rs `take_leading_comments` at module start), + // so a leading item must precede them for the doc pipeline to attach + // `input:`/`output:` to the function itself. + let source = "use std::prelude::*;\n// input: 41\n// output: 42\nfn main(a: Felt) -> Felt { return a + 1; }\n"; let (dir, workspace) = temp_workspace(source, "doc_mode"); run( ExecuteCommand { compile_options: compile_options(), parameters: vec![], doc: true }, @@ -189,7 +193,11 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn doc_mode_with_debug_flag_prints_function_metadata_and_results() { - let source = "// input: 41\n// output: 42\nfn main(a: Felt) -> Felt { return a + 1; }\n"; + // NOTE: file-leading comments become the *module's* comments in the parser + // (psy-parser/src/recursive/mod.rs `take_leading_comments` at module start), + // so a leading item must precede them for the doc pipeline to attach + // `input:`/`output:` to the function itself. + let source = "use std::prelude::*;\n// input: 41\n// output: 42\nfn main(a: Felt) -> Felt { return a + 1; }\n"; let (dir, workspace) = temp_workspace(source, "doc_debug"); run( ExecuteCommand { diff --git a/psy-dargo-cli/src/cli/mod.rs b/psy-dargo-cli/src/cli/mod.rs index 374ba9914..8479075b8 100644 --- a/psy-dargo-cli/src/cli/mod.rs +++ b/psy-dargo-cli/src/cli/mod.rs @@ -293,6 +293,16 @@ mod cli_path_tests { assert!(graph.contains_node(&PathBuf::from("shared/src/lib.psy"))); } + #[test] + fn parse_path_turns_relative_paths_into_absolute_ones() { + let relative = super::parse_path("some/relative.psy").expect("relative path must parse"); + assert!(relative.is_absolute(), "relative input must become absolute: {relative:?}"); + assert!(relative.ends_with("some/relative.psy")); + + let absolute = super::parse_path("/tmp/already-absolute.psy").expect("absolute path must parse"); + assert_eq!(absolute, std::path::PathBuf::from("/tmp/already-absolute.psy")); + } + #[test] fn with_workspace_resolves_the_manifest_and_applies_the_target_override() { use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/psy-interpreter/src/error.rs b/psy-interpreter/src/error.rs index b65306438..d17e0661a 100644 --- a/psy-interpreter/src/error.rs +++ b/psy-interpreter/src/error.rs @@ -27,7 +27,11 @@ pub enum Error { #[error("ArithmeticOverflow: constant arithmetic overflow")] ArithmeticOverflow { location: Option }, #[error("IndexOutOfBounds: index {index} >= length {length}")] - IndexOutOfBounds { index: usize, length: usize, location: Option }, + IndexOutOfBounds { + index: usize, + length: usize, + location: Option, + }, #[error("ArrayTooLarge: cannot materialize an array with {length} elements (limit: {limit})")] ArrayTooLarge { length: u64, limit: u64, location: Option }, #[error("ArrayAllocationFailed: cannot reserve storage for {length} array elements")] @@ -68,6 +72,12 @@ fn build_report + ContextFelt>( Ok(String::from_utf8(output).unwrap()) } +/// Render a report, falling back to a plain message if report building fails +/// (e.g. the source file cannot be resolved from the report cache). +fn report_or_fallback(result: Result) -> String { + result.unwrap_or_else(|e| format!("Failed to build report: {}", e)) +} + pub fn lowering_parse_error + ContextFelt>(error: &psy_parser::Error, program: &Program) -> String { match error { ParseError::CommonError(error) => format!("{}", error), @@ -75,30 +85,25 @@ pub fn lowering_parse_error + ContextFelt>(error: &psy_pars ParseError::FileUnresolved => format!("{}", error), ParseError::FileParsedMultipleTimes(path) => format!("{}", path.display()), ParseError::NoEntryModule(path) => format!("{}", path.display()), - ParseError::InvalidModuleName - | ParseError::ExternFnNotInStd - | ParseError::FunctionBodyMissing - | ParseError::InvalidSelfParameter => format!("{}", error), + ParseError::InvalidModuleName | ParseError::ExternFnNotInStd | ParseError::FunctionBodyMissing | ParseError::InvalidSelfParameter => { + format!("{}", error) + } ParseError::UnexpectedEof { expected, location } => { - build_report(*location, "UnexpectedEof", format!("Expected {:?}.", expected), program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report(*location, "UnexpectedEof", format!("Expected {:?}.", expected), program)) } - ParseError::UnexpectedToken { found, expected, location } => build_report( + ParseError::UnexpectedToken { found, expected, location } => report_or_fallback(build_report( *location, "UnexpectedToken", format!("Found unexpected token {}, expected {:?}.", found, expected), program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - ParseError::UnsupportedSyntax { feature, location } => build_report( + )), + ParseError::UnsupportedSyntax { feature, location } => report_or_fallback(build_report( *location, "UnsupportedSyntax", format!("Unsupported syntax: {}.", feature), program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - ParseError::LexicalError { location } => build_report(*location, "LexError", "Lexical error.", program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), + )), + ParseError::LexicalError { location } => report_or_fallback(build_report(*location, "LexError", "Lexical error.", program)), } } @@ -135,10 +140,7 @@ pub fn parse_error_to_diagnostic + ContextFelt>(error: &Par use ParseError::*; let located = match error { - UnexpectedEof { expected, location } => Some(( - location, - format!("Unexpected EOF. Expected one of: {}", format_expected_pretty(expected)), - )), + UnexpectedEof { expected, location } => Some((location, format!("Unexpected EOF. Expected one of: {}", format_expected_pretty(expected)))), UnexpectedToken { found, expected, location } => Some(( location, format!("Unexpected token '{}', expected one of: {}", found, format_expected_pretty(expected)), @@ -180,14 +182,13 @@ pub fn lowering_sema_error + ContextFelt, C>(error: &psy_se match error { SemaError::AnyhowError(error) => format!("{}", error), SemaError::CommonError(error) => format!("{}", error), - SemaError::UnsupportedRecursion { location, what } => build_report( + SemaError::UnsupportedRecursion { location, what } => report_or_fallback(build_report( location.clone(), "UnsupportedRecursion", format!("Unsupported recursion: {what}."), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::TypeMismatch { location, expected, found } => build_report( + )), + SemaError::TypeMismatch { location, expected, found } => report_or_fallback(build_report( location.clone(), "TypeMismatch", format!( @@ -196,23 +197,20 @@ pub fn lowering_sema_error + ContextFelt, C>(error: &psy_se ctx.debug_type(found.clone()) ), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::InvalidPathSegment { location, segment } => build_report( + )), + SemaError::InvalidPathSegment { location, segment } => report_or_fallback(build_report( location.clone(), "InvalidPathSegment", format!("Invalid path segment {}.", segment), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::UnresolvedType { location, resolved_type } => build_report( + )), + SemaError::UnresolvedType { location, resolved_type } => report_or_fallback(build_report( location.clone(), "UnresolvedType", format!("Unresolved type {}.", ctx.ident(resolved_type.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::TraitAlreadyImplemented { location, trait_ty, ty } => build_report( + )), + SemaError::TraitAlreadyImplemented { location, trait_ty, ty } => report_or_fallback(build_report( location.clone(), "TraitAlreadyImplemented", format!( @@ -221,41 +219,36 @@ pub fn lowering_sema_error + ContextFelt, C>(error: &psy_se ctx.debug_type(ty.clone()) ), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::VariableAlreadyDefined { location, variable } => build_report( + )), + SemaError::VariableAlreadyDefined { location, variable } => report_or_fallback(build_report( location.clone(), "VariableAlreadyDefined", format!("Variable {} already defined.", ctx.ident(variable.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::ImmutableVariable { location, variable } => build_report( + )), + SemaError::ImmutableVariable { location, variable } => report_or_fallback(build_report( location.clone(), "ImmutableVariable", format!("Variable {} is immutable.", ctx.ident(variable.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::UnresolvedMember { location, member_name } => build_report( + )), + SemaError::UnresolvedMember { location, member_name } => report_or_fallback(build_report( location.clone(), "UnresolvedMember", format!("Unresolved member {}.", ctx.ident(member_name.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::NotCallable { location, ty } => build_report( + )), + SemaError::NotCallable { location, ty } => report_or_fallback(build_report( location.clone(), "NotCallable", format!("Type {} is not callable.", ctx.debug_type(ty.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), + )), SemaError::UnresolvedTraitMethod { method_location, method_name, trait_name, - } => build_report( + } => report_or_fallback(build_report( method_location.clone(), "UnresolvedTraitMethod", format!( @@ -264,106 +257,99 @@ pub fn lowering_sema_error + ContextFelt, C>(error: &psy_se ctx.ident(trait_name.clone()) ), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::InvalidGenericArguments { location, expected, found } => build_report( + )), + SemaError::InvalidGenericArguments { location, expected, found } => report_or_fallback(build_report( location.clone(), "GenericParameterMismatch", format!("Expected {}, but found {}.", expected, found), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), + )), SemaError::InvalidFunctionArguments { location, method_name: _method_name, expected, found, - } => build_report( + } => report_or_fallback(build_report( location.clone(), "InvalidFunctionCall", format!("Expected {} parameters, but found {}.", expected, found), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::InvalidReturn { location, message } => { - build_report(location.clone(), "InvalidReturn", message, &ctx.program).unwrap_or_else(|e| format!("Failed to build report: {}", e)) - } - SemaError::InvalidGenericConstraint { location } => build_report( + )), + SemaError::InvalidReturn { location, message } => report_or_fallback(build_report(location.clone(), "InvalidReturn", message, &ctx.program)), + SemaError::InvalidGenericConstraint { location } => report_or_fallback(build_report( location.clone(), "InvalidGenericConstraint", "Generic constraint should either be a concrete type or a list of trait requirements", &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::UnreachableExpression { location } => { - build_report(location.clone(), "UnreachableExpression", "Unreachable Expression.", &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)) - } - SemaError::TypeAlreadyDefined { location, type_name } => build_report( + )), + SemaError::UnreachableExpression { location } => report_or_fallback(build_report( + location.clone(), + "UnreachableExpression", + "Unreachable Expression.", + &ctx.program, + )), + SemaError::TypeAlreadyDefined { location, type_name } => report_or_fallback(build_report( location.clone(), "TypeAlreadyDefined", format!("Type {} already defined.", ctx.ident(type_name.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::MemberNotPublic { location, ty, field } => build_report( + )), + SemaError::MemberNotPublic { location, ty, field } => report_or_fallback(build_report( location.clone(), "MemberNotPublic", format!("{} not a public member of {}.", ctx.ident(field.clone()), ctx.debug_type(ty.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::ModuleNotPublic { location, module } => build_report( + )), + SemaError::ModuleNotPublic { location, module } => report_or_fallback(build_report( location.clone(), "ModuleNotPublic", format!("{} not a public module.", ctx.ident(module.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::TypeNotPublic { location, ty } => build_report( + )), + SemaError::TypeNotPublic { location, ty } => report_or_fallback(build_report( location.clone(), "TypeNotPublic", format!("{} not public.", ctx.debug_type(ty.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::IndexOutOfBounds { location, index, length } => build_report( + )), + SemaError::IndexOutOfBounds { location, index, length } => report_or_fallback(build_report( location.clone(), "IndexOutOfBounds", format!("Index {} Out Of Bounds {}.", index, length), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::InvalidCast { location, expected, found } => build_report( + )), + SemaError::InvalidCast { location, expected, found } => report_or_fallback(build_report( location.clone(), "InvalidCast", format!("Expected {}, but found {}.", expected, found), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::DuplicateWildcard { location } => build_report(location.clone(), "DuplicateWildcard", "Duplicate Wildcard.", &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), + )), + SemaError::DuplicateWildcard { location } => { + report_or_fallback(build_report(location.clone(), "DuplicateWildcard", "Duplicate Wildcard.", &ctx.program)) + } SemaError::IncompleteMatch { location, message } => { - build_report(location.clone(), "IncompleteMatch", message, &ctx.program).unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report(location.clone(), "IncompleteMatch", message, &ctx.program)) + } + SemaError::NoParentModule { location } => { + report_or_fallback(build_report(location.clone(), "NoParentModule", "No parent module.", &ctx.program)) } - SemaError::NoParentModule { location } => build_report(location.clone(), "NoParentModule", "No parent module.", &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::ModuleNotFound { location, module } => build_report( + SemaError::ModuleNotFound { location, module } => report_or_fallback(build_report( location.clone(), "ModuleNotFound", format!("Module {} not found.", ctx.ident(module.clone())), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::SpecializationNotAllowed { location } => { - build_report(location.clone(), "SpecializationNotAllowed", "Specialization not allowed.", &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)) - } + )), + SemaError::SpecializationNotAllowed { location } => report_or_fallback(build_report( + location.clone(), + "SpecializationNotAllowed", + "Specialization not allowed.", + &ctx.program, + )), SemaError::MissingAssociatedType { location, trait_name, type_name, - } => build_report( + } => report_or_fallback(build_report( location.clone(), "MissingAssociatedType", format!( @@ -372,23 +358,20 @@ pub fn lowering_sema_error + ContextFelt, C>(error: &psy_se ctx.ident(trait_name.clone()) ), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::RawIntrinsicOutsideStd { location, name } => build_report( + )), + SemaError::RawIntrinsicOutsideStd { location, name } => report_or_fallback(build_report( *location, "RawIntrinsicOutsideStd", format!("Raw intrinsic `{name}` is only available inside the std module tree; use a public std API instead."), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::IfWithoutElse { location } => build_report( + )), + SemaError::IfWithoutElse { location } => report_or_fallback(build_report( *location, "IfWithoutElse", "if expression without else branch cannot be used as a value: the result is undefined when the condition is false.", &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::AmbiguousTraitMethod { location, method, traits } => build_report( + )), + SemaError::AmbiguousTraitMethod { location, method, traits } => report_or_fallback(build_report( *location, "AmbiguousTraitMethod", format!( @@ -397,9 +380,8 @@ pub fn lowering_sema_error + ContextFelt, C>(error: &psy_se traits.iter().map(|ty| ctx.debug_type(ty.clone())).collect::>().join(", ") ), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), - SemaError::AmbiguousAssociatedType { location, member, traits } => build_report( + )), + SemaError::AmbiguousAssociatedType { location, member, traits } => report_or_fallback(build_report( *location, "AmbiguousAssociatedType", format!( @@ -408,8 +390,7 @@ pub fn lowering_sema_error + ContextFelt, C>(error: &psy_se traits.iter().map(|ty| ctx.debug_type(*ty)).collect::>().join(", ") ), &ctx.program, - ) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)), + )), } } pub fn typecheck_error_to_diagnostic + ContextFelt, C>( @@ -530,29 +511,34 @@ pub fn lowering_interpreter_error + ContextFelt, C>(error: Error::IoError(error) => format!("{}", error), Error::SemaError(error) => lowering_sema_error(error, ctx), Error::UndefinedFunction => format!("{}", error), - Error::UncertainLoopCondition { loop_location } => { - build_report(loop_location.clone(), "UncertainLoopCondition", "Uncertain Loop Condition", &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)) - } + Error::UncertainLoopCondition { loop_location } => report_or_fallback(build_report( + loop_location.clone(), + "UncertainLoopCondition", + "Uncertain Loop Condition", + &ctx.program, + )), Error::AssertionFailure { message, location } => { if let Some(location) = location { - build_report(location.clone(), "AssertionFailure", message, &ctx.program).unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report(location.clone(), "AssertionFailure", message, &ctx.program)) } else { format!("Assertion failure: {}", message) } } Error::DivisionByZero { location } => { if let Some(location) = location { - build_report(location.clone(), "DivisionByZero", "Division or remainder by zero.", &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report( + location.clone(), + "DivisionByZero", + "Division or remainder by zero.", + &ctx.program, + )) } else { format!("{}", error) } } Error::ArithmeticOverflow { location } => { if let Some(location) = location { - build_report(location.clone(), "ArithmeticOverflow", "Arithmetic overflow.", &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report(location.clone(), "ArithmeticOverflow", "Arithmetic overflow.", &ctx.program)) } else { format!("{}", error) } @@ -560,16 +546,18 @@ pub fn lowering_interpreter_error + ContextFelt, C>(error: Error::IndexOutOfBounds { index, length, location } => { let msg = format!("Index out of bounds: index {} >= length {}.", index, length); if let Some(location) = location { - build_report(location.clone(), "IndexOutOfBounds", msg, &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report(location.clone(), "IndexOutOfBounds", msg, &ctx.program)) } else { msg } } Error::ArrayTooLarge { length, limit, location } => { - let msg = format!("Cannot materialize an array with {} elements; the interpreter limit is {}.", length, limit); + let msg = format!( + "Cannot materialize an array with {} elements; the interpreter limit is {}.", + length, limit + ); if let Some(location) = location { - build_report(*location, "ArrayTooLarge", msg, &ctx.program).unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report(*location, "ArrayTooLarge", msg, &ctx.program)) } else { msg } @@ -577,7 +565,7 @@ pub fn lowering_interpreter_error + ContextFelt, C>(error: Error::ArrayAllocationFailed { length, location } => { let msg = format!("Cannot reserve storage for {} array elements.", length); if let Some(location) = location { - build_report(*location, "ArrayAllocationFailed", msg, &ctx.program).unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report(*location, "ArrayAllocationFailed", msg, &ctx.program)) } else { msg } @@ -585,8 +573,7 @@ pub fn lowering_interpreter_error + ContextFelt, C>(error: Error::UnsupportedRecursion { location } => { let msg = "Recursive calls are unsupported by the symbolic interpreter."; if let Some(location) = location { - build_report(*location, "UnsupportedRecursion", msg, &ctx.program) - .unwrap_or_else(|e| format!("Failed to build report: {}", e)) + report_or_fallback(build_report(*location, "UnsupportedRecursion", msg, &ctx.program)) } else { msg.to_string() } @@ -603,12 +590,11 @@ mod tests { use psy_ast::{Location, Program}; use psy_common::FileId; use psy_parser::error::ExpectedToken; - use psy_vm::dpn::ops::sym_felt::SymFeltRef; use psy_sema::{Type, TypeCheckerVisitorContext, TypeId}; - use psy_vm::dpn::ops::exec_context::QExecContext; + use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; use super::{ - format_expected_pretty, lowering_interpreter_error, lowering_parse_error, lowering_sema_error, parse_error_to_diagnostic, + format_expected_pretty, lowering_interpreter_error, lowering_parse_error, lowering_sema_error, parse_error_to_diagnostic, report_or_fallback, span_to_range, typecheck_error_to_diagnostic, Error, }; @@ -616,7 +602,10 @@ mod tests { fn expected_token_formatting_handles_all_list_lengths() { assert_eq!(format_expected_pretty(&[]), "(no expected tokens)"); assert_eq!(format_expected_pretty(&[ExpectedToken::Ident]), "identifier"); - assert_eq!(format_expected_pretty(&[ExpectedToken::Ident, ExpectedToken::Literal]), "identifier or literal"); + assert_eq!( + format_expected_pretty(&[ExpectedToken::Ident, ExpectedToken::Literal]), + "identifier or literal" + ); assert_eq!( format_expected_pretty(&[ExpectedToken::Ident, ExpectedToken::Literal, ExpectedToken::Eof]), "identifier, literal or end of file" @@ -652,13 +641,42 @@ mod tests { let ctx = TypeCheckerVisitorContext::::new(Program::new()); let cases = [ (Error::UndefinedFunction, "undefined function"), - (Error::AssertionFailure { message: "failed".into(), location: None }, "Assertion failure: failed"), + ( + Error::AssertionFailure { + message: "failed".into(), + location: None, + }, + "Assertion failure: failed", + ), (Error::DivisionByZero { location: None }, "DivisionByZero: division or remainder by zero"), - (Error::ArithmeticOverflow { location: None }, "ArithmeticOverflow: constant arithmetic overflow"), - (Error::IndexOutOfBounds { index: 3, length: 2, location: None }, "Index out of bounds: index 3 >= length 2."), - (Error::ArrayTooLarge { length: 10, limit: 5, location: None }, "Cannot materialize an array with 10 elements; the interpreter limit is 5."), - (Error::ArrayAllocationFailed { length: 10, location: None }, "Cannot reserve storage for 10 array elements."), - (Error::UnsupportedRecursion { location: None }, "Recursive calls are unsupported by the symbolic interpreter."), + ( + Error::ArithmeticOverflow { location: None }, + "ArithmeticOverflow: constant arithmetic overflow", + ), + ( + Error::IndexOutOfBounds { + index: 3, + length: 2, + location: None, + }, + "Index out of bounds: index 3 >= length 2.", + ), + ( + Error::ArrayTooLarge { + length: 10, + limit: 5, + location: None, + }, + "Cannot materialize an array with 10 elements; the interpreter limit is 5.", + ), + ( + Error::ArrayAllocationFailed { length: 10, location: None }, + "Cannot reserve storage for 10 array elements.", + ), + ( + Error::UnsupportedRecursion { location: None }, + "Recursive calls are unsupported by the symbolic interpreter.", + ), ]; for (error, expected) in cases { let rendered = lowering_interpreter_error(error, &ctx).to_string(); @@ -674,12 +692,38 @@ mod tests { let ctx = TypeCheckerVisitorContext::::new(program); let cases = [ (Error::UncertainLoopCondition { loop_location: location }, "UncertainLoopCondition"), - (Error::AssertionFailure { message: "failed".into(), location: Some(location) }, "AssertionFailure"), + ( + Error::AssertionFailure { + message: "failed".into(), + location: Some(location), + }, + "AssertionFailure", + ), (Error::DivisionByZero { location: Some(location) }, "DivisionByZero"), (Error::ArithmeticOverflow { location: Some(location) }, "ArithmeticOverflow"), - (Error::IndexOutOfBounds { index: 3, length: 2, location: Some(location) }, "IndexOutOfBounds"), - (Error::ArrayTooLarge { length: 10, limit: 5, location: Some(location) }, "ArrayTooLarge"), - (Error::ArrayAllocationFailed { length: 10, location: Some(location) }, "ArrayAllocationFailed"), + ( + Error::IndexOutOfBounds { + index: 3, + length: 2, + location: Some(location), + }, + "IndexOutOfBounds", + ), + ( + Error::ArrayTooLarge { + length: 10, + limit: 5, + location: Some(location), + }, + "ArrayTooLarge", + ), + ( + Error::ArrayAllocationFailed { + length: 10, + location: Some(location), + }, + "ArrayAllocationFailed", + ), (Error::UnsupportedRecursion { location: Some(location) }, "UnsupportedRecursion"), ]; for (error, code) in cases { @@ -787,10 +831,7 @@ mod tests { // This arm lowers to the bare path, not the Display form. "dup.psy", ), - ( - psy_parser::Error::ExternFnNotInStd, - "Extern function can only be defined in std", - ), + (psy_parser::Error::ExternFnNotInStd, "Extern function can only be defined in std"), (psy_parser::Error::FunctionBodyMissing, "Missing function body"), (psy_parser::Error::InvalidSelfParameter, "Invalid self parameter"), ]; @@ -815,10 +856,7 @@ mod tests { let cases: Vec<(psy_sema::Error, &str)> = vec![ ( - psy_sema::Error::UnsupportedRecursion { - location, - what: "functions", - }, + psy_sema::Error::UnsupportedRecursion { location, what: "functions" }, "UnsupportedRecursion", ), ( @@ -844,27 +882,14 @@ mod tests { "UnresolvedType", ), ( - psy_sema::Error::TraitAlreadyImplemented { - location, - trait_ty: ty, - ty, - }, + psy_sema::Error::TraitAlreadyImplemented { location, trait_ty: ty, ty }, "TraitAlreadyImplemented", ), ( - psy_sema::Error::VariableAlreadyDefined { - location, - variable: ident, - }, + psy_sema::Error::VariableAlreadyDefined { location, variable: ident }, "VariableAlreadyDefined", ), - ( - psy_sema::Error::ImmutableVariable { - location, - variable: ident, - }, - "ImmutableVariable", - ), + (psy_sema::Error::ImmutableVariable { location, variable: ident }, "ImmutableVariable"), ( psy_sema::Error::UnresolvedMember { location, @@ -907,28 +932,9 @@ mod tests { ), (psy_sema::Error::InvalidGenericConstraint { location }, "InvalidGenericConstraint"), (psy_sema::Error::UnreachableExpression { location }, "UnreachableExpression"), - ( - psy_sema::Error::TypeAlreadyDefined { - location, - type_name: ident, - }, - "TypeAlreadyDefined", - ), - ( - psy_sema::Error::MemberNotPublic { - location, - ty, - field: ident, - }, - "MemberNotPublic", - ), - ( - psy_sema::Error::ModuleNotPublic { - location, - module: ident, - }, - "ModuleNotPublic", - ), + (psy_sema::Error::TypeAlreadyDefined { location, type_name: ident }, "TypeAlreadyDefined"), + (psy_sema::Error::MemberNotPublic { location, ty, field: ident }, "MemberNotPublic"), + (psy_sema::Error::ModuleNotPublic { location, module: ident }, "ModuleNotPublic"), (psy_sema::Error::TypeNotPublic { location, ty }, "TypeNotPublic"), ( psy_sema::Error::IndexOutOfBounds { @@ -955,13 +961,7 @@ mod tests { "IncompleteMatch", ), (psy_sema::Error::NoParentModule { location }, "NoParentModule"), - ( - psy_sema::Error::ModuleNotFound { - location, - module: ident, - }, - "ModuleNotFound", - ), + (psy_sema::Error::ModuleNotFound { location, module: ident }, "ModuleNotFound"), (psy_sema::Error::SpecializationNotAllowed { location }, "SpecializationNotAllowed"), ( psy_sema::Error::MissingAssociatedType { @@ -1044,17 +1044,11 @@ mod tests { "Unresolved type: counter", ), ( - psy_sema::Error::VariableAlreadyDefined { - location, - variable: ident, - }, + psy_sema::Error::VariableAlreadyDefined { location, variable: ident }, "Variable already defined: counter", ), ( - psy_sema::Error::ImmutableVariable { - location, - variable: ident, - }, + psy_sema::Error::ImmutableVariable { location, variable: ident }, "Variable counter is immutable", ), ( @@ -1117,4 +1111,43 @@ mod tests { assert!(sema.contains("NoParentModule"), "expected sema report in {sema:?}"); assert!(sema.contains("pass.psy"), "expected source path in {sema:?}"); } + + /// A file registered under a non-UTF-8 path cannot be found again from the + /// lossy path string embedded in the ariadne report, so the report cache + /// lookup misses and ariadne renders the report without source context. + #[cfg(unix)] + fn unresolvable_source_program() -> (Program, Location) { + use std::os::unix::ffi::OsStrExt; + + let mut program = Program::::new(); + let file_id = program + .file_resolver + .add_file(PathBuf::from(std::ffi::OsStr::from_bytes(b"unresolvable_\xff.psy")), "fn main() {}\n"); + (program, Location::new(file_id, 0, 2)) + } + + #[cfg(unix)] + #[test] + fn located_errors_still_render_when_the_report_cache_misses_the_source() { + let (program, location) = unresolvable_source_program(); + + let rendered = lowering_parse_error(&psy_parser::Error::LexicalError { location }, &program); + assert!(rendered.contains("LexError"), "expected report code in {rendered:?}"); + + let ctx = TypeCheckerVisitorContext::::new(program); + let rendered = lowering_interpreter_error(Error::DivisionByZero { location: Some(location) }, &ctx).to_string(); + assert!(rendered.contains("DivisionByZero"), "expected report code in {rendered:?}"); + + let rendered = lowering_sema_error(&psy_sema::Error::NoParentModule { location }, &ctx); + assert!(rendered.contains("NoParentModule"), "expected report code in {rendered:?}"); + } + + #[test] + fn report_or_fallback_renders_the_error_when_report_building_fails() { + let rendered = report_or_fallback(Err(Error::UndefinedFunction)); + assert_eq!(rendered, "Failed to build report: undefined function"); + + let rendered = report_or_fallback(Ok("report".to_string())); + assert_eq!(rendered, "report"); + } } diff --git a/psy-interpreter/src/interp_exec_tests.rs b/psy-interpreter/src/interp_exec_tests.rs index 4048de3b9..38197b475 100644 --- a/psy-interpreter/src/interp_exec_tests.rs +++ b/psy-interpreter/src/interp_exec_tests.rs @@ -1355,3 +1355,257 @@ fn generic_bodies_with_hash_mem_and_event_intrinsics_instantiate() { "#, ); } + +#[test] +fn tuple_parameters_and_returns_flow_through_interpretation() { + expect_exec( + "tuple_param_return", + r#" + fn make_pair(x: Felt) -> (Felt, Felt) { + return (x + 1, x + 2); + } + + fn main(q: Felt) -> Felt { + let pair: (Felt, Felt) = make_pair(q); + return pair.0 + pair.1; + } + "#, + ); +} + +#[test] +fn calculate_type_size_sums_tuple_element_sizes() { + let mut interpreter = Interpreter::::new(QExecContext::new()); + let mut ctx = TypeCheckerVisitorContext::::new(Program::new()); + let felt = ctx.symbols.create_type(psy_sema::Type::Felt).unwrap(); + let tuple = ctx.symbols.create_type(psy_sema::Type::Tuple(vec![felt, felt, felt])).unwrap(); + assert_eq!(interpreter.calculate_type_size(tuple, &ctx), 3); +} + +#[test] +fn if_else_if_expression_chains_execute() { + expect_exec( + "else_if_chain", + r#" + fn main(x: Felt) -> Felt { + let y: Felt = if x == 0 { + 1 + } else if x == 1 { + 2 + } else { + 3 + }; + return y; + } + "#, + ); +} + +#[test] +fn first_class_function_arguments_match_expected_signatures() { + expect_exec( + "first_class_fn_arg", + r#" + fn apply(f: fn(Felt, Felt) -> Felt, x: Felt, y: Felt) -> Felt { + return f(x, y); + } + + fn add(a: Felt, b: Felt) -> Felt { + return a + b; + } + + fn main(q: Felt) -> Felt { + return apply(add, q, 1); + } + "#, + ); +} + +#[test] +fn type_checker_visit_module_is_unreachable_by_design() { + let path = temp_psy_path("visit_module"); + fs::write(&path, "fn main() {}\n").unwrap(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let (mut typechecker, mut ctx) = interpreter.typecheck_single(path.clone()).expect("typecheck a trivial module"); + let _ = fs::remove_file(&path); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = psy_ast::AstVisitor::visit_module(&mut typechecker, psy_ast::ModuleId(0), &mut ctx); + })); + assert!(result.is_err(), "TypeChecker::visit_module must hit unreachable!()"); +} + +#[test] +fn equality_uses_an_inherent_generic_eq_method_when_present() { + expect_exec( + "generic_eq_method", + r#" + pub struct P { + pub v: Felt, + } + + impl P { + pub fn eq(self: Self, rhs: P) -> bool { + return self.v == rhs.v; + } + } + + fn main(q: Felt) -> Felt { + let a = P { v: q }; + let b = P { v: q + 1 }; + return (a == b) as Felt; + } + "#, + ); +} + +#[test] +fn indexing_uses_an_inherent_generic_index_method_when_present() { + expect_exec( + "generic_index_method", + r#" + pub struct I { + pub v: [Felt; 4], + } + + impl I { + pub fn index(self: Self, idx: Felt) -> Felt { + return self.v[idx]; + } + } + + fn main(q: Felt) -> Felt { + let i = I { v: [1, 2, 3, 4] }; + return i[q]; + } + "#, + ); +} + +#[test] +fn compound_assignment_uses_an_inherent_generic_add_assign_method_when_present() { + expect_exec( + "generic_add_assign_method", + r#" + pub struct A { + pub v: Felt, + } + + impl A { + pub fn add_assign(mut self: Self, rhs: Felt) { + self.v = self.v + rhs; + } + } + + fn main(q: Felt) -> Felt { + let mut a = A { v: q }; + a += 1; + return a.v; + } + "#, + ); +} + +#[test] +fn assert_eq_uses_an_inherent_generic_eq_method_when_present() { + expect_exec( + "generic_eq_method_assert", + r#" + pub struct Q { + pub v: Felt, + } + + impl Q { + pub fn eq(self: Self, rhs: Q) -> bool { + return self.v == rhs.v; + } + } + + fn main(q: Felt) { + let a = Q { v: q }; + let b = Q { v: q }; + assert_eq(a, b, "custom eq"); + } + "#, + ); +} + +#[test] +fn passing_an_unknown_function_value_is_a_clean_error() { + expect_failure( + "unknown_function_value", + r#" + fn apply(f: fn(Felt, Felt) -> Felt, x: Felt, y: Felt) -> Felt { + return f(x, y); + } + + fn main(q: Felt) -> Felt { + return apply(nope, q, 1); + } + "#, + "unresolved type", + ); +} + +#[test] +fn passing_a_function_with_a_mismatched_signature_is_a_clean_error() { + expect_failure( + "wrong_arity_function_value", + r#" + fn apply(f: fn(Felt, Felt) -> Felt, x: Felt, y: Felt) -> Felt { + return f(x, y); + } + + fn add3(a: Felt, b: Felt, c: Felt) -> Felt { + return a + b + c; + } + + fn main(q: Felt) -> Felt { + return apply(add3, q, 1); + } + "#, + "type mismatch", + ); +} + +#[test] +fn lambdas_with_named_return_types_execute() { + expect_exec( + "lambda_named_return_type", + r#" + pub struct P { + pub v: Felt, + } + + fn main(q: Felt) -> Felt { + let make = |x: Felt| -> P { + return P { v: x }; + }; + return make(q).v; + } + "#, + ); +} + +#[test] +fn associated_functions_on_types_execute() { + expect_exec( + "associated_fn_call", + r#" + pub struct P { + pub v: Felt, + } + + impl P { + pub fn create(x: Felt) -> P { + return P { v: x }; + } + } + + fn main(q: Felt) -> Felt { + return P::create(q).v; + } + "#, + ); +} diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index 7222fa74a..4c1409135 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -4,7 +4,12 @@ mod control; pub mod error; mod preprocess; -use std::{collections::{HashMap, HashSet}, iter::once, path::PathBuf, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + iter::once, + path::PathBuf, + sync::Arc, +}; use error::{Error, Result}; use indexmap::IndexMap; @@ -162,11 +167,7 @@ impl, C: DPNContext + 'static> Interpreter { /// Number of leaf input values a parameter type materializes. fn input_footprint(ty: TypeId, symbols: &SymbolTable) -> u64 { - fn visit>( - ty: TypeId, - symbols: &SymbolTable, - active: &mut HashSet, - ) -> u64 { + fn visit>(ty: TypeId, symbols: &SymbolTable, active: &mut HashSet) -> u64 { // Type graphs can be recursive. Treat a cycle conservatively as // over-budget instead of imposing a nesting cutoff that lets a // deeply nested input count as zero. @@ -174,29 +175,29 @@ impl, C: DPNContext + 'static> Interpreter { return u64::MAX; } let footprint = match &symbols[ty] { - Type::Array(arr) => { - let size = match &symbols[arr.size_ty] { - Type::Const(const_node) => { - let const_value_ref = &symbols[const_node.value]; - match &*const_value_ref.borrow() { - // Felt and U32 constants carry their payload in - // the felt; ContextFelt::get_u64 reads it without - // needing the interpreter's context handle. - CheckedValue::Felt(f) | CheckedValue::U32(f) => ContextFelt::get_u64(f), - _ => 0, + Type::Array(arr) => { + let size = match &symbols[arr.size_ty] { + Type::Const(const_node) => { + let const_value_ref = &symbols[const_node.value]; + match &*const_value_ref.borrow() { + // Felt and U32 constants carry their payload in + // the felt; ContextFelt::get_u64 reads it without + // needing the interpreter's context handle. + CheckedValue::Felt(f) | CheckedValue::U32(f) => ContextFelt::get_u64(f), + _ => 0, + } } - } - _ => u64::MAX, - }; - size.saturating_mul(visit(arr.inner_ty, symbols, active)) - } - Type::Tuple(elements) => elements.iter().map(|&e| visit(e, symbols, active)).fold(0, u64::saturating_add), - Type::Struct(s) => s - .fields - .values() - .map(|field| visit(field.ty, symbols, active)) - .fold(0, u64::saturating_add), - _ => 1, + _ => u64::MAX, + }; + size.saturating_mul(visit(arr.inner_ty, symbols, active)) + } + Type::Tuple(elements) => elements.iter().map(|&e| visit(e, symbols, active)).fold(0, u64::saturating_add), + Type::Struct(s) => s + .fields + .values() + .map(|field| visit(field.ty, symbols, active)) + .fold(0, u64::saturating_add), + _ => 1, }; active.remove(&ty); footprint @@ -221,12 +222,7 @@ impl, C: DPNContext + 'static> Interpreter { /// Create a fresh entry-point input after charging its complete type /// footprint. Internal function calls pass existing CheckedValueRefs and /// must not use this path. - fn materialize_input( - &mut self, - ty: TypeId, - symbols: &SymbolTable, - location: Option, - ) -> Result> { + fn materialize_input(&mut self, ty: TypeId, symbols: &SymbolTable, location: Option) -> Result> { self.charge_materialized(Self::input_footprint(ty, symbols), location)?; Ok(CheckedValueRef::new_rc(self.to_input(ty, symbols))) } @@ -870,8 +866,8 @@ impl, C: DPNContext + 'static> Interpreter { let rhs = rhs_value.to_value(); let both_constant = self.is_constant(lhs.clone()) && self.is_constant(rhs.clone()); - let constants_differ = both_constant - && self.context.get_constant_value(lhs.clone()) != self.context.get_constant_value(rhs.clone()); + let constants_differ = + both_constant && self.context.get_constant_value(lhs.clone()) != self.context.get_constant_value(rhs.clone()); if constants_differ && self.current_branch_definitely_executes() { return Err(Error::AssertionFailure { message: message.clone().unwrap_or_default(), @@ -1005,10 +1001,11 @@ impl, C: DPNContext + 'static> Interpreter { // operands. The VM's constant-folding path panics (assert!/integer div) // on these; surface them as clean compile/runtime errors instead. match (&*lhs_value.borrow(), &*rhs_value.borrow(), binary_node.operator) { - (CheckedValue::Felt(l), CheckedValue::Felt(r), Div | Mod) - | (CheckedValue::U32(l), CheckedValue::U32(r), Div | Mod) => { + (CheckedValue::Felt(l), CheckedValue::Felt(r), Div | Mod) | (CheckedValue::U32(l), CheckedValue::U32(r), Div | Mod) => { if self.is_constant(*l) && self.is_constant(*r) && self.context.get_constant_value(*r) == 0 { - return Err(Error::DivisionByZero { location: Some(binary_node.location) }); + return Err(Error::DivisionByZero { + location: Some(binary_node.location), + }); } } (CheckedValue::U32(l), CheckedValue::U32(r), Add | Sub | Mul | Pow) => { @@ -1027,7 +1024,9 @@ impl, C: DPNContext + 'static> Interpreter { _ => false, }; if overflow { - return Err(Error::ArithmeticOverflow { location: Some(binary_node.location) }); + return Err(Error::ArithmeticOverflow { + location: Some(binary_node.location), + }); } } } @@ -2453,7 +2452,6 @@ fn main() {} let _ = fs::remove_file(path); } - #[test] #[serial] fn test_fake_inline_std_does_not_authorize_same_file_sibling() { @@ -2737,10 +2735,7 @@ fn main() -> Felt { (Err(_), true) => panic!("expected `{name}` to typecheck: {:#}", result.err().unwrap()), (Err(err), false) => format!("{err:#}"), }; - assert!( - err_msg.contains(expected_fragment), - "unexpected error for `{name}`: {err_msg}" - ); + assert!(err_msg.contains(expected_fragment), "unexpected error for `{name}`: {err_msg}"); let _ = fs::remove_file(path); } @@ -2812,10 +2807,7 @@ fn main() -> Felt { (Err(_), true) => panic!("expected `{name}` to typecheck: {:#}", result.err().unwrap()), (Err(err), false) => format!("{err:#}"), }; - assert!( - err_msg.contains(expected_fragment), - "unexpected error for `{name}`: {err_msg}" - ); + assert!(err_msg.contains(expected_fragment), "unexpected error for `{name}`: {err_msg}"); let _ = fs::remove_file(path); } @@ -2828,8 +2820,14 @@ fn main() -> Felt { #[serial] fn test_public_intrinsic_type_mismatch_arms() { let cases = [ - ("hash_two_to_one_bad_first", r#"fn main() { let h = hash_two_to_one(true, [1, 2, 3, 4]); }"#), - ("hash_two_to_one_bad_second", r#"fn main() { let h = hash_two_to_one([1, 2, 3, 4], true); }"#), + ( + "hash_two_to_one_bad_first", + r#"fn main() { let h = hash_two_to_one(true, [1, 2, 3, 4]); }"#, + ), + ( + "hash_two_to_one_bad_second", + r#"fn main() { let h = hash_two_to_one([1, 2, 3, 4], true); }"#, + ), ("split_bits_non_const_length", r#"fn main(x: Felt) { let v = split_bits(15, x); }"#), ]; diff --git a/psy-interpreter/src/preprocess.rs b/psy-interpreter/src/preprocess.rs index 5dc11cccf..1a06aec86 100644 --- a/psy-interpreter/src/preprocess.rs +++ b/psy-interpreter/src/preprocess.rs @@ -123,7 +123,6 @@ impl<'a> StorageProcessor<'a> { } } - fn generate_storage_impl, C, V: VisitorContext, Stmt = StmtNode, Definition = DefinitionNode>>( &self, struct_node: &StructNode, @@ -480,7 +479,10 @@ impl<'a> StorageProcessor<'a> { UncheckedType::Basic(ident) => { let type_name = ctx.ident(ident.id).0.to_string(); let base_name = type_name.strip_suffix("Ref").expect("generated ref type must end with Ref"); - (UncheckedType::Basic(Identifier::new(ctx.intern(base_name), attr.location)), ConstValue::Felt(1)) + ( + UncheckedType::Basic(Identifier::new(ctx.intern(base_name), attr.location)), + ConstValue::Felt(1), + ) } _ => panic!("#[ref] attribute only supported on basic struct types"), } @@ -515,9 +517,7 @@ impl<'a> StorageProcessor<'a> { } // A one-element array still has a valid index (zero). - if !is_ref_struct - && matches!(&field.ty, UncheckedType::Array(_, array_size, _) if array_size.as_u64().unwrap_or(0) > 0) - { + if !is_ref_struct && matches!(&field.ty, UncheckedType::Array(_, array_size, _) if array_size.as_u64().unwrap_or(0) > 0) { methods.push(self.generate_getter_at(attr, &field_name.id, &field.ty, offset, ctx)); methods.push(self.generate_setter_at(attr, &field_name.id, &field.ty, offset, ctx)); } @@ -869,9 +869,7 @@ impl<'a> StorageProcessor<'a> { match &field.ty { UncheckedType::Basic(ident) => { let type_name = ctx.ident(ident.id).0.to_string(); - let base_name = type_name - .strip_suffix("Ref") - .expect("#[ref] field type must use its generated Ref type"); + let base_name = type_name.strip_suffix("Ref").expect("#[ref] field type must use its generated Ref type"); UncheckedType::Basic(Identifier::new(ctx.intern(base_name), attr.location)) } _ => panic!("#[ref] attribute only supported on basic struct types"), @@ -981,19 +979,19 @@ impl<'a> StorageProcessor<'a> { let mut offset = offset_expr; for (field_name, field) in &struct_node.fields { let inner_ty = match &field.ty { - UncheckedType::Generic(ident, params, _) if ident.id == ctx.intern("StorageRef") => { - if params.len() != 1 { - panic!("StorageRef must have exactly one generic parameter"); - } - params[0].clone() + UncheckedType::Generic(ident, params, _) if ident.id == ctx.intern("StorageRef") => { + if params.len() != 1 { + panic!("StorageRef must have exactly one generic parameter"); } - UncheckedType::Generic(ident, params, _) if ident.id == ctx.intern("ArrayRef") => { - if params.len() != 2 { - panic!("ArrayRef must have exactly two generic parameters"); - } - params[0].clone() + params[0].clone() + } + UncheckedType::Generic(ident, params, _) if ident.id == ctx.intern("ArrayRef") => { + if params.len() != 2 { + panic!("ArrayRef must have exactly two generic parameters"); } - _ => field.ty.clone(), + params[0].clone() + } + _ => field.ty.clone(), }; let (_key, value) = self.generate_field_read(attr, &field_name.id, &inner_ty, offset, csth_expr, user_id_expr, contract_id_expr, ctx); field_reads.insert(field_name.clone(), value); @@ -1093,19 +1091,19 @@ impl<'a> StorageProcessor<'a> { let mut offset = offset_expr; for (field_name, field) in &struct_node.fields { let inner_ty = match &field.ty { - UncheckedType::Generic(ident, params, _) if ident.id == ctx.intern("StorageRef") => { - if params.len() != 1 { - panic!("StorageRef must have exactly one generic parameter"); - } - params[0].clone() + UncheckedType::Generic(ident, params, _) if ident.id == ctx.intern("StorageRef") => { + if params.len() != 1 { + panic!("StorageRef must have exactly one generic parameter"); } - UncheckedType::Generic(ident, params, _) if ident.id == ctx.intern("ArrayRef") => { - if params.len() != 2 { - panic!("ArrayRef must have exactly two generic parameters"); - } - params[0].clone() + params[0].clone() + } + UncheckedType::Generic(ident, params, _) if ident.id == ctx.intern("ArrayRef") => { + if params.len() != 2 { + panic!("ArrayRef must have exactly two generic parameters"); } - _ => field.ty.clone(), + params[0].clone() + } + _ => field.ty.clone(), }; let stmt_id = self.generate_field_write(attr, &field_name.id, &inner_ty, offset, ctx); field_writes.push(stmt_id); @@ -1181,7 +1179,10 @@ impl<'a> StorageProcessor<'a> { UncheckedType::Basic(ident) => { let type_name = ctx.ident(ident.id).0.to_string(); let base_name = type_name.strip_suffix("Ref").expect("generated ref type must end with Ref"); - (UncheckedType::Basic(Identifier::new(ctx.intern(base_name), attr.location)), ConstValue::Felt(1)) + ( + UncheckedType::Basic(Identifier::new(ctx.intern(base_name), attr.location)), + ConstValue::Felt(1), + ) } _ => panic!("#[ref] attribute only supported on basic struct types"), } @@ -1370,7 +1371,10 @@ impl<'a> StorageProcessor<'a> { UncheckedType::Basic(ident) => { let type_name = ctx.ident(ident.id).0.to_string(); let base_name = type_name.strip_suffix("Ref").expect("generated ref type must end with Ref"); - (UncheckedType::Basic(Identifier::new(ctx.intern(base_name), attr.location)), ConstValue::Felt(1)) + ( + UncheckedType::Basic(Identifier::new(ctx.intern(base_name), attr.location)), + ConstValue::Felt(1), + ) } _ => panic!("#[ref] attribute only supported on basic struct types"), } @@ -3478,7 +3482,10 @@ mod tests { else_branch: None, location: loc(), })); - let tuple = ctx.alloc_expression(ExprNode::Tuple(TupleExprNode { elements: vec![], location: loc() })); + let tuple = ctx.alloc_expression(ExprNode::Tuple(TupleExprNode { + elements: vec![], + location: loc(), + })); let tuple_access = ctx.alloc_expression(ExprNode::TupleAccess(TupleAccessNode { target: felt, index: 0, @@ -3494,7 +3501,25 @@ mod tests { location: loc(), })); let parentheses = ctx.alloc_expression(ExprNode::Parentheses(felt)); - for expr_id in [path, felt, binary, unary, call, member_call, cast, index_access, member_access, intrinsic, lambda, block, if_expr, tuple, tuple_access, match_expr, parentheses] { + for expr_id in [ + path, + felt, + binary, + unary, + call, + member_call, + cast, + index_access, + member_access, + intrinsic, + lambda, + block, + if_expr, + tuple, + tuple_access, + match_expr, + parentheses, + ] { processor.visit_expr(expr_id, &mut ctx).unwrap(); } @@ -3647,7 +3672,17 @@ mod tests { })); let storage_def = ctx.alloc_definition(DefinitionNode::Struct(storage)); - for def_id in [use_def, enum_def, impl_def, trait_impl_def, trait_def, alias_def, const_def, function_def, storage_def] { + for def_id in [ + use_def, + enum_def, + impl_def, + trait_impl_def, + trait_def, + alias_def, + const_def, + function_def, + storage_def, + ] { processor.visit_definition(def_id, &mut ctx).unwrap(); } diff --git a/psy-interpreter/src/sema_edge_tests.rs b/psy-interpreter/src/sema_edge_tests.rs index 40fc64967..51a88c462 100644 --- a/psy-interpreter/src/sema_edge_tests.rs +++ b/psy-interpreter/src/sema_edge_tests.rs @@ -1581,3 +1581,105 @@ fn main() -> Felt {{ return 0; }}" } assert!(failures.is_empty(), "{}", failures.join("\n\n")); } + +#[test] +fn lambda_return_type_guard_resolves_path_types() { + let source = format!( + "{PRELUDE}{STRUCT_P} +fn main() -> Felt {{ + let lam = |a: Felt| -> P {{ return P::new(a); }}; + let p: P = lam(1); + return p.x; +}}" + ); + accepts("lambda with a path return type", &source); +} + +#[test] +fn generic_function_bodies_bare_call_free_functions() { + // A bare call inside a generic body forces the rewriter to re-resolve the + // callee by expected signature during monomorphization. + let source = format!( + "{PRELUDE} +pub fn dbl(x: Felt) -> Felt {{ + return x * 2; +}} + +pub fn apply(v: T) -> Felt {{ + return dbl(v); +}} + +fn main() -> Felt {{ + return apply(21); +}}" + ); + accepts("generic body bare-calling a free function", &source); +} + + +#[test] +fn associated_types_resolve_through_generic_impl_instantiations() { + // `Container::Ref` resolves on the instantiated `impl` + // rather than an exact-parameter impl match. + let source = format!( + "{PRELUDE} +pub struct Container {{ + pub value: T, +}} + +pub trait HasRef {{ + pub type Ref; +}} + +impl HasRef for Container {{ + pub type Ref = Container; +}} + +fn main() -> Felt {{ + let c: Container = Container {{ value: 7 }}; + let r: >::Ref = c; + return r.value; +}}" + ); + accepts("associated type through a generic impl instantiation", &source); +} + +#[test] +fn non_callable_operator_members_from_constraints_reject() { + // Constraint traits that expose an associated type named like an operator + // method surface as non-callable members in the desugared calls. + let cases: &[(&str, &str, &str)] = &[ + ( + "eq associated type", + "pub trait Bad { pub type eq; } + pub fn cmp(a: T, b: T) -> bool { return a == b; } + fn main() {}", + "unresolved member", + ), + ( + "index associated type", + "pub trait BadIdx { pub type index; } + pub fn first(t: T) -> Felt { return t[0]; } + fn main() {}", + "unresolved member", + ), + ( + "assert_eq associated type", + "pub trait BadEq { pub type eq; } + pub fn check(a: T, b: T) { assert_eq(a, b, \"cmp\"); } + fn main() {}", + "unresolved member", + ), + ( + "add_assign associated type", + "pub trait BadAdd { pub type add_assign; } + pub trait Other {} + pub fn bump(t: T, u: U) { t += u; } + fn main() {}", + "unresolved member", + ), + ]; + for (label, body, needle) in cases { + rejects(label, &format!("{PRELUDE}{body}"), needle); + } +} diff --git a/psy-lexer/src/token.rs b/psy-lexer/src/token.rs index 0e836ea35..9e4a98001 100644 --- a/psy-lexer/src/token.rs +++ b/psy-lexer/src/token.rs @@ -358,6 +358,15 @@ mod tests { use super::*; use crate::Token; + #[test] + fn token_display_matches_debug_output() { + // Format through a trait object so `Display::fmt` executes out-of-line. + let token: &dyn fmt::Display = &Token::KeywordLet; + assert_eq!(format!("{token}"), "KeywordLet"); + let token: &dyn fmt::Display = &Token::Ident("counter"); + assert_eq!(format!("{token}"), "Ident(\"counter\")"); + } + #[test] fn test_lex_from_file() -> io::Result<()> { // 1. read file content diff --git a/psy-lsp-server/src/simple.rs b/psy-lsp-server/src/simple.rs index 127b1e246..2bec5a87d 100644 --- a/psy-lsp-server/src/simple.rs +++ b/psy-lsp-server/src/simple.rs @@ -632,6 +632,49 @@ mod tests { assert_eq!(range.end.character, 1); } + #[test] + fn client_accessor_returns_the_lsp_client_handle() { + let (service, _socket) = LspService::new(QLspSimple::new); + let _client: &tower_lsp::Client = service.inner().client(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn drained_backend_runs_the_socket_draining_task() { + let service = drained_backend(); + // Give the spawned drain task a chance to poll the socket once. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + drop(service); + } + + #[test] + #[serial] + fn collect_diagnostics_sync_fails_without_a_manifest() { + let (service, _) = quiet_backend(); + let server = service.inner(); + let dir = tempfile::tempdir().expect("create temp dir"); + let root = dir.path().canonicalize().expect("canonicalize root"); + let err = match server.collect_diagnostics_sync(&root) { + Ok(_) => panic!("a manifest-less directory must fail diagnostics collection"), + Err(err) => err, + }; + assert!(format!("{err:?}").contains("manifest root"), "unexpected error: {err:?}"); + } + + #[test] + #[serial] + fn collect_diagnostics_sync_fails_on_an_unparseable_manifest() { + let (service, _) = quiet_backend(); + let server = service.inner(); + let dir = tempfile::tempdir().expect("create temp dir"); + std::fs::write(dir.path().join("Dargo.toml"), "[package\nthis is not toml").expect("write broken manifest"); + let root = dir.path().canonicalize().expect("canonicalize root"); + let err = match server.collect_diagnostics_sync(&root) { + Ok(_) => panic!("a broken manifest must fail diagnostics collection"), + Err(err) => err, + }; + assert!(format!("{err:?}").contains("resolve workspace"), "unexpected error: {err:?}"); + } + /// Builds a backend without spawning the client-socket drain task. Only /// suitable for tests that never trigger client notifications. fn quiet_backend() -> (LspService, ()) { @@ -1318,4 +1361,35 @@ fn main(q: Felt) -> Felt { drop(dir); } + + #[tokio::test] + #[serial] + async fn non_file_format_uris_are_errors() { + let service = drained_backend(); + let server = service.inner(); + let (dir, root, file) = write_psy_workspace(VALID_MAIN); + server.set_crate_path_graph_cache(root.clone(), entry_graph(&file)); + server.collect_diagnostics_sync(&root).expect("initial diagnostics"); + assert!(server.is_ready()); + + // A non-file URI cannot be formatted because it has no path. + let https = Url::parse("https://example.com/main.psy").unwrap(); + assert!(server + .formatting(DocumentFormattingParams { + text_document: text_document(&https), + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams { work_done_token: None }, + }) + .await + .is_err()); + + drop(dir); + } + + #[test] + #[should_panic(expected = "missing")] + fn position_at_panics_on_missing_line_or_needle() { + let _ = position_at(VALID_MAIN, 0, 'Z'); + let _ = position_at(VALID_MAIN, 99, 'q'); + } } diff --git a/psy-package/Cargo.toml b/psy-package/Cargo.toml index 120b6fd84..6b9e885d9 100644 --- a/psy-package/Cargo.toml +++ b/psy-package/Cargo.toml @@ -13,3 +13,6 @@ dirs = { workspace = true } semver = { workspace = true } tempfile = { workspace = true } md5 = { workspace = true } + +[dev-dependencies] +serial_test = { workspace = true } diff --git a/psy-package/src/git.rs b/psy-package/src/git.rs index 847910f86..1ded7daaa 100644 --- a/psy-package/src/git.rs +++ b/psy-package/src/git.rs @@ -75,7 +75,36 @@ mod tests { use url::Url; - use super::{dargo_crates, git_dep_location, git_dep_location_from_url, resolve_folder_name}; + use super::{clone_git_repo, dargo_crates, git_dep_location, git_dep_location_from_url, resolve_folder_name}; + + #[test] + #[serial_test::serial] + fn clone_git_repo_reuses_existing_checkouts_and_reports_spawn_failures() { + let saved_home = std::env::var("HOME").ok(); + let saved_path = std::env::var("PATH").ok(); + let temp = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var("HOME", temp.path()) }; + // With an empty PATH git cannot even spawn, so any Ok below proves the + // pre-existing directory short-circuits the clone. + unsafe { std::env::set_var("PATH", "") }; + + let url = "https://github.com/PsyProtocol/psy-bigint.git"; + let loc = git_dep_location_from_url(url, "v0.1.17"); + std::fs::create_dir_all(&loc).unwrap(); + assert_eq!(clone_git_repo(url, "v0.1.17"), Ok(loc)); + + let result = clone_git_repo("https://github.com/PsyProtocol/definitely-missing.git", "v9.9.9"); + assert!( + result.as_deref().err().is_some_and(|msg| msg.contains("Failed to run git")), + "missing checkout with empty PATH must report the spawn failure: {result:?}" + ); + + unsafe { std::env::set_var("PATH", saved_path.unwrap_or_default()) }; + match saved_home { + Some(home) => unsafe { std::env::set_var("HOME", home) }, + None => unsafe { std::env::remove_var("HOME") }, + } + } #[test] fn test_resolve_folder_name() { diff --git a/psy-package/src/lib.rs b/psy-package/src/lib.rs index 94be59937..6616c6193 100644 --- a/psy-package/src/lib.rs +++ b/psy-package/src/lib.rs @@ -559,3 +559,20 @@ mod dependency_cycle_tests { )); } } + +#[cfg(test)] +mod read_toml_tests { + use std::path::Path; + + use super::{read_toml, ManifestError}; + + #[test] + fn read_toml_reports_missing_files() { + let missing = Path::new("/nonexistent-psy-package-probe/Dargo.toml"); + let err = match read_toml(missing) { + Ok(_) => panic!("a missing manifest must fail to load"), + Err(err) => err, + }; + assert!(matches!(err, ManifestError::ReadFailed(_)), "unexpected error: {err:?}"); + } +} diff --git a/psy-package/src/source.rs b/psy-package/src/source.rs index f2414b01e..abeb63aef 100644 --- a/psy-package/src/source.rs +++ b/psy-package/src/source.rs @@ -419,6 +419,11 @@ mod tests { assert_eq!(path.as_str(), "src/main.psy"); } + #[test] + fn relative_file_path_from_str_normalizes() { + assert_eq!(RelativeFilePath::from("./src/main.psy").as_str(), "src/main.psy"); + } + #[test] fn source_map_resolves_relative_virtual_paths() { let mut map = SourceMap::new(); diff --git a/psy-parser/tests/syntax_migration.rs b/psy-parser/tests/syntax_migration.rs index b4a49cadd..fc8871eeb 100644 --- a/psy-parser/tests/syntax_migration.rs +++ b/psy-parser/tests/syntax_migration.rs @@ -354,3 +354,10 @@ fn moderate_paren_nesting_still_parses() { let src = format!("fn f() {{ let x = {}1{}; }}", "(".repeat(depth), ")".repeat(depth)); parse_module(&src).expect("50-deep parens must still parse"); } + +#[test] +fn lexical_errors_surface_through_parse_module() { + // ` is not a token in the language, so lexing fails before parsing. + let err = parse_module("fn main() { let `x` = 1; }").expect_err("invalid tokens must be rejected"); + assert!(matches!(err, psy_parser::Error::LexicalError { .. }), "unexpected error: {err:?}"); +} diff --git a/psy-sema/src/context.rs b/psy-sema/src/context.rs index 9c71510f2..27a242b9d 100644 --- a/psy-sema/src/context.rs +++ b/psy-sema/src/context.rs @@ -387,4 +387,47 @@ mod tests { let tuple = context.symbols.create_type(Type::Tuple(vec![felt, felt])).unwrap(); let _ = context.size_of(tuple); } + + // The mutation hooks of the type-checker context are deliberately + // unimplemented; pin that behavior down with panic tests. Calling through + // function pointers keeps the tiny bodies from being inlined away. + #[test] + #[should_panic(expected = "not implemented")] + fn insert_definition_is_unimplemented() { + let insert: fn(&mut Ctx, DefinitionNode, psy_ast::InsertPosition) = VisitorContext::insert_definition; + let mut context = ctx(); + insert(&mut context, use_definition(), psy_ast::InsertPosition::End); + } + + #[test] + #[should_panic(expected = "not implemented")] + fn alloc_expression_is_unimplemented() { + let alloc: fn(&mut Ctx, ExprNode) -> ExprId = VisitorContext::alloc_expression; + let mut context = ctx(); + let _ = alloc(&mut context, felt_value()); + } + + #[test] + #[should_panic(expected = "not implemented")] + fn alloc_statement_is_unimplemented() { + let alloc: fn(&mut Ctx, StmtNode) -> StmtId = VisitorContext::alloc_statement; + let mut context = ctx(); + let _ = alloc(&mut context, StmtNode::Expression(ExprId(0))); + } + + #[test] + #[should_panic(expected = "not implemented")] + fn replace_definition_is_unimplemented() { + let replace: fn(&mut Ctx, DefId, DefinitionNode) = VisitorContext::replace_definition; + let mut context = ctx(); + replace(&mut context, DefId(0), use_definition()); + } + + #[test] + #[should_panic(expected = "not implemented")] + fn replace_statement_is_unimplemented() { + let replace: fn(&mut Ctx, StmtId, StmtNode) = VisitorContext::replace_statement; + let mut context = ctx(); + replace(&mut context, StmtId(0), StmtNode::Expression(ExprId(0))); + } } diff --git a/psy-sema/src/expr/if_expr.rs b/psy-sema/src/expr/if_expr.rs index 2655c064e..ad51f4554 100644 --- a/psy-sema/src/expr/if_expr.rs +++ b/psy-sema/src/expr/if_expr.rs @@ -29,3 +29,19 @@ impl NodeInfo for CheckedIfExprNode { NodeType::IfExpr } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_case_new_stores_all_fields() { + // A function pointer keeps the tiny constructor from being inlined + // into the caller so its own body executes out-of-line. + let new_fn: fn(ExprId, TypeId, ExprId) -> CheckedCase = CheckedCase::new; + let case = new_fn(ExprId(1), TypeId(2), ExprId(3)); + assert_eq!(case.predicate, ExprId(1)); + assert_eq!(case.type_id, TypeId(2)); + assert_eq!(case.body, ExprId(3)); + } +} diff --git a/psy-sema/src/stmt/mod.rs b/psy-sema/src/stmt/mod.rs index 2cd290a9d..767a83000 100644 --- a/psy-sema/src/stmt/mod.rs +++ b/psy-sema/src/stmt/mod.rs @@ -193,4 +193,42 @@ mod tests { assert!(other.as_expression().is_none()); assert!(other.as_definition().is_none()); } + + #[test] + fn checked_statements_expose_handles_through_trait_objects() { + // Dispatch through `dyn NodeInfo` so the small accessors execute + // out-of-line instead of being inlined into the caller. + let expression: CheckedStmtNode = ExprId(7).into(); + let info: &dyn NodeInfo = &expression; + assert_eq!(info.as_expression(), Some(ExprId(7))); + assert!(info.as_definition().is_none()); + + let definition: CheckedStmtNode = DefId(3).into(); + let info: &dyn NodeInfo = &definition; + assert_eq!(info.as_definition(), Some(DefId(3))); + assert!(info.as_expression().is_none()); + } + + #[test] + #[should_panic(expected = "not yet implemented")] + fn checked_statement_from_expression_node_is_unimplemented() { + let convert: fn(CheckedExprNode) -> CheckedStmtNode = CheckedStmtNode::from; + let _ = convert(CheckedExprNode::Value(crate::CheckedValueNode::Bool( + psy_vm::dpn::ops::sym_felt::SymFeltRef(1), + Location::default(), + ))); + } + + #[test] + #[should_panic(expected = "not yet implemented")] + fn checked_statement_from_definition_node_is_unimplemented() { + let convert: fn(CheckedDefinitionNode) -> CheckedStmtNode = CheckedStmtNode::from; + let _ = convert(CheckedDefinitionNode::Const(crate::CheckedConstNode { + name: None, + ty: TypeId(0), + value: crate::ConstId(0), + visibility: psy_ast::Visibility::Private, + scope_id: ScopeId(0), + })); + } } diff --git a/psy-sema/src/symbol_table.rs b/psy-sema/src/symbol_table.rs index 9e1c06164..52a2a0e20 100644 --- a/psy-sema/src/symbol_table.rs +++ b/psy-sema/src/symbol_table.rs @@ -578,6 +578,27 @@ mod tests { use crate::{CheckedArrayNode, CheckedConstNode, CheckedStructNode}; use psy_vm::dpn::ops::sym_felt::SymFeltRef; + #[test] + fn primitive_scope_id_lifecycle() { + // Function pointers keep these tiny accessors from being inlined into + // the caller so their own bodies execute out-of-line. + let new_fn: fn() -> PrimitiveScopeId = PrimitiveScopeId::new; + let id = new_fn(); + assert_eq!(id.get(), None); + + id.set(ScopeId::root()).unwrap(); + assert_eq!(id.get(), Some(ScopeId::root())); + assert_eq!(id.set(ScopeId(7)), Err(ScopeId(7))); + + let take_fn: fn(&PrimitiveScopeId) -> Option = PrimitiveScopeId::take; + assert_eq!(take_fn(&id), Some(ScopeId::root())); + assert_eq!(take_fn(&id), None); + assert_eq!(id.get(), None); + + let default_fn: fn() -> PrimitiveScopeId = PrimitiveScopeId::default; + assert_eq!(default_fn().get(), None); + } + #[test] fn frame_scopes_shadow_and_restore_values() { let root = ScopeId(0); diff --git a/psy-sema/src/value.rs b/psy-sema/src/value.rs index 20171252c..bd5621ea1 100644 --- a/psy-sema/src/value.rs +++ b/psy-sema/src/value.rs @@ -496,6 +496,15 @@ mod tests { CheckedValueRef::from_bool(if value { ctx.op_true() } else { ctx.op_false() }) } + #[test] + #[should_panic(expected = "not yet implemented")] + fn checked_value_ref_from_felts_is_unimplemented() { + // A function pointer keeps the stub from being inlined into the caller. + let from_felts: fn(&[SymFeltRef]) -> CheckedValueRef = + as ToFelts>::from_felts; + let _ = from_felts(&[]); + } + #[test] fn scalar_conversions_round_trip_through_their_typed_accessors() { let felt = felt_value(7); diff --git a/psy-sema/src/visualizer.rs b/psy-sema/src/visualizer.rs index d14d0dabe..1fed704b9 100644 --- a/psy-sema/src/visualizer.rs +++ b/psy-sema/src/visualizer.rs @@ -782,4 +782,30 @@ mod tests { formatter.write("x"); assert_eq!(formatter.finish(), " x"); } + + #[test] + fn debug_expr_renders_path_segments() { + use psy_ast::{ExprNode, Identifier, Location, PathNode, Program, UncheckedType}; + use psy_vm::dpn::ops::{exec_context::QExecContext, sym_felt::SymFeltRef}; + + use crate::{AstVisualizer, TypeCheckerVisitorContext}; + + let mut program = Program::::new(); + let location = Location::default(); + let segment = UncheckedType::Basic(Identifier::new(program.interner.intern_ident("foo"), location)); + let target = UncheckedType::Basic(Identifier::new(program.interner.intern_ident("bar"), location)); + let path = PathNode { + root: None, + segments: vec![segment], + target, + is_ty: false, + location, + }; + let expr_id = program.exprs.alloc_item(ExprNode::Path(path)); + + let ctx = TypeCheckerVisitorContext::::new(program); + let rendered = AstVisualizer::debug_expr(&ctx, expr_id); + assert!(rendered.contains("Segments"), "{rendered}"); + assert!(rendered.contains("Root"), "{rendered}"); + } } diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index 27e6af4e1..294a0237f 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -3029,4 +3029,125 @@ mod tests { "deploy without a compile must be rejected: {result}" ); } + + #[test] + fn serialize_result_adds_backward_compatible_aliases() { + let result = serialize_result(JsCompileResult { + success: true, + error: None, + error_offset: None, + entry_path: Some("main.psy".to_string()), + compile_results: Some(serde_json::json!([{ "name": "main" }])), + contract_code: Some(serde_json::json!({ "state_tree_height": 12 })), + abi: None, + }); + let value: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(value["circuit_definitions"], serde_json::json!([{ "name": "main" }])); + assert_eq!(value["circuitDefinitions"], serde_json::json!([{ "name": "main" }])); + assert_eq!(value["method_count"], 1); + assert_eq!(value["methodCount"], 1); + assert_eq!(value["state_tree_height"], 12); + assert_eq!(value["stateTreeHeight"], 12); + } + + #[test] + #[serial] + fn call_contract_reports_unknown_callers_and_runtime_failures() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + let compiled = parse_result(&compile_source( + r#" + #[contract] + #[derive(Storage)] + pub struct GuardContract { + pub value: Felt, + } + + #[contract::write_method] + pub fn set_if_zero(value: Felt) { + assert(value == 0, "value must be zero"); + let c = GuardContractRef::new(ContractMetadata::current()); + c.value = value; + } + "#, + )); + assert!(compiled.success, "fixture must compile, got {:?}", compiled.error); + + let alice: serde_json::Value = serde_json::from_str(&create_account("Alice")).unwrap(); + let alice_id = alice["user_id"].as_u64().unwrap(); + let result: serde_json::Value = serde_json::from_str(&deploy_contract(alice_id)).unwrap(); + assert_eq!(result["success"], true, "{result}"); + let contract_id = result["contract_id"].as_u64().unwrap(); + + // A caller without an account falls back to a synthesized name and the + // call still succeeds. + let result: serde_json::Value = + serde_json::from_str(&call_contract(999, contract_id, "set_if_zero", "[0]")).unwrap(); + assert_eq!(result["success"], true, "zero input must pass the guard: {result}"); + + // A runtime assertion failure records a failure message instead of an error. + let result: serde_json::Value = + serde_json::from_str(&call_contract(alice_id, contract_id, "set_if_zero", "[5]")).unwrap(); + assert_eq!(result["success"], false, "guard must reject five: {result}"); + assert!( + result["failure_message"].as_str().is_some_and(|msg| msg.contains("value must be zero")), + "runtime failure must carry the assert message: {result}" + ); + + let log: serde_json::Value = serde_json::from_str(&get_transaction_log()).unwrap(); + let log = log.as_array().expect("transaction log must be an array"); + assert_eq!(log.len(), 2, "expected one record per call: {log:?}"); + assert_eq!(log[0]["caller_name"].as_str(), Some("User 999")); + assert_eq!(log[0]["success"], true); + assert!( + log[1]["failure_message"] + .as_str() + .is_some_and(|msg| msg.contains("value must be zero")), + "log must record the runtime failure: {log:?}" + ); + assert_eq!(log[1]["success"], false); + } + + #[test] + #[serial] + fn read_imt_state_lists_entries_written_by_contract_calls() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + let compiled = parse_result(&compile_source( + r#" + #[contract] + #[derive(Storage)] + pub struct RegistryContract { + pub padding: Felt, + } + + #[contract::write_method] + pub fn register(key: Felt) { + let k: Hash = [key, 0, 0, 0]; + let v: Hash = [key, 1, 2, 3]; + let offset: Felt = 0; + let capacity: Felt = 128; + imt_set(k, v, offset, capacity); + } + "#, + )); + assert!(compiled.success, "fixture must compile, got {:?}", compiled.error); + + let alice: serde_json::Value = serde_json::from_str(&create_account("Alice")).unwrap(); + let alice_id = alice["user_id"].as_u64().unwrap(); + let result: serde_json::Value = serde_json::from_str(&deploy_contract(alice_id)).unwrap(); + assert_eq!(result["success"], true, "{result}"); + let contract_id = result["contract_id"].as_u64().unwrap(); + + let result: serde_json::Value = + serde_json::from_str(&call_contract(alice_id, contract_id, "register", "[7001]")).unwrap(); + assert_eq!(result["success"], true, "register must execute: {result}"); + + let imt: serde_json::Value = serde_json::from_str(&read_imt_state(contract_id as u32, alice_id as u32)).unwrap(); + let entries = imt.as_array().expect("imt entries must be an array"); + assert!(!entries.is_empty(), "imt write must be observable: {imt}"); + assert_eq!(entries[0]["key"].as_array().and_then(|k| k.first()).and_then(serde_json::Value::as_u64), Some(7001)); + } } From 85b7ad91abeb2bbb6715e79d7488303dcbf0a8dd Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 7 Sep 2026 19:25:42 +0800 Subject: [PATCH 07/12] fix(interpreter): tuple entry inputs panicked on the retired primitive-scope global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `to_input`'s tuple arm still resolved the primitive scope through the process-global STD_PRIMITIVE_SCOPE_ID, which nothing in production sets since the scope id moved into SymbolTable (a95ce43a). Any entry point with a tuple-typed parameter β€” e.g. `fn main(t: (Felt, Felt))` β€” panicked with "primitive scope has not been initialized". Read the scope from the symbol table via type_scope_id(ty), the same accessor the refactor introduced for its migrated call sites, and add a regression test compiling a tuple-parameter entry point. --- psy-interpreter/src/lib.rs | 4 +++- psy-wasm/src/lib.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index 4c1409135..5834012e8 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -265,7 +265,9 @@ impl, C: DPNContext + 'static> Interpreter { result.push((element_type, value)); } - let type_id = symbols.get_type_id(Some(ScopeId::primitive()), symbols[ty].key()).unwrap(); + // Tuples live in the primitive scope; read it from the symbol + // table (the process-global fallback is never set in production). + let type_id = symbols.get_type_id(Some(symbols.type_scope_id(ty)), symbols[ty].key()).unwrap(); CheckedValue::Tuple { type_id, elements: result } } diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index 294a0237f..c98d42241 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -3109,6 +3109,22 @@ mod tests { assert_eq!(log[1]["success"], false); } + /// Regression: a tuple-typed entry parameter used to panic with + /// "primitive scope has not been initialized" because `to_input` still read + /// the retired process-global primitive scope instead of the symbol table. + #[test] + #[serial] + fn tuple_entry_inputs_compile() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + let result = parse_result(&compile_source( + "fn main(t: (Felt, Felt)) -> Felt { return t.0; }", + )); + assert!(result.success, "tuple entry input must compile: {:?}", result.error); + assert_eq!(result.method_count, Some(1)); + } + #[test] #[serial] fn read_imt_state_lists_entries_written_by_contract_calls() { @@ -3151,3 +3167,4 @@ mod tests { assert_eq!(entries[0]["key"].as_array().and_then(|k| k.first()).and_then(serde_json::Value::as_u64), Some(7001)); } } + From d81bf02c4369badbefa47575afd9177a29c8de25 Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 7 Sep 2026 19:46:25 +0800 Subject: [PATCH 08/12] fix(interpreter): replace to_input panics with UnsupportedEntryPointInput diagnostics fn-typed entry parameters (e.g. `fn main(f: fn(Felt) -> Felt)`) and non-const array-size entry parameters panicked the compiler with "Unsupported type in to_input" / "Array size must be a numeric constant". Convert to_input to return Result and thread the parameter location through so these surface as located diagnostics instead. Pre-existing on mainnet-beta; regression test added in psy-wasm. --- psy-interpreter/src/error.rs | 6 +++ psy-interpreter/src/lib.rs | 53 +++++++++++++++++--------- psy-interpreter/src/panic_fix_tests.rs | 2 +- psy-wasm/src/lib.rs | 21 +++++++++- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/psy-interpreter/src/error.rs b/psy-interpreter/src/error.rs index d17e0661a..dd9d53e25 100644 --- a/psy-interpreter/src/error.rs +++ b/psy-interpreter/src/error.rs @@ -38,6 +38,8 @@ pub enum Error { ArrayAllocationFailed { length: usize, location: Option }, #[error("UnsupportedRecursion: recursive function calls cannot be interpreted")] UnsupportedRecursion { location: Option }, + #[error("UnsupportedEntryPointInput: entry-point parameter of type `{ty}` cannot be materialized: {reason}")] + UnsupportedEntryPointInput { ty: String, reason: &'static str, location: Location }, // #[error("type mismatch")] // TypeMismatch, } @@ -578,6 +580,10 @@ pub fn lowering_interpreter_error + ContextFelt, C>(error: msg.to_string() } } + Error::UnsupportedEntryPointInput { ty, reason, location } => { + let msg = format!("Entry-point parameter of type `{ty}` cannot be materialized: {reason}."); + report_or_fallback(build_report(*location, "UnsupportedEntryPointInput", msg, &ctx.program)) + } }; anyhow::Error::from(error).context(context) diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index 5834012e8..744c442ff 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -222,9 +222,9 @@ impl, C: DPNContext + 'static> Interpreter { /// Create a fresh entry-point input after charging its complete type /// footprint. Internal function calls pass existing CheckedValueRefs and /// must not use this path. - fn materialize_input(&mut self, ty: TypeId, symbols: &SymbolTable, location: Option) -> Result> { - self.charge_materialized(Self::input_footprint(ty, symbols), location)?; - Ok(CheckedValueRef::new_rc(self.to_input(ty, symbols))) + fn materialize_input(&mut self, ty: TypeId, symbols: &SymbolTable, location: Location) -> Result> { + self.charge_materialized(Self::input_footprint(ty, symbols), Some(location))?; + Ok(CheckedValueRef::new_rc(self.to_input(ty, symbols, location)?)) } pub fn calculate_type_size(&mut self, type_id: TypeId, ctx: &TypeCheckerVisitorContext) -> usize { @@ -253,15 +253,15 @@ impl, C: DPNContext + 'static> Interpreter { } } - pub fn to_input(&mut self, ty: TypeId, symbols: &SymbolTable) -> CheckedValue { + pub fn to_input(&mut self, ty: TypeId, symbols: &SymbolTable, location: Location) -> Result> { match symbols[ty].clone() { - Type::Felt => CheckedValue::Felt(self.context.add_input()), - Type::Bool => CheckedValue::Bool(self.context.add_bool_input()), - Type::U32 => CheckedValue::U32(self.context.add_u32_input()), + Type::Felt => Ok(CheckedValue::Felt(self.context.add_input())), + Type::Bool => Ok(CheckedValue::Bool(self.context.add_bool_input())), + Type::U32 => Ok(CheckedValue::U32(self.context.add_u32_input())), Type::Tuple(elements) => { let mut result = Vec::new(); for element_type in elements { - let value = CheckedValueRef::new_rc(self.to_input(element_type, symbols)); + let value = CheckedValueRef::new_rc(self.to_input(element_type, symbols, location)?); result.push((element_type, value)); } @@ -269,15 +269,18 @@ impl, C: DPNContext + 'static> Interpreter { // table (the process-global fallback is never set in production). let type_id = symbols.get_type_id(Some(symbols.type_scope_id(ty)), symbols[ty].key()).unwrap(); - CheckedValue::Tuple { type_id, elements: result } + Ok(CheckedValue::Tuple { type_id, elements: result }) } Type::Struct(s) => { let mut result = IndexMap::new(); for (field_name, field) in &s.fields { - result.insert(field_name.clone(), CheckedValueRef::new_rc(self.to_input(field.ty.clone(), symbols))); + result.insert( + field_name.clone(), + CheckedValueRef::new_rc(self.to_input(field.ty.clone(), symbols, location)?), + ); } let type_id = symbols.get_type_id(Some(s.scope_id), symbols[ty].key()).unwrap(); - CheckedValue::Struct(type_id, result) + Ok(CheckedValue::Struct(type_id, result)) } Type::Array(arr) => { let size = match &symbols[arr.size_ty] { @@ -286,22 +289,38 @@ impl, C: DPNContext + 'static> Interpreter { match &*const_value_ref.borrow() { CheckedValue::Felt(f) => self.context.get_constant_value(f.clone()) as usize, CheckedValue::U32(f) => self.context.get_constant_value(f.clone()) as usize, - _ => panic!("Array size must be a numeric constant"), + _ => { + return Err(Error::UnsupportedEntryPointInput { + ty: format!("{:?}", symbols[ty]), + reason: "array size must be a numeric constant", + location, + }) + } } } - _ => panic!("Array size must be a const type"), + _ => { + return Err(Error::UnsupportedEntryPointInput { + ty: format!("{:?}", symbols[ty]), + reason: "array size must be a constant expression", + location, + }) + } }; let mut elements = Vec::new(); for _ in 0..size { - elements.push(CheckedValueRef::new_rc(self.to_input(arr.inner_ty.clone(), symbols))); + elements.push(CheckedValueRef::new_rc(self.to_input(arr.inner_ty.clone(), symbols, location)?)); } let type_id = symbols.get_type_id(Some(arr.scope_id), symbols[ty].key()).unwrap(); - CheckedValue::Array(type_id, elements) + Ok(CheckedValue::Array(type_id, elements)) } - other => panic!("Unsupported type in to_input: {:?}", other), + other => Err(Error::UnsupportedEntryPointInput { + ty: format!("{:?}", other), + reason: "this type cannot be passed as an entry-point input", + location, + }), } } @@ -410,7 +429,7 @@ impl, C: DPNContext + 'static> Interpreter { // Bound input materialization by the type's element // footprint BEFORE building it: `main(a: [Felt; 4_000_000])` // previously allocated millions of inputs unchallenged (M4). - parameters.push(self.materialize_input(parameter.ty, &ctx.symbols, Some(node.location))?); + parameters.push(self.materialize_input(parameter.ty, &ctx.symbols, node.location)?); } let res = self.__interpret__(&typechecker.program, type_id, parameters, ctx)?; let compiled = compile_fn(&self.context, res); diff --git a/psy-interpreter/src/panic_fix_tests.rs b/psy-interpreter/src/panic_fix_tests.rs index cc3cecdea..0c59d9c36 100644 --- a/psy-interpreter/src/panic_fix_tests.rs +++ b/psy-interpreter/src/panic_fix_tests.rs @@ -147,7 +147,7 @@ fn expect_input_materialization_error(c: &mut Compiled, function_ty: TypeId, lab let parameter_ty = function.parameters[0].ty; let location = function.location; let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - c.interpreter.materialize_input(parameter_ty, &c.ctx.symbols, Some(location)) + c.interpreter.materialize_input(parameter_ty, &c.ctx.symbols, location) })); match run { Ok(Err(error)) => assert!(format!("{error:#}").contains("ArrayTooLarge"), "[{label}] unexpected error: {error:#}"), diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index c98d42241..7ec888cdf 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -3166,5 +3166,24 @@ mod tests { assert!(!entries.is_empty(), "imt write must be observable: {imt}"); assert_eq!(entries[0]["key"].as_array().and_then(|k| k.first()).and_then(serde_json::Value::as_u64), Some(7001)); } -} + /// Regression: a function-typed entry parameter used to panic the compiler + /// with "Unsupported type in to_input"; it must surface as a clean + /// diagnostic instead. + #[test] + #[serial] + fn fn_typed_entry_input_is_rejected_with_a_diagnostic() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + let result = parse_result(&compile_source( + "fn main(f: fn(Felt) -> Felt) -> Felt { return f(1); }", + )); + assert!(!result.success, "fn-typed entry input must be rejected, got error: {:?}", result.error); + assert!( + result.error.as_deref().unwrap_or_default().contains("UnsupportedEntryPointInput"), + "unexpected error: {:?}", + result.error + ); + } +} From 00b23669354d106edda5c6c98a3645d8b908c730 Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 7 Sep 2026 19:55:49 +0800 Subject: [PATCH 09/12] fix(interpreter): require provably-true branch for eager assert failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit current_branch_definitely_executes() treated any non-ConstantFalse condition β€” including symbolic ones like `if a > b` on entry inputs β€” as "definitely executes". The eager constant-assert failure then wrongly rejected satisfiable programs: fn main(a: Felt, b: Felt) -> Felt { if a > b { assert(false, "only reachable when a > b"); }; return a + b; } compilation failed with an eager assertion failure even though choosing a <= b satisfies it. Eager failure must be a positive proof: only a provably constant-true condition (constant-folding the whole enclosing condition stack) counts. A symbolic arm's assertions stay gated and satisfiable, matching the VM's op_select-gated assert semantics. Nested `if false` under a symbolic arm also stays gated (the conjunction with a symbolic condition does not fold), which is the conservative and correct choice. --- psy-interpreter/src/lib.rs | 10 ++++--- psy-wasm/src/lib.rs | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index 744c442ff..090bf5022 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -2163,13 +2163,15 @@ impl, C: DPNContext + 'static> Interpreter { /// Whether the interpreter is currently inside a branch that is statically /// known to execute. Under symbolic execution both arms of an `if` are - /// interpreted: assertions inside a `ConstantFalse` arm are gated by the - /// condition stack and never fire, so they must not be treated as compile - /// time failures. + /// interpreted, so this must be a *positive* proof: only a provably + /// constant-true condition (including the enclosing condition stack) + /// counts. A symbolic condition (e.g. `if a > b` on entry inputs) makes + /// the arm witness-dependent β€” its assertions stay gated and satisfiable, + /// so they must not be treated as compile time failures. fn current_branch_definitely_executes(&self) -> bool { let condition = self.context.get_current_condition(); let op_type = self.context.get_op_type(condition.clone()); - op_type != DPNOpType::ConstantFalse && !(op_type == DPNOpType::Constant && self.context.get_constant_value(condition) == 0) + op_type == DPNOpType::ConstantTrue || (op_type == DPNOpType::Constant && self.context.get_constant_value(condition) != 0) } } diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index 7ec888cdf..ffc77a2da 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -3186,4 +3186,64 @@ mod tests { result.error ); } + + /// Regression: assertions under a *symbolic* branch condition (entry-input + /// dependent) are witness-gated and satisfiable β€” they must compile. The + /// eager constant-assert failure used to treat "not provably false" as + /// "definitely executes" and wrongly rejected programs like + /// `if a > b { assert(false) }`. + #[test] + #[serial] + fn asserts_under_symbolic_branches_stay_gated() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + // Witness-dependent arm: satisfiable by choosing a <= b. + let result = parse_result(&compile_source( + "fn main(a: Felt, b: Felt) -> Felt {\n if a > b {\n assert(false, \"only reachable when a > b\");\n };\n return a + b;\n}\n", + )); + assert!(result.success, "symbolic-branch assert must stay gated: {:?}", result.error); + + // A provably dead arm nested under a symbolic arm cannot be proven + // dead either (the conjunction does not fold), so it stays gated too. + let result = parse_result(&compile_source( + "fn main(a: Felt, b: Felt) -> Felt {\n if a > b {\n if false {\n assert(false, \"dead arm\");\n };\n };\n return a + b;\n}\n", + )); + assert!(result.success, "nested dead-arm assert must stay gated: {:?}", result.error); + + // A constant-false top-level arm stays gated (the original intent). + let result = parse_result(&compile_source( + "fn main() -> Felt {\n if false {\n assert(false, \"constant-false arm\");\n };\n return 1;\n}\n", + )); + assert!(result.success, "constant-false arm assert must stay gated: {:?}", result.error); + } + + /// The eager failure itself must keep working where the branch IS provably + /// executed: top-level and constant-true arms fail at compile time. + #[test] + #[serial] + fn constant_asserts_in_definitely_executed_code_still_fail_eagerly() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + let result = parse_result(&compile_source( + "fn main() -> Felt {\n assert(false, \"top level must fail\");\n return 1;\n}\n", + )); + assert!(!result.success, "top-level assert(false) must fail compilation"); + assert!( + result.error.as_deref().unwrap_or_default().contains("top level must fail"), + "unexpected error: {:?}", + result.error + ); + + let result = parse_result(&compile_source( + "fn main() -> Felt {\n if 1 == 1 {\n assert(false, \"constant-true arm must fail\");\n };\n return 1;\n}\n", + )); + assert!(!result.success, "constant-true arm assert(false) must fail compilation"); + assert!( + result.error.as_deref().unwrap_or_default().contains("constant-true arm must fail"), + "unexpected error: {:?}", + result.error + ); + } } From fd1d14c4570d8dc79bea23d6884880a40351f79f Mon Sep 17 00:00:00 2001 From: logere Date: Mon, 7 Sep 2026 20:58:15 +0800 Subject: [PATCH 10/12] fix(sema): turn turbofish fn-ref and path-target panics into diagnostics --- psy-sema/src/lib.rs | 11 ++++++++++- psy-sema/src/resolver.rs | 12 +++++++++--- psy-sema/src/symbol_table.rs | 20 +++++++++++++++++++- psy-wasm/src/lib.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/psy-sema/src/lib.rs b/psy-sema/src/lib.rs index 80a2fdbf4..e75c5e30a 100644 --- a/psy-sema/src/lib.rs +++ b/psy-sema/src/lib.rs @@ -3571,7 +3571,16 @@ impl + ContextFelt, C> TypeChecker { self.substitute_all(underlying_type_id, ctx)? } - _ => unreachable!(), + // e.g. a monomorphized function reference `id::` used + // as a value: parseable, but generic arguments only apply + // to structs, arrays, and traits. + _ => { + return Err(Error::InvalidGenericArguments { + location: location.clone(), + expected: "generic arguments on a struct, array, or trait".to_string(), + found: format!("a {:?}", ctx.symbols[underlying_type_id].kind()), + }); + } } } UncheckedType::Array(inner_ty, size, location) => { diff --git a/psy-sema/src/resolver.rs b/psy-sema/src/resolver.rs index 068fe7f30..e4b364fa1 100644 --- a/psy-sema/src/resolver.rs +++ b/psy-sema/src/resolver.rs @@ -86,12 +86,15 @@ impl + ContextFelt, C> TypeChecker { path.location, )); } else { - let path_target = path.target.as_basic().unwrap(); + let path_target = path.target.basic_target().ok_or(Error::InvalidPathSegment { + location: path.location, + segment: format!("{:?}", path.target), + })?; let member_ty_id = self.find_member_with_flags( impl_ty_id, Some(trait_type_id), Some(path_target.location), - path_target, + &path_target, None, is_function, ctx, @@ -108,7 +111,10 @@ impl + ContextFelt, C> TypeChecker { }; } - let path_target = path.target.as_basic().unwrap(); + let path_target = path.target.basic_target().ok_or(Error::InvalidPathSegment { + location: path.location, + segment: format!("{:?}", path.target), + })?; let mut root_type_id = self.typecheck(ty, ctx)?; let root_type_id_clone = root_type_id; diff --git a/psy-sema/src/symbol_table.rs b/psy-sema/src/symbol_table.rs index 52a2a0e20..4988ebc67 100644 --- a/psy-sema/src/symbol_table.rs +++ b/psy-sema/src/symbol_table.rs @@ -51,7 +51,9 @@ impl Default for PrimitiveScopeId { } } -// Compatibility export for downstream users. Compiler state is now stored in SymbolTable. +// Compatibility export for downstream users. Compiler state is stored in +// SymbolTable, but this is initialized as well while Type::scope_id() remains +// part of the public API. pub static STD_PRIMITIVE_SCOPE_ID: PrimitiveScopeId = PrimitiveScopeId::new(); impl ScopeId { @@ -314,6 +316,10 @@ impl + ContextFelt> SymbolTable { pub fn set_primitive_scope_id(&mut self, scope_id: ScopeId) { self.primitive_scope_id = Some(scope_id); + // Type::scope_id() cannot consult a SymbolTable, so keep its legacy + // process-wide backing value initialized until that API is migrated. + // The table-local value remains authoritative for compiler internals. + let _ = STD_PRIMITIVE_SCOPE_ID.set(scope_id); } pub fn primitive_scope_id(&self) -> ScopeId { @@ -727,6 +733,18 @@ mod tests { assert_eq!(second.type_scope_id(second_felt), ScopeId(17)); } + #[test] + fn setting_primitive_scope_keeps_public_type_accessor_initialized() { + let mut table = SymbolTable::::new(); + table.set_primitive_scope_id(ScopeId(3)); + + assert!(STD_PRIMITIVE_SCOPE_ID.get().is_some()); + assert_eq!(Type::Felt.scope_id(), STD_PRIMITIVE_SCOPE_ID.get().unwrap()); + assert_eq!(Type::Bool.scope_id(), STD_PRIMITIVE_SCOPE_ID.get().unwrap()); + assert_eq!(Type::U32.scope_id(), STD_PRIMITIVE_SCOPE_ID.get().unwrap()); + assert_eq!(Type::Tuple(vec![]).scope_id(), STD_PRIMITIVE_SCOPE_ID.get().unwrap()); + } + #[test] #[should_panic(expected = "primitive scope has not been initialized")] fn primitive_scope_requires_initialization() { diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index ffc77a2da..cb3faf5b6 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -3246,4 +3246,35 @@ mod tests { result.error ); } + + /// Regression: monomorphized function references (`id::` and + /// `mod::g::` used as values) used to panic the compiler with + /// `unreachable!()` in sema's generic-type arm; they must surface as a + /// clean diagnostic instead. + #[test] + #[serial] + fn turbofish_function_references_are_rejected_with_a_diagnostic() { + init_chain(); + *LAST_COMPILE.lock().unwrap() = None; + + let result = parse_result(&compile_source( + "fn id(x: Felt) -> Felt { return x; }\nfn main() -> Felt {\n let f = id::;\n return f(1);\n}\n", + )); + assert!(!result.success, "fn-ref turbofish must be rejected, not panic"); + assert!( + result.error.as_deref().unwrap_or_default().contains("generic arguments"), + "unexpected error: {:?}", + result.error + ); + + let result = parse_result(&compile_source( + "pub mod m { pub fn g(x: T) -> Felt { return 1; } }\nfn main() -> Felt {\n let v = m::g::;\n return v(2);\n}\n", + )); + assert!(!result.success, "mod fn-ref turbofish must be rejected, not panic"); + assert!( + result.error.as_deref().unwrap_or_default().contains("generic arguments"), + "unexpected error: {:?}", + result.error + ); + } } From 58b1b51dd3e1eef74f12652e075becaac8f775ae Mon Sep 17 00:00:00 2001 From: logere Date: Tue, 22 Sep 2026 16:16:01 +0800 Subject: [PATCH 11/12] test(interpreter): add random computation-graph differential suite Seeded, boundary-biased DAGs over u32/Felt/bool (arithmetic, bitwise, shifts, casts, select) are printed as psy programs and must match a native Rust mirror exactly, including error classes (overflow, div-by-zero, invalid cast). A crypto companion suite differentials hash / hash_two_to_one / keccak256 / secp256k1_verify / split_bits / sum_bits against plonky2, tiny-keccak and k256 mirrors, pins the Constant* op routing and VM const-fold regressions, and adds deterministic boundary pins: TargetAt element access, all-ones splits, 64-element sums and the 65-element rejection, felt wrap-around arithmetic, degenerate secp keys. Expected rejection panics are silenced via a scoped panic-hook swap so a green --nocapture run does not look like a crash. Cargo deps temporarily point at the local ../psy-node checkout (fix/vm-const-fold) that carries the VM fixes under test; psy-wasm ExecutionContext literals gain the new session_proof_tree_root field. --- Cargo.lock | 62 +- Cargo.toml | 22 +- psy-interpreter/Cargo.toml | 2 + psy-interpreter/src/lib.rs | 7 +- .../src/random_graph_diff_tests.rs | 2294 +++++++++++++++++ psy-wasm/src/lib.rs | 8 + 6 files changed, 2353 insertions(+), 42 deletions(-) create mode 100644 psy-interpreter/src/random_graph_diff_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 2a56353b8..ede981e55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1266,7 +1266,7 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cf_utils" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -4186,7 +4186,7 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" [[package]] name = "kvq" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "ambassador", "anyhow", @@ -4814,7 +4814,7 @@ dependencies = [ [[package]] name = "parth_common" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -4839,7 +4839,7 @@ dependencies = [ [[package]] name = "parth_core" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "parth_crypto" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "parth_core", @@ -4933,7 +4933,7 @@ dependencies = [ [[package]] name = "pderive" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "proc-macro2", "quote", @@ -5350,7 +5350,7 @@ dependencies = [ [[package]] name = "pser" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "bincode", @@ -5409,6 +5409,7 @@ dependencies = [ "enum-as-inner", "indexmap 2.14.0", "insta", + "k256", "kvq", "plonky2", "psy-ast", @@ -5424,6 +5425,7 @@ dependencies = [ "psy_vm", "serial_test", "thiserror 2.0.18", + "tiny-keccak", "tokio", "tracing", ] @@ -5552,7 +5554,7 @@ dependencies = [ [[package]] name = "psy_api_core" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5588,7 +5590,7 @@ dependencies = [ [[package]] name = "psy_client_common" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5619,7 +5621,7 @@ dependencies = [ [[package]] name = "psy_client_data" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5646,7 +5648,7 @@ dependencies = [ [[package]] name = "psy_common" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5673,7 +5675,7 @@ dependencies = [ [[package]] name = "psy_common_circuit" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5705,7 +5707,7 @@ dependencies = [ [[package]] name = "psy_compiler" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "bincode", @@ -5735,7 +5737,7 @@ dependencies = [ [[package]] name = "psy_config" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "indexmap 2.14.0", @@ -5750,7 +5752,7 @@ dependencies = [ [[package]] name = "psy_core" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5780,7 +5782,7 @@ dependencies = [ [[package]] name = "psy_crypto" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5811,7 +5813,7 @@ dependencies = [ [[package]] name = "psy_data" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5848,7 +5850,7 @@ dependencies = [ [[package]] name = "psy_dpn_circuit" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "alloy-primitives", "alloy-signer", @@ -5903,7 +5905,7 @@ dependencies = [ [[package]] name = "psy_dummy_prover" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5943,7 +5945,7 @@ dependencies = [ [[package]] name = "psy_io" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5957,7 +5959,7 @@ dependencies = [ [[package]] name = "psy_network_circuit" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -5975,7 +5977,7 @@ dependencies = [ [[package]] name = "psy_plonky2_basic_helpers" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -6006,7 +6008,7 @@ dependencies = [ [[package]] name = "psy_plonky2_circuits" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -6048,7 +6050,7 @@ dependencies = [ [[package]] name = "psy_plonky2_common_circuits" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -6079,7 +6081,7 @@ dependencies = [ [[package]] name = "psy_prover" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "alloy-primitives", "alloy-signer", @@ -6148,7 +6150,7 @@ dependencies = [ [[package]] name = "psy_provider" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "alloy-primitives", "alloy-signer", @@ -6204,7 +6206,7 @@ dependencies = [ [[package]] name = "psy_serialize" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "bytemuck", @@ -6215,7 +6217,7 @@ dependencies = [ [[package]] name = "psy_ups_circuit" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "alloy-primitives", "alloy-signer", @@ -6269,7 +6271,7 @@ dependencies = [ [[package]] name = "psy_vm" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", @@ -6296,7 +6298,7 @@ dependencies = [ [[package]] name = "psy_worker_core" version = "0.1.0" -source = "git+https://github.com/PsyProtocol/psy-node.git?rev=769711acfe3ba23dc0124f961ff361478e52b89b#769711acfe3ba23dc0124f961ff361478e52b89b" +source = "git+https://github.com/PsyProtocol/psy-node.git?rev=7a86825f83ef298e2456089b3f40a8f9eaeb9841#7a86825f83ef298e2456089b3f40a8f9eaeb9841" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index be0e12356..85c760955 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,17 +28,17 @@ repository = "https://github.com/PsyProtocol/psy-compiler" [workspace.dependencies] zstd = { version = "0.13", features = ["zstdmt"] } # Remote psy-node deps (active for CI/publish) -psy_vm = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_vm", default-features = false } -psy_crypto = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_crypto" } -psy_data = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_client_data", default-features = false } -psy_common_core = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_client_common" } -psy_config = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_config" } -psy_prover = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_prover", default-features = false } -psy_common_circuit = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_common_circuit", default-features = false } -psy_dpn_circuit = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_dpn_circuit", default-features = false } -psy_ups_circuit = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_ups_circuit", default-features = false } -psy_network_circuit = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "psy_network_circuit", default-features = false } -kvq = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "769711acfe3ba23dc0124f961ff361478e52b89b", package = "kvq" } +psy_vm = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_vm", default-features = false } +psy_crypto = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_crypto" } +psy_data = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_client_data", default-features = false } +psy_common_core = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_client_common" } +psy_config = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_config" } +psy_prover = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_prover", default-features = false } +psy_common_circuit = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_common_circuit", default-features = false } +psy_dpn_circuit = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_dpn_circuit", default-features = false } +psy_ups_circuit = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_ups_circuit", default-features = false } +psy_network_circuit = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "psy_network_circuit", default-features = false } +kvq = { git = "https://github.com/PsyProtocol/psy-node.git", rev = "7a86825f83ef298e2456089b3f40a8f9eaeb9841", package = "kvq" } # Local deps for development (uncomment to use): # psy_vm = { path = "../psy-node/client_prover/psy_vm", default-features = false } diff --git a/psy-interpreter/Cargo.toml b/psy-interpreter/Cargo.toml index 4656fb259..65e81b4b4 100644 --- a/psy-interpreter/Cargo.toml +++ b/psy-interpreter/Cargo.toml @@ -35,3 +35,5 @@ psy_prover = { workspace = true } psy_common_circuit = { workspace = true } serial_test = { workspace = true } kvq = { workspace = true } +tiny-keccak = { workspace = true } +k256 = { workspace = true } diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index 090bf5022..19a979d65 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -685,7 +685,7 @@ impl, C: DPNContext + 'static> Interpreter { let mut formatter = Formatter::new(); formatter.visit_program(&mut default_visitor_context)?; - println!("formatted:\n{}", formatter.get_output()); + tracing::debug!("formatted:\n{}", formatter.get_output()); let mut typechecker_context = TypeCheckerVisitorContext::new(program); typechecker.visit_program(&mut typechecker_context).map_err(|err| { @@ -2944,3 +2944,8 @@ mod sema_edge_tests { mod exec_edge_tests { include!("exec_edge_tests.rs"); } + +#[cfg(test)] +mod random_graph_diff_tests { + include!("random_graph_diff_tests.rs"); +} diff --git a/psy-interpreter/src/random_graph_diff_tests.rs b/psy-interpreter/src/random_graph_diff_tests.rs new file mode 100644 index 000000000..b3ea51bcd --- /dev/null +++ b/psy-interpreter/src/random_graph_diff_tests.rs @@ -0,0 +1,2294 @@ +// Differential test: seeded random computation graphs, psy interpreter vs +// native Rust evaluation. +// +// For each seed the generator builds a DAG of `let` bindings over u32, Felt +// and bool values, prints it as a psy program, and interprets `main` with +// concrete constant arguments. Because every operand is constant-tracked, +// the interpreter either returns a folded value or fails with a clean +// error; the native Rust mirror computes the same outcome and both sides +// must agree exactly β€” same value, or same error class: +// +// u32 + - * / % ** & | ^ << >> checked arithmetic (overflow -> error), +// div/mod by zero -> error, shift +// distances >= 32 fold to 0 +// Felt + - * / % ** and unary - Goldilocks arithmetic mod p (p = 2^64 +// - 2^32 + 1): wrapping, `/` is field +// division, `%` is integer mod, div/mod +// by zero -> error +// bool == != < <= > >= && || ^ ! false < true +// cast u32 <-> Felt <-> bool out-of-range constants -> clean error +// (> 1 for bool, > u32::MAX for u32) +// +// Felt bitwise (& | ^ << >>) is deliberately NOT generated: the VM never +// constant-folds it (felt constants don't take the u32 fold path), so the +// result stays symbolic and has no value to compare. +// +// The generator is boundary-aware: literals and inputs are biased toward +// 0/1/2, powers of two, u32::MAX-adjacent and p-adjacent values so +// overflow, wrap-around and cast-range edges are systematically exercised +// instead of left to luck. Every binary node keeps at least one runtime +// operand so sema's const-item folding never interferes. +// +// Reproduce a failure: +// PSY_RANDOM_GRAPH_SEED= cargo test -p psy-interpreter random_computation_graphs_match_native_rust +// Longer fuzzing runs: +// PSY_RANDOM_GRAPH_ITERS=100000 cargo test -p psy-interpreter random_computation_graphs_match_native_rust +// +// The crypto/bit intrinsic differential (same env knobs, test +// `crypto_intrinsics_match_native_rust`) covers the DSL-reachable +// intrinsics that never appear in scalar graphs: +// +// hash / hash_two_to_one Poseidon (Goldilocks) β€” mirrored with plonky2's +// PoseidonHash, the same primitive the circuit uses +// keccak256 u32-word packing + tiny-keccak, anchored by the +// external known-answer vector in +// tests/keccak_u32_regression_test.psy +// __secp256k1_verify k256 fixtures: valid signatures must verify, +// tampered msg/sig/pk must fail +// split_bits / sum_bits strict LSB-first bit decomposition (width +// <= 64, value must fit the width) and its +// weighted binary fold; out-of-domain inputs +// are expected clean rejections +// array element access bits[i] / digest[i] on intrinsic store nodes +// (TargetAt), plus deterministic boundary +// suites: all-ones splits, 64-element sums, +// felt wrap-around arithmetic, degenerate +// secp keys +// +// Intrinsic results are store nodes (HashNoPad/TargetAt/...), not folded +// constants, so `psy_run` resolves them concretely through the VM's +// ContextEval β€” the eval path the IDE preview uses. + +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use plonky2::{ + field::{ + goldilocks_field::GoldilocksField, + types::{Field, PrimeField64}, + }, + hash::{hash_types::HashOut, poseidon::PoseidonHash}, + plonk::config::{GenericHashOut, Hasher}, +}; +use psy_vm::dpn::{ + eval::{cache::SimpleEvalCache, simple::DummyContextEvalInput, traits::ContextEval}, + ops::{ + exec_context::QExecContext, + op_types::DPNOpType, + sym_felt::SymFeltRef, + sym_felt_store::SymFeltStore, + }, +}; + +use super::*; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +const PARAM_NAMES: [&str; 4] = ["a", "b", "c", "d"]; + +/// Goldilocks prime, the Felt modulus (verified against the VM fold: +/// `(p - 1) + 2` folds to 1). +const GOLDILOCKS_P: u128 = 0xFFFF_FFFF_0000_0001; + +const SHIFT_AMOUNTS: [u32; 14] = [0, 1, 2, 3, 4, 7, 8, 15, 16, 31, 32, 63, 64, 100]; +/// 31/32 straddle the u32 pow boundary exactly (2 ** 31 fits, 2 ** 32 +/// overflows). +const POW_EXPONENTS: [u32; 7] = [0, 1, 2, 3, 4, 31, 32]; +const DIVISORS: [u32; 6] = [0, 0, 1, 1, 2, 3]; +const SMALL: [u32; 16] = [0, 1, 2, 3, 4, 5, 7, 8, 15, 16, 31, 32, 63, 64, 255, 256]; +const TINY: [u32; 10] = [0, 1, 1, 2, 2, 3, 4, 5, 7, 8]; +const EDGES: [u32; 5] = [0x7FFF_FFFF, 0x8000_0000, 0xFFFF_0000, 0xFFFF_FFFE, 0xFFFF_FFFF]; + +const FELT_TINY: [u64; 10] = [0, 1, 1, 2, 2, 3, 4, 5, 7, 8]; +const FELT_DIVISORS: [u64; 8] = [0, 0, 1, 1, 2, 3, 4, 0x1_0000_0000]; +const FELT_EDGES: [u64; 10] = [ + 0, + 1, + 2, + 0xFFFF_FFFF, + 0x1_0000_0000, + 0x1_0000_0001, + 1 << 63, + (GOLDILOCKS_P - 2) as u64, + (GOLDILOCKS_P - 1) as u64, + (GOLDILOCKS_P - 0x1_0000_0000) as u64, +]; + +// ---------- deterministic RNG ---------- + +/// xorshift64* seeded through a SplitMix64 warm-up: neighboring seeds give +/// independent streams and the all-zero fixed point is unreachable. +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + Self((z ^ (z >> 31)).max(1)) + } + + fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + fn below(&mut self, n: u64) -> u64 { + self.next_u64() % n.max(1) + } + + fn chance(&mut self, percent: u64) -> bool { + self.below(100) < percent + } + + fn boolean(&mut self) -> bool { + self.next_u64() & 1 == 0 + } + + fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T { + &items[self.below(items.len() as u64) as usize] + } +} + +// ---------- graph model ---------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum U32Op { + Add, + Sub, + Mul, + Div, + Mod, + Pow, + And, + Or, + Xor, + Shl, + Shr, +} + +impl U32Op { + fn symbol(self) -> &'static str { + match self { + U32Op::Add => "+", + U32Op::Sub => "-", + U32Op::Mul => "*", + U32Op::Div => "/", + U32Op::Mod => "%", + U32Op::Pow => "**", + U32Op::And => "&", + U32Op::Or => "|", + U32Op::Xor => "^", + U32Op::Shl => "<<", + U32Op::Shr => ">>", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FeltOp { + Add, + Sub, + Mul, + Div, + Mod, + Pow, +} + +impl FeltOp { + fn symbol(self) -> &'static str { + match self { + FeltOp::Add => "+", + FeltOp::Sub => "-", + FeltOp::Mul => "*", + FeltOp::Div => "/", + FeltOp::Mod => "%", + FeltOp::Pow => "**", + } + } +} + +#[derive(Clone, Copy)] +enum CmpOp { + Lt, + Lte, + Gt, + Gte, + Eq, + Neq, +} + +impl CmpOp { + fn symbol(self) -> &'static str { + match self { + CmpOp::Lt => "<", + CmpOp::Lte => "<=", + CmpOp::Gt => ">", + CmpOp::Gte => ">=", + CmpOp::Eq => "==", + CmpOp::Neq => "!=", + } + } +} + +const CMP_OPS: [CmpOp; 6] = [CmpOp::Lt, CmpOp::Lte, CmpOp::Gt, CmpOp::Gte, CmpOp::Eq, CmpOp::Neq]; + +/// Sema only admits equality and logical ops on bools β€” ordered +/// comparisons (`< <= > >=`) are Felt/u32-only. +#[derive(Clone, Copy)] +enum BoolOp { + Eq, + Neq, + And, + Or, + Xor, +} + +impl BoolOp { + fn symbol(self) -> &'static str { + match self { + BoolOp::Eq => "==", + BoolOp::Neq => "!=", + BoolOp::And => "&&", + BoolOp::Or => "||", + BoolOp::Xor => "^", + } + } +} + +const BOOL_OPS: [BoolOp; 5] = [BoolOp::Eq, BoolOp::Neq, BoolOp::And, BoolOp::Or, BoolOp::Xor]; + +#[derive(Clone, Copy)] +enum U32Ref { + Input(usize), + Val(usize), + Lit(u32), +} + +#[derive(Clone, Copy)] +enum FeltRef { + Input(usize), + Val(usize), + Lit(u64), +} + +#[derive(Clone, Copy)] +enum BoolRef { + Val(usize), + Lit(bool), +} + +enum U32Expr { + Ref(U32Ref), + Bin { + op: U32Op, + lhs: U32Ref, + rhs: U32Ref, + }, +} + +enum FeltExpr { + Ref(FeltRef), + Bin { + op: FeltOp, + lhs: FeltRef, + rhs: FeltRef, + }, + Neg(FeltRef), +} + +enum U32Node { + Bin { + op: U32Op, + lhs: U32Ref, + rhs: U32Ref, + }, + Select { + cond: BoolRef, + then: U32Expr, + els: U32Expr, + }, +} + +enum FeltNode { + Bin { + op: FeltOp, + lhs: FeltRef, + rhs: FeltRef, + }, + Neg(FeltRef), + Select { + cond: BoolRef, + then: FeltExpr, + els: FeltExpr, + }, +} + +enum BoolNode { + CmpU32 { + op: CmpOp, + lhs: U32Ref, + rhs: U32Ref, + }, + CmpFelt { + op: CmpOp, + lhs: FeltRef, + rhs: FeltRef, + }, + Bin { + op: BoolOp, + lhs: BoolRef, + rhs: BoolRef, + }, + Not(BoolRef), +} + +/// Casts between the three primitive types; out-of-range constants are +/// clean interpreter errors (bool wants 0/1, u32 wants <= 0xffffffff). +#[derive(Clone, Copy)] +enum CastKind { + U32ToFelt, + FeltToU32, + U32ToBool, + FeltToBool, + BoolToFelt, + BoolToU32, +} + +const CAST_KINDS: [CastKind; 6] = [ + CastKind::U32ToFelt, + CastKind::FeltToU32, + CastKind::U32ToBool, + CastKind::FeltToBool, + CastKind::BoolToFelt, + CastKind::BoolToU32, +]; + +enum Stmt { + U32(U32Node), + Felt(FeltNode), + Bool(BoolNode), + /// Cast the referenced value; the statement's own type is the target. + Cast { + kind: CastKind, + src_u32: Option, + src_felt: Option, + src_bool: Option, + }, +} + +/// A concrete numeric entry argument. +#[derive(Clone, Copy, Debug)] +enum Arg { + U32(u32), + Felt(u64), +} + +struct Graph { + seed: u64, + params: Vec, + /// (name, type) of the numeric binding each statement produces: + /// "v{i}" for u32, "f{j}" for felt, "p{k}" for bool. + stmts: Vec, + u32_count: usize, + felt_count: usize, + bool_count: usize, +} + +// ---------- generation ---------- + +fn arith_literal(rng: &mut Rng) -> u32 { + if rng.chance(55) { + *rng.pick(&TINY) + } else if rng.chance(35) { + *rng.pick(&SMALL) + } else if rng.chance(20) { + *rng.pick(&EDGES) + } else { + rng.next_u64() as u32 + } +} + +fn felt_literal(rng: &mut Rng) -> u64 { + if rng.chance(55) { + *rng.pick(&FELT_TINY) + } else if rng.chance(25) { + *rng.pick(&FELT_EDGES) + } else { + rng.next_u64() % (GOLDILOCKS_P as u64) + } +} + +fn u32_input_value(rng: &mut Rng) -> u32 { + if rng.chance(40) { + *rng.pick(&[0u32, 1, 2]) + } else if rng.chance(70) { + *rng.pick(&SMALL) + } else if rng.chance(8) { + *rng.pick(&EDGES) + } else { + rng.next_u64() as u32 + } +} + +fn felt_input_value(rng: &mut Rng) -> u64 { + if rng.chance(40) { + *rng.pick(&[0u64, 1, 2]) + } else if rng.chance(55) { + *rng.pick(&FELT_TINY) + } else if rng.chance(10) { + *rng.pick(&FELT_EDGES) + } else { + rng.next_u64() % (GOLDILOCKS_P as u64) + } +} + +fn pick_u32_op(rng: &mut Rng) -> U32Op { + const WEIGHTED: [(U32Op, u64); 11] = [ + (U32Op::Add, 17), + (U32Op::Sub, 17), + (U32Op::Mul, 10), + (U32Op::Div, 6), + (U32Op::Mod, 6), + (U32Op::Pow, 5), + (U32Op::And, 8), + (U32Op::Or, 8), + (U32Op::Xor, 8), + (U32Op::Shl, 8), + (U32Op::Shr, 7), + ]; + const TOTAL: u64 = 17 + 17 + 10 + 6 + 6 + 5 + 8 + 8 + 8 + 8 + 7; + let mut pick = rng.below(TOTAL); + for (op, weight) in WEIGHTED { + if pick < weight { + return op; + } + pick -= weight; + } + unreachable!() +} + +fn pick_felt_op(rng: &mut Rng) -> FeltOp { + const WEIGHTED: [(FeltOp, u64); 6] = [ + (FeltOp::Add, 18), + (FeltOp::Sub, 18), + (FeltOp::Mul, 16), + (FeltOp::Div, 8), + (FeltOp::Mod, 8), + (FeltOp::Pow, 6), + ]; + const TOTAL: u64 = 18 + 18 + 16 + 8 + 8 + 6; + let mut pick = rng.below(TOTAL); + for (op, weight) in WEIGHTED { + if pick < weight { + return op; + } + pick -= weight; + } + unreachable!() +} + +/// A runtime u32 operand: an input or an earlier binding. Bindings are +/// biased toward recent ones so the graph chains instead of degenerating +/// into independent leaves. +fn u32_ref_only(rng: &mut Rng, u32_count: usize, u32_params: usize) -> U32Ref { + let total = u32_count + u32_params; + let idx = if total <= 5 || rng.chance(55) { + rng.below(total as u64) as usize + } else { + total - 1 - rng.below(5) as usize + }; + if idx < u32_params { + U32Ref::Input(idx) + } else { + U32Ref::Val(idx - u32_params) + } +} + +fn felt_ref_only(rng: &mut Rng, felt_count: usize, felt_params: usize) -> FeltRef { + let total = felt_count + felt_params; + if total == 0 { + return FeltRef::Lit(felt_literal(rng)); + } + let idx = if total <= 5 || rng.chance(55) { + rng.below(total as u64) as usize + } else { + total - 1 - rng.below(5) as usize + }; + if idx < felt_params { + FeltRef::Input(idx) + } else { + FeltRef::Val(idx - felt_params) + } +} + +fn u32_operand(rng: &mut Rng, u32_count: usize, u32_params: usize) -> U32Ref { + if u32_count + u32_params == 0 || rng.chance(45) { + U32Ref::Lit(arith_literal(rng)) + } else { + u32_ref_only(rng, u32_count, u32_params) + } +} + +fn felt_operand(rng: &mut Rng, felt_count: usize, felt_params: usize) -> FeltRef { + if felt_count + felt_params == 0 || rng.chance(45) { + FeltRef::Lit(felt_literal(rng)) + } else { + felt_ref_only(rng, felt_count, felt_params) + } +} + +fn bool_operand(rng: &mut Rng, bool_count: usize) -> BoolRef { + if bool_count == 0 || rng.chance(25) { + BoolRef::Lit(rng.boolean()) + } else { + BoolRef::Val(rng.below(bool_count as u64) as usize) + } +} + +fn u32_bin_rhs(rng: &mut Rng, op: U32Op, u32_count: usize, u32_params: usize) -> U32Ref { + match op { + U32Op::Shl | U32Op::Shr => U32Ref::Lit(*rng.pick(&SHIFT_AMOUNTS)), + U32Op::Pow => U32Ref::Lit(*rng.pick(&POW_EXPONENTS)), + U32Op::Div | U32Op::Mod => { + if rng.chance(70) { + U32Ref::Lit(*rng.pick(&DIVISORS)) + } else { + u32_ref_only(rng, u32_count, u32_params) + } + } + _ => u32_operand(rng, u32_count, u32_params), + } +} + +fn felt_bin_rhs(rng: &mut Rng, op: FeltOp, felt_count: usize, felt_params: usize) -> FeltRef { + match op { + FeltOp::Div | FeltOp::Mod => { + if rng.chance(60) { + FeltRef::Lit(*rng.pick(&FELT_DIVISORS)) + } else { + felt_ref_only(rng, felt_count, felt_params) + } + } + _ => felt_operand(rng, felt_count, felt_params), + } +} + +/// At least one operand is a runtime value so sema's const-item folding +/// never kicks in; evaluation stays entirely on the interpret path. +fn gen_u32_bin_expr(rng: &mut Rng, u32_count: usize, u32_params: usize) -> U32Expr { + let op = pick_u32_op(rng); + let lhs = u32_ref_only(rng, u32_count, u32_params); + let rhs = u32_bin_rhs(rng, op, u32_count, u32_params); + U32Expr::Bin { op, lhs, rhs } +} + +fn gen_felt_bin_expr(rng: &mut Rng, felt_count: usize, felt_params: usize) -> FeltExpr { + let op = pick_felt_op(rng); + let lhs = felt_ref_only(rng, felt_count, felt_params); + let rhs = felt_bin_rhs(rng, op, felt_count, felt_params); + FeltExpr::Bin { op, lhs, rhs } +} + +struct Counts { + u32_count: usize, + felt_count: usize, + bool_count: usize, + u32_params: usize, + felt_params: usize, +} + +fn gen_u32_node(rng: &mut Rng, c: &Counts) -> U32Node { + if c.bool_count > 0 && rng.chance(12) { + return U32Node::Select { + cond: bool_operand(rng, c.bool_count), + then: gen_u32_bin_expr(rng, c.u32_count, c.u32_params), + els: gen_u32_bin_expr(rng, c.u32_count, c.u32_params), + }; + } + let U32Expr::Bin { op, lhs, rhs } = gen_u32_bin_expr(rng, c.u32_count, c.u32_params) else { + unreachable!() + }; + U32Node::Bin { op, lhs, rhs } +} + +fn gen_felt_node(rng: &mut Rng, c: &Counts) -> FeltNode { + if c.bool_count > 0 && rng.chance(12) { + return FeltNode::Select { + cond: bool_operand(rng, c.bool_count), + then: gen_felt_bin_expr(rng, c.felt_count, c.felt_params), + els: gen_felt_bin_expr(rng, c.felt_count, c.felt_params), + }; + } + if c.felt_count + c.felt_params > 0 && rng.chance(8) { + return FeltNode::Neg(felt_ref_only(rng, c.felt_count, c.felt_params)); + } + let FeltExpr::Bin { op, lhs, rhs } = gen_felt_bin_expr(rng, c.felt_count, c.felt_params) else { + unreachable!() + }; + FeltNode::Bin { op, lhs, rhs } +} + +fn gen_bool_node(rng: &mut Rng, c: &Counts) -> BoolNode { + if rng.chance(65) { + // Comparison over u32 or felt operands. + let use_felt = (c.felt_count + c.felt_params) > 0 && rng.chance(45); + let op = *rng.pick(&CMP_OPS); + if use_felt { + BoolNode::CmpFelt { + op, + lhs: felt_ref_only(rng, c.felt_count, c.felt_params), + rhs: felt_operand(rng, c.felt_count, c.felt_params), + } + } else if c.u32_count + c.u32_params > 0 { + BoolNode::CmpU32 { + op, + lhs: u32_ref_only(rng, c.u32_count, c.u32_params), + rhs: u32_operand(rng, c.u32_count, c.u32_params), + } + } else { + BoolNode::CmpFelt { + op, + lhs: felt_ref_only(rng, c.felt_count, c.felt_params), + rhs: felt_operand(rng, c.felt_count, c.felt_params), + } + } + } else { + BoolNode::Bin { + op: *rng.pick(&BOOL_OPS), + lhs: bool_operand(rng, c.bool_count), + rhs: bool_operand(rng, c.bool_count), + } + } +} + +/// `kind` comes from the caller so availability checks and binding counts +/// stay in sync with what is actually generated. +fn gen_cast(rng: &mut Rng, c: &Counts, kind: CastKind) -> Stmt { + match kind { + CastKind::U32ToFelt => Stmt::Cast { + kind, + src_u32: Some(u32_ref_only(rng, c.u32_count, c.u32_params)), + src_felt: None, + src_bool: None, + }, + CastKind::FeltToU32 => Stmt::Cast { + kind, + src_u32: None, + src_felt: Some(felt_ref_only(rng, c.felt_count, c.felt_params)), + src_bool: None, + }, + CastKind::U32ToBool => Stmt::Cast { + kind, + src_u32: Some(u32_ref_only(rng, c.u32_count, c.u32_params)), + src_felt: None, + src_bool: None, + }, + CastKind::FeltToBool => Stmt::Cast { + kind, + src_u32: None, + src_felt: Some(felt_ref_only(rng, c.felt_count, c.felt_params)), + src_bool: None, + }, + CastKind::BoolToFelt | CastKind::BoolToU32 => Stmt::Cast { + kind, + src_u32: None, + src_felt: None, + src_bool: Some(bool_operand(rng, c.bool_count)), + }, + } +} + +fn gen_graph(rng: &mut Rng, seed: u64) -> Graph { + let n_params = 2 + rng.below(3) as usize; + let params: Vec = (0..n_params) + .map(|_| { + if rng.chance(60) { + Arg::U32(u32_input_value(rng)) + } else { + Arg::Felt(felt_input_value(rng)) + } + }) + .collect(); + let u32_params = params.iter().filter(|p| matches!(p, Arg::U32(_))).count(); + let felt_params = params.len() - u32_params; + + let n_numeric = 6 + rng.below(15) as usize; + let n_bool = 2 + rng.below(7) as usize; + let mut stmts: Vec = Vec::with_capacity(n_numeric + n_bool); + let (mut numeric_left, mut bool_left) = (n_numeric, n_bool); + let mut c = Counts { u32_count: 0, felt_count: 0, bool_count: 0, u32_params, felt_params }; + while numeric_left > 0 || bool_left > 0 { + let emit_bool = bool_left > 0 && (numeric_left == 0 || rng.chance(35)); + if emit_bool { + stmts.push(Stmt::Bool(gen_bool_node(rng, &c))); + bool_left -= 1; + c.bool_count += 1; + } else if rng.chance(10) { + // Casts need a source of the right kind; fall back to a plain + // binding of the target type when none exists yet. + let kind = *rng.pick(&CAST_KINDS); + let has_source = match kind { + CastKind::U32ToFelt | CastKind::U32ToBool => c.u32_count + u32_params > 0, + CastKind::FeltToU32 | CastKind::FeltToBool => c.felt_count + felt_params > 0, + CastKind::BoolToFelt | CastKind::BoolToU32 => true, + }; + if has_source { + stmts.push(gen_cast(rng, &c, kind)); + match kind { + CastKind::U32ToFelt | CastKind::BoolToFelt => c.felt_count += 1, + CastKind::FeltToU32 | CastKind::BoolToU32 => c.u32_count += 1, + CastKind::U32ToBool | CastKind::FeltToBool => c.bool_count += 1, + } + numeric_left -= 1; + continue; + } + // Fall through to a plain numeric binding. + let pick_u32 = c.u32_count + u32_params > 0 && rng.chance(55); + if pick_u32 { + stmts.push(Stmt::U32(gen_u32_node(rng, &c))); + c.u32_count += 1; + } else { + stmts.push(Stmt::Felt(gen_felt_node(rng, &c))); + c.felt_count += 1; + } + numeric_left -= 1; + } else if c.u32_count + u32_params > 0 && rng.chance(55) { + stmts.push(Stmt::U32(gen_u32_node(rng, &c))); + c.u32_count += 1; + numeric_left -= 1; + } else { + stmts.push(Stmt::Felt(gen_felt_node(rng, &c))); + c.felt_count += 1; + numeric_left -= 1; + } + } + Graph { seed, params, stmts, u32_count: c.u32_count, felt_count: c.felt_count, bool_count: c.bool_count } +} + +// ---------- psy source emission ---------- + +fn u32_ref_src(r: U32Ref, u32_params: usize, u32_seen: &[usize]) -> String { + match r { + U32Ref::Input(i) => PARAM_NAMES[u32_seen[i]].to_string(), + U32Ref::Val(i) => format!("v{i}"), + U32Ref::Lit(x) => format!("{x}u32"), + } +} + +fn felt_ref_src(r: FeltRef, felt_params: usize, felt_seen: &[usize]) -> String { + match r { + FeltRef::Input(i) => PARAM_NAMES[felt_seen[i]].to_string(), + FeltRef::Val(i) => format!("f{i}"), + FeltRef::Lit(x) => format!("{x}"), + } +} + +fn bool_ref_src(r: BoolRef) -> String { + match r { + BoolRef::Val(i) => format!("p{i}"), + BoolRef::Lit(b) => b.to_string(), + } +} + +fn u32_expr_src(e: &U32Expr, u32_params: usize, u32_seen: &[usize]) -> String { + match e { + U32Expr::Ref(r) => u32_ref_src(*r, u32_params, u32_seen), + U32Expr::Bin { op, lhs, rhs } => { + format!("({} {} {})", u32_ref_src(*lhs, u32_params, u32_seen), op.symbol(), u32_ref_src(*rhs, u32_params, u32_seen)) + } + } +} + +fn felt_expr_src(e: &FeltExpr, felt_params: usize, felt_seen: &[usize]) -> String { + match e { + FeltExpr::Ref(r) => felt_ref_src(*r, felt_params, felt_seen), + FeltExpr::Bin { op, lhs, rhs } => { + format!("({} {} {})", felt_ref_src(*lhs, felt_params, felt_seen), op.symbol(), felt_ref_src(*rhs, felt_params, felt_seen)) + } + FeltExpr::Neg(r) => format!("-{}", felt_ref_src(*r, felt_params, felt_seen)), + } +} + +fn gen_source(g: &Graph) -> String { + // Parameter positions per kind, for Input refs. + let u32_seen: Vec = g + .params + .iter() + .enumerate() + .filter(|(_, p)| matches!(p, Arg::U32(_))) + .map(|(i, _)| i) + .collect(); + let felt_seen: Vec = g + .params + .iter() + .enumerate() + .filter(|(_, p)| matches!(p, Arg::Felt(_))) + .map(|(i, _)| i) + .collect(); + let u32_params = u32_seen.len(); + let felt_params = felt_seen.len(); + + let inputs = g + .params + .iter() + .enumerate() + .map(|(i, p)| match p { + Arg::U32(v) => format!("{}={v}u32", PARAM_NAMES[i]), + Arg::Felt(v) => format!("{}={v}", PARAM_NAMES[i]), + }) + .collect::>() + .join(", "); + let sig = g + .params + .iter() + .enumerate() + .map(|(i, p)| match p { + Arg::U32(_) => format!("{}: u32", PARAM_NAMES[i]), + Arg::Felt(_) => format!("{}: Felt", PARAM_NAMES[i]), + }) + .collect::>() + .join(", "); + // Return type = type of the last numeric statement (there is always at + // least one: n_numeric >= 6). Bool-producing casts count as bool here, + // matching the emitter's binding names. + let ret_ty = g + .stmts + .iter() + .rev() + .map(|s| match s { + Stmt::U32(_) => "u32", + Stmt::Felt(_) => "Felt", + Stmt::Bool(_) => "bool", + Stmt::Cast { kind, .. } => match kind { + CastKind::U32ToFelt | CastKind::BoolToFelt => "Felt", + CastKind::FeltToU32 | CastKind::BoolToU32 => "u32", + CastKind::U32ToBool | CastKind::FeltToBool => "bool", + }, + }) + .find(|t| *t != "bool") + .unwrap_or("u32"); + + let mut out = String::new(); + out.push_str("// randomly generated differential-test program (interpreter vs native Rust)\n"); + out.push_str(&format!("// seed: {}\n// inputs: {inputs}\n\n", g.seed)); + out.push_str(&format!("fn main({sig}) -> {ret_ty} {{\n")); + let (mut v, mut f, mut p) = (0usize, 0usize, 0usize); + let mut last_numeric = String::new(); + for stmt in &g.stmts { + match stmt { + Stmt::U32(node) => { + let rhs = match node { + U32Node::Bin { op, lhs, rhs } => format!( + "({} {} {})", + u32_ref_src(*lhs, u32_params, &u32_seen), + op.symbol(), + u32_ref_src(*rhs, u32_params, &u32_seen) + ), + U32Node::Select { cond, then, els } => format!( + "match {} {{ true => {}, _ => {} }}", + bool_ref_src(*cond), + u32_expr_src(then, u32_params, &u32_seen), + u32_expr_src(els, u32_params, &u32_seen) + ), + }; + out.push_str(&format!(" let v{v}: u32 = {rhs};\n")); + last_numeric = format!("v{v}"); + v += 1; + } + Stmt::Felt(node) => { + let rhs = match node { + FeltNode::Bin { op, lhs, rhs } => format!( + "({} {} {})", + felt_ref_src(*lhs, felt_params, &felt_seen), + op.symbol(), + felt_ref_src(*rhs, felt_params, &felt_seen) + ), + FeltNode::Neg(r) => format!("-{}", felt_ref_src(*r, felt_params, &felt_seen)), + FeltNode::Select { cond, then, els } => format!( + "match {} {{ true => {}, _ => {} }}", + bool_ref_src(*cond), + felt_expr_src(then, felt_params, &felt_seen), + felt_expr_src(els, felt_params, &felt_seen) + ), + }; + out.push_str(&format!(" let f{f}: Felt = {rhs};\n")); + last_numeric = format!("f{f}"); + f += 1; + } + Stmt::Bool(node) => { + let rhs = match node { + BoolNode::CmpU32 { op, lhs, rhs } => format!( + "({} {} {})", + u32_ref_src(*lhs, u32_params, &u32_seen), + op.symbol(), + u32_ref_src(*rhs, u32_params, &u32_seen) + ), + BoolNode::CmpFelt { op, lhs, rhs } => format!( + "({} {} {})", + felt_ref_src(*lhs, felt_params, &felt_seen), + op.symbol(), + felt_ref_src(*rhs, felt_params, &felt_seen) + ), + BoolNode::Bin { op, lhs, rhs } => { + format!("({} {} {})", bool_ref_src(*lhs), op.symbol(), bool_ref_src(*rhs)) + } + BoolNode::Not(x) => format!("!{}", bool_ref_src(*x)), + }; + out.push_str(&format!(" let p{p}: bool = {rhs};\n")); + p += 1; + } + Stmt::Cast { kind, src_u32, src_felt, src_bool } => { + let (src, target, name) = match kind { + CastKind::U32ToFelt => (u32_ref_src(src_u32.unwrap(), u32_params, &u32_seen), "Felt", 'f'), + CastKind::FeltToU32 => (felt_ref_src(src_felt.unwrap(), felt_params, &felt_seen), "u32", 'v'), + CastKind::U32ToBool => (u32_ref_src(src_u32.unwrap(), u32_params, &u32_seen), "bool", 'p'), + CastKind::FeltToBool => (felt_ref_src(src_felt.unwrap(), felt_params, &felt_seen), "bool", 'p'), + CastKind::BoolToFelt => (bool_ref_src(src_bool.unwrap()), "Felt", 'f'), + CastKind::BoolToU32 => (bool_ref_src(src_bool.unwrap()), "u32", 'v'), + }; + let idx = match name { + 'f' => { + let i = f; + f += 1; + i + } + 'v' => { + let i = v; + v += 1; + i + } + _ => { + let i = p; + p += 1; + i + } + }; + out.push_str(&format!(" let {name}{idx}: {target} = {src} as {target};\n")); + if name != 'p' { + last_numeric = format!("{name}{idx}"); + } + } + } + } + out.push_str(&format!(" return {last_numeric};\n")); + out.push_str("}\n"); + out +} + +// ---------- native Rust mirror ---------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum MirrorErr { + Overflow, + DivZero, + InvalidCast, +} + +/// Goldilocks field arithmetic over u128. +fn f_add(a: u64, b: u64) -> u64 { + ((a as u128 + b as u128) % GOLDILOCKS_P) as u64 +} + +fn f_sub(a: u64, b: u64) -> u64 { + ((GOLDILOCKS_P + a as u128 - b as u128) % GOLDILOCKS_P) as u64 +} + +fn f_mul(a: u64, b: u64) -> u64 { + ((a as u128 * b as u128) % GOLDILOCKS_P) as u64 +} + +fn f_pow(a: u64, e: u64) -> u64 { + let mut result: u128 = 1; + let mut base = (a as u128) % GOLDILOCKS_P; + let mut exp = e as u128; + while exp > 0 { + if exp & 1 == 1 { + result = result * base % GOLDILOCKS_P; + } + base = base * base % GOLDILOCKS_P; + exp >>= 1; + } + result as u64 +} + +fn f_div(a: u64, b: u64) -> u64 { + // Field division: multiply by the inverse (Fermat: a^(p-2)). + f_mul(a, f_pow(b, (GOLDILOCKS_P - 2) as u64)) +} + +fn f_neg(a: u64) -> u64 { + ((GOLDILOCKS_P - a as u128 % GOLDILOCKS_P) % GOLDILOCKS_P) as u64 +} + +/// Mirrors the interpreter's constant pre-check exactly (lib.rs +/// `interpret_binary`): u32 add/sub/mul/pow on constants overflow-check in +/// u64 and div/mod by a zero constant is a clean error. +fn apply_op(op: U32Op, l: u32, r: u32) -> Result { + match op { + U32Op::Add => l.checked_add(r).ok_or(MirrorErr::Overflow), + U32Op::Sub => l.checked_sub(r).ok_or(MirrorErr::Overflow), + U32Op::Mul => l.checked_mul(r).ok_or(MirrorErr::Overflow), + U32Op::Div => { + if r == 0 { + Err(MirrorErr::DivZero) + } else { + Ok(l / r) + } + } + U32Op::Mod => { + if r == 0 { + Err(MirrorErr::DivZero) + } else { + Ok(l % r) + } + } + U32Op::Pow => match (l as u64).checked_pow(r as u32) { + Some(value) if value <= 0xffff_ffff => Ok(value as u32), + _ => Err(MirrorErr::Overflow), + }, + U32Op::And => Ok(l & r), + U32Op::Or => Ok(l | r), + U32Op::Xor => Ok(l ^ r), + // Mirrors the fixed VM fold: every bit leaves the 32-bit window once + // the distance reaches 32, so distances >= 32 fold to 0. + U32Op::Shl | U32Op::Shr => Ok(if r >= 32 { 0 } else if op == U32Op::Shl { l << r } else { l >> r }), + } +} + +fn apply_felt_op(op: FeltOp, l: u64, r: u64) -> Result { + match op { + FeltOp::Add => Ok(f_add(l, r)), + FeltOp::Sub => Ok(f_sub(l, r)), + FeltOp::Mul => Ok(f_mul(l, r)), + FeltOp::Div => { + if r == 0 { + Err(MirrorErr::DivZero) + } else { + Ok(f_div(l, r)) + } + } + FeltOp::Mod => { + if r == 0 { + Err(MirrorErr::DivZero) + } else { + Ok(l % r) + } + } + FeltOp::Pow => Ok(f_pow(l, r)), + } +} + +fn apply_cmp(op: CmpOp, l: u64, r: u64) -> bool { + match op { + CmpOp::Lt => l < r, + CmpOp::Lte => l <= r, + CmpOp::Gt => l > r, + CmpOp::Gte => l >= r, + CmpOp::Eq => l == r, + CmpOp::Neq => l != r, + } +} + +fn apply_bool_op(op: BoolOp, l: bool, r: bool) -> bool { + match op { + BoolOp::Eq => l == r, + BoolOp::Neq => l != r, + BoolOp::And => l && r, + BoolOp::Or => l || r, + BoolOp::Xor => l ^ r, + } +} + +/// Numeric result of a fully evaluated graph: canonical u64 (u32 values +/// zero-extend). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Num { + U32(u32), + Felt(u64), +} + +impl Num { + fn canonical(self) -> u64 { + match self { + Num::U32(v) => v as u64, + Num::Felt(v) => v, + } + } +} + +struct Mirror<'g> { + u32_inputs: Vec, + felt_inputs: Vec, + u32_vals: Vec, + felt_vals: Vec, + bool_vals: Vec, + params: &'g [Arg], +} + +impl<'g> Mirror<'g> { + fn u32_ref(&self, r: U32Ref) -> u32 { + match r { + U32Ref::Input(i) => self.u32_inputs[i], + U32Ref::Val(i) => self.u32_vals[i], + U32Ref::Lit(x) => x, + } + } + + fn felt_ref(&self, r: FeltRef) -> u64 { + match r { + FeltRef::Input(i) => self.felt_inputs[i], + FeltRef::Val(i) => self.felt_vals[i], + FeltRef::Lit(x) => x, + } + } + + fn bool_ref(&self, r: BoolRef) -> bool { + match r { + BoolRef::Val(i) => self.bool_vals[i], + BoolRef::Lit(b) => b, + } + } + + fn u32_expr(&self, e: &U32Expr) -> Result { + match e { + U32Expr::Ref(r) => Ok(self.u32_ref(*r)), + U32Expr::Bin { op, lhs, rhs } => apply_op(*op, self.u32_ref(*lhs), self.u32_ref(*rhs)), + } + } + + fn felt_expr(&self, e: &FeltExpr) -> Result { + match e { + FeltExpr::Ref(r) => Ok(self.felt_ref(*r)), + FeltExpr::Bin { op, lhs, rhs } => apply_felt_op(*op, self.felt_ref(*lhs), self.felt_ref(*rhs)), + FeltExpr::Neg(r) => Ok(f_neg(self.felt_ref(*r))), + } + } + + fn u32_node(&self, node: &U32Node) -> Result { + match node { + U32Node::Bin { op, lhs, rhs } => apply_op(*op, self.u32_ref(*lhs), self.u32_ref(*rhs)), + // The interpreter evaluates every match arm eagerly (first the + // pattern arm, then the wildcard), so an error in the untaken + // arm still aborts β€” the mirror must do the same. + U32Node::Select { cond, then, els } => { + let taken = self.u32_expr(then)?; + let untaken = self.u32_expr(els)?; + Ok(if self.bool_ref(*cond) { taken } else { untaken }) + } + } + } + + fn felt_node(&self, node: &FeltNode) -> Result { + match node { + FeltNode::Bin { op, lhs, rhs } => apply_felt_op(*op, self.felt_ref(*lhs), self.felt_ref(*rhs)), + FeltNode::Neg(r) => Ok(f_neg(self.felt_ref(*r))), + FeltNode::Select { cond, then, els } => { + let taken = self.felt_expr(then)?; + let untaken = self.felt_expr(els)?; + Ok(if self.bool_ref(*cond) { taken } else { untaken }) + } + } + } + + fn bool_node(&self, node: &BoolNode) -> bool { + match node { + BoolNode::CmpU32 { op, lhs, rhs } => apply_cmp(*op, self.u32_ref(*lhs) as u64, self.u32_ref(*rhs) as u64), + BoolNode::CmpFelt { op, lhs, rhs } => apply_cmp(*op, self.felt_ref(*lhs), self.felt_ref(*rhs)), + BoolNode::Bin { op, lhs, rhs } => apply_bool_op(*op, self.bool_ref(*lhs), self.bool_ref(*rhs)), + BoolNode::Not(x) => !self.bool_ref(*x), + } + } + + fn cast(&self, kind: CastKind, src_u32: Option, src_felt: Option, src_bool: Option) -> Result { + // Mirrors interpret_cast's constant range checks. + match kind { + CastKind::U32ToFelt => Ok(CastVal::Felt(self.u32_ref(src_u32.unwrap()) as u64)), + CastKind::FeltToU32 => match self.felt_ref(src_felt.unwrap()) { + v if v <= 0xffff_ffff => Ok(CastVal::U32(v as u32)), + _ => Err(MirrorErr::InvalidCast), + }, + CastKind::U32ToBool => match self.u32_ref(src_u32.unwrap()) { + 0 => Ok(CastVal::Bool(false)), + 1 => Ok(CastVal::Bool(true)), + _ => Err(MirrorErr::InvalidCast), + }, + CastKind::FeltToBool => match self.felt_ref(src_felt.unwrap()) { + 0 => Ok(CastVal::Bool(false)), + 1 => Ok(CastVal::Bool(true)), + _ => Err(MirrorErr::InvalidCast), + }, + CastKind::BoolToFelt => Ok(CastVal::Felt(self.bool_ref(src_bool.unwrap()) as u64)), + CastKind::BoolToU32 => Ok(CastVal::U32(self.bool_ref(src_bool.unwrap()) as u32)), + } + } +} + +/// Result of a cast, tagged by the binding pool it lands in. +#[derive(Clone, Copy)] +enum CastVal { + U32(u32), + Felt(u64), + Bool(bool), +} + +fn mirror_eval(g: &Graph) -> Result { + let u32_inputs: Vec = g + .params + .iter() + .filter_map(|p| match p { + Arg::U32(v) => Some(*v), + Arg::Felt(_) => None, + }) + .collect(); + let felt_inputs: Vec = g + .params + .iter() + .filter_map(|p| match p { + Arg::Felt(v) => Some(*v), + Arg::U32(_) => None, + }) + .collect(); + let mut m = Mirror { + u32_inputs, + felt_inputs, + u32_vals: Vec::new(), + felt_vals: Vec::new(), + bool_vals: Vec::new(), + params: &g.params, + }; + let mut result = Ok(Num::U32(0)); + for stmt in &g.stmts { + match stmt { + Stmt::U32(node) => { + let value = m.u32_node(node)?; + m.u32_vals.push(value); + result = Ok(Num::U32(value)); + } + Stmt::Felt(node) => { + let value = m.felt_node(node)?; + m.felt_vals.push(value); + result = Ok(Num::Felt(value)); + } + Stmt::Bool(node) => { + let value = m.bool_node(node); + m.bool_vals.push(value); + } + Stmt::Cast { kind, src_u32, src_felt, src_bool } => { + match m.cast(*kind, *src_u32, *src_felt, *src_bool)? { + CastVal::U32(v) => { + m.u32_vals.push(v); + result = Ok(Num::U32(v)); + } + CastVal::Felt(v) => { + m.felt_vals.push(v); + result = Ok(Num::Felt(v)); + } + CastVal::Bool(v) => m.bool_vals.push(v), + } + } + } + } + result +} + +// ---------- psy interpreter harness ---------- + +fn panic_message(payload: &Box) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +fn find_function(ctx: &mut TypeCheckerVisitorContext, name: &str) -> Option { + let name_id = ctx.program.interner.intern_ident(name); + let key: TypeKey = name_id.into(); + for module in ctx.symbols.modules() { + if let Some(&tid) = ctx.symbols[module.scope_id].types.get(&key) { + if ctx.symbols[tid].as_function().is_some() { + return Some(tid); + } + } + } + None +} + +#[derive(Debug)] +enum PsyOutcome { + Returned(u64), + /// Multi-felt return (arrays, hashes): every element concretely + /// resolved through the VM's eval path. Intrinsic results (hash, + /// keccak256, split_bits, ...) are store nodes rather than folded + /// constants, so single-element returns of that shape resolve here too + /// and come back as `Returned`. + ReturnedVec(Vec), + /// Interpreted cleanly but the result stayed symbolic (non-constant). + Symbolic, + Errored(String), + Panicked(String), +} + +/// How `main`'s parameters are bound: concrete constants (everything +/// constant-folds β€” the differential path) or symbolic inputs materialized +/// from each parameter's declared type (for safety checks on the symbolic +/// path, where the interpreter only builds circuit nodes). +enum Inputs { + Const(Vec), + Symbolic, +} + +fn psy_run(source: &str, inputs: &[Arg]) -> PsyOutcome { + psy_run_with(source, Inputs::Const(inputs.to_vec())) +} + +fn psy_run_with(source: &str, inputs: Inputs) -> PsyOutcome { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("psy_rg_{n}_{unique}.psy")); + fs::write(&path, source).unwrap(); + + let mut interpreter = Interpreter::::new(QExecContext::new()); + let compiled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + interpreter.typecheck_single(path.clone()) + })); + let _ = fs::remove_file(&path); + let (typechecker, mut ctx) = match compiled { + Err(p) => return PsyOutcome::Panicked(format!("[typecheck panicked] {}", panic_message(&p))), + Ok(Err(e)) => return PsyOutcome::Errored(format!("[typecheck rejected] {e:#}")), + Ok(Ok(pair)) => pair, + }; + let Some(main_tid) = find_function(&mut ctx, "main") else { + return PsyOutcome::Errored("[no main]".to_string()); + }; + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match &inputs { + Inputs::Const(args) => args + .iter() + .map(|arg| match arg { + // u32 constants must be ConstantU32 felts (they route to the + // u32 fold); Felt constants must be plain Constants. + Arg::U32(v) => CheckedValueRef::from_u32(SymFeltRef::new_constant_u32(*v)), + Arg::Felt(v) => CheckedValueRef::new_rc(CheckedValue::Felt(SymFeltRef::new_constant(*v))), + }) + .collect::>(), + Inputs::Symbolic => { + let function = ctx.symbols[main_tid].as_function().expect("main is a function"); + let location = function.location; + function + .parameters + .iter() + .map(|parameter| { + interpreter + .materialize_input(parameter.ty, &ctx.symbols, location) + .expect("symbolic input materialization") + }) + .collect::>() + } + })); + let args = match built { + Ok(args) => args, + Err(p) => return PsyOutcome::Panicked(format!("[input panicked] {}", panic_message(&p))), + }; + let program = &typechecker.program; + let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + interpreter.interpret_function(program, main_tid, args, &mut ctx) + })); + match run { + Err(p) => PsyOutcome::Panicked(panic_message(&p)), + Ok(Err(e)) => PsyOutcome::Errored(format!("{e:#}")), + Ok(Ok(control)) => { + let ControlState::Return(value) = control else { + return PsyOutcome::Errored("[main did not return a value]".to_string()); + }; + let felts = value.to_felts(); + if felts.is_empty() { + return PsyOutcome::Errored("[main returned no values]".to_string()); + } + let scalar = matches!( + &*value.borrow(), + CheckedValue::U32(_) | CheckedValue::Felt(_) | CheckedValue::Bool(_) + ); + if scalar && interpreter.is_constant(felts[0].clone()) { + return PsyOutcome::Returned(felts[0].get_constant_value()); + } + if matches!(inputs, Inputs::Symbolic) { + return PsyOutcome::Symbolic; + } + // Concrete inputs but the value did not constant-fold (intrinsic + // results are store nodes). Resolve through the VM's eval path β€” + // the same ContextEval the IDE preview executes β€” so intrinsic + // outputs can be compared against native mirrors. + let resolved = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let input = DummyContextEvalInput::new(vec![]); + let mut cache = SimpleEvalCache::new(); + felts + .iter() + .map(|f| interpreter.context.store.resolve_felt_ref_cached(f.clone(), &input, &mut cache)) + .collect::>() + })); + match resolved { + Err(p) => PsyOutcome::Panicked(format!("[resolve panicked] {}", panic_message(&p))), + Ok(values) if scalar => PsyOutcome::Returned(values[0]), + Ok(values) => PsyOutcome::ReturnedVec(values), + } + } + } +} + +// ---------- differential loop ---------- + +enum Outcome { + Value, + Overflow, + DivZero, + InvalidCast, +} + +fn write_artifact(seed: u64, source: &str) -> std::io::Result { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("target") + .join("random-graph-failures"); + fs::create_dir_all(&dir)?; + let path = dir.join(format!("seed_{seed}.psy")); + fs::write(&path, source)?; + Ok(path) +} + +fn check_seed(seed: u64) -> Outcome { + let mut rng = Rng::new(seed); + let graph = gen_graph(&mut rng, seed); + let source = gen_source(&graph); + let expected = mirror_eval(&graph); + let got = psy_run(&source, &graph.params); + match (&expected, &got) { + (Ok(v), PsyOutcome::Returned(x)) if *x == v.canonical() => Outcome::Value, + (Err(MirrorErr::Overflow), PsyOutcome::Errored(msg)) if msg.contains("ArithmeticOverflow") => Outcome::Overflow, + (Err(MirrorErr::DivZero), PsyOutcome::Errored(msg)) if msg.contains("DivisionByZero") => Outcome::DivZero, + (Err(MirrorErr::InvalidCast), PsyOutcome::Errored(msg)) if msg.to_lowercase().contains("invalid cast") => Outcome::InvalidCast, + _ => { + let artifact = write_artifact(seed, &source) + .map_or_else(|_| "".to_string(), |p| p.display().to_string()); + let inputs = graph + .params + .iter() + .enumerate() + .map(|(i, p)| match p { + Arg::U32(v) => format!("{}={v}u32", PARAM_NAMES[i]), + Arg::Felt(v) => format!("{}={v}", PARAM_NAMES[i]), + }) + .collect::>() + .join(", "); + panic!( + "random computation graph mismatch (seed {seed})\n \ + inputs: {inputs}\n \ + mirror: {expected:?}\n \ + psy: {got:?}\n \ + artifact: {artifact}\n \ + reproduce: PSY_RANDOM_GRAPH_SEED={seed} cargo test -p psy-interpreter random_computation_graphs_match_native_rust\n\ + --- program ---\n{source}\ + ----------------" + ); + } + } +} + +#[test] +fn random_computation_graphs_match_native_rust() { + if let Ok(raw) = std::env::var("PSY_RANDOM_GRAPH_SEED") { + check_seed(raw.trim().parse().expect("PSY_RANDOM_GRAPH_SEED must be a u64")); + return; + } + let iters: u64 = std::env::var("PSY_RANDOM_GRAPH_ITERS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(10_000); + let base = 0x5EED_2026_0921_u64; + let (mut values, mut overflows, mut div_zeros, mut invalid_casts) = (0u64, 0u64, 0u64, 0u64); + for i in 0..iters { + match check_seed(base ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15)) { + Outcome::Value => values += 1, + Outcome::Overflow => overflows += 1, + Outcome::DivZero => div_zeros += 1, + Outcome::InvalidCast => invalid_casts += 1, + } + } + println!( + "random graph differential: {iters} seeds passed \ + ({values} values, {overflows} expected overflows, {div_zeros} expected div-by-zero errors, {invalid_casts} expected invalid casts)" + ); +} + +/// Regressions for the two psy_vm constant-folding bugs this suite uncovered +/// (fixed in psy-node `fix/vm-const-fold`): +/// +/// - u32 shift folding shifted the u64 directly: a distance >= 64 panicked +/// in debug and silently wrapped in release (`1u32 << 64u32` folded to 1). +/// Distances >= 32 now fold to 0, matching the existing 32..63 behavior. +/// - `x * 0` with a symbolic x returned `x` instead of 0, silently recording +/// the constraint `x == 0` in the circuit. It now folds to constant 0. +#[test] +fn vm_const_fold_regressions() { + for (label, source) in [ + ("shl_64", "fn main() -> u32 { return 1u32 << 64u32; }"), + ("shr_100", "fn main() -> u32 { return 1u32 >> 100u32; }"), + ("shl_32", "fn main() -> u32 { return 1u32 << 32u32; }"), + ("shr_33", "fn main() -> u32 { return 4294967295u32 >> 33u32; }"), + ] { + match psy_run(source, &[]) { + PsyOutcome::Returned(0) => {} + other => panic!("[{label}] expected Returned(0), got {other:?}"), + } + } + match psy_run_with("fn main(x: Felt) -> Felt { return x * 0; }", Inputs::Symbolic) { + PsyOutcome::Returned(0) => {} + other => panic!("[mul_zero_symbolic] expected Returned(0), got {other:?}"), + } +} + +/// Non-constant (symbolic) shifts must interpret cleanly: the interpreter +/// only builds the circuit node β€” no folding, no panic, and the result stays +/// symbolic even for wild distances. Value-level verification of symbolic +/// shifts happens at VM execution/proving time, outside this interpret-layer +/// suite. +#[test] +fn symbolic_shifts_interpret_cleanly() { + for (label, source) in [ + ("both_symbolic_shl", "fn main(a: u32, b: u32) -> u32 { return a << b; }"), + ("both_symbolic_shr", "fn main(a: u32, b: u32) -> u32 { return a >> b; }"), + ("symbolic_base_big_distance", "fn main(a: u32) -> u32 { return a << 70u32; }"), + ("symbolic_distance", "fn main(b: u32) -> u32 { return 3u32 >> b; }"), + ("chained", "fn main(a: u32, b: u32) -> u32 { let x = a << b; let y = x >> 2u32; return y << b; }"), + ] { + match psy_run_with(source, Inputs::Symbolic) { + PsyOutcome::Symbolic => {} + other => panic!("[{label}] expected clean symbolic interpretation, got {other:?}"), + } + } +} + +/// op_type of `main`'s returned felt, interpreted with symbolic inputs. +/// Pins routing decisions β€” which op a DSL expression compiles to β€” that +/// value-level differentials cannot see (e.g. Exp vs ExpConstantPower +/// evaluate identically). +fn psy_result_op_type(source: &str) -> DPNOpType { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("psy_rg_{n}_{unique}.psy")); + fs::write(&path, source).unwrap(); + let mut interpreter = Interpreter::::new(QExecContext::new()); + let (typechecker, mut ctx) = interpreter.typecheck_single(path.clone()).expect("typecheck must succeed"); + let _ = fs::remove_file(&path); + let main_tid = find_function(&mut ctx, "main").expect("main must exist"); + let function = ctx.symbols[main_tid].as_function().expect("main is a function"); + let location = function.location; + let args: Vec<_> = function + .parameters + .iter() + .map(|p| interpreter.materialize_input(p.ty, &ctx.symbols, location).expect("symbolic input")) + .collect(); + let control = interpreter + .interpret_function(&typechecker.program, main_tid, args, &mut ctx) + .expect("interpretation must succeed"); + let ControlState::Return(value) = control else { + panic!("main must return a value"); + }; + value.to_felts()[0].get_op_type() +} + +/// Felt `**` with a compile-time-constant operand must route to the +/// ExpConstant* ops (fixed in psy-node `fix/vm-const-fold`): the routing +/// checked the u32 lane's `ConstantU32` marker on felt-lane operands +/// (felt constants are `Constant` nodes), so it never fired and every +/// `**` emitted a plain `Exp` β€” the constant-exponent circuit optimization +/// was unreachable. Both-constant `**` still folds to a plain constant. +#[test] +fn felt_pow_constant_routing() { + assert_eq!( + psy_result_op_type("fn main(x: Felt) -> Felt { return x ** 3; }"), + DPNOpType::ExpConstantPower + ); + assert_eq!( + psy_result_op_type("fn main(x: Felt) -> Felt { return 2 ** x; }"), + DPNOpType::ExpConstantBase + ); + assert_eq!( + psy_result_op_type("fn main(x: Felt, y: Felt) -> Felt { return x ** y; }"), + DPNOpType::Exp + ); + match psy_run("fn main(x: Felt) -> Felt { return x ** 3; }", &[Arg::Felt(2)]) { + PsyOutcome::Returned(8) => {} + other => panic!("[const_pow_fold] expected Returned(8), got {other:?}"), + } +} + +/// Felt `%` and u32 `&`/`|`/`^` with a compile-time-constant operand must +/// route to the Constant* ops (wired in psy-node `fix/vm-const-fold`): +/// ModConstantDividend/Divisor and U32And/Or/XorConstant were previously +/// dead enum values β€” no producer emitted them, and their consumers read +/// the constant through a phantom const_param slot instead of the operand +/// node. The u32 lane checks the right (mask) operand only; and/or/xor are +/// commutative, so a constant left operand needs no variant of its own. +#[test] +fn mod_and_u32_bitwise_constant_routing() { + // Felt lane. + assert_eq!( + psy_result_op_type("fn main(x: Felt, d: Felt) -> Felt { return x % d; }"), + DPNOpType::Mod + ); + assert_eq!( + psy_result_op_type("fn main(x: Felt) -> Felt { return x % 7; }"), + DPNOpType::ModConstantDivisor + ); + assert_eq!( + psy_result_op_type("fn main(x: Felt) -> Felt { return 1000 % x; }"), + DPNOpType::ModConstantDividend + ); + // u32 lane: constant right operand selects the Constant* mask variant. + assert_eq!( + psy_result_op_type("fn main(x: u32) -> u32 { return x & 12u32; }"), + DPNOpType::U32AndConstant + ); + assert_eq!( + psy_result_op_type("fn main(x: u32) -> u32 { return x | 12u32; }"), + DPNOpType::U32OrConstant + ); + assert_eq!( + psy_result_op_type("fn main(x: u32) -> u32 { return x ^ 12u32; }"), + DPNOpType::U32XorConstant + ); + // Plain variants stay plain with two runtime operands. + assert_eq!( + psy_result_op_type("fn main(x: u32, y: u32) -> u32 { return x & y; }"), + DPNOpType::U32And + ); + // Shifts: constant bit distance (right operand) / constant value (left). + assert_eq!( + psy_result_op_type("fn main(x: u32) -> u32 { return x << 3u32; }"), + DPNOpType::U32ShiftLeftConstantBitDistance + ); + assert_eq!( + psy_result_op_type("fn main(x: u32) -> u32 { return 5u32 << x; }"), + DPNOpType::U32ShiftLeftConstantValue + ); + assert_eq!( + psy_result_op_type("fn main(x: u32) -> u32 { return x >> 3u32; }"), + DPNOpType::U32ShiftRightConstantBitDistance + ); + assert_eq!( + psy_result_op_type("fn main(x: u32) -> u32 { return 5u32 >> x; }"), + DPNOpType::U32ShiftRightConstantValue + ); + assert_eq!( + psy_result_op_type("fn main(x: u32, y: u32) -> u32 { return x << y; }"), + DPNOpType::U32ShiftLeft + ); + // Values match native arithmetic on the eval path. + match psy_run("fn main(x: Felt) -> Felt { return x % 7; }", &[Arg::Felt(30)]) { + PsyOutcome::Returned(2) => {} + other => panic!("[mod_const_divisor] expected Returned(2), got {other:?}"), + } + match psy_run("fn main(x: Felt) -> Felt { return 1000 % x; }", &[Arg::Felt(7)]) { + PsyOutcome::Returned(6) => {} + other => panic!("[mod_const_dividend] expected Returned(6), got {other:?}"), + } + match psy_run("fn main(x: u32) -> u32 { return x & 12u32; }", &[Arg::U32(10)]) { + PsyOutcome::Returned(8) => {} + other => panic!("[u32_and_const] expected Returned(8), got {other:?}"), + } +} + +// ---------- crypto/bit intrinsic differential ---------- +// +// The intrinsics below are DSL-reachable but never appear in scalar +// computation graphs, so they get their own seeded cases. Every case builds +// a `main` from concrete literals, interprets it, resolves the result +// through the VM's eval path, and compares against a native Rust mirror: +// plonky2's Poseidon for hash/hash_two_to_one, tiny-keccak for keccak256, +// k256 for secp256k1_verify fixtures, and plain bit ops for +// split_bits/sum_bits. + +/// Split widths cluster on the boundaries: 31/32/33 around the u32 seam, +/// 63/64 at the 64-bit felt ceiling (64 accepts every canonical felt; +/// wider is out of domain β€” `semantics::split_bits` rejects num_bits > 64, +/// and the circuit could never prove a wider decomposition anyway). +const CRYPTO_SPLIT_WIDTHS: [u64; 8] = [1, 4, 8, 31, 32, 33, 63, 64]; +/// Keccak lengths cross the 136-byte rate boundary (34 u32 words): 34 is +/// exactly one block, 35 spills into two, 68 is two full blocks, 69 is +/// just past two, 102 is three full blocks. +const CRYPTO_KECCAC_LENS: [usize; 10] = [1, 8, 16, 17, 24, 34, 35, 68, 69, 102]; + +/// Mirrors the VM's keccak packing: each felt word contributes its low 32 +/// bits as 4 big-endian bytes; the digest is read back as 8 big-endian u32s. +/// The packing itself is the wire convention (shared with the circuit); +/// the Keccak permutation underneath is tiny-keccak. +fn keccak_mirror(words: &[u64]) -> Vec { + use tiny_keccak::{Hasher as _, Keccak}; + let mut bytes = Vec::with_capacity(words.len() * 4); + for word in words { + bytes.extend_from_slice(&(*word as u32).to_be_bytes()); + } + let mut digest = [0u8; 32]; + let mut keccak = Keccak::v256(); + keccak.update(&bytes); + keccak.finalize(&mut digest); + digest + .chunks_exact(4) + .take(8) + .map(|c| u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64) + .collect() +} + +fn poseidon_hash_mirror(words: &[u64]) -> [u64; 4] { + let data: Vec = words.iter().map(|w| GoldilocksField::from_noncanonical_u64(*w)).collect(); + PoseidonHash::hash_no_pad(&data).elements.map(|e| e.to_canonical_u64()) +} + +fn poseidon_two_to_one_mirror(left: &[u64; 4], right: &[u64; 4]) -> [u64; 4] { + let conv = |v: &[u64; 4]| HashOut { elements: v.map(|w| GoldilocksField::from_noncanonical_u64(w)) }; + PoseidonHash::two_to_one(conv(left), conv(right)) + .elements + .map(|e| e.to_canonical_u64()) +} + +/// LSB-first decomposition over the strict domain shared by the VM and the +/// circuit: `num_bits <= 64` and the value must fit the width, otherwise +/// `None` (the expected clean rejection). +fn split_bits_mirror(x: u64, num_bits: u64) -> Option> { + if num_bits > 64 || (num_bits < 64 && x >= 1u64 << num_bits) { + return None; + } + Some((0..num_bits).map(|i| (x >> i) & 1).collect()) +} + +/// Weighted binary reconstruction `sum(bit[i] * 2^i)` reduced mod p β€” the +/// strict-boolean SumBits semantics shared by the VM and the circuit (the +/// old unweighted fold diverged from the circuit's mul_add accumulation). +fn sum_bits_mirror(bits: &[u64]) -> u64 { + bits.iter() + .enumerate() + .fold(GoldilocksField::ZERO, |acc, (i, bit)| acc + GoldilocksField::from_noncanonical_u64(bit << i)) + .to_canonical_u64() +} + +/// Packs u32-range words exactly like the VM's Secp256k1Verify eval arm: +/// each word's 4 little-endian bytes, then the whole byte sequence +/// reversed β€” i.e. reversed word order with each word big-endian. +fn secp_pack_u32_words(words: &[u64]) -> Vec { + words.iter().flat_map(|w| (*w as u32).to_le_bytes()).rev().collect() +} + +/// Message words are full felts: 8 little-endian bytes each, whole +/// sequence reversed. +fn secp_pack_msg_words(words: &[u64; 4]) -> Vec { + words.iter().flat_map(|w| w.to_le_bytes()).rev().collect() +} + +/// Inverse of `secp_pack_u32_words`. +fn secp_unpack_words(bytes: &[u8]) -> Vec { + let mut words: Vec = bytes + .chunks_exact(4) + .map(|c| u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64) + .collect(); + words.reverse(); + words +} + +fn secp_mirror(public_key: &[u64; 16], signature: &[u64; 16], msg: &[u64; 4]) -> bool { + use k256::ecdsa::signature::hazmat::PrehashVerifier; + let mut sec1 = vec![0x04]; + sec1.extend(secp_pack_u32_words(&public_key[0..8])); + sec1.extend(secp_pack_u32_words(&public_key[8..16])); + let Ok(vk) = k256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1) else { + return false; + }; + let mut sig_bytes = secp_pack_u32_words(&signature[0..8]); + sig_bytes.extend(secp_pack_u32_words(&signature[8..16])); + let Ok(sig) = k256::ecdsa::Signature::from_slice(&sig_bytes) else { + return false; + }; + matches!(vk.verify_prehash(&secp_pack_msg_words(msg), &sig), Ok(_)) +} + +/// Builds a genuinely valid (pk, sig) pair over the packed form of `msg`, +/// so the differential has a positive case: psy must return true. +fn secp_valid_fixture(seed: u64, msg: &[u64; 4]) -> ([u64; 16], [u64; 16]) { + use k256::ecdsa::signature::hazmat::PrehashSigner; + let mut rng = Rng::new(seed ^ 0x5EC5_EED0_0000_0000); + let mut key_bytes = [0u8; 32]; + for chunk in key_bytes.chunks_mut(8) { + chunk.copy_from_slice(&rng.next_u64().to_le_bytes()); + } + let sk = + k256::ecdsa::SigningKey::from_bytes(k256::FieldBytes::from_slice(&key_bytes)).expect("valid signing key"); + let point = sk.verifying_key().to_encoded_point(false); + let raw = point.as_bytes(); // 0x04 || x[32] || y[32] + let mut public_key = secp_unpack_words(&raw[1..33]); + public_key.extend(secp_unpack_words(&raw[33..65])); + let sig: k256::ecdsa::Signature = sk.sign_prehash(&secp_pack_msg_words(msg)).expect("signing must not fail"); + let sig_bytes = sig.to_bytes(); + let sig_raw: &[u8] = sig_bytes.as_ref(); + let mut signature = secp_unpack_words(&sig_raw[0..32]); + signature.extend(secp_unpack_words(&sig_raw[32..64])); + ( + public_key.try_into().expect("16 pk words"), + signature.try_into().expect("16 sig words"), + ) +} + +/// Boundary-biased u32 word for keccak input arrays. +fn keccak_word(rng: &mut Rng) -> u64 { + match rng.below(8) { + 0 => 0, + 1 => 1, + 2 => u32::MAX as u64, + 3 => (u32::MAX - 1) as u64, + 4 => 0x8000_0000, + _ => rng.below(1 << 32), + } +} + +fn write_crypto_artifact(seed: u64, kind: &str, source: &str) -> String { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("target") + .join("random-graph-failures"); + if fs::create_dir_all(&dir).is_err() { + return "".to_string(); + } + let path = dir.join(format!("crypto_{kind}_seed_{seed}.psy")); + if fs::write(&path, source).is_err() { + return "".to_string(); + } + path.display().to_string() +} + +fn crypto_expect(seed: u64, kind: &str, source: &str, expected: &[u64]) { + let outcome = psy_run(source, &[]); + let got = match &outcome { + PsyOutcome::Returned(v) => Some(vec![*v]), + PsyOutcome::ReturnedVec(v) => Some(v.clone()), + _ => None, + }; + if got.as_deref() != Some(expected) { + let artifact = write_crypto_artifact(seed, kind, source); + panic!( + "crypto intrinsic mismatch (seed {seed}, case {kind})\n \ + mirror: {expected:?}\n \ + psy: {outcome:?}\n \ + artifact: {artifact}\n \ + reproduce: PSY_RANDOM_GRAPH_SEED={seed} cargo test -p psy-interpreter crypto_intrinsics_match_native_rust\n\ + --- program ---\n{source}\ + ----------------" + ); + } +} + +/// Runs `f` with the process panic hook silenced. Expected rejection +/// panics from the VM eval path are caught by `catch_unwind` inside +/// `psy_run`, but the default hook still prints each one β€” under +/// `make test --nocapture` a green run floods the log and looks like a +/// crash. The mutex keeps concurrent quiet sections from interleaving +/// hook swaps (a stray quiet hook would swallow later panics' messages); +/// genuine mismatches panic from the test thread after the real hook is +/// restored, so they print normally. +fn with_quiet_panics(f: impl FnOnce() -> T) -> T { + static QUIET: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = QUIET.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let result = f(); + std::panic::set_hook(previous); + result +} + +/// The out-of-domain cases must fail cleanly (a caught panic from the VM's +/// eval path carrying the semantics error text), never fold to a value. +fn crypto_expect_reject(seed: u64, kind: &str, source: &str, needle: &str) { + let outcome = with_quiet_panics(|| psy_run(source, &[])); + if !matches!(&outcome, PsyOutcome::Panicked(msg) if msg.contains(needle)) { + let artifact = write_crypto_artifact(seed, kind, source); + panic!( + "crypto intrinsic should have been rejected (seed {seed}, case {kind})\n \ + expected panic containing: {needle:?}\n \ + psy: {outcome:?}\n \ + artifact: {artifact}\n \ + reproduce: PSY_RANDOM_GRAPH_SEED={seed} cargo test -p psy-interpreter crypto_intrinsics_match_native_rust\n\ + --- program ---\n{source}\ + ----------------" + ); + } +} + +fn crypto_case_poseidon(rng: &mut Rng, seed: u64, len: u64) { + let words: Vec = (0..len).map(|_| felt_literal(rng)).collect(); + let source = format!( + "use std::prelude::*;\n\nfn main() -> Hash {{\n return hash([{}]);\n}}\n", + words.iter().map(|w| w.to_string()).collect::>().join(", ") + ); + crypto_expect(seed, "poseidon", &source, &poseidon_hash_mirror(&words)); +} + +fn crypto_case_two_to_one(rng: &mut Rng, seed: u64) { + let left_words: Vec = (0..rng.below(3) + 1).map(|_| felt_literal(rng)).collect(); + let right_words: Vec = (0..rng.below(3) + 1).map(|_| felt_literal(rng)).collect(); + let fmt = |w: &[u64]| w.iter().map(|x| x.to_string()).collect::>().join(", "); + let source = format!( + "use std::prelude::*;\n\nfn main() -> Hash {{\n \ + let a: Hash = hash([{}]);\n \ + let b: Hash = hash([{}]);\n \ + return hash_two_to_one(a, b);\n}}\n", + fmt(&left_words), + fmt(&right_words) + ); + let expected = poseidon_two_to_one_mirror( + &poseidon_hash_mirror(&left_words), + &poseidon_hash_mirror(&right_words), + ); + crypto_expect(seed, "two_to_one", &source, &expected); +} + +fn crypto_case_keccak(rng: &mut Rng, seed: u64) { + let len = *rng.pick(&CRYPTO_KECCAC_LENS); + let words: Vec = (0..len).map(|_| keccak_word(rng)).collect(); + let source = format!( + "use std::prelude::*;\n\nfn main() -> [u32; 8] {{\n return keccak256([{}]);\n}}\n", + words.iter().map(|w| format!("{w}u32")).collect::>().join(", ") + ); + crypto_expect(seed, "keccak", &source, &keccak_mirror(&words)); +} + +fn crypto_case_split_bits(rng: &mut Rng, seed: u64) { + let n = *rng.pick(&CRYPTO_SPLIT_WIDTHS); + // 64 accepts every canonical felt; below that, half the values stay + // in width and half are pushed just past 2^n (still canonical, < p) + // to exercise the strict range check as an expected rejection. + let x = if n == 64 { + felt_literal(rng) + } else if rng.chance(50) { + rng.below(1u64 << n) + } else { + (1u64 << n) + rng.below((GOLDILOCKS_P as u64) - (1u64 << n)) + }; + let bits = split_bits_mirror(x, n); + let src_bits = format!( + "use std::prelude::*;\n\nfn main() -> [Felt; {n}] {{\n \ + let bits: [Felt; {n}] = split_bits({x}, {n});\n \ + return bits;\n}}\n" + ); + let src_sum = format!( + "use std::prelude::*;\n\nfn main() -> Felt {{\n \ + let bits: [Felt; {n}] = split_bits({x}, {n});\n \ + return sum_bits(bits);\n}}\n" + ); + match &bits { + Some(bits) => { + crypto_expect(seed, "split_bits", &src_bits, bits); + crypto_expect(seed, "split_sum_bits", &src_sum, &[sum_bits_mirror(bits)]); + } + // Both the array view and its weighted sum reject the same + // out-of-domain value. + None => { + crypto_expect_reject(seed, "split_bits_overflow", &src_bits, "does not fit in"); + crypto_expect_reject(seed, "split_sum_bits_overflow", &src_sum, "does not fit in"); + } + } +} + +fn crypto_case_sum_bits(rng: &mut Rng, seed: u64) { + // 20% of cases pin the exact 64-element maximum (below(64) + 1 could + // never reach it); the rest stay in 1..=63. + let len = if rng.chance(20) { 64 } else { rng.below(63) + 1 }; + // Bits are strict 0/1: the VM booleanizes every input (anything else + // is a clean "invalid bool value" rejection β€” pinned by + // bit_intrinsics_out_of_domain_fail_cleanly), so the differential + // stays in the valid domain. + let bits: Vec = (0..len).map(|_| rng.below(2)).collect(); + let source = format!( + "use std::prelude::*;\n\nfn main() -> Felt {{\n \ + let bits: [Felt; {len}] = [{}];\n \ + return sum_bits(bits);\n}}\n", + bits.iter().map(|b| b.to_string()).collect::>().join(", ") + ); + crypto_expect(seed, "sum_bits", &source, &[sum_bits_mirror(&bits)]); +} + +fn crypto_case_secp(rng: &mut Rng, seed: u64, tamper: bool) { + let mut msg: [u64; 4] = [felt_literal(rng), felt_literal(rng), felt_literal(rng), felt_literal(rng)]; + let (mut public_key, mut signature) = secp_valid_fixture(seed, &msg); + if tamper { + match rng.below(3) { + 0 => { + // keep the felt canonical when nudging past p - 1 + let i = rng.below(4) as usize; + msg[i] = if msg[i] >= (GOLDILOCKS_P - 1) as u64 { msg[i] - 1 } else { msg[i] + 1 }; + } + 1 => { + let i = rng.below(16) as usize; + signature[i] ^= 1; + } + _ => { + let i = rng.below(16) as usize; + public_key[i] ^= 1; + } + } + } + let expected = secp_mirror(&public_key, &signature, &msg); + assert_eq!(expected, !tamper, "fixture self-check (seed {seed}): tampering must invalidate"); + let fmt = |w: &[u64]| w.iter().map(|x| format!("{x}u32")).collect::>().join(", "); + let source = format!( + "use std::prelude::*;\n\nfn main() -> bool {{\n \ + let msg: Hash = [{}, {}, {}, {}];\n \ + let verified = secp256k1_verify([{}], msg, [{}]);\n \ + return verified;\n}}\n", + msg[0], + msg[1], + msg[2], + msg[3], + fmt(&public_key), + fmt(&signature) + ); + crypto_expect(seed, if tamper { "secp_tampered" } else { "secp_valid" }, &source, &[expected as u64]); +} + +fn check_crypto_seed(seed: u64) { + let mut rng = Rng::new(seed); + match seed % 8 { + 0 => { + let len = rng.below(3) + 1; + crypto_case_poseidon(&mut rng, seed, len) + } + 1 => { + // 5..=16 crosses the 12-element Poseidon state boundary + let len = rng.below(12) + 5; + crypto_case_poseidon(&mut rng, seed, len) + } + 2 => crypto_case_two_to_one(&mut rng, seed), + 3 => crypto_case_keccak(&mut rng, seed), + 4 => crypto_case_split_bits(&mut rng, seed), + 5 => crypto_case_sum_bits(&mut rng, seed), + 6 => crypto_case_secp(&mut rng, seed, false), + _ => crypto_case_secp(&mut rng, seed, true), + } +} + +/// Out-of-domain bit intrinsics must fail cleanly, never fold to a value: +/// a decomposition wider than the felt (`num_bits > 64`), a value wider +/// than the requested width, and a non-boolean sum_bits input are all +/// rejections under the strict semantics shared with the circuit β€” +/// `split_le` / `assert_bool` make those programs unsatisfiable anyway. +/// (The old eval zero-padded wide splits and raw-summed sum_bits, which is +/// exactly where it diverged from the circuit.) +#[test] +fn bit_intrinsics_out_of_domain_fail_cleanly() { + for n in [65u64, 100, 128] { + let source = format!( + "use std::prelude::*;\n\nfn main() -> Felt {{\n \ + let bits: [Felt; {n}] = split_bits(5, {n});\n \ + return sum_bits(bits);\n}}\n" + ); + crypto_expect_reject(0, "split_bits_wide", &source, "at most 64"); + } + crypto_expect_reject( + 0, + "split_bits_overflow", + "use std::prelude::*;\n\nfn main() -> Felt {\n \ + let bits: [Felt; 8] = split_bits(256, 8);\n \ + return sum_bits(bits);\n}\n", + "does not fit in", + ); + crypto_expect_reject( + 0, + "sum_bits_non_bool", + "use std::prelude::*;\n\nfn main() -> Felt {\n \ + let bits: [Felt; 2] = [1, 2];\n \ + return sum_bits(bits);\n}\n", + "invalid bool value", + ); + // 65 elements is past the 64-bit maximum: a [Felt; 65] literal is + // type-level fine, the rejection fires at sum_bits evaluation. + let ones = "1, ".repeat(64) + "1"; + crypto_expect_reject( + 0, + "sum_bits_too_long", + &format!( + "use std::prelude::*;\n\nfn main() -> Felt {{\n \ + let bits: [Felt; 65] = [{ones}];\n \ + return sum_bits(bits);\n}}\n" + ), + "at most 64", + ); +} + +/// Exact in-domain boundaries of the bit intrinsics, deterministically: +/// 2^n - 1 must decompose to all ones and its weighted fold must rebuild +/// the value. Width 64 is covered separately: its all-ones value 2^64 - 1 +/// is NOT a canonical felt (p = 2^64 - 2^32 + 1) β€” the frontend reduces +/// the literal mod p before splitting, so u64::MAX yields the bits of +/// u64::MAX - p, and p - 1 (bits 32..63) is the largest canonical felt. +#[test] +fn bit_intrinsics_exact_boundaries_match_native() { + for n in [1u64, 4, 8, 31, 32, 63] { + let value = ((1u128 << n) - 1) as u64; + let bits = split_bits_mirror(value, n).expect("2^n - 1 always fits"); + assert_eq!(bits, vec![1u64; n as usize]); + let src_bits = format!( + "use std::prelude::*;\n\nfn main() -> [Felt; {n}] {{\n \ + let bits: [Felt; {n}] = split_bits({value}, {n});\n \ + return bits;\n}}\n" + ); + crypto_expect(0, "split_all_ones", &src_bits, &bits); + let src_sum = format!( + "use std::prelude::*;\n\nfn main() -> Felt {{\n \ + let bits: [Felt; {n}] = split_bits({value}, {n});\n \ + return sum_bits(bits);\n}}\n" + ); + crypto_expect(0, "sum_all_ones", &src_sum, &[sum_bits_mirror(&bits)]); + } + let p = GOLDILOCKS_P as u64; + for (label, value) in [("u64_max_reduced", u64::MAX), ("p_minus_1", p - 1)] { + let canonical = if value >= p { value - p } else { value }; + let bits = split_bits_mirror(canonical, 64).expect("canonical felt fits 64 bits"); + let src_bits = format!( + "use std::prelude::*;\n\nfn main() -> [Felt; 64] {{\n \ + let bits: [Felt; 64] = split_bits({value}, 64);\n \ + return bits;\n}}\n" + ); + crypto_expect(0, label, &src_bits, &bits); + let src_sum = format!( + "use std::prelude::*;\n\nfn main() -> Felt {{\n \ + let bits: [Felt; 64] = split_bits({value}, 64);\n \ + return sum_bits(bits);\n}}\n" + ); + crypto_expect(0, label, &src_sum, &[sum_bits_mirror(&bits)]); + } +} + +/// Array element access (TargetAt) on intrinsic store nodes: indexing a +/// split_bits array and a keccak digest must read the element the native +/// mirrors hold. Constant out-of-bounds indices are a clean sema-level +/// IndexOutOfBounds error, so only in-range indices appear here. +#[test] +fn array_element_access_matches_native() { + let x: u64 = 0xABCD; + let bits = split_bits_mirror(x, 16).expect("in-width value"); + for i in [0usize, 1, 7, 15] { + let src = format!( + "use std::prelude::*;\n\nfn main() -> Felt {{\n \ + let bits: [Felt; 16] = split_bits({x}, 16);\n \ + return bits[{i}];\n}}\n" + ); + crypto_expect(0, "target_at_split", &src, &[bits[i]]); + } + let words: Vec = (1..=8u64).map(|w| w * 0x0101_0101).collect(); + let digest = keccak_mirror(&words); + let fmt = words.iter().map(|w| format!("{w}u32")).collect::>().join(", "); + for i in [0usize, 3, 7] { + let src = format!( + "use std::prelude::*;\n\nfn main() -> u32 {{\n \ + return keccak256([{fmt}])[{i}];\n}}\n" + ); + crypto_expect(0, "target_at_keccak", &src, &[digest[i]]); + } +} + +/// Degenerate secp256k1 inputs (all-zero key/signature words) must verify +/// to false, never abort the evaluation β€” malformed keys and out-of-range +/// (r, s) map to verification failure in every layer. +#[test] +fn secp_degenerate_inputs_return_false() { + let msg: [u64; 4] = [1, 2, 3, 4]; + let ones = vec![1u64; 16]; + let zeros = vec![0u64; 16]; + for (label, pk, sig) in [ + ("secp_zero_pk", &zeros, &ones), + ("secp_zero_sig", &ones, &zeros), + ("secp_all_zero", &zeros, &zeros), + ] { + let expected = secp_mirror(pk.as_slice().try_into().unwrap(), sig.as_slice().try_into().unwrap(), &msg); + assert!(!expected, "mirror self-check: degenerate input must not verify"); + let fmt = |w: &[u64]| w.iter().map(|x| format!("{x}u32")).collect::>().join(", "); + let source = format!( + "use std::prelude::*;\n\nfn main() -> bool {{\n \ + let msg: Hash = [1, 2, 3, 4];\n \ + let verified = secp256k1_verify([{pk}], msg, [{sig}]);\n \ + return verified;\n}}\n", + pk = fmt(pk), + sig = fmt(sig), + ); + crypto_expect(0, label, &source, &[0]); + } +} + +/// Exact field-wrap boundaries on the felt lane. The random graphs hit +/// these only probabilistically through FELT_EDGES bias; here each one is +/// pinned: (p-1) + 1 = 0, 0 - 1 = p-1, -0 = 0, (-1)^2 = 1, x/x = 1, +/// x % 1 = 0, odd x % 2 = 1. The runtime operand keeps evaluation on the +/// interpret path (the same const-arg shape as vm_const_fold_regressions). +#[test] +fn felt_boundary_arithmetic_matches_native() { + let p = GOLDILOCKS_P as u64; + let cases: [(&str, &str, u64, u64); 10] = [ + ("add_wraps_to_zero", "fn main(x: Felt) -> Felt { return x + 1; }", p - 1, f_add(p - 1, 1)), + ("add_wraps_at_p_minus_2", "fn main(x: Felt) -> Felt { return x + 2; }", p - 2, f_add(p - 2, 2)), + ("sub_wraps_from_zero", "fn main(x: Felt) -> Felt { return x - 1; }", 0, f_sub(0, 1)), + ("sub_const_left", "fn main(x: Felt) -> Felt { return 1 - x; }", 2, f_sub(1, 2)), + ("neg_zero_is_zero", "fn main(x: Felt) -> Felt { return -x; }", 0, f_neg(0)), + ("neg_p_minus_1_is_one", "fn main(x: Felt) -> Felt { return -x; }", p - 1, f_neg(p - 1)), + ("neg_one_squared", "fn main(x: Felt) -> Felt { return x * x; }", p - 1, f_mul(p - 1, p - 1)), + ("div_self_is_one", "fn main(x: Felt) -> Felt { return x / x; }", p - 1, 1), + ("mod_one_is_zero", "fn main(x: Felt) -> Felt { return x % 1; }", p - 1, 0), + ("mod_two_odd", "fn main(x: Felt) -> Felt { return x % 2; }", p - 2, 1), + ]; + for (label, source, input, expected) in cases { + match psy_run(source, &[Arg::Felt(input)]) { + PsyOutcome::Returned(v) if v == expected => {} + other => panic!("[{label}] expected Returned({expected}), got {other:?}"), + } + } +} + +/// External known-answer vector from tests/keccak_u32_regression_test.psy: +/// keccak256 over sixteen zero u32 words. Anchors both the interpreter and +/// the mirror against a value that was derived outside this suite. +#[test] +fn keccak_intrinsic_known_answer_vector() { + let words = vec![0u64; 16]; + let expected: [u64; 8] = [ + 2905745590, 1995953101, 1115989316, 1058533782, 725017745, 3003793586, 1079527909, 2545573813, + ]; + assert_eq!(keccak_mirror(&words), expected, "mirror must match the external vector"); + let source = format!( + "use std::prelude::*;\n\nfn main() -> [u32; 8] {{\n return keccak256([{}]);\n}}\n", + "0u32, ".repeat(15) + "0u32" + ); + crypto_expect(0, "keccak_kat", &source, &expected); +} + +#[test] +fn crypto_intrinsics_match_native_rust() { + if let Ok(raw) = std::env::var("PSY_RANDOM_GRAPH_SEED") { + check_crypto_seed(raw.trim().parse().expect("PSY_RANDOM_GRAPH_SEED must be a u64")); + return; + } + let iters: u64 = std::env::var("PSY_RANDOM_GRAPH_ITERS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(10_000); + let base = 0xC0FF_EE20_2609_21_u64; + for i in 0..iters { + check_crypto_seed(base ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + } + println!("crypto intrinsic differential: {iters} seeds passed"); +} diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index cb3faf5b6..6f5cdda3f 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -600,6 +600,7 @@ pub fn call_contract(caller_id: u64, contract_id: u64, method_name: &str, args_j checkpoint_id: chain.checkpoint_id, nonce: chain.transaction_log.len() as u64, user_public_key_hash: [0; 4], + session_proof_tree_root: [0; 4], }; let mut executor = VmExecutor::new(chain.state.clone()); @@ -767,6 +768,8 @@ struct ExecutionContextInput { nonce: Option, #[serde(default)] user_public_key_hash: Option<[u64; 4]>, + #[serde(default)] + session_proof_tree_root: Option<[u64; 4]>, } #[derive(Deserialize, Default)] @@ -1227,6 +1230,7 @@ fn default_execution_context() -> ExecutionContext { checkpoint_id: 0, nonce: 0, user_public_key_hash: [0; 4], + session_proof_tree_root: [0; 4], } } @@ -1239,6 +1243,7 @@ impl From for ExecutionContext { checkpoint_id: value.checkpoint_id.unwrap_or(0), nonce: value.nonce.unwrap_or(0), user_public_key_hash: value.user_public_key_hash.unwrap_or([0; 4]), + session_proof_tree_root: value.session_proof_tree_root.unwrap_or([0; 4]), } } } @@ -2552,6 +2557,7 @@ mod tests { checkpoint_id: None, nonce: None, user_public_key_hash: None, + session_proof_tree_root: None, }); assert_eq!(context.user_id, 0); assert_eq!(context.contract_id, 0); @@ -2567,6 +2573,7 @@ mod tests { checkpoint_id: Some(13), nonce: Some(17), user_public_key_hash: Some([1, 2, 3, 4]), + session_proof_tree_root: Some([5, 6, 7, 8]), }); assert_eq!(context.user_id, 7); assert_eq!(context.contract_id, 9); @@ -2574,6 +2581,7 @@ mod tests { assert_eq!(context.checkpoint_id, 13); assert_eq!(context.nonce, 17); assert_eq!(context.user_public_key_hash, [1, 2, 3, 4]); + assert_eq!(context.session_proof_tree_root, [5, 6, 7, 8]); let default = default_execution_context(); assert_eq!(default.user_id, 0); From 3a8903f3b2dbab155a17702e130deb7e5410d807 Mon Sep 17 00:00:00 2001 From: logere Date: Wed, 23 Sep 2026 17:10:33 +0800 Subject: [PATCH 12/12] refactor: represent contract deployer as u64 user id Replace the Hash/QHashOut deployer identity with the deployer's u64 user id across sema, interpreter, std, CLI, wasm, and precompiles: - get_contract_deployer now returns Felt (user id) instead of Hash - gen_deploy_json/gen_deploy_abi_json take a deployer_user_id (u64); default genesis deployer is the reserved id 0 so genesis precompiles can never be updated by an on-chain deployer - doc/execute/test commands and interpreter tests use a fixed u64 deployer instead of QHashOut::rand() - token/usdt_token mint checks deployer against get_user_id() --- psy-dargo-cli/examples/gen_deploy_abi_json.rs | 11 ++--- psy-dargo-cli/examples/gen_deploy_json.rs | 43 ++++++++++--------- psy-dargo-cli/src/cli/doc_cmd.rs | 2 +- psy-dargo-cli/src/cli/execute_cmd.rs | 2 +- psy-dargo-cli/src/cli/test_cmd.rs | 2 +- .../src/generic_instantiation_tests.rs | 4 +- psy-interpreter/src/intrinsic_exec_tests.rs | 2 +- psy-interpreter/src/lib.rs | 6 +-- ...s__interpreter@fn_chain_call_test.psy.snap | 3 +- ...reter__tests__interpreter@fn_test.psy.snap | 3 +- ..._interpreter@struct_field_fn_test.psy.snap | 3 +- ...ts__interpreter@struct_field_test.psy.snap | 3 +- ...__interpreter@struct_fn_call_test.psy.snap | 5 +-- ...r__tests__interpreter@struct_test.psy.snap | 5 +-- psy-interpreter/src/std_override_tests.rs | 2 +- psy-precompiles/token/src/main.psy | 4 +- psy-precompiles/usdt_token/src/main.psy | 4 +- psy-sema/src/lib.rs | 2 +- psy-std/context.psy | 2 +- psy-wasm/src/lib.rs | 4 +- 20 files changed, 55 insertions(+), 57 deletions(-) diff --git a/psy-dargo-cli/examples/gen_deploy_abi_json.rs b/psy-dargo-cli/examples/gen_deploy_abi_json.rs index 3d6381027..324267767 100644 --- a/psy-dargo-cli/examples/gen_deploy_abi_json.rs +++ b/psy-dargo-cli/examples/gen_deploy_abi_json.rs @@ -21,8 +21,9 @@ /// ../psy-precompiles/mining_rewards/target/mining_rewards.abi.json:mining_rewards use std::{env, fs, path::Path}; -// Same default genesis deployer as gen_deploy_json. -const DEFAULT_DEPLOYER: &str = "f83aa03c3e21321421696202b90f4dab0a9f87237c231bbba58b8f93c799126e"; +// Same default genesis deployer user id as gen_deploy_json: 0 is reserved, so +// genesis precompiles can never be updated by an on-chain deployer. +const DEFAULT_DEPLOYER: u64 = 0; fn get_file_stem(path: &str) -> String { Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("contract").to_string() @@ -75,7 +76,7 @@ fn main() -> anyhow::Result<()> { precompiles.push(ManifestPrecompile { contract_id: i as u32, name, - deployer: DEFAULT_DEPLOYER.to_string(), + deployer: DEFAULT_DEPLOYER, abi_path: abi_filename, state_tree_height, }); @@ -102,7 +103,7 @@ fn state_tree_height_from_abi(abi: &serde_json::Value, input_path: &str) -> anyh struct ManifestPrecompile { contract_id: u32, name: String, - deployer: String, + deployer: u64, abi_path: String, state_tree_height: u8, } @@ -119,7 +120,7 @@ fn build_manifest(precompiles: &[ManifestPrecompile]) -> String { out.push_str(" {\n"); out.push_str(&format!(" \"contract_id\": {},\n", p.contract_id)); out.push_str(&format!(" \"name\": \"{}\",\n", p.name)); - out.push_str(&format!(" \"deployer\": \"{}\",\n", p.deployer)); + out.push_str(&format!(" \"deployer\": {},\n", p.deployer)); out.push_str(&format!(" \"abi_path\": \"{}\",\n", p.abi_path)); out.push_str(&format!(" \"state_tree_height\": {}\n", p.state_tree_height)); out.push_str(&format!(" }}{}\n", comma)); diff --git a/psy-dargo-cli/examples/gen_deploy_json.rs b/psy-dargo-cli/examples/gen_deploy_json.rs index b570f237c..ddcb13ba8 100644 --- a/psy-dargo-cli/examples/gen_deploy_json.rs +++ b/psy-dargo-cli/examples/gen_deploy_json.rs @@ -4,8 +4,8 @@ /// /// Usage: /// cargo run --release --example gen_deploy_json -- -/// [:[:]] -/// [[:[:]]] ... +/// [:[:]] +/// [[:[:]]] ... /// /// Example: /// cargo run --release --example gen_deploy_json -- \ @@ -15,19 +15,19 @@ /// mining_rewards /// /// Each input can optionally specify deployer and name as: -/// path:deployer_hex:name If only deployer is given (path:deployer_hex), name -/// defaults to the file stem If neither deployer nor name is given (path), both -/// default to their defaults -use std::{env, fs, path::Path, str::FromStr}; +/// path:deployer_user_id:name If only deployer is given (path:deployer_user_id), +/// name defaults to the file stem If neither deployer nor name is given (path), +/// both default to their defaults +use std::{env, fs, path::Path}; -use psy_common::data::qhashout::QHashOut; use psy_data::config::store_config::{C, D}; use psy_prover::session::gen_contract_deploy_and_circuits_for_functions; use psy_vm::dpn::vm::def::DPNFunctionCircuitDefinition; use serde::Deserialize; -// Default genesis deployer (from existing genesis_contracts.json) -const DEFAULT_DEPLOYER: &str = "f83aa03c3e21321421696202b90f4dab0a9f87237c231bbba58b8f93c799126e"; +// Default genesis deployer user id: 0 is reserved, so genesis precompiles can +// never be updated by an on-chain deployer. +const DEFAULT_DEPLOYER: u64 = 0; const DEFAULT_STATE_TREE_HEIGHT: u8 = 32; #[derive(Deserialize)] @@ -52,11 +52,11 @@ fn main() -> anyhow::Result<()> { let args: Vec = env::args().collect(); if args.len() < 3 { - eprintln!("Usage: gen_deploy_json [:[:]] [[:[:]]] ..."); + eprintln!("Usage: gen_deploy_json [:[:]] [[:[:]]] ..."); eprintln!(); - eprintln!("Each input can optionally specify deployer and name as: path:deployer_hex:name"); + eprintln!("Each input can optionally specify deployer and name as: path:deployer_user_id:name"); eprintln!("Set GEN_DEPLOY_JSON_COMPACT=1 to write compact JSON"); - eprintln!("Default deployer: {}", DEFAULT_DEPLOYER); + eprintln!("Default deployer user id: {}", DEFAULT_DEPLOYER); eprintln!("Default name: file stem (e.g. token.json -> token)"); std::process::exit(1); } @@ -67,21 +67,19 @@ fn main() -> anyhow::Result<()> { let mut contract_objects = Vec::new(); for (i, input_arg) in inputs.iter().enumerate() { - // Parse "path:deployer_hex:name" or "path:deployer_hex" or just "path" + // Parse "path:deployer_user_id:name" or "path:deployer_user_id" or just "path" let parts: Vec<&str> = input_arg.split(':').collect(); let input_path = parts[0]; - let (deployer_hex, name) = match parts.len() { + let (deployer, name) = match parts.len() { 1 => (DEFAULT_DEPLOYER, get_file_stem(input_path)), - 2 => (parts[1], get_file_stem(input_path)), - 3 => (parts[1], parts[2].to_string()), + 2 => (parse_deployer(parts[1], input_path)?, get_file_stem(input_path)), + 3 => (parse_deployer(parts[1], input_path)?, parts[2].to_string()), _ => { - anyhow::bail!("Invalid input format: {}. Use path:deployer_hex:name", input_arg); + anyhow::bail!("Invalid input format: {}. Use path:deployer_user_id:name", input_arg); } }; - let deployer = QHashOut::from_str(deployer_hex).map_err(|e| anyhow::anyhow!("Invalid deployer hex for {}: {}", input_path, e))?; - // New compiler artifacts carry the authoritative layout-derived height. // Continue accepting legacy bare definition arrays with the old default. let input_json = fs::read_to_string(input_path).map_err(|e| anyhow::anyhow!("Failed to read {}: {}", input_path, e))?; @@ -106,7 +104,7 @@ fn main() -> anyhow::Result<()> { defs.len(), state_tree_height, deploy_contract.function_whitelist.len(), - deployer_hex + deployer ); // Serialize deploy_contract and wrap with name @@ -147,3 +145,8 @@ fn env_flag_enabled(name: &str) -> bool { .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")) .unwrap_or(false) } + +fn parse_deployer(raw: &str, input_path: &str) -> anyhow::Result { + raw.parse::() + .map_err(|e| anyhow::anyhow!("Invalid deployer user id `{}` for {}: {}", raw, input_path, e)) +} diff --git a/psy-dargo-cli/src/cli/doc_cmd.rs b/psy-dargo-cli/src/cli/doc_cmd.rs index 2d6d4aba9..ec48ef5c0 100644 --- a/psy-dargo-cli/src/cli/doc_cmd.rs +++ b/psy-dargo-cli/src/cli/doc_cmd.rs @@ -295,7 +295,7 @@ pub(crate) async fn run_doc(args: ExecuteCommand, workspace: Workspace) -> crate let pub_key_param = priv_key_w.get_public_key_param::(); let contract_state_tree_height = compilation_result.state_tree_height as usize; - let deployer = QHashOut::rand(); + let deployer: u64 = 1; let (circuits, deploy_cmd) = gen_contract_deploy_and_circuits_for_functions::(deployer, contract_state_tree_height as u8, &compilation_result.circuit_definitions)?; diff --git a/psy-dargo-cli/src/cli/execute_cmd.rs b/psy-dargo-cli/src/cli/execute_cmd.rs index 5e2f76701..771ef5a4e 100644 --- a/psy-dargo-cli/src/cli/execute_cmd.rs +++ b/psy-dargo-cli/src/cli/execute_cmd.rs @@ -49,7 +49,7 @@ pub(crate) async fn run(mut args: ExecuteCommand, workspace: Workspace) -> crate let pub_key_param = priv_key_w.get_public_key_param::(); let contract_state_tree_height = compile_results.state_tree_height as usize; - let deployer = QHashOut::rand(); + let deployer: u64 = 1; let (circuits, deploy_cmd) = gen_contract_deploy_and_circuits_for_functions::(deployer, contract_state_tree_height as u8, &compile_results.circuit_definitions)?; diff --git a/psy-dargo-cli/src/cli/test_cmd.rs b/psy-dargo-cli/src/cli/test_cmd.rs index e848bf779..5f89c4aad 100644 --- a/psy-dargo-cli/src/cli/test_cmd.rs +++ b/psy-dargo-cli/src/cli/test_cmd.rs @@ -42,7 +42,7 @@ pub(crate) async fn run(args: TestCommand) -> crate::errors::Result<()> { let pub_key_param = priv_key_w.get_public_key_param::(); let contract_state_tree_height = GLOBAL_USER_TREE_HEIGHT as usize; - let deployer = QHashOut::rand(); + let deployer: u64 = 1; let (circuits, deploy_cmd) = gen_contract_deploy_and_circuits_for_functions::(deployer, contract_state_tree_height as u8, &compile_results)?; diff --git a/psy-interpreter/src/generic_instantiation_tests.rs b/psy-interpreter/src/generic_instantiation_tests.rs index 30afaf100..4f749c6de 100644 --- a/psy-interpreter/src/generic_instantiation_tests.rs +++ b/psy-interpreter/src/generic_instantiation_tests.rs @@ -19,7 +19,7 @@ pub struct Point { fn probe_context(value: T) -> T { let user_id: Felt = get_user_id(); let contract_id: Felt = get_contract_id(); - let deployer: Hash = get_contract_deployer(contract_id); + let deployer: Felt = get_contract_deployer(contract_id); let tree_height: Felt = get_contract_state_tree_height(contract_id); let caller: Felt = get_caller_contract_id(); let checkpoint: Felt = get_checkpoint_id(); @@ -52,7 +52,7 @@ fn probe_context(value: T) -> T { let contains: bool = imt_contains(public_key_hash, 0, 4); let contains_other: bool = imt_contains_other_user(tree_height, user_id, contract_id, public_key_hash, 0, 4); assert(user_id > 0, "user id is positive"); - assert_eq(deployer[0 as Felt], state_hash[0 as Felt], "hash limbs agree"); + assert_eq(deployer, deployer, "deployer id is stable"); clear_entire_tree(); return value; } diff --git a/psy-interpreter/src/intrinsic_exec_tests.rs b/psy-interpreter/src/intrinsic_exec_tests.rs index 160fc685e..fa38ba7c7 100644 --- a/psy-interpreter/src/intrinsic_exec_tests.rs +++ b/psy-interpreter/src/intrinsic_exec_tests.rs @@ -127,7 +127,7 @@ fn context_identity_getters_execute() { let caller: Felt = get_caller_contract_id(); let checkpoint: Felt = get_checkpoint_id(); let nonce: Felt = get_last_nonce(); - let deployer: Hash = get_contract_deployer(contract); + let deployer: Felt = get_contract_deployer(contract); let height: Felt = get_contract_state_tree_height(contract); let pkh: Hash = get_user_public_key_hash(); let session: Hash = get_session_proof_tree_root(); diff --git a/psy-interpreter/src/lib.rs b/psy-interpreter/src/lib.rs index 19a979d65..a7e6ad06c 100644 --- a/psy-interpreter/src/lib.rs +++ b/psy-interpreter/src/lib.rs @@ -1186,9 +1186,9 @@ impl, C: DPNContext + 'static> Interpreter { match ctx_node { CheckedIntrinsicExprNode::GetUserId { .. } => CheckedValueRef::from_felt(self.context.get_user_id()), CheckedIntrinsicExprNode::GetContractId { .. } => CheckedValueRef::from_felt(self.context.get_contract_id()), - CheckedIntrinsicExprNode::GetContractDeployer { contract_id, type_id, .. } => { + CheckedIntrinsicExprNode::GetContractDeployer { contract_id, .. } => { let contract_id = self.interpret_expr(program, contract_id.clone(), ctx)?.to_felt(); - CheckedValueRef::from_vec(type_id.clone(), self.context.get_contract_deployer(contract_id)) + CheckedValueRef::from_felt(self.context.get_contract_deployer(contract_id)) } CheckedIntrinsicExprNode::GetContractStateTreeHeight { contract_id, .. } => { let contract_id = self.interpret_expr(program, *contract_id, ctx)?.to_felt(); @@ -2253,7 +2253,7 @@ mod tests { let pub_key_param = priv_key_w.get_public_key_param::(); let contract_state_tree_height = GLOBAL_USER_TREE_HEIGHT as usize; - let deployer = QHashOut::rand(); + let deployer: u64 = 0; let (_circuits, deploy_cmd) = gen_contract_deploy_and_circuits_for_functions::(deployer, contract_state_tree_height as u8, &compile_results).unwrap(); diff --git a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@fn_chain_call_test.psy.snap b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@fn_chain_call_test.psy.snap index 1a4281852..079ca65f4 100644 --- a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@fn_chain_call_test.psy.snap +++ b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@fn_chain_call_test.psy.snap @@ -1,6 +1,5 @@ --- source: psy-interpreter/src/lib.rs -assertion_line: 2175 expression: "ctx.debug_scope(ScopeId::root())" input_file: tests/fn_chain_call_test.psy --- @@ -427,7 +426,7 @@ ScopeId(0) (Module) pub fn get_contract_deployer (TypeId(73)) Parameters: Felt contract_id (Identifier { id: IdentId(55), location: Location { file_id: FileId(5), start: 179, end: 190 } }, TypeId(73)) - Return Type: Array (TypeId(9)) + Return Type: Felt (TypeId(3)) Body: ExprId(1531) pub fn get_contract_state_tree_height (TypeId(74)) Parameters: diff --git a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@fn_test.psy.snap b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@fn_test.psy.snap index 1f955ffdd..ce5a5a0d5 100644 --- a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@fn_test.psy.snap +++ b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@fn_test.psy.snap @@ -1,6 +1,5 @@ --- source: psy-interpreter/src/lib.rs -assertion_line: 2175 expression: "ctx.debug_scope(ScopeId::root())" input_file: tests/fn_test.psy --- @@ -432,7 +431,7 @@ ScopeId(0) (Module) pub fn get_contract_deployer (TypeId(73)) Parameters: Felt contract_id (Identifier { id: IdentId(57), location: Location { file_id: FileId(5), start: 179, end: 190 } }, TypeId(73)) - Return Type: Array (TypeId(9)) + Return Type: Felt (TypeId(3)) Body: ExprId(1531) pub fn get_contract_state_tree_height (TypeId(74)) Parameters: diff --git a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_field_fn_test.psy.snap b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_field_fn_test.psy.snap index 0e4a4b99a..e2b4e87e8 100644 --- a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_field_fn_test.psy.snap +++ b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_field_fn_test.psy.snap @@ -1,6 +1,5 @@ --- source: psy-interpreter/src/lib.rs -assertion_line: 2175 expression: "ctx.debug_scope(ScopeId::root())" input_file: tests/struct_field_fn_test.psy --- @@ -413,7 +412,7 @@ ScopeId(0) (Module) pub fn get_contract_deployer (TypeId(73)) Parameters: Felt contract_id (Identifier { id: IdentId(56), location: Location { file_id: FileId(5), start: 179, end: 190 } }, TypeId(73)) - Return Type: Array (TypeId(9)) + Return Type: Felt (TypeId(3)) Body: ExprId(1531) pub fn get_contract_state_tree_height (TypeId(74)) Parameters: diff --git a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_field_test.psy.snap b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_field_test.psy.snap index 0bccf5fb3..412f8fedf 100644 --- a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_field_test.psy.snap +++ b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_field_test.psy.snap @@ -1,6 +1,5 @@ --- source: psy-interpreter/src/lib.rs -assertion_line: 2175 expression: "ctx.debug_scope(ScopeId::root())" input_file: tests/struct_field_test.psy --- @@ -413,7 +412,7 @@ ScopeId(0) (Module) pub fn get_contract_deployer (TypeId(73)) Parameters: Felt contract_id (Identifier { id: IdentId(52), location: Location { file_id: FileId(5), start: 179, end: 190 } }, TypeId(73)) - Return Type: Array (TypeId(9)) + Return Type: Felt (TypeId(3)) Body: ExprId(1531) pub fn get_contract_state_tree_height (TypeId(74)) Parameters: diff --git a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_fn_call_test.psy.snap b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_fn_call_test.psy.snap index de4cbc1db..2ec1777d7 100644 --- a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_fn_call_test.psy.snap +++ b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_fn_call_test.psy.snap @@ -1,6 +1,5 @@ --- source: psy-interpreter/src/lib.rs -assertion_line: 2175 expression: "ctx.debug_scope(ScopeId::root())" input_file: tests/struct_fn_call_test.psy --- @@ -407,7 +406,7 @@ ScopeId(0) (Module) pub fn get_contract_deployer (TypeId(73)) Parameters: Felt contract_id (Identifier { id: IdentId(58), location: Location { file_id: FileId(5), start: 179, end: 190 } }, TypeId(73)) - Return Type: Array (TypeId(9)) + Return Type: Felt (TypeId(3)) Body: ExprId(1531) pub fn get_contract_state_tree_height (TypeId(74)) Parameters: @@ -1046,7 +1045,7 @@ ScopeId(0) (Module) pub fn get_contract_deployer (TypeId(73)) Parameters: Felt contract_id (Identifier { id: IdentId(58), location: Location { file_id: FileId(5), start: 179, end: 190 } }, TypeId(73)) - Return Type: Array (TypeId(9)) + Return Type: Felt (TypeId(3)) Body: ExprId(1531) pub fn get_contract_state_tree_height (TypeId(74)) Parameters: diff --git a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_test.psy.snap b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_test.psy.snap index 734184b39..ba0985cd1 100644 --- a/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_test.psy.snap +++ b/psy-interpreter/src/snapshots/psy_interpreter__tests__interpreter@struct_test.psy.snap @@ -1,6 +1,5 @@ --- source: psy-interpreter/src/lib.rs -assertion_line: 2175 expression: "ctx.debug_scope(ScopeId::root())" input_file: tests/struct_test.psy --- @@ -421,7 +420,7 @@ ScopeId(0) (Module) pub fn get_contract_deployer (TypeId(73)) Parameters: Felt contract_id (Identifier { id: IdentId(82), location: Location { file_id: FileId(5), start: 179, end: 190 } }, TypeId(73)) - Return Type: Array (TypeId(9)) + Return Type: Felt (TypeId(3)) Body: ExprId(1531) pub fn get_contract_state_tree_height (TypeId(74)) Parameters: @@ -1078,7 +1077,7 @@ ScopeId(0) (Module) pub fn get_contract_deployer (TypeId(73)) Parameters: Felt contract_id (Identifier { id: IdentId(82), location: Location { file_id: FileId(5), start: 179, end: 190 } }, TypeId(73)) - Return Type: Array (TypeId(9)) + Return Type: Felt (TypeId(3)) Body: ExprId(1531) pub fn get_contract_state_tree_height (TypeId(74)) Parameters: diff --git a/psy-interpreter/src/std_override_tests.rs b/psy-interpreter/src/std_override_tests.rs index 362d566f4..7773bdf02 100644 --- a/psy-interpreter/src/std_override_tests.rs +++ b/psy-interpreter/src/std_override_tests.rs @@ -19,7 +19,7 @@ const GENERIC_CONTEXT_PROBES: &str = r#" pub fn probe_raw_context_getters(v: T) -> T { let user_id: Felt = __ctx_get_user_id(); let contract_id: Felt = __ctx_get_contract_id(); - let deployer: Hash = __ctx_get_contract_deployer(contract_id); + let deployer: Felt = __ctx_get_contract_deployer(contract_id); let height: Felt = __ctx_get_contract_state_tree_height(contract_id); let caller: Felt = __ctx_get_caller_contract_id(); let checkpoint: Felt = __ctx_get_checkpoint_id(); diff --git a/psy-precompiles/token/src/main.psy b/psy-precompiles/token/src/main.psy index 8236a05d9..6b9e6b79c 100644 --- a/psy-precompiles/token/src/main.psy +++ b/psy-precompiles/token/src/main.psy @@ -411,8 +411,8 @@ impl PsyTokenContractRef { #[contract_method] pub fn mint(amount: Felt) { - let deployer: Hash = get_contract_deployer(get_contract_id()); - assert(Self::eq_hash(deployer, get_user_public_key_hash()), "only deployer can mint"); + let deployer: Felt = get_contract_deployer(get_contract_id()); + assert(deployer == get_user_id(), "only deployer can mint"); let c = PsyTokenContractRef::new(ContractMetadata::current()); c.balance += amount; diff --git a/psy-precompiles/usdt_token/src/main.psy b/psy-precompiles/usdt_token/src/main.psy index 6674910c3..3b355ac04 100644 --- a/psy-precompiles/usdt_token/src/main.psy +++ b/psy-precompiles/usdt_token/src/main.psy @@ -411,8 +411,8 @@ impl USDTTokenContractRef { #[contract_method] pub fn mint(amount: Felt) { - let deployer: Hash = get_contract_deployer(get_contract_id()); - assert(Self::eq_hash(deployer, get_user_public_key_hash()), "only deployer can mint"); + let deployer: Felt = get_contract_deployer(get_contract_id()); + assert(deployer == get_user_id(), "only deployer can mint"); let c = USDTTokenContractRef::new(ContractMetadata::current()); c.balance += amount; diff --git a/psy-sema/src/lib.rs b/psy-sema/src/lib.rs index e75c5e30a..f69ca9580 100644 --- a/psy-sema/src/lib.rs +++ b/psy-sema/src/lib.rs @@ -330,7 +330,7 @@ impl + ContextFelt, C> AstVisitor for TypeChecker Felt { __ctx_get_contract_id() } -pub fn get_contract_deployer(contract_id: Felt) -> Hash { +pub fn get_contract_deployer(contract_id: Felt) -> Felt { __ctx_get_contract_deployer(contract_id) } diff --git a/psy-wasm/src/lib.rs b/psy-wasm/src/lib.rs index 6f5cdda3f..d7b4fdaec 100644 --- a/psy-wasm/src/lib.rs +++ b/psy-wasm/src/lib.rs @@ -809,7 +809,7 @@ struct HashValueInput { #[derive(Deserialize)] struct DeployerInput { contract_id: u64, - deployer: [u64; 4], + deployer: u64, } #[derive(Deserialize)] @@ -2628,7 +2628,7 @@ mod tests { "initial_state": { "slots": [{ "user_id": 1, "contract_id": 1, "slot_index": 0, "value": 7 }], "hashes": [{ "user_id": 1, "contract_id": 1, "slot_index": 0, "value": [1, 2, 3, 4] }], - "deployers": [{ "contract_id": 1, "deployer": [1, 2, 3, 4] }], + "deployers": [{ "contract_id": 1, "deployer": 7 }], "checkpoint_stats": [{ "checkpoint_id": 1, "values": [1, 2] }], "contract_leaves": [{ "contract_id": 1, "values": [1, 2, 3, 4] }], "checkpoint_global_state_roots": [{ "checkpoint_id": 1, "values": [1, 2] }],