From fb19e46d447be9e55eb36b0bf2e4ba65a88f2a5a Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 2 Aug 2026 14:39:50 +1000 Subject: [PATCH] Parse version from magic line --- src/editor/server.rs | 7 ++ src/formatting/formatter.rs | 8 +- src/language/types.rs | 89 +++++++++++++- src/parsing/checks/parser.rs | 78 ++++++++++-- src/parsing/checks/verify.rs | 4 +- src/parsing/parser.rs | 114 ++++++++++++------ src/problem/messages.rs | 31 +++++ tests/broken/parsing/BadVersion.tq | 1 + tests/broken/parsing/MissingVersion.tq | 1 + tests/broken/parsing/TheFuture.tq | 1 + .../parsing/{MagicLine.tq => ThePast.tq} | 0 tests/formatting/formatter.rs | 4 +- tests/samples/parsing/HeaderAndDeclaration.tq | 2 +- 13 files changed, 285 insertions(+), 55 deletions(-) create mode 100644 tests/broken/parsing/BadVersion.tq create mode 100644 tests/broken/parsing/MissingVersion.tq create mode 100644 tests/broken/parsing/TheFuture.tq rename tests/broken/parsing/{MagicLine.tq => ThePast.tq} (100%) diff --git a/src/editor/server.rs b/src/editor/server.rs index 0d509b85..adf6dbc1 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -734,6 +734,13 @@ impl TechniqueLanguageServer { ParsingError::InvalidHeader(_) => { ("Invalid header line".to_string(), DiagnosticSeverity::ERROR) } + ParsingError::InvalidVersion(_) => { + ("Invalid version string".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InsufficientVersion(_, version) => ( + format!("Requires technique {}", version), + DiagnosticSeverity::ERROR, + ), ParsingError::InvalidIdentifier(_, id) => ( format!("Invalid identifier '{}'", id), DiagnosticSeverity::ERROR, diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 57adb0d4..eb48819c 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -606,7 +606,13 @@ impl<'i> Formatter<'i> { fn format_header(&mut self, metadata: &'i Metadata) { self.switch_syntax(Syntax::Header); - self.append_str("% technique v1\n"); + self.append_str("% technique "); + self.append_str( + &metadata + .version + .to_string(), + ); + self.append_char('\n'); if let Some(license) = metadata.license { self.append_str("! "); diff --git a/src/language/types.rs b/src/language/types.rs index 1ddaafb7..10258a78 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -22,9 +22,88 @@ pub struct Document<'i> { pub body: Option>, } +#[derive(Copy, Clone, Eq, Debug, PartialEq, PartialOrd, Ord)] +pub struct Version { + pub major: u32, + pub minor: Option, + pub patch: Option, +} + +impl Version { + pub const fn new(major: u32, minor: Option, patch: Option) -> Self { + Version { + major, + minor, + patch, + } + } + + pub fn compiler() -> Self { + Version { + major: env!("CARGO_PKG_VERSION_MAJOR") + .parse() + .unwrap(), + minor: Some( + env!("CARGO_PKG_VERSION_MINOR") + .parse() + .unwrap(), + ), + patch: Some( + env!("CARGO_PKG_VERSION_PATCH") + .parse() + .unwrap(), + ), + } + } + + pub fn supported_by(&self, compiler: &Version) -> bool { + // Transitional: the entire existing corpus declares v1 while the + // compiler is still at 0.x. Remove when the tooling reaches 1.0. + if compiler.major == 0 + && self.major == 1 + && self + .minor + .is_none() + { + return true; + } + + if self.major != compiler.major { + return false; + } + + ( + self.minor + .unwrap_or(0), + self.patch + .unwrap_or(0), + ) <= ( + compiler + .minor + .unwrap_or(0), + compiler + .patch + .unwrap_or(0), + ) + } +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "v{}", self.major)?; + if let Some(minor) = self.minor { + write!(f, ".{}", minor)?; + } + if let Some(patch) = self.patch { + write!(f, ".{}", patch)?; + } + Ok(()) + } +} + #[derive(Eq, Debug)] pub struct Metadata<'i> { - pub version: u8, + pub version: Version, pub license: Option<&'i str>, pub copyright: Option<&'i str>, pub domain: Option<&'i str>, @@ -43,7 +122,7 @@ impl PartialEq for Metadata<'_> { impl Default for Metadata<'_> { fn default() -> Self { Metadata { - version: 1, + version: Version::new(1, None, None), license: None, copyright: None, domain: None, @@ -840,7 +919,7 @@ mod check { fn maker<'i>() -> Metadata<'i> { let t1 = Metadata { - version: 1, + version: Version::new(1, None, None), license: None, copyright: None, domain: None, @@ -853,7 +932,7 @@ mod check { #[test] fn ast_construction() { let t1 = Metadata { - version: 1, + version: Version::new(1, None, None), license: None, copyright: None, domain: None, @@ -863,7 +942,7 @@ mod check { assert_eq!(Metadata::default(), t1); let t2 = Metadata { - version: 1, + version: Version::new(1, None, None), license: Some("MIT"), copyright: Some("ACME, Inc"), domain: Some("checklist"), diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index dc799c62..c43cab31 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -7,13 +7,13 @@ fn magic_line() { assert!(is_magic_line(input.source)); let result = input.read_magic_line(); - assert_eq!(result, Ok(1)); + assert_eq!(result, Ok(Version::new(1, None, None))); input.initialize("%technique v1"); assert!(is_magic_line(input.source)); let result = input.read_magic_line(); - assert_eq!(result, Ok(1)); + assert_eq!(result, Ok(Version::new(1, None, None))); input.initialize("%techniquev1"); assert!(is_magic_line(input.source)); @@ -23,6 +23,69 @@ fn magic_line() { assert!(result.is_err()); } +#[test] +fn magic_line_semantic_version() { + let mut input = Parser::new(); + + // v1 stands until the compiler reaches 2.0 + input.initialize("% technique v1"); + let result = input.read_magic_line(); + assert_eq!(result, Ok(Version::new(1, None, None))); + + // v0 was the original language version and is not accepted + input.initialize("% technique v0"); + let result = input.read_magic_line(); + assert_eq!(result, Err(ParsingError::InvalidVersion(Span::new(12, 2)))); + + // a malformed version is distinguished from one that is merely too new + input.initialize("% technique v1.0.0.0"); + let result = input.read_magic_line(); + assert_eq!(result, Err(ParsingError::InvalidVersion(Span::new(12, 8)))); + + input.initialize("% technique vX"); + let result = input.read_magic_line(); + assert_eq!(result, Err(ParsingError::InvalidVersion(Span::new(12, 2)))); + + // the version is required + input.initialize("% technique"); + let result = input.read_magic_line(); + assert_eq!(result, Err(ParsingError::InvalidVersion(Span::new(11, 0)))); + + input.initialize("% technique v9999.9"); + let result = input.read_magic_line(); + assert_eq!( + result, + Err(ParsingError::InsufficientVersion( + Span::new(12, 7), + Version::new(9999, Some(9), None) + )) + ); +} + +#[test] +fn version_supported_by_compiler() { + // a document asking for v1 is met by any 1.x compiler + assert!(Version::new(1, None, None).supported_by(&Version::new(1, Some(90), Some(6)))); + assert!(Version::new(1, Some(3), None).supported_by(&Version::new(1, Some(90), Some(6)))); + assert!(Version::new(1, Some(3), Some(1)).supported_by(&Version::new(1, Some(3), Some(1)))); + + // but not by an older 1.x, nor by the next major + assert!(!Version::new(1, Some(9), None).supported_by(&Version::new(1, Some(3), Some(0)))); + assert!(!Version::new(1, Some(3), Some(2)).supported_by(&Version::new(1, Some(3), Some(1)))); + assert!(!Version::new(1, None, None).supported_by(&Version::new(2, Some(0), Some(0)))); + assert!(!Version::new(2, None, None).supported_by(&Version::new(1, Some(90), Some(6)))); + + // 0.x behaves the same way, so an earlier release is met by a later one + assert!(Version::new(0, Some(6), Some(6)).supported_by(&Version::new(0, Some(7), Some(0)))); + assert!(Version::new(0, Some(7), None).supported_by(&Version::new(0, Some(7), Some(0)))); + assert!(!Version::new(0, Some(7), Some(1)).supported_by(&Version::new(0, Some(7), Some(0)))); + + // while the compiler is at 0.x a bare v1 is admitted, but only bare + assert!(Version::new(1, None, None).supported_by(&Version::new(0, Some(7), Some(0)))); + assert!(!Version::new(1, Some(0), None).supported_by(&Version::new(0, Some(7), Some(0)))); + assert!(!Version::new(2, None, None).supported_by(&Version::new(0, Some(7), Some(0)))); +} + #[test] fn magic_line_wrong_keyword_error_position() { // Test that error position points to the first character of the wrong keyword @@ -41,18 +104,17 @@ fn magic_line_wrong_keyword_error_position() { #[test] fn magic_line_wrong_version_error_position() { // Test that error position points to the version number after "v" in wrong version strings - assert_eq!(analyze_magic_line("% technique v0"), 13); // Points to "0" in "v0" - assert_eq!(analyze_magic_line("% technique v2"), 14); // Points to "2" in "v2" with extra space - assert_eq!(analyze_magic_line("% technique\tv0"), 13); // Points to "0" in "v0" with tab assert_eq!(analyze_magic_line("% technique vX"), 15); // Points to "X" in "vX" with multiple spaces - assert_eq!(analyze_magic_line("% technique v99"), 13); // Points to "9" in "v99" - assert_eq!(analyze_magic_line("% technique v0.5"), 15); // Points to "0" in "v0.5" with multiple spaces + assert_eq!(analyze_magic_line("% technique\tvX"), 13); // Points to "X" in "vX" with tab + assert_eq!(analyze_magic_line("% technique v1.0.0.0"), 13); // Points to "1" when there are too many components // Test edge case where there's no "v" at all - should point to where version should start assert_eq!(analyze_magic_line("% technique 1.0"), 12); // Points to "1" when there's no "v" - assert_eq!(analyze_magic_line("% technique v1.0"), 14); // Points to "." when there is a "v1" but it has minor version assert_eq!(analyze_magic_line("% technique 2"), 13); // Points to "2" when there's no "v" with extra space assert_eq!(analyze_magic_line("% technique beta"), 12); // Points to "b" in "beta" when there's no "v" + + // Test where the version is missing entirely + assert_eq!(analyze_magic_line("% technique"), 11); // Points past the keyword } #[test] diff --git a/src/parsing/checks/verify.rs b/src/parsing/checks/verify.rs index 42392ac8..bd7a9d4c 100644 --- a/src/parsing/checks/verify.rs +++ b/src/parsing/checks/verify.rs @@ -17,7 +17,7 @@ fn technique_header() { assert_eq!( metadata, Ok(Metadata { - version: 1, + version: Version::new(1, None, None), license: None, copyright: None, domain: None, @@ -37,7 +37,7 @@ fn technique_header() { assert_eq!( metadata, Ok(Metadata { - version: 1, + version: Version::new(1, None, None), license: Some("MIT"), copyright: Some("ACME, Inc"), domain: Some("checklist"), diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index a1dc4507..e3f2a63b 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -36,6 +36,8 @@ pub enum ParsingError { // more specific errors InvalidCharacter(Span, char), InvalidHeader(Span), + InvalidVersion(Span), + InsufficientVersion(Span, Version), InvalidIdentifier(Span, String), InvalidForma(Span), InvalidGenus(Span), @@ -76,6 +78,7 @@ impl ParsingError { | ParsingError::UnexpectedEndOfInput(span) | ParsingError::MissingParenthesis(span) | ParsingError::InvalidHeader(span) + | ParsingError::InvalidVersion(span) | ParsingError::InvalidForma(span) | ParsingError::InvalidGenus(span) | ParsingError::InvalidSignature(span) @@ -106,6 +109,7 @@ impl ParsingError { ParsingError::Expected(span, _) | ParsingError::ExpectedMatchingChar(span, _, _, _) | ParsingError::InvalidCharacter(span, _) + | ParsingError::InsufficientVersion(span, _) | ParsingError::InvalidIdentifier(span, _) => *span, } } @@ -216,6 +220,13 @@ impl<'i> Parser<'i> { Err(error) => { self.problems .push(error); + // otherwise these lines are offered to the body parser + while is_magic_line(self.source) + || is_spdx_line(self.source) + || is_domain_line(self.source) + { + self.skip_to_next_line(); + } None } } @@ -780,21 +791,64 @@ impl<'i> Parser<'i> { Ok(()) } - // hard wire the version for now. If we ever grow to supporting multiple major - // versions then this will be a lot more complicated than just dealing with a - // different natural number here. - fn read_magic_line(&mut self) -> Result { + fn read_magic_line(&mut self) -> Result { self.take_until(&['\n'], |inner| { - let re = regex!(r"%\s*technique\s+v1\s*$"); + let re = regex!(r"^\s*%\s*technique(?:\s+(\S+))?\s*$"); - if re.is_match(inner.source) { - Ok(1) - } else { - let error_offset = analyze_magic_line(inner.source); - Err(ParsingError::InvalidHeader(Span::new( - inner.offset + error_offset, + let cap = re + .captures(inner.source) + .ok_or(ParsingError::InvalidHeader(Span::new( + inner.offset + analyze_magic_line(inner.source), 0, - ))) + )))?; + + let one = cap + .get(1) + .ok_or(ParsingError::InvalidVersion(Span::new( + inner.offset + analyze_magic_line(inner.source), + 0, + )))?; + + let span = Span::new( + inner.offset + one.start(), + one.as_str() + .len(), + ); + + let re = regex!(r"^v(\d{1,9})(?:\.(\d{1,9}))?(?:\.(\d{1,9}))?$"); + + let cap = re + .captures(one.as_str()) + .ok_or(ParsingError::InvalidVersion(span))?; + + let number = |i: usize| { + cap.get(i) + .and_then(|one| { + one.as_str() + .parse() + .ok() + }) + }; + + let version = Version { + major: number(1).ok_or(ParsingError::InvalidVersion(span))?, + minor: number(2), + patch: number(3), + }; + + // v0 was the original language version, long since retired. + if version.major == 0 + && version + .minor + .is_none() + { + Err(ParsingError::InvalidVersion(span))? + } + + if version.supported_by(&Version::compiler()) { + Ok(version) + } else { + Err(ParsingError::InsufficientVersion(span, version)) } }) } @@ -3003,33 +3057,21 @@ fn analyze_magic_line(content: &str) -> usize { return 0; } - // If both "technique" and "v1" are present but still invalid (like "v1.0"), - // point to the character immediately after "v1" - if trimmed.contains("technique") && trimmed.contains("v1") { - if let Some(v1_pos) = content.find("v1") { - return v1_pos + 2; // Position after "v1" - } - } - - // Point to where version should be if missing v1 - if !trimmed.contains("v1") { - // Find position after "technique" - if let Some(pos) = content.find("technique") { - let after_technique = pos + "technique".len(); - // Skip whitespace to find the actual version string - let remaining = &content[after_technique..]; - for (i, ch) in remaining.char_indices() { - if !ch.is_whitespace() { - // If we found a 'v', point to the character after it (the version number) - if ch == 'v' && i + 1 < remaining.len() { - return after_technique + i + 1; - } - // Otherwise point to where we found the non-whitespace character - return after_technique + i; + if let Some(pos) = content.find("technique") { + let after_technique = pos + "technique".len(); + // Skip whitespace to find the actual version string + let remaining = &content[after_technique..]; + for (i, ch) in remaining.char_indices() { + if !ch.is_whitespace() { + // If we found a 'v', point to the character after it (the version number) + if ch == 'v' && i + 1 < remaining.len() { + return after_technique + i + 1; } + // Otherwise point to where we found the non-whitespace character + return after_technique + i; } - return after_technique; } + return after_technique; } // If structure is roughly correct but still invalid, point to start diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 1657252a..a1f32a45 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -118,6 +118,37 @@ to be used when rendering the Technique. Common domains include ), ) } + ParsingError::InvalidVersion(_) => ( + "Invalid version".to_string(), + format!( + r#" +The first line must list the minimum version of the Technique +language your document is written to, prefixed with 'v'. +For example {}, {}, or {}. + "#, + renderer.style(crate::formatting::Syntax::Header, "v1"), + renderer.style(crate::formatting::Syntax::Header, "v1.3"), + renderer.style(crate::formatting::Syntax::Header, "v2.17.1"), + ) + .trim_ascii() + .to_string(), + ), + ParsingError::InsufficientVersion(_, version) => ( + "Compiler out of date".to_string(), + format!( + r#" +The document declares that it needs at least version {} of the language, +but this is technique {}. + "#, + renderer.style(crate::formatting::Syntax::Header, &version.to_string()), + renderer.style( + crate::formatting::Syntax::Header, + &Version::compiler().to_string() + ), + ) + .trim_ascii() + .to_string(), + ), ParsingError::InvalidCharacter(_, c) => ( format!("Invalid character '{}'", c), "This character is not allowed here.".to_string(), diff --git a/tests/broken/parsing/BadVersion.tq b/tests/broken/parsing/BadVersion.tq new file mode 100644 index 00000000..9f1209bd --- /dev/null +++ b/tests/broken/parsing/BadVersion.tq @@ -0,0 +1 @@ +% technique v0.x diff --git a/tests/broken/parsing/MissingVersion.tq b/tests/broken/parsing/MissingVersion.tq new file mode 100644 index 00000000..1ea3016f --- /dev/null +++ b/tests/broken/parsing/MissingVersion.tq @@ -0,0 +1 @@ +% technique diff --git a/tests/broken/parsing/TheFuture.tq b/tests/broken/parsing/TheFuture.tq new file mode 100644 index 00000000..f7726787 --- /dev/null +++ b/tests/broken/parsing/TheFuture.tq @@ -0,0 +1 @@ +% technique v0.99999 diff --git a/tests/broken/parsing/MagicLine.tq b/tests/broken/parsing/ThePast.tq similarity index 100% rename from tests/broken/parsing/MagicLine.tq rename to tests/broken/parsing/ThePast.tq diff --git a/tests/formatting/formatter.rs b/tests/formatting/formatter.rs index f711f653..287a2387 100644 --- a/tests/formatting/formatter.rs +++ b/tests/formatting/formatter.rs @@ -22,7 +22,7 @@ mod verify { let document = Document { source: None, header: Some(Metadata { - version: 1, + version: Version::new(1, None, None), license: Some("MIT"), copyright: None, domain: Some("checklist"), @@ -71,7 +71,7 @@ first : A -> B let document = Document { source: None, header: Some(Metadata { - version: 1, + version: Version::new(1, None, None), license: Some("PD"), copyright: Some("2025 The First Procedure Society, Inc"), domain: None, diff --git a/tests/samples/parsing/HeaderAndDeclaration.tq b/tests/samples/parsing/HeaderAndDeclaration.tq index 313d5bd2..e522a9a5 100644 --- a/tests/samples/parsing/HeaderAndDeclaration.tq +++ b/tests/samples/parsing/HeaderAndDeclaration.tq @@ -1,4 +1,4 @@ -% technique v1 +% technique v0.6 ! MIT; © 2024 ACME, Inc & checklist