From c1a426c0a9f0ed3cc4456ea486510a90b3bbd4fc Mon Sep 17 00:00:00 2001 From: Sdoba16 Date: Wed, 1 Jul 2026 09:16:15 +0200 Subject: [PATCH 1/4] Add version checking engine --- Cargo.lock | 7 + Cargo.toml | 1 + src/error.rs | 49 +++++ src/version.rs | 482 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 539 insertions(+) create mode 100644 src/version.rs diff --git a/Cargo.lock b/Cargo.lock index 6ea9b5aa..0508782c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -579,6 +579,12 @@ dependencies = [ "secp256k1-sys", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.188" @@ -655,6 +661,7 @@ dependencies = [ "getrandom", "itertools", "miniscript", + "semver", "serde", "serde_json", "simplicity-lang", diff --git a/Cargo.toml b/Cargo.toml index cff2c274..773958e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/error.rs b/src/error.rs index 2bcb11d4..9ece628f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -485,6 +485,13 @@ pub enum Error { UnstableFeature { feature: UnstableFeature, }, + InvalidSimcVersionSyntax { + err: String, + }, + SimcVersionMismatch { + required: String, + current: String, + }, DependencyPathNotFound { path: PathBuf, }, @@ -660,6 +667,13 @@ impl fmt::Display for Error { f, "The '{feature}' feature is not enabled.\nEnable it with: -Z {feature}" ), + Error::InvalidSimcVersionSyntax { err } => { + write!(f, "Invalid version syntax: {err}") + } + Error::SimcVersionMismatch { required, current } => write!( + f, + "Incompatible compiler version: file requires `{required}`, but the compiler is `{current}`. Update the compiler or the `simc` directive." + ), Error::DependencyPathNotFound { path } => write!( f, "Path not found: {}", path.display() @@ -1110,6 +1124,41 @@ let x: u32 = Left( assert_eq!(&expected[1..], &error.to_string()); } + #[test] + fn display_compiler_version_invalid_syntax() { + let file = "simc \"abc\";\nfn main() {}"; + let error = Error::InvalidSimcVersionSyntax { + err: "unexpected character 'a'".to_string(), + } + .with_span(Span::new_in_default_file(0..11)) + .with_content(Arc::from(file)); + + let expected = r#" + | +1 | simc "abc"; + | ^^^^^^^^^^^ Invalid version syntax: unexpected character 'a'"#; + + assert_eq!(&expected[1..], &error.to_string()); + } + + #[test] + fn display_compiler_version_mismatch() { + let file = "simc \">= 0.6.0\";\nfn main() {}"; + let error = Error::SimcVersionMismatch { + required: ">= 0.6.0".to_string(), + current: "0.5.0".to_string(), + } + .with_span(Span::new_in_default_file(0..16)) + .with_content(Arc::from(file)); + + let expected = r#" + | +1 | simc ">= 0.6.0"; + | ^^^^^^^^^^^^^^^^ Incompatible compiler version: file requires `>= 0.6.0`, but the compiler is `0.5.0`. Update the compiler or the `simc` directive."#; + + assert_eq!(&expected[1..], &error.to_string()); + } + // --- Tests with filename --- #[test] fn display_single_line_with_file() { diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 00000000..ced3505c --- /dev/null +++ b/src/version.rs @@ -0,0 +1,482 @@ +use std::borrow::Cow; + +use semver::{Version, VersionReq}; + +use crate::error::{Error, ErrorCollector, RichError, Span}; +use crate::source::SourceFile; + +/// Defines the `simc "";` directive syntax +pub const DIRECTIVE_PREFIX: &str = "simc \""; +pub const DIRECTIVE_SUFFIX: &str = "\";"; + +/// The running compiler's version (`CARGO_PKG_VERSION`). +pub fn current_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +/// Render a `simc "";` directive line +fn directive_for(version: &str) -> String { + format!("{DIRECTIVE_PREFIX}{version}{DIRECTIVE_SUFFIX}") +} + +/// Validate the leading `simc "...";` directive against the running compiler, +/// returning the diagnostic and its span on failure. Works on raw content, +/// independent of parsing the body, so directive errors surface clearly. +pub fn check(content: &str) -> Result<(), (Error, Span)> { + let (req_str, span) = match scan_directive(content) { + DirectiveScan::Found { req, span } => (req, span), + // Absence is allowed — only a present directive is enforced (the CLI warns). + DirectiveScan::Absent => return Ok(()), + // Report a broken directive here, rather than let it confuse the lexer. + DirectiveScan::Malformed(span) => { + return Err(( + Error::InvalidSimcVersionSyntax { + err: malformed_directive_message(), + }, + span, + )); + } + DirectiveScan::Duplicate(span) => { + return Err(( + Error::Syntax { + expected: vec!["Exactly one compiler version directive".to_string()], + label: None, + found: Some("Multiple directives".to_string()), + }, + span, + )); + } + DirectiveScan::Misplaced(span) => { + return Err(( + Error::Syntax { + expected: vec![ + "the `simc` directive to be the first item in the file".to_string() + ], + label: None, + found: Some("a compiler version directive after program code".to_string()), + }, + span, + )); + } + }; + + let required = req_str.trim(); + let req = VersionRequirement::parse(required) + .map_err(|e| (Error::InvalidSimcVersionSyntax { err: e }, span))?; + + let current = current_version(); + let current_ver = Version::parse(current).expect("CARGO_PKG_VERSION is valid semver"); + if !req.matches(¤t_ver) { + let err = Error::SimcVersionMismatch { + required: required.to_string(), + current: current.to_string(), + }; + return Err((err, span)); + } + + Ok(()) +} + +/// Run [`check`] on a source file and record any diagnostic in `handler`. The +/// per-file entry point used by the driver and `TemplateProgram`. +pub fn check_source + Clone>(source: &S, handler: &mut ErrorCollector) { + let source_file: SourceFile = source.clone().into(); + if let Err((err, span)) = check(&source_file.content()) { + handler.push(RichError::new(err, span).with_source(source_file)); + } +} + +/// The CLI advisory for a file that declares no directive, or `None` when one is +/// present (a malformed one is not "absent" — it surfaces as a hard error in +/// [`check`]). +pub fn missing_directive_warning(content: &str) -> Option { + matches!(requirement_of(content), Ok(None)).then(|| { + format!( + "no compiler version directive; consider adding `{}`", + directive_for(current_version()) + ) + }) +} + +/// Why [`requirement_of`] could not hand back a clean requirement. Only a single, +/// well-formed leading directive yields one; every other shape a file's directive can +/// take is a distinct variant here, so tooling can react without lexing the program. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DirectiveError { + /// More than one directive in the file (at most one is allowed). + Duplicate, + /// A directive that appears after program code (it must be the first item). + Misplaced, + /// A directive-looking line that is not a well-formed `simc "...";`, or a + /// requirement string that is not valid semver. Carries the diagnostic message. + InvalidSyntax(String), +} + +/// Cheaply read a file's declared version requirement (leading directive only, no +/// lexing), for external tooling. `Ok(None)` when absent. A malformed directive is +/// reported as [`DirectiveError::InvalidSyntax`], the same as [`check`], so a typo is +/// never silently treated as "no directive". +pub fn requirement_of(content: &str) -> Result, DirectiveError> { + match scan_directive(content) { + DirectiveScan::Found { req, .. } => VersionRequirement::parse(req.trim()) + .map(Some) + .map_err(DirectiveError::InvalidSyntax), + DirectiveScan::Absent => Ok(None), + DirectiveScan::Malformed(_) => { + Err(DirectiveError::InvalidSyntax(malformed_directive_message())) + } + DirectiveScan::Duplicate(_) => Err(DirectiveError::Duplicate), + DirectiveScan::Misplaced(_) => Err(DirectiveError::Misplaced), + } +} + +/// A parsed directive requirement: the semver range written inside the quotes. Wraps +/// [`semver::VersionReq`] so [`Self::matches`] can apply compiler-aware pre-release +/// handling — a plain release range still accepts a pre-release build of that version. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VersionRequirement { + req: VersionReq, +} + +impl VersionRequirement { + /// Parse a requirement string such as `>=0.6.0` or `=0.6.0`. + pub fn parse(s: &str) -> Result { + VersionReq::parse(s) + .map(|req| VersionRequirement { req }) + .map_err(|e| e.to_string()) + } + + /// The underlying requirement, for external tooling that + /// intersects ranges across a project's files. + pub fn req(&self) -> &VersionReq { + &self.req + } + + #[allow(rustdoc::private_intra_doc_links)] + /// Whether `version` satisfies the requirement, after pre-release + /// normalization (see [`Self::effective_version`]). + pub fn matches(&self, version: &Version) -> bool { + self.req.matches(&self.effective_version(version)) + } + + /// Strip the compiler's pre-release tag (`0.6.0-rc.0` → `0.6.0`) when the + /// requirement names no pre-release, so a release range still accepts a matching + /// pre-release compiler. Without this, semver would reject `0.6.0-rc.0` for a + /// plain `>=0.6.0`. + fn effective_version(&self, version: &Version) -> Version { + let req_allows_pre = self.req.comparators.iter().any(|c| !c.pre.is_empty()); + if req_allows_pre || version.pre.is_empty() { + version.clone() + } else { + Version { + pre: semver::Prerelease::EMPTY, + ..version.clone() + } + } + } +} + +/// Replace the directive with equal-length spaces so the parser never sees it +/// while later byte offsets (and thus error spans) stay correct. +pub(crate) fn blank_version_directive(content: &str) -> Cow<'_, str> { + let directives: Vec<_> = leading_directives(content).collect(); + if directives.is_empty() { + return Cow::Borrowed(content); + } + + let mut buf = content.to_string(); + // Blank duplicates too; `check` reports them, not the parser. + for (_, span) in &directives { + buf.replace_range(span.start..span.end, &" ".repeat(span.end - span.start)); + } + // A directive-only file blanks to whitespace, i.e. an empty program. + Cow::Owned(if buf.trim().is_empty() { + String::new() + } else { + buf + }) +} + +/// The leading lines that carry code or directives, as `(full line, trimmed line, +/// byte offset of the line)`. Comments and blank lines are skipped; offsets keep +/// counting through them so spans stay correct. +fn meaningful_lines(content: &str) -> impl Iterator { + let mut in_block_comment = false; + let mut offset = 0; + content.split_inclusive('\n').filter_map(move |line| { + let start = offset; + offset += line.len(); + let trimmed = skip_block_comments(line.trim_start(), &mut in_block_comment); + let skippable = in_block_comment || trimmed.is_empty() || trimmed.starts_with("//"); + (!skippable).then_some((line, trimmed, start)) + }) +} + +/// The leading `simc "...";` directives in order, stopping at the first line of +/// program code. More than one item means the file declared duplicates. +fn leading_directives(content: &str) -> impl Iterator { + meaningful_lines(content) + .map_while(|(line, trimmed, offset)| extract_directive_from_line(line, trimmed, offset)) +} + +/// The outcome of scanning a file for its compiler-version directive: the single +/// well-formed leading directive, its clean absence, or the specific way it is broken. +/// Centralizes the placement rules so [`check`] and [`requirement_of`] agree, and so a +/// misplaced or malformed directive yields a clear diagnostic instead of leaking to the +/// lexer as a confusing parse error. +enum DirectiveScan<'a> { + /// A single well-formed directive, correctly placed before any program code. + Found { req: &'a str, span: Span }, + /// No directive anywhere in the file. + Absent, + /// A directive-looking line that is not a well-formed `simc "...";`. + Malformed(Span), + /// More than one directive. + Duplicate(Span), + /// A well-formed directive that appears after program code. + Misplaced(Span), +} + +/// Scan `content` for its compiler-version directive. A directive must be the first +/// meaningful item and may appear at most once; the first deviation is reported with +/// the span of the offending line. Scans past the leading region so a directive +/// misplaced after code is caught rather than handed to the lexer. +fn scan_directive(content: &str) -> DirectiveScan<'_> { + let mut found: Option<(&str, Span)> = None; + let mut seen_code = false; + for (line, trimmed, offset) in meaningful_lines(content) { + if let Some((req, span)) = extract_directive_from_line(line, trimmed, offset) { + if found.is_some() { + return DirectiveScan::Duplicate(span); + } + if seen_code { + return DirectiveScan::Misplaced(span); + } + found = Some((req, span)); + } else if !seen_code && directive_looking(trimmed) { + // Before code, a `simc`-looking line that didn't parse is malformed. After + // code we leave it be, so a `simc`-prefixed identifier isn't misreported. + return DirectiveScan::Malformed(directive_looking_span(line, trimmed, offset)); + } else { + seen_code = true; + } + } + match found { + Some((req, span)) => DirectiveScan::Found { req, span }, + None => DirectiveScan::Absent, + } +} + +/// Parse one line as a `simc "...";` directive, returning the requirement string +/// and the span covering `simc "...";`. +fn extract_directive_from_line<'a>( + line: &str, + trimmed: &'a str, + current_offset: usize, +) -> Option<(&'a str, Span)> { + if !trimmed.starts_with("simc") { + return None; + } + let after_simc = &trimmed[4..]; + if !after_simc.is_empty() && !after_simc.starts_with(|c: char| c.is_whitespace() || c == '"') { + return None; + } + + let rest = after_simc.trim_start(); + let rest = rest.strip_prefix('"')?; + let end_quote_idx = rest.find('"')?; + let req_str = &rest[..end_quote_idx]; + + let after_quote = rest[end_quote_idx + 1..].trim_start(); + if !after_quote.starts_with(';') { + return None; + } + + // Both are suffixes of `line`, so their lengths give the byte offsets directly. + let span_start = current_offset + (line.len() - trimmed.len()); + let span_end = span_start + (trimmed.len() - after_quote.len()) + 1; + + Some((req_str, Span::new(span_start, span_end))) +} + +/// Skip past `/* ... */` so a directive may follow a leading comment block. +fn skip_block_comments<'a>(mut trimmed: &'a str, in_block_comment: &mut bool) -> &'a str { + loop { + if *in_block_comment { + if let Some(end_idx) = trimmed.find("*/") { + trimmed = trimmed[end_idx + 2..].trim_start(); + *in_block_comment = false; + } else { + break; + } + } else if let Some(rest) = trimmed.strip_prefix("/*") { + *in_block_comment = true; + trimmed = rest; + } else { + break; + } + } + trimmed +} + +/// Whether a trimmed line begins a `simc "...";` directive attempt (well-formed or +/// not) — used to tell a malformed directive apart from ordinary program code. +fn directive_looking(trimmed: &str) -> bool { + trimmed.strip_prefix("simc").is_some_and(|after| { + after.is_empty() || after.starts_with(|c: char| c.is_whitespace() || c == '"') + }) +} + +/// The span covering a directive-looking line, for reporting a malformed directive. +fn directive_looking_span(line: &str, trimmed: &str, offset: usize) -> Span { + let start = offset + (line.len() - trimmed.len()); + Span::new(start, start + trimmed.trim_end().len()) +} + +/// The message shared by [`check`] and [`requirement_of`] for a leading line that +/// looks like a directive but is malformed, so the two agree on the wording. +fn malformed_directive_message() -> String { + format!( + "malformed compiler version directive; expected `{}`", + directive_for("") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `matches` accepts or rejects requirements against the running compiler, + /// exercising the pre-release normalization in `effective_version`: a release + /// range still accepts the pre-release compiler, but semver pre-release gating + /// (and reordered compound ranges) still bite. `0.6.0-rc.0` stands in for the + /// compiler. + #[test] + fn matches_respects_operators_and_prerelease() { + let cur = Version::parse("0.6.0-rc.0").unwrap(); + let accepted = [ + "*", + "0.6.0", + "^0.6.0", + "~0.6.0", + ">=0.6.0", + ">0.1.0", + "=0.6.0-rc.0", + "^0.6.0-rc.0", + ]; + let rejected = [ + "=0.5.0", + ">99.0.0", + "<0.0.1", + "<0.6.0", // -rc tag stripped, so 0.6.0 is not < 0.6.0 + ">=0.1.0-alpha.1", // pre-release gating: different base, so no match + ">=0.7.0, =0.6.0", // the `=0.6.0` must not rescue the failing `>=0.7.0` + ]; + for req in accepted { + let req = VersionRequirement::parse(req).unwrap(); + assert!(req.matches(&cur), "`{req:?}` should match {cur}"); + } + for req in rejected { + let parsed = VersionRequirement::parse(req).unwrap(); + assert!(!parsed.matches(&cur), "`{req}` should not match {cur}"); + } + } + + #[test] + fn malformed_leading_directive_is_syntax_error_not_missing() { + // A directive attempt with a missing semicolon or closing quote is a syntax + // error, not an absent directive. + for src in [ + "simc \"=0.5.0\"\nfn main() {}", + "simc \"=0.5.0\nfn main() {}", + ] { + let (err, _span) = check(src).unwrap_err(); + assert!( + matches!(err, Error::InvalidSimcVersionSyntax { .. }), + "{src:?}" + ); + } + } + + #[test] + fn misplaced_or_extra_directive_is_reported_not_leaked() { + // A well-formed directive after program code is misplaced (it must be the first + // item) — reported here, not handed to the parser as a stray `simc` token. + let misplaced = "use foo;\nsimc \"*\";"; + assert!(matches!( + check(misplaced).unwrap_err().0, + Error::Syntax { .. } + )); + assert_eq!(requirement_of(misplaced), Err(DirectiveError::Misplaced)); + + // A malformed *second* directive (before any code) is a syntax error, not a + // silently ignored extra line that map_while used to stop short of. + let bad_second = "simc \"*\";\nsimc \"bad\nfn main() {}"; + assert!(matches!( + check(bad_second).unwrap_err().0, + Error::InvalidSimcVersionSyntax { .. } + )); + assert!(matches!( + requirement_of(bad_second), + Err(DirectiveError::InvalidSyntax(_)) + )); + } + + #[test] + fn directive_scanned_through_leading_comments() { + // The directive is the first *meaningful* line: leading `//` lines, blank + // lines, and `/* */` blocks (multi-line or inline) are skipped, and the span + // still lands exactly on `simc "...";` because offsets keep counting through + // the skipped text. `*` matches any compiler, so this stays version-bump proof. + for src in [ + "// header\n// notes\n\n/* a block\n comment */\nsimc \"*\";\nfn main() {}", + "/* lead */ simc \"*\";\nfn main() {}", + ] { + assert!( + check(src).is_ok(), + "should accept directive after comments: {src:?}" + ); + let DirectiveScan::Found { req, span } = scan_directive(src) else { + panic!("directive found past the comments: {src:?}"); + }; + assert_eq!(req, "*", "{src:?}"); + assert_eq!( + &src[span.start..span.end], + "simc \"*\";", + "span must cover the directive in {src:?}" + ); + } + + // A commented-out directive does not count; the real one after it does. + let commented = "// simc \"=99.0.0\";\nsimc \"*\";\nfn main() {}"; + let DirectiveScan::Found { req, .. } = scan_directive(commented) else { + panic!("real directive after the commented-out one"); + }; + assert_eq!(req, "*"); + } + + #[test] + fn requirement_of_reads_or_rejects_directive() { + // `req()` exposes the underlying semver requirement for range-intersecting tooling. + let parsed = requirement_of("simc \">=0.1.0\";\nfn main() {}") + .unwrap() + .expect("directive present"); + assert_eq!(parsed.req(), &VersionReq::parse(">=0.1.0").unwrap()); + assert_eq!(requirement_of("fn main() {}"), Ok(None)); + assert!(matches!( + requirement_of("simc \"not-a-version\";\nfn main() {}"), + Err(DirectiveError::InvalidSyntax(_)) + )); + // A malformed directive is a syntax error here too, not silently "absent", + // so tooling and the compiler agree (the missing semicolon below). + assert!(matches!( + requirement_of("simc \"=0.1.0\"\nfn main() {}"), + Err(DirectiveError::InvalidSyntax(_)) + )); + // Duplicates are only reachable through this external entry point. + assert_eq!( + requirement_of("simc \"=0.1.0\";\nsimc \"=0.2.0\";\nfn main() {}"), + Err(DirectiveError::Duplicate) + ); + } +} From cdb618fee7bf2b92119a05b8cfec100bf90c8b2e Mon Sep 17 00:00:00 2001 From: Sdoba16 Date: Wed, 1 Jul 2026 09:16:31 +0200 Subject: [PATCH 2/4] Integrate version checking engine into compiler --- src/driver/mod.rs | 9 +++- src/lexer.rs | 8 +++- src/lib.rs | 111 +++++++++++++++++++++++++++++++++++----------- src/main.rs | 5 +++ src/parse.rs | 12 ++++- src/tracker.rs | 10 +++-- src/version.rs | 5 ++- 7 files changed, 123 insertions(+), 37 deletions(-) diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 4273f8d4..bdb7dc3d 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -38,6 +38,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"; @@ -350,6 +351,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(), @@ -491,7 +495,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)); @@ -505,6 +510,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(), diff --git a/src/lexer.rs b/src/lexer.rs index b98ef3ea..ec6aae92 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -275,6 +275,7 @@ mod tests { use chumsky::error::Rich; use super::*; + use crate::version::blank_version_directive; fn lex<'src>( input: &'src str, @@ -369,10 +370,13 @@ mod tests { fn lexer_test() { use chumsky::prelude::*; - // Check if the lexer parses the example file without errors. + // Check if the lexer parses the example file without errors. The leading + // `simc "...";` directive is blanked before lexing in every production path + // (it is not a language token), so mirror that here. let src = include_str!("../examples/last_will.simf"); + let src = blank_version_directive(src); - let (tokens, lex_errs) = lexer().parse(src).into_output_errors(); + let (tokens, lex_errs) = lexer().parse(src.as_ref()).into_output_errors(); let _ = tokens.unwrap(); assert!(lex_errs.is_empty()); diff --git a/src/lib.rs b/src/lib.rs index ee036498..51333f56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod test_utils; pub mod tracker; pub mod types; pub mod value; +pub mod version; mod witness; use std::sync::Arc; @@ -48,6 +49,7 @@ use crate::source::SourceFile; pub use crate::types::ResolvedType; pub use crate::unstable::{UnstableFeature, UnstableFeatures}; pub use crate::value::Value; +use crate::version::check_source; pub use crate::witness::{Arguments, Parameters, WitnessTypes, WitnessValues}; /// The template of a SimplicityHL program. @@ -153,31 +155,37 @@ impl TemplateProgram { let source = SourceFile::anonymous(file.clone()); let mut error_handler = ErrorCollector::new(); - let parsed_program = parse::Program::parse_from_str_with_errors( + // Fail fast on a directive problem so the parser never sees a `simc` line: a + // malformed or misplaced directive surfaces as a clear diagnostic here, instead + // of a confusing downstream parse error. + check_source(&source, &mut error_handler); + if error_handler.has_errors() { + return Err(error_handler); + } + + let Some(program) = parse::Program::parse_from_str_with_errors( MAIN_MODULE, source, unstable_features, &mut error_handler, - ); + ) else { + return Err(error_handler); + }; - if let Some(program) = parsed_program { - let Ok(ast_program) = ast::Program::analyze(&program, jet_hinter.clone_box()) - .with_content(Arc::clone(&file)) - .map_err(|e| error_handler.push(e)) - else { - Err(error_handler)? - }; + let Ok(ast_program) = ast::Program::analyze(&program, jet_hinter.clone_box()) + .with_content(Arc::clone(&file)) + .map_err(|e| error_handler.push(e)) + else { + return Err(error_handler); + }; - Ok(Self { - simfony: ast_program, - file, - jet_hinter, - source_map: SourceMap::default(), - resolved_program: program, - }) - } else { - Err(error_handler)? - } + Ok(Self { + simfony: ast_program, + file, + jet_hinter, + source_map: SourceMap::default(), + resolved_program: program, + }) } fn dependency_helper( @@ -186,6 +194,15 @@ impl TemplateProgram { unstable_features: &UnstableFeatures, handler: &mut ErrorCollector, ) -> Result<(Option, SourceMap), String> { + // Run the version check first and fail fast: a malformed, misplaced, or + // incompatible directive surfaces as a clear diagnostic here, instead of being + // hidden behind — or compounded by — a raw lexer/parser error. (`check_source` + // pushes to `handler`; `parse_from_str_with_errors` returns `Some` only when the + // handler is error-free, so no post-parse re-check is needed.) + check_source(&source, handler); + if handler.has_errors() { + return Err(handler.to_string()); + } let program = parse::Program::parse_from_str_with_errors( MAIN_MODULE, source.clone(), @@ -1044,7 +1061,7 @@ pub(crate) mod tests { let ws = TempWorkspace::new("crate_success"); let root = ws.create_dir("workspace"); ws.create_file( - format!("workspace/{MAIN}").as_str(), + "workspace/main.simf", "use crate::utils::add;\nfn main() { assert!(jet::eq_32(add(2, 2), 4)); }", ); ws.create_file( @@ -1073,7 +1090,8 @@ pub(crate) mod tests { let program = TemplateProgram::new(code, Box::new(ElementsJetHinter::new())); assert!( program.is_ok(), - "TemplateProgram::new should successfully compile anonymous source files without requiring canonical paths" + "TemplateProgram::new should successfully compile anonymous source files without requiring canonical paths, error: {:?}", + program.err() ); } @@ -1270,7 +1288,8 @@ pub(crate) mod tests { #[test] fn empty_function_body_nonempty_return() { - let prog_text = r#"fn my_true() -> bool { + let prog_text = r#" +fn my_true() -> bool { // function body is empty, although function must return `bool` } @@ -1530,13 +1549,53 @@ fn main() { regression_test("transfer_with_timeout"); } } + + /// An omitted directive is allowed: the compiler only enforces a directive that is + /// present, so a directive-less program compiles. The dependency-path equivalent is + /// covered end to end by the CLI test `cli_version_missing_warns_but_compiles`. + #[test] + fn directive_omitted() { + let result = TemplateProgram::new( + "fn main() {}", + Box::new(crate::ast::ElementsJetHinter::new()), + ); + assert!(result.is_ok(), "expected success, got: {:?}", result.err()); + } + + // Smoke tests that the version check is wired into `TemplateProgram::new`: one + // compatible directive compiles, one incompatible directive aborts. The semver + // matching and per-kind messages are covered exhaustively in `version`'s unit + // tests, so they are not re-asserted through the pipeline here. + #[test] + fn compatible_directive_compiles() { + let compatible = format!( + "simc \"{}\";\nfn main() {{}}", + crate::version::current_version() + ); + assert!( + TemplateProgram::new(compatible, Box::new(crate::ast::ElementsJetHinter::new())) + .is_ok() + ); + } + + #[test] + fn incompatible_directive_aborts() { + let too_old = "simc \">= 99.99.99\";\nfn main() {}"; + let err = TemplateProgram::new(too_old, Box::new(crate::ast::ElementsJetHinter::new())) + .unwrap_err() + .to_string(); + assert!( + err.contains("Incompatible compiler version"), + "Expected 'Incompatible compiler version', got: {}", + err + ); + } } #[cfg(test)] mod error_tests { use std::path::Path; - use super::tests::MAIN; use super::*; use crate::ast::ElementsJetHinter; @@ -1563,7 +1622,7 @@ mod error_tests { let root_dir = ws.create_dir("workspace"); let lib_dir = ws.create_dir("workspace/lib"); let main_path = ws.create_file( - format!("workspace/{MAIN}").as_str(), + "workspace/main.simf", "use lib::bad::f;\nfn main() { f(); }\n", ); let bad_path = ws.create_file( @@ -1594,7 +1653,7 @@ mod error_tests { let root_dir = ws.create_dir("workspace"); let lib_dir = ws.create_dir("workspace/lib"); let main_path = ws.create_file( - format!("workspace/{MAIN}").as_str(), + "workspace/main.simf", "use lib::nested::two;\nfn main() { assert!(jet::eq_32(two(), 2)); }\n", ); ws.create_file( @@ -1619,7 +1678,7 @@ mod error_tests { let root_dir = ws.create_dir("workspace"); let lib_dir = ws.create_dir("workspace/lib"); let main_path = ws.create_file( - format!("workspace/{MAIN}").as_str(), + "workspace/main.simf", "use lib::missing::Thing;\nfn main() {}\n", ); let dependencies = dependency_map(&root_dir, "lib", &lib_dir); diff --git a/src/main.rs b/src/main.rs index 7a870034..c3a5e5ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use base64::engine::general_purpose::STANDARD; use clap::{Arg, ArgAction, Command}; use simplicityhl::ast::ElementsJetHinter; +use simplicityhl::version::missing_directive_warning; use simplicityhl::{ resolution::DependencyMapBuilder, source::CanonPath, source::CanonSourceFile, AbiMeta, CompiledProgram, @@ -112,6 +113,10 @@ fn main() -> Result<(), Box> { let prog_file = matches.get_one::("prog_file").unwrap(); let main_path = CanonPath::canonicalize(Path::new(prog_file))?; let main_text = std::fs::read_to_string(main_path.as_path()).map_err(|e| e.to_string())?; + // Entry file only; deps are still version-checked in the driver, just not warned. + if let Some(warning) = missing_directive_warning(&main_text) { + eprintln!("Warning: {warning}"); + } let include_debug_symbols = matches.get_flag("debug"); let output_json = matches.get_flag("json"); let abi_param = matches.get_flag("abi"); diff --git a/src/parse.rs b/src/parse.rs index 57fe3a60..5e555338 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -31,6 +31,7 @@ use crate::str::{ }; use crate::types::{AliasedType, BuiltinAlias, TypeConstructible, UIntType}; use crate::unstable::{impl_require_feature, UnstableFeature, UnstableFeatures}; +use crate::version::blank_version_directive; /// A program is a sequence of items. #[derive(Clone, Debug)] @@ -1207,7 +1208,8 @@ type ParseError<'src> = extra::Err; /// This implementation only returns first encountered error. impl ParseFromStr for A { fn parse_from_str(s: &str) -> Result { - let (tokens, mut lex_errs) = crate::lexer::lex(MAIN_MODULE, s); + let blanked = blank_version_directive(s); + let (tokens, mut lex_errs) = crate::lexer::lex(MAIN_MODULE, &blanked); let Some(tokens) = tokens else { return Err(lex_errs.pop().unwrap_or(RichError::parsing_error( @@ -1243,7 +1245,11 @@ impl ParseF handler: &mut ErrorCollector, ) -> Option { let source: SourceFile = source.into(); - let src = source.content().to_string(); + let original = source.content().to_string(); + // Blank the `simc "...";` directive (replacing it with equal-length spaces) + // before lexing, so the grammar never sees it while byte offsets — and thus + // error spans — stay aligned with the original source. + let src = blank_version_directive(&original); let (tokens, lex_errs) = crate::lexer::lex(file_id, &src); let lex_ok = lex_errs.is_empty(); @@ -1502,6 +1508,8 @@ impl ChumskyParse for Item { // Lazy item here let mod_parser = Module::parser_with_items(item).map(Item::Module); + // The `simc "...";` directive is removed from the source before lexing + // (see `version::blank_version_directive`), so the grammar never sees it. choice((func_parser, use_parser, type_parser, mod_parser)) }) } diff --git a/src/tracker.rs b/src/tracker.rs index bd463e04..7d436454 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -451,8 +451,9 @@ mod tests { #[test] fn test_debug_and_jet_tracing() { + let program_text = TEST_PROGRAM; let program = - TemplateProgram::new(TEST_PROGRAM, Box::new(ElementsJetHinter::new())).unwrap(); + TemplateProgram::new(program_text, Box::new(ElementsJetHinter::new())).unwrap(); let program = program.instantiate(Arguments::default(), true).unwrap(); let satisfied = program.satisfy(WitnessValues::default()).unwrap(); @@ -522,8 +523,9 @@ mod tests { fn test_arith_jet_trace_regression() { let env = create_test_env(); + let program_text = TEST_ARITHMETIC_JETS; let program = - TemplateProgram::new(TEST_ARITHMETIC_JETS, Box::new(ElementsJetHinter::new())).unwrap(); + TemplateProgram::new(program_text, Box::new(ElementsJetHinter::new())).unwrap(); let program = program.instantiate(Arguments::default(), true).unwrap(); let satisfied = program.satisfy(WitnessValues::default()).unwrap(); @@ -577,9 +579,9 @@ mod tests { let env = create_test_env(); + let program_text = TEST_FULL_MULTIPLY_JETS; let program = - TemplateProgram::new(TEST_FULL_MULTIPLY_JETS, Box::new(ElementsJetHinter::new())) - .unwrap(); + TemplateProgram::new(program_text, Box::new(ElementsJetHinter::new())).unwrap(); let program = program.instantiate(Arguments::default(), true).unwrap(); let satisfied = program.satisfy(WitnessValues::default()).unwrap(); diff --git a/src/version.rs b/src/version.rs index ced3505c..a2311bc6 100644 --- a/src/version.rs +++ b/src/version.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use semver::{Version, VersionReq}; +use crate::driver::MAIN_MODULE; use crate::error::{Error, ErrorCollector, RichError, Span}; use crate::source::SourceFile; @@ -296,7 +297,7 @@ fn extract_directive_from_line<'a>( let span_start = current_offset + (line.len() - trimmed.len()); let span_end = span_start + (trimmed.len() - after_quote.len()) + 1; - Some((req_str, Span::new(span_start, span_end))) + Some((req_str, Span::new(MAIN_MODULE, span_start..span_end))) } /// Skip past `/* ... */` so a directive may follow a leading comment block. @@ -330,7 +331,7 @@ fn directive_looking(trimmed: &str) -> bool { /// The span covering a directive-looking line, for reporting a malformed directive. fn directive_looking_span(line: &str, trimmed: &str, offset: usize) -> Span { let start = offset + (line.len() - trimmed.len()); - Span::new(start, start + trimmed.trim_end().len()) + Span::new(MAIN_MODULE, start..start + trimmed.trim_end().len()) } /// The message shared by [`check`] and [`requirement_of`] for a leading line that From f12923a216e8ca4ce6e538270b71673ed9d52761 Mon Sep 17 00:00:00 2001 From: Sdoba16 Date: Wed, 1 Jul 2026 09:16:42 +0200 Subject: [PATCH 3/4] Add version tests for multi-file resolution --- src/driver/mod.rs | 3 + src/driver/version_tests.rs | 225 ++++++++++++++++++++++++++++++++++++ tests/cli.rs | 152 +++++++++++++++++++++++- 3 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 src/driver/version_tests.rs diff --git a/src/driver/mod.rs b/src/driver/mod.rs index bdb7dc3d..370e3162 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -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; diff --git a/src/driver/version_tests.rs b/src/driver/version_tests.rs new file mode 100644 index 00000000..e44725b8 --- /dev/null +++ b/src/driver/version_tests.rs @@ -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() {}"#, + )], + ); +} diff --git a/tests/cli.rs b/tests/cli.rs index aa76eef3..3aa0d80e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,10 +1,34 @@ use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Output}; fn repo_path(path: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join(path) } +/// Write `content` to a uniquely named `.simf` file in the test temp dir and run +/// `simc` on it, returning the process output. Used by the version-directive tests +/// to exercise the real binary on standalone files. +fn run_simc_on_source(name: &str, content: &str) -> Output { + let file = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("{name}.simf")); + std::fs::write(&file, content).expect("failed to write source file"); + Command::new(env!("CARGO_BIN_EXE_simc")) + .arg(file) + .output() + .expect("failed to run simc") +} + +/// Write each `(relative path, content)` under a unique temp project root (creating +/// parent directories) and return the root. Used to drive multi-file `--dep` builds. +fn setup_project(name: &str, files: &[(&str, &str)]) -> PathBuf { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + for (rel, content) in files { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).expect("failed to create project dirs"); + std::fs::write(&path, content).expect("failed to write project file"); + } + root +} + #[test] fn cli_dependency_can_use_crate_root() { let root = repo_path("functional-tests/valid-test-cases/external-library-uses-crate"); @@ -87,3 +111,129 @@ fn cli_reserved_crate_mapping_fails() { stderr ); } + +/// A compatible version directive compiles from the command line, with no +/// missing-directive warning. `*` matches any compiler, so this stays valid across +/// version bumps and acts as the positive control for the rejection tests below. +#[test] +fn cli_version_compatible_accepted() { + let output = run_simc_on_source("version_ok", "simc \"*\";\nfn main() {}\n"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "simc should accept a compatible directive\nstderr:\n{stderr}", + ); + assert!( + !stderr.contains("no compiler version directive"), + "a present directive must not trigger the missing-directive warning, got:\n{stderr}" + ); +} + +/// A directive the running compiler cannot satisfy is rejected. `>99.0.0` is +/// permanently too new, so the build aborts with a non-zero exit and a clear message. +#[test] +fn cli_version_incompatible_rejected() { + let output = run_simc_on_source("version_incompatible", "simc \">99.0.0\";\nfn main() {}\n"); + assert!( + !output.status.success(), + "simc must reject an incompatible directive" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Incompatible compiler version"), + "Expected 'Incompatible compiler version', got:\n{stderr}" + ); +} + +/// A directive is optional: a file with none still compiles, but the CLI prints a +/// warning suggesting one be added. +#[test] +fn cli_version_missing_warns_but_compiles() { + let output = run_simc_on_source("version_missing", "fn main() {}\n"); + assert!( + output.status.success(), + "simc must accept a file with no version directive" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("no compiler version directive"), + "Expected a missing-directive warning, got:\n{stderr}" + ); +} + +/// A directive whose requirement is not valid semver is a syntax error, not a +/// version mismatch. +#[test] +fn cli_version_invalid_syntax_rejected() { + let output = run_simc_on_source( + "version_bad_syntax", + "simc \"not-a-version\";\nfn main() {}\n", + ); + assert!( + !output.status.success(), + "simc must reject a malformed version requirement" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Invalid version syntax"), + "Expected 'Invalid version syntax', got:\n{stderr}" + ); +} + +/// A directive that is structurally broken (here a missing semicolon) is rejected +/// before the requirement is even parsed — a different path than an invalid semver +/// string above. +#[test] +fn cli_version_malformed_directive_rejected() { + let output = run_simc_on_source("version_malformed", "simc \"1.0\"\nfn main() {}\n"); + assert!( + !output.status.success(), + "simc must reject a directive with a missing semicolon" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("malformed compiler version directive"), + "Expected 'malformed compiler version directive', got:\n{stderr}" + ); +} + +/// An incompatible directive in a *dependency* (reached via `--dep`), not the entry +/// file, also aborts the build and the diagnostic points at the dependency. +#[test] +fn cli_dependency_version_mismatch_rejected() { + let root = setup_project( + "version_dep_mismatch", + &[ + ( + "main.simf", + "simc \"*\";\nuse lib::module::add;\nfn main() {}\n", + ), + ("lib/module.simf", "simc \">99.0.0\";\npub fn add() {}\n"), + ], + ); + let dep_arg = format!("{}:lib={}", root.display(), root.join("lib").display()); + + let output = Command::new(env!("CARGO_BIN_EXE_simc")) + .arg(root.join("main.simf")) + .arg("-Z") + .arg("imports") + .arg("--dep") + .arg(dep_arg) + .output() + .expect("failed to run simc"); + + assert!( + !output.status.success(), + "simc must reject an incompatible directive in a dependency" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Incompatible compiler version") && stderr.contains("module.simf"), + "expected an incompatible-version error pointing at the dependency, got:\n{stderr}" + ); +} From 948b18dccac57bd62e81cf182d4565d463795c70 Mon Sep 17 00:00:00 2001 From: Sdoba16 Date: Wed, 1 Jul 2026 09:16:53 +0200 Subject: [PATCH 4/4] Add documentation for versioning --- doc/versioning.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 doc/versioning.md diff --git a/doc/versioning.md b/doc/versioning.md new file mode 100644 index 00000000..cbc4f500 --- /dev/null +++ b/doc/versioning.md @@ -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.