Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
# Unreleased

## Breaking Changes

* Reserve the `simc` keyword for the compiler version directive: identifiers named `simc` no longer lex. [#263](https://github.com/BlockstreamResearch/SimplicityHL/pull/263)

## Added

* Add the optional `simc "<range>";` compiler version directive: a fail-fast SemVer compatibility check run on the raw source before lexing, covering the entry file and every reachable dependency. A missing directive produces a CLI warning; tooling can read the declared range without compiling via `version::SimcDirective::requirement_of`. See `doc/versioning.md`. [#263](https://github.com/BlockstreamResearch/SimplicityHL/pull/263)
* Add `compiler_version` to `simc` output (JSON field and text printout) and `compiler_version()` accessors on `TemplateProgram` and `CompiledProgram`, identifying the exact compiler that produced a program. [#263](https://github.com/BlockstreamResearch/SimplicityHL/pull/263)

# 0.6.0 - 2026-06-26

## Breaking Changes
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ itertools = "0.13.0"
arbitrary = { version = "1", optional = true, features = ["derive"] }
clap = "4.5.37"
chumsky = "0.11.2"
semver = "1.0.27"

[target.wasm32-unknown-unknown.dependencies]
getrandom = { version = "0.2", features = ["js"] }
Expand Down
33 changes: 33 additions & 0 deletions doc/versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Compiler versioning

Every `.simf` file may begin with a compiler version directive:

```text
simc ">=0.6.0";
```

It is an optional fail-fast check: if the running compiler does not satisfy the
declared [SemVer](https://semver.org) range, compilation stops with a clear message
before lexing — even when the rest of the file is written for a newer language
version. Without a directive the file still compiles and `simc` prints a warning.
The directive must be the first non-comment item, at most once per file; `simc` is
a reserved keyword. In multi-file projects the entry file and every reachable
dependency are checked.

Range versions are plain `x.y.z` numbers: pre-release tags are not allowed in a
directive, and a pre-release compiler (e.g. `0.6.0-rc.2`) counts as its base
version.

## Reproducibility

A range does not pin the output: different compiler versions can satisfy it and
produce different Commitment Merkle Roots (CMRs), hence different addresses. Pin an
exact version (`=x.y.z`) for anything you deploy. Selecting, pinning, and fetching
compilers is the job of higher-level tooling, not `simc`.

## Details

See the rustdoc of `src/version.rs`: the checking flow, the frozen directive
syntax, pre-release handling, and the tooling entry point
(`SimcDirective::requirement_of`) are documented next to the code, so they cannot
drift.
3 changes: 3 additions & 0 deletions src/driver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
mod linearization;
mod resolve_order;

#[cfg(test)]
mod version_tests;

use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
Expand Down
257 changes: 257 additions & 0 deletions src/driver/version_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
//! Multi-file enforcement of `simc "...";` directives: the entry file and every
//! reachable dependency are checked. Semver matching itself is covered by
//! `crate::version`'s unit tests.

use crate::driver::tests::setup_graph_raw;

/// Builds the given files (with `{v}` replaced by the current compiler version)
/// through the dependency-graph build, returning success and collected diagnostics.
fn build(files: &[(&str, &str)]) -> (bool, String) {
// Ranges cannot name pre-releases, so an `-rc` compiler substitutes its base version.
let v = env!("CARGO_PKG_VERSION").split('-').next().unwrap();
let owned: Vec<(&str, String)> = files
.iter()
.map(|(p, c)| (*p, c.replace("{v}", v)))
.collect();
let refs: Vec<(&str, &str)> = owned.iter().map(|(p, c)| (*p, c.as_str())).collect();
let (graph_opt, _, _ws, handler) = setup_graph_raw(refs);
let ok = graph_opt.is_some() && !handler.has_errors();
(ok, handler.to_string())
}

fn assert_builds(files: &[(&str, &str)]) {
let (ok, errors) = build(files);
assert!(ok, "build failed unexpectedly. Errors:\n{errors}");
}

/// Like [`assert_builds`], but the build must fail with a diagnostic containing
/// `expected_err`.
fn assert_build_fails(expected_err: &str, files: &[(&str, &str)]) {
let (ok, errors) = build(files);
assert!(!ok, "build succeeded when it should have failed");
assert!(
errors.contains(expected_err),
"Expected error containing '{expected_err}' but got:\n{errors}"
);
}

/// A multi-file program whose every file declares a compatible directive compiles:
/// each directive is checked and stripped, and the bodies still parse across `use`.
#[test]
fn mixed_valid_operators() {
assert_builds(&[
(
"main.simf",
r#"simc "^{v}";
use lib::A::foo;
fn main() {}"#,
),
(
"libs/lib/A.simf",
r#"simc "={v}";
use crate::B::foo;
pub fn foo() {}"#,
),
(
"libs/lib/B.simf",
r#"simc ">0.1.0";
use crate::C::foo;
pub fn foo() {}"#,
),
(
"libs/lib/C.simf",
r#"simc "*";
pub fn foo() {}"#,
),
]);
}

/// The entry file's directive is checked.
#[test]
fn main_too_old_fails() {
assert_build_fails(
"Incompatible compiler version",
&[
(
"main.simf",
r#"simc ">99.0.0";
use lib::A::foo;
fn main() {}"#,
),
(
"libs/lib/A.simf",
r#"simc "={v}";
pub fn foo() {}"#,
),
],
);
}

/// Every reachable dependency's directive is checked, not just the entry file.
#[test]
fn lib_too_old_fails() {
assert_build_fails(
"Incompatible compiler version",
&[
(
"main.simf",
r#"simc "={v}";
use lib::A::foo;
fn main() {}"#,
),
(
"libs/lib/A.simf",
r#"simc ">99.0.0";
pub fn foo() {}"#,
),
],
);
}

/// A file that is never imported is not checked, even with an incompatible directive.
#[test]
fn unreferenced_file_with_invalid_version_ignored() {
assert_builds(&[
(
"main.simf",
r#"simc "={v}";
use lib::A::foo;
fn main() {}"#,
),
(
"libs/lib/A.simf",
r#"simc "={v}";
pub fn foo() {}"#,
),
(
"libs/lib/B.simf",
r#"simc ">99.0.0";
pub fn unused() {}"#,
),
]);
}

/// An omitted directive is allowed through the driver: only a present directive is
/// enforced, so a directive-less entry file builds successfully.
#[test]
fn directive_omitted() {
assert_builds(&[("main.simf", "fn main() {}")]);
}

/// The compatibility check runs on the raw text, before lexing: an incompatible
/// compiler is reported even when the file's body cannot be tokenized (here a string
/// literal, which the language does not have), instead of a pile of lex errors.
#[test]
fn incompatible_reported_despite_unlexable_body() {
assert_build_fails(
"Incompatible compiler version",
&[(
"main.simf",
r#"simc ">99.0.0";
fn main() { let s = "future syntax"; }"#,
)],
);
}

/// A stray directive is reported alone: its `"<range>";` remnant and the rest of
/// the parse must not add noise on top of the reserved-keyword error.
#[test]
fn stray_directive_reports_single_error() {
let (ok, errors) = build(&[(
"main.simf",
r#"simc "={v}";
simc "={v}";
fn main() {}"#,
)]);
assert!(!ok, "duplicate directive must fail the build");
assert!(
errors.contains("reserved"),
"expected the reserved-keyword error, got:\n{errors}"
);
assert!(
!errors.contains("Cannot parse"),
"remnant noise must be suppressed, got:\n{errors}"
);
}

/// A file may declare at most one directive: a second one is misplaced and gets a
/// targeted diagnostic.
#[test]
fn multiple_directives_same_file_fails() {
assert_build_fails(
"must be the first item",
&[(
"main.simf",
r#"simc "={v}";
simc "={v}";
fn main() {}"#,
)],
);
}

/// A directive after another item is misplaced and gets a targeted diagnostic,
/// with no noise from its `"<range>";` remnant.
#[test]
fn directive_after_item_fails() {
let (ok, errors) = build(&[(
"main.simf",
r#"fn main() {}
simc "={v}";"#,
)]);
assert!(!ok, "misplaced directive must fail the build");
assert!(
errors.contains("must be the first item"),
"expected the reserved-keyword error, got:\n{errors}"
);
assert!(
!errors.contains("Cannot parse"),
"remnant noise must be suppressed, got:\n{errors}"
);
}

/// A malformed version requirement surfaces through the pipeline.
#[test]
fn invalid_syntax_main() {
assert_build_fails(
"Invalid version requirement",
&[
(
"main.simf",
r#"simc "foo";
use lib::A::foo;
fn main() {}"#,
),
(
"libs/lib/A.simf",
r#"simc "={v}";
pub fn foo() {}"#,
),
],
);
}

/// A directive may follow a leading line comment; a commented-out directive does not
/// count.
#[test]
fn version_in_comment_ignored() {
assert_builds(&[(
"main.simf",
r#"// simc "=99.0.0";
simc "={v}";
fn main() {}"#,
)]);
}

/// A directive may follow a leading block comment; one inside the comment does not
/// count.
#[test]
fn version_in_block_comment_ignored() {
assert_builds(&[(
"main.simf",
r#"/*
simc "=99.0.0";
*/
simc "={v}";
fn main() {}"#,
)]);
}
Loading
Loading