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
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
40 changes: 40 additions & 0 deletions doc/versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Compiler versioning

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

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

It is a **fail-fast compatibility check**: if the running compiler does not satisfy the range, compilation stops with a clear message instead of a confusing parser error. The directive is **optional** — a file without one still compiles, and `simc` prints a warning suggesting you add it. When present it must be the first non-comment item, at most once per file.

A version *range* does not pin the output: several compiler versions can satisfy it and produce different Commitment Merkle Roots (CMRs), hence different addresses. See [Reproducibility](#reproducibility).

## Version ranges

Standard [SemVer](https://semver.org) operators apply:

- `^0.6.0` / `0.6.0` — compatible updates (`0.6.1` yes, `0.7.0` no)
- `~0.6` — patch updates only
- `=0.6.0` — that exact version
- `>=0.6.0` — inequalities
- `0.x.x` — wildcards
- `>=0.6.0, <1.0.0` — comma-separated bounds

A pre-release compiler (e.g. `0.6.0-rc.0`) matches only ranges that request that base version.

## Multi-file projects

The entry file and every reachable dependency are checked; if any is incompatible, compilation halts. A stable library cannot be silently built with an incompatible compiler.

## Reproducibility

A deployed contract's address is its CMR, so pin an **exact** version (`=x.y.z`) for anything you deploy and verify the CMR that `simc` prints — a range alone is not reproducible. Selecting, pinning, and fetching compilers is the job of higher-level tooling, not `simc`.

## Tooling

The declared range is machine-readable without compiling the program. See the rustdoc on `version::requirement_of`.

## Flattened output

A flattened multi-file project carries no `simc` directive; since directives are optional it still recompiles. Threading the merged range through flattening is future work.
12 changes: 11 additions & 1 deletion 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 All @@ -38,6 +41,7 @@ use crate::parse::{self, ParseFromStrWithErrors};
use crate::resolution::{DependencyMap, ResolvedUse};
use crate::source::{CanonPath, CanonSourceFile};
use crate::unstable::UnstableFeatures;
use crate::version::check_source;

/// The reserved identifier for the program's entry point.
pub(crate) const MAIN_STR: &str = "main";
Expand Down Expand Up @@ -350,6 +354,9 @@ impl DependencyGraph {

let mut error_handler = ErrorCollector::new();
let source = CanonSourceFile::new(path.clone(), Arc::from(content));

check_source(&source, &mut error_handler);

let ast = parse::Program::parse_from_str_with_errors(
new_id,
source.clone(),
Expand Down Expand Up @@ -491,7 +498,8 @@ pub(crate) mod tests {
let mut root_file_path = None;
let mut root_content = String::new();

// Create all requested files
// Create all requested files. A directive is optional, so files are written
// verbatim — version_tests.rs declares directives explicitly where it tests them.
for (path, content) in files {
let full_path = format!("workspace/{}", path);
let created_file = canon(&ws.create_file(&full_path, content));
Expand All @@ -505,6 +513,8 @@ pub(crate) mod tests {
let root_p = root_file_path.expect("main.simf must be defined in file list");
let main_canon_source = CanonSourceFile::new(root_p, Arc::from(root_content));

check_source(&main_canon_source, &mut handler);

let main_program_option = parse::Program::parse_from_str_with_errors(
MAIN_MODULE,
main_canon_source.clone(),
Expand Down
225 changes: 225 additions & 0 deletions src/driver/version_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
//! Integration tests for the *multi-file* enforcement of `simc "...";` directives:
//! the entry file and every reachable dependency are checked, unreachable files
//! are not, and our directive scanner (duplicates, comments) behaves end to end.
//!
//! Semver matching and mismatch-message classification are tested directly and far
//! more cheaply in `crate::version`'s unit tests; they are deliberately not
//! re-asserted through the dependency graph here.

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

/// Builds the given files (replacing `{v}` with the current compiler version), runs
/// the dependency-graph build, and asserts the expected outcome. When `expect_success`
/// is false and `expected_err` is `Some`, the collected diagnostics must contain it.
fn check_versions(expect_success: bool, expected_err: Option<&str>, files: &[(&str, &str)]) {
let v = env!("CARGO_PKG_VERSION");
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);

if expect_success {
assert!(
graph_opt.is_some() && !handler.has_errors(),
"Scenario failed unexpectedly. Errors:\n{handler}"
);
return;
}

assert!(
graph_opt.is_none() || handler.has_errors(),
"Scenario succeeded when it should have failed."
);
if let Some(err) = expected_err {
assert!(
handler.to_string().contains(err),
"Expected error containing '{err}' but got:\n{handler}"
);
}
}

/// 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() {
check_versions(
true,
None,
&[
(
"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() {
check_versions(
false,
Some("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() {
check_versions(
false,
Some("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() {
check_versions(
true,
None,
&[
(
"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() {
check_versions(true, None, &[("main.simf", "fn main() {}")]);
}

/// A file may declare at most one directive.
#[test]
fn multiple_directives_same_file_fails() {
check_versions(
false,
Some("Exactly one compiler version directive"),
&[(
"main.simf",
r#"simc "={v}";
simc "={v}";
fn main() {}"#,
)],
);
}

/// A malformed version requirement surfaces through the pipeline.
#[test]
fn invalid_syntax_main() {
check_versions(
false,
Some("Invalid version syntax"),
&[
(
"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() {
check_versions(
true,
None,
&[(
"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() {
check_versions(
true,
None,
&[(
"main.simf",
r#"/*
simc "=99.0.0";
*/
simc "={v}";
fn main() {}"#,
)],
);
}
Loading
Loading