From 89129a8401763dacc3fbeda48530fba24c77704e Mon Sep 17 00:00:00 2001 From: Ruslan Semagin Date: Thu, 27 Aug 2026 15:03:46 +0300 Subject: [PATCH 1/3] feat!: expose byte spans for module paths and versions Parse with a locating stream so every module path and version keeps the byte offset range it was read from. `Module` and `ModuleReplacement` now carry `path_span` and `version_span`, which makes the crate usable as a language server backend: diagnostics, inlay hints and code actions can map a dependency back to its exact position in the file. Spans are ignored by `PartialEq`, so existing comparisons keep working unchanged; only struct-literal construction and exhaustive destructuring break. `Module::new` and `ModuleReplacement::new` build values with empty spans for that case. BREAKING CHANGE: `Module` and `ModuleReplacement` gained public span fields, so they can no longer be built with a struct literal or matched with an exhaustive pattern without them. Use `Module::new` / `ModuleReplacement::new`, or add `..` to the pattern. --- README.md | 27 ++++++- src/lib.rs | 202 ++++++++++++++++++++++++++++++++++++++++++------- src/parser.rs | 96 +++++++++++++---------- tests/parse.rs | 56 +++++++++++++- 4 files changed, 309 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index a7c1b02..de341f7 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,30 @@ assert_eq!(go_mod.go, Some("1.21".to_string())); assert_eq!( go_mod.require, vec![ModuleDependency { - module: Module { - module_path: "golang.org/x/net".to_string(), - version: "v0.20.0".to_string() - }, + module: Module::new("golang.org/x/net", "v0.20.0"), indirect: false }] ); ``` + +## Positions + +Every parsed module path and version carries its `Span` — a byte offset range +into the input — which makes the parser usable as a language server backend +(diagnostics, inlay hints, code actions): + +```rust +use gomod_parser::GoMod; +use std::str::FromStr; + +let input = "module github.com/example\n\nrequire golang.org/x/net v0.20.0\n"; + +let go_mod = GoMod::from_str(input).unwrap(); +let dependency = &go_mod.require[0]; + +assert_eq!(&input[dependency.module.path_span.clone()], "golang.org/x/net"); +assert_eq!(&input[dependency.module.version_span.clone()], "v0.20.0"); +``` + +Spans are ignored by `PartialEq`, so values parsed from different files still +compare equal by path and version. diff --git a/src/lib.rs b/src/lib.rs index ca99ee2..1eb19b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,14 +21,31 @@ //! assert_eq!( //! go_mod.require, //! vec![ModuleDependency { -//! module: Module { -//! module_path: "golang.org/x/net".to_string(), -//! version: "v0.20.0".to_string() -//! }, +//! module: Module::new("golang.org/x/net", "v0.20.0"), //! indirect: false //! }] //! ); //! ``` +//! +//! # Positions +//! +//! Every parsed module path and version carries its [`Span`] — a byte offset +//! range into the input — which makes the parser usable as a language server +//! backend. Spans do not take part in [`PartialEq`], so values parsed from +//! different offsets still compare equal by path and version: +//! +//! ```rust +//! use gomod_parser::GoMod; +//! use std::str::FromStr; +//! +//! let input = "module github.com/example\n\nrequire golang.org/x/net v0.20.0\n"; +//! +//! let go_mod = GoMod::from_str(input).unwrap(); +//! let dependency = &go_mod.require[0]; +//! +//! assert_eq!(&input[dependency.module.path_span.clone()], "golang.org/x/net"); +//! assert_eq!(&input[dependency.module.version_span.clone()], "v0.20.0"); +//! ``` #![warn(clippy::pedantic)] #![warn(clippy::nursery)] @@ -36,6 +53,8 @@ use crate::parser::{gomod, Directive}; use std::collections::HashMap; +use std::ops::Range; +use winnow::stream::LocatingSlice; use winnow::Parser; mod combinator; @@ -62,7 +81,10 @@ impl std::str::FromStr for GoMod { fn from_str(input: &str) -> Result { let mut res = Self::default(); - for directive in &mut gomod.parse(input).map_err(|e| e.to_string())? { + for directive in &mut gomod + .parse(LocatingSlice::new(input)) + .map_err(|e| e.to_string())? + { match directive { Directive::Comment(d) => res.comment.push((**d).to_string()), Directive::Module(d) => res.module = (**d).to_string(), @@ -82,25 +104,78 @@ impl std::str::FromStr for GoMod { } } -#[derive(Debug, PartialEq, Eq)] +pub type Span = Range; + +#[derive(Debug)] pub struct Module { pub module_path: String, pub version: String, + pub path_span: Span, + pub version_span: Span, +} + +impl Module { + #[must_use] + pub fn new(module_path: impl Into, version: impl Into) -> Self { + Self { + module_path: module_path.into(), + version: version.into(), + path_span: 0..0, + version_span: 0..0, + } + } } +impl PartialEq for Module { + fn eq(&self, other: &Self) -> bool { + self.module_path == other.module_path && self.version == other.version + } +} + +impl Eq for Module {} + #[derive(Debug, PartialEq, Eq)] pub struct ModuleDependency { pub module: Module, pub indirect: bool, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug)] pub struct ModuleReplacement { pub module_path: String, pub version: Option, + pub path_span: Span, + pub version_span: Option, pub replacement: Replacement, } +impl ModuleReplacement { + #[must_use] + pub fn new( + module_path: impl Into, + version: Option, + replacement: Replacement, + ) -> Self { + Self { + module_path: module_path.into(), + version, + path_span: 0..0, + version_span: None, + replacement, + } + } +} + +impl PartialEq for ModuleReplacement { + fn eq(&self, other: &Self) -> bool { + self.module_path == other.module_path + && self.version == other.version + && self.replacement == other.replacement + } +} + +impl Eq for ModuleReplacement {} + #[derive(Debug, PartialEq, Eq)] pub enum Replacement { FilePath(String), @@ -147,33 +222,24 @@ mod tests { assert_eq!( go_mod.require, vec![ModuleDependency { - module: Module { - module_path: "golang.org/x/net".to_string(), - version: "v0.20.0".to_string() - }, + module: Module::new("golang.org/x/net", "v0.20.0"), indirect: false }] ); assert_eq!( go_mod.exclude, vec![ModuleDependency { - module: Module { - module_path: "golang.org/x/net".to_string(), - version: "v0.19.1".to_string() - }, + module: Module::new("golang.org/x/net", "v0.19.1"), indirect: false }] ); assert_eq!( go_mod.replace, - vec![ModuleReplacement { - module_path: "golang.org/x/net".to_string(), - version: Some("v0.19.0".to_string()), - replacement: Replacement::Module(Module { - module_path: "example.com/fork/net".to_string(), - version: "v0.19.1".to_string(), - }) - }] + vec![ModuleReplacement::new( + "golang.org/x/net", + Some("v0.19.0".to_string()), + Replacement::Module(Module::new("example.com/fork/net", "v0.19.1")) + )] ); assert_eq!( go_mod.retract, @@ -182,6 +248,91 @@ mod tests { assert_eq!(go_mod.comment, vec!["Complete example".to_string()]); } + #[test] + fn test_require_spans() { + let input = indoc! {r#" + module github.com/spans + + require ( + golang.org/x/net v0.20.0 + golang.org/x/sys v0.16.0 // indirect + ) + "#}; + + let go_mod = GoMod::from_str(input).unwrap(); + + let net = &go_mod.require[0].module; + assert_eq!(&input[net.path_span.clone()], "golang.org/x/net"); + assert_eq!(&input[net.version_span.clone()], "v0.20.0"); + + let sys = &go_mod.require[1].module; + assert_eq!(&input[sys.path_span.clone()], "golang.org/x/sys"); + assert_eq!(&input[sys.version_span.clone()], "v0.16.0"); + + assert!(net.version_span.end < sys.path_span.start); + } + + #[test] + fn test_exclude_spans() { + let input = indoc! {r#" + module github.com/spans + + exclude golang.org/x/net v0.19.1 + "#}; + + let go_mod = GoMod::from_str(input).unwrap(); + + let module = &go_mod.exclude[0].module; + assert_eq!(&input[module.path_span.clone()], "golang.org/x/net"); + assert_eq!(&input[module.version_span.clone()], "v0.19.1"); + } + + #[test] + fn test_replace_spans() { + let input = indoc! {r#" + module github.com/spans + + replace ( + golang.org/x/net v0.19.0 => example.com/fork/net v0.19.1 + golang.org/x/sys => ../sys + ) + "#}; + + let go_mod = GoMod::from_str(input).unwrap(); + + let versioned = &go_mod.replace[0]; + assert_eq!(&input[versioned.path_span.clone()], "golang.org/x/net"); + assert_eq!(&input[versioned.version_span.clone().unwrap()], "v0.19.0"); + match &versioned.replacement { + Replacement::Module(module) => { + assert_eq!(&input[module.path_span.clone()], "example.com/fork/net"); + assert_eq!(&input[module.version_span.clone()], "v0.19.1"); + } + Replacement::FilePath(path) => panic!("unexpected file path replacement: {path}"), + } + + let unversioned = &go_mod.replace[1]; + assert_eq!(&input[unversioned.path_span.clone()], "golang.org/x/sys"); + assert_eq!(unversioned.version_span, None); + } + + #[test] + fn test_spans_ignored_by_eq() { + let input = indoc! {r#" + module github.com/spans + + require golang.org/x/net v0.20.0 + "#}; + + let go_mod = GoMod::from_str(input).unwrap(); + + assert_ne!(go_mod.require[0].module.version_span, 0..0); + assert_eq!( + go_mod.require[0].module, + Module::new("golang.org/x/net", "v0.20.0") + ); + } + #[test] fn test_invalid_content() { let input = indoc! {r#" @@ -277,10 +428,7 @@ mod tests { assert_eq!( go_mod.require, vec![ModuleDependency { - module: Module { - module_path: "golang.org/x/net".to_string(), - version: "v0.20.0".to_string() - }, + module: Module::new("golang.org/x/net", "v0.20.0"), indirect: false }] ); diff --git a/src/parser.rs b/src/parser.rs index 101a2a0..787bff8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -3,7 +3,7 @@ use crate::{Module, ModuleDependency, ModuleReplacement, ModuleRetract, Replacem use std::collections::HashMap; use winnow::ascii::{multispace0, multispace1, space0, space1}; use winnow::combinator::{fail, not, opt, peek, preceded, repeat, terminated}; -use winnow::stream::AsChar; +use winnow::stream::{AsChar, LocatingSlice}; use winnow::token::{any, take_till, take_while}; use winnow::{dispatch, Parser, Result}; @@ -25,15 +25,15 @@ pub(crate) enum Directive<'a> { Ignore(Vec), } -pub(crate) fn gomod<'a>(input: &mut &'a str) -> Result>> { - repeat(0.., |i: &mut &'a str| { +pub(crate) fn gomod<'a>(input: &mut LocatingSlice<&'a str>) -> Result>> { + repeat(0.., |i: &mut LocatingSlice<&'a str>| { // check for comments first comment.parse_next(i).or_else(|_| directive.parse_next(i)) }) .parse_next(input) } -fn directive<'a>(input: &mut &'a str) -> Result> { +fn directive<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let _ = take_while(0.., CRLF).parse_next(input)?; dispatch!(peek(not_whitespace); "module" => module, @@ -51,28 +51,28 @@ fn directive<'a>(input: &mut &'a str) -> Result> { .parse_next(input) } -fn comment<'a>(input: &mut &'a str) -> Result> { +fn comment<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded((opt(space0), "//", opt(space0)), take_till(0.., CRLF)).parse_next(input)?; let _ = take_while(0.., CRLF).parse_next(input)?; Ok(Directive::Comment(res)) } -fn module<'a>(input: &mut &'a str) -> Result> { +fn module<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded(("module", space1), take_till(1.., CRLF)).parse_next(input)?; let _ = take_while(0.., CRLF).parse_next(input)?; Ok(Directive::Module(res)) } -fn go<'a>(input: &mut &'a str) -> Result> { +fn go<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded(("go", space1), take_till(1.., CRLF)).parse_next(input)?; let _ = take_while(0.., CRLF).parse_next(input)?; Ok(Directive::Go(res)) } -fn godebug<'a>(input: &mut &'a str) -> Result> { +fn godebug<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded( ("godebug", space1), dispatch! {peek(any); @@ -86,7 +86,7 @@ fn godebug<'a>(input: &mut &'a str) -> Result> { Ok(Directive::GoDebug(HashMap::from_iter(res))) } -fn godebug_single(input: &mut &str) -> Result> { +fn godebug_single(input: &mut LocatingSlice<&str>) -> Result> { // terminate, if `)` is found peek(not(')')).parse_next(input)?; @@ -96,7 +96,7 @@ fn godebug_single(input: &mut &str) -> Result> { Ok(vec![(key.into(), value.into())]) } -fn godebug_multi(input: &mut &str) -> Result> { +fn godebug_multi(input: &mut LocatingSlice<&str>) -> Result> { let _ = ("(", multispace1).parse_next(input)?; let res: Vec> = repeat(1.., terminated(godebug_single, multispace0)).parse_next(input)?; @@ -105,7 +105,7 @@ fn godebug_multi(input: &mut &str) -> Result> { Ok(res.into_iter().flatten().collect::>()) } -fn tool<'a>(input: &mut &'a str) -> Result> { +fn tool<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded( ("tool", space1), dispatch! {peek(any); @@ -119,7 +119,7 @@ fn tool<'a>(input: &mut &'a str) -> Result> { Ok(Directive::Tool(res)) } -fn tool_single(input: &mut &str) -> Result> { +fn tool_single(input: &mut LocatingSlice<&str>) -> Result> { // terminate, if `)` is found peek(not(')')).parse_next(input)?; @@ -131,7 +131,7 @@ fn tool_single(input: &mut &str) -> Result> { Ok(vec![value.into()]) } -fn tool_multi(input: &mut &str) -> Result> { +fn tool_multi(input: &mut LocatingSlice<&str>) -> Result> { let _ = ("(", multispace1).parse_next(input)?; let res: Vec> = repeat(1.., terminated(tool_single, multispace0)).parse_next(input)?; @@ -140,14 +140,14 @@ fn tool_multi(input: &mut &str) -> Result> { Ok(res.into_iter().flatten().collect::>()) } -fn toolchain<'a>(input: &mut &'a str) -> Result> { +fn toolchain<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded(("toolchain", space1), take_till(1.., CRLF)).parse_next(input)?; let _ = take_while(0.., CRLF).parse_next(input)?; Ok(Directive::Toolchain(res)) } -fn require<'a>(input: &mut &'a str) -> Result> { +fn require<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded( ("require", space1), dispatch! {peek(any); @@ -161,14 +161,14 @@ fn require<'a>(input: &mut &'a str) -> Result> { Ok(Directive::Require(res)) } -fn require_single(input: &mut &str) -> Result> { +fn require_single(input: &mut LocatingSlice<&str>) -> Result> { // terminate, if `)` is found peek(not(')')).parse_next(input)?; - let (module_path, _, version) = ( - take_till(1.., AsChar::is_space), + let ((module_path, path_span), _, (version, version_span)) = ( + take_till(1.., AsChar::is_space).with_span(), space1, - take_till(1.., WHITESPACES), + take_till(1.., WHITESPACES).with_span(), ) .parse_next(input)?; @@ -178,12 +178,14 @@ fn require_single(input: &mut &str) -> Result> { module: Module { module_path: module_path.to_string(), version: version.to_string(), + path_span, + version_span, }, indirect, }]) } -fn require_multi(input: &mut &str) -> Result> { +fn require_multi(input: &mut LocatingSlice<&str>) -> Result> { let _ = ("(", multispace1).parse_next(input)?; let res: Vec> = repeat(1.., terminated(require_single, multispace0)).parse_next(input)?; @@ -192,7 +194,7 @@ fn require_multi(input: &mut &str) -> Result> { Ok(res.into_iter().flatten().collect::>()) } -fn exclude<'a>(input: &mut &'a str) -> Result> { +fn exclude<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded( ("exclude", space1), dispatch! {peek(any); @@ -206,7 +208,7 @@ fn exclude<'a>(input: &mut &'a str) -> Result> { Ok(Directive::Exclude(res)) } -fn replace<'a>(input: &mut &'a str) -> Result> { +fn replace<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded( ("replace", space1), dispatch! {peek(any); @@ -220,43 +222,57 @@ fn replace<'a>(input: &mut &'a str) -> Result> { Ok(Directive::Replace(res)) } -fn replace_single(input: &mut &str) -> Result> { +fn replace_single(input: &mut LocatingSlice<&str>) -> Result> { // terminate, if `)` is found peek(not(')')).parse_next(input)?; - let (src_path, src_version) = ( - terminated(take_till(1.., AsChar::is_space), space1), + let ((src_path, src_path_span), src_version) = ( + terminated(take_till(1.., AsChar::is_space).with_span(), space1), opt(terminated( - preceded(peek(not("=>")), take_till(1.., AsChar::is_space)), + preceded( + peek(not("=>")), + take_till(1.., AsChar::is_space).with_span(), + ), space1, )), ) .parse_next(input)?; let _ = ("=>", space1).parse_next(input)?; - let (dest_path, dest_version) = ( - terminated(take_till(1.., WHITESPACES), space0), - opt(terminated(take_till(1.., WHITESPACES), multispace1)), + let ((dest_path, dest_path_span), dest_version) = ( + terminated(take_till(1.., WHITESPACES).with_span(), space0), + opt(terminated( + take_till(1.., WHITESPACES).with_span(), + multispace1, + )), ) .parse_next(input)?; let replacement = dest_version.map_or_else( || Replacement::FilePath(dest_path.to_string()), - |version| { + |(version, version_span)| { Replacement::Module(Module { module_path: dest_path.to_string(), version: version.to_string(), + path_span: dest_path_span, + version_span, }) }, ); + let (src_version, src_version_span) = src_version.map_or((None, None), |(version, span)| { + (Some(version.to_string()), Some(span)) + }); + Ok(vec![ModuleReplacement { module_path: src_path.to_string(), - version: src_version.map(ToString::to_string), + version: src_version, + path_span: src_path_span, + version_span: src_version_span, replacement, }]) } -fn replace_multi(input: &mut &str) -> Result> { +fn replace_multi(input: &mut LocatingSlice<&str>) -> Result> { let _ = ("(", multispace1).parse_next(input)?; let res: Vec> = repeat(1.., terminated(replace_single, multispace0)).parse_next(input)?; @@ -268,7 +284,7 @@ fn replace_multi(input: &mut &str) -> Result> { .collect::>()) } -fn retract<'a>(input: &mut &'a str) -> Result> { +fn retract<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded( ("retract", space1), dispatch! {peek(any); @@ -282,7 +298,7 @@ fn retract<'a>(input: &mut &'a str) -> Result> { Ok(Directive::Retract(res)) } -fn retract_single(input: &mut &str) -> Result> { +fn retract_single(input: &mut LocatingSlice<&str>) -> Result> { // terminate, if `)` is found peek(not(')')).parse_next(input)?; @@ -298,7 +314,7 @@ fn retract_single(input: &mut &str) -> Result> { Ok(vec![res]) } -fn version_range(input: &mut &str) -> Result { +fn version_range(input: &mut LocatingSlice<&str>) -> Result { let lower_bound = preceded('[', take_till(1.., |c| c == ',' || c == ' ')).parse_next(input)?; let _ = (',', space0).parse_next(input)?; let upper_bound = @@ -310,13 +326,13 @@ fn version_range(input: &mut &str) -> Result { )) } -fn version_single(input: &mut &str) -> Result { +fn version_single(input: &mut LocatingSlice<&str>) -> Result { let version = terminated(take_till(1.., WHITESPACES), multispace1).parse_next(input)?; Ok(ModuleRetract::Single(version.to_string())) } -fn retract_multi(input: &mut &str) -> Result> { +fn retract_multi(input: &mut LocatingSlice<&str>) -> Result> { let _ = ("(", multispace1).parse_next(input)?; let res: Vec> = repeat(1.., terminated(retract_single, multispace0)).parse_next(input)?; @@ -325,7 +341,7 @@ fn retract_multi(input: &mut &str) -> Result> { Ok(res.into_iter().flatten().collect::>()) } -fn ignore<'a>(input: &mut &'a str) -> Result> { +fn ignore<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let res = preceded( ("ignore", space1), dispatch! {peek(any); @@ -339,7 +355,7 @@ fn ignore<'a>(input: &mut &'a str) -> Result> { Ok(Directive::Ignore(res)) } -fn ignore_single(input: &mut &str) -> Result> { +fn ignore_single(input: &mut LocatingSlice<&str>) -> Result> { // terminate, if `)` is found peek(not(')')).parse_next(input)?; @@ -351,7 +367,7 @@ fn ignore_single(input: &mut &str) -> Result> { Ok(vec![path.to_string()]) } -fn ignore_multi(input: &mut &str) -> Result> { +fn ignore_multi(input: &mut LocatingSlice<&str>) -> Result> { let _ = ("(", multispace1).parse_next(input)?; let res: Vec> = repeat(1.., terminated(ignore_single, multispace0)).parse_next(input)?; diff --git a/tests/parse.rs b/tests/parse.rs index 98bfc76..666b9ed 100644 --- a/tests/parse.rs +++ b/tests/parse.rs @@ -1,4 +1,4 @@ -use gomod_parser::GoMod; +use gomod_parser::{GoMod, Replacement}; use std::collections::HashMap; use std::fs::read_to_string; use std::path::PathBuf; @@ -139,3 +139,57 @@ fn test_no_trailing_newline() { ] ); } + +fn assert_spans_match(file_content: &str, gomod: &GoMod) { + for dependency in gomod.require.iter().chain(gomod.exclude.iter()) { + let module = &dependency.module; + assert_eq!(&file_content[module.path_span.clone()], module.module_path); + assert_eq!(&file_content[module.version_span.clone()], module.version); + } + + for replacement in &gomod.replace { + assert_eq!( + &file_content[replacement.path_span.clone()], + replacement.module_path + ); + match (&replacement.version, &replacement.version_span) { + (Some(version), Some(span)) => assert_eq!(&file_content[span.clone()], version), + (None, None) => {} + (version, span) => panic!("version {version:?} does not match span {span:?}"), + } + if let Replacement::Module(module) = &replacement.replacement { + assert_eq!(&file_content[module.path_span.clone()], module.module_path); + assert_eq!(&file_content[module.version_span.clone()], module.version); + } + } +} + +#[test] +fn test_spans_on_fixtures() { + for file_name in [ + "compress.mod", + "docker_docs.mod", + "godebug.mod", + "ignore.mod", + "iris.mod", + "kubernetes.mod", + "prometheus.mod", + "tool.mod", + ] { + let file_content = get_test_file_content(file_name); + let gomod = file_content.parse::().unwrap(); + + assert_spans_match(&file_content, &gomod); + } +} + +#[test] +fn test_spans_with_carriage_return() { + let file_content = get_test_file_content("compress.mod") + .replace("\r", "") + .replace("\n", "\r\n"); + + let gomod = file_content.parse::().unwrap(); + + assert_spans_match(&file_content, &gomod); +} From 1739a62b32ed2d144fa9fe14e8b6c926d7aaff05 Mon Sep 17 00:00:00 2001 From: Ruslan Semagin Date: Thu, 27 Aug 2026 15:24:03 +0300 Subject: [PATCH 2/3] feat: expose the module directive span Address review feedback: capture the top-level module path with with_span() and expose it as GoMod::module_span, so the module declaration can be targeted the same way as dependencies. PartialEq for GoMod ignores the new field, consistent with Module. Also document the replace-side spans and both constructors in the README. --- README.md | 6 ++++++ src/lib.rs | 47 +++++++++++++++++++++++++++++++++++++++++++---- src/parser.rs | 9 +++++---- tests/parse.rs | 2 ++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index de341f7..5f773f9 100644 --- a/README.md +++ b/README.md @@ -53,5 +53,11 @@ assert_eq!(&input[dependency.module.path_span.clone()], "golang.org/x/net"); assert_eq!(&input[dependency.module.version_span.clone()], "v0.20.0"); ``` +The `module` directive exposes its own `GoMod::module_span`, and `replace` +directives carry the same pair of spans on `ModuleReplacement` — with +`version_span` set to `None` for an unversioned source — plus the spans of the +nested `Module` when the destination is a module rather than a file path. Use +`Module::new` and `ModuleReplacement::new` to build values without spans. + Spans are ignored by `PartialEq`, so values parsed from different files still compare equal by path and version. diff --git a/src/lib.rs b/src/lib.rs index 1eb19b4..bd65980 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,8 +31,9 @@ //! //! Every parsed module path and version carries its [`Span`] — a byte offset //! range into the input — which makes the parser usable as a language server -//! backend. Spans do not take part in [`PartialEq`], so values parsed from -//! different offsets still compare equal by path and version: +//! backend. The same holds for [`GoMod::module_span`] and for both sides of a +//! `replace` directive. Spans do not take part in [`PartialEq`], so values +//! parsed from different offsets still compare equal by path and version: //! //! ```rust //! use gomod_parser::GoMod; @@ -60,10 +61,11 @@ use winnow::Parser; mod combinator; pub mod parser; -#[derive(Debug, Default, PartialEq, Eq)] +#[derive(Debug, Default)] pub struct GoMod { pub comment: Vec, pub module: String, + pub module_span: Span, pub go: Option, pub godebug: HashMap, pub tool: Vec, @@ -75,6 +77,24 @@ pub struct GoMod { pub ignore: Vec, } +impl PartialEq for GoMod { + fn eq(&self, other: &Self) -> bool { + self.comment == other.comment + && self.module == other.module + && self.go == other.go + && self.godebug == other.godebug + && self.tool == other.tool + && self.toolchain == other.toolchain + && self.require == other.require + && self.exclude == other.exclude + && self.replace == other.replace + && self.retract == other.retract + && self.ignore == other.ignore + } +} + +impl Eq for GoMod {} + impl std::str::FromStr for GoMod { type Err = String; @@ -87,7 +107,10 @@ impl std::str::FromStr for GoMod { { match directive { Directive::Comment(d) => res.comment.push((**d).to_string()), - Directive::Module(d) => res.module = (**d).to_string(), + Directive::Module(d, span) => { + res.module = (**d).to_string(); + res.module_span = span.clone(); + } Directive::Go(d) => res.go = Some((**d).to_string()), Directive::GoDebug(d) => res.godebug.extend((*d).clone()), Directive::Tool(d) => res.tool.append(d), @@ -248,6 +271,22 @@ mod tests { assert_eq!(go_mod.comment, vec!["Complete example".to_string()]); } + #[test] + fn test_module_span() { + let input = indoc! {r#" + // leading comment + + module github.com/spans + + go 1.24 + "#}; + + let go_mod = GoMod::from_str(input).unwrap(); + + assert_eq!(go_mod.module, "github.com/spans".to_string()); + assert_eq!(&input[go_mod.module_span.clone()], "github.com/spans"); + } + #[test] fn test_require_spans() { let input = indoc! {r#" diff --git a/src/parser.rs b/src/parser.rs index 787bff8..08c92d5 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,5 +1,5 @@ use crate::combinator::not_whitespace; -use crate::{Module, ModuleDependency, ModuleReplacement, ModuleRetract, Replacement}; +use crate::{Module, ModuleDependency, ModuleReplacement, ModuleRetract, Replacement, Span}; use std::collections::HashMap; use winnow::ascii::{multispace0, multispace1, space0, space1}; use winnow::combinator::{fail, not, opt, peek, preceded, repeat, terminated}; @@ -13,7 +13,7 @@ const CRLF: [char; 2] = ['\r', '\n']; #[derive(Debug, PartialEq, Eq)] pub(crate) enum Directive<'a> { Comment(&'a str), - Module(&'a str), + Module(&'a str, Span), Go(&'a str), GoDebug(HashMap), Tool(Vec), @@ -59,10 +59,11 @@ fn comment<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { } fn module<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { - let res = preceded(("module", space1), take_till(1.., CRLF)).parse_next(input)?; + let (res, span) = + preceded(("module", space1), take_till(1.., CRLF).with_span()).parse_next(input)?; let _ = take_while(0.., CRLF).parse_next(input)?; - Ok(Directive::Module(res)) + Ok(Directive::Module(res, span)) } fn go<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { diff --git a/tests/parse.rs b/tests/parse.rs index 666b9ed..146f3b3 100644 --- a/tests/parse.rs +++ b/tests/parse.rs @@ -141,6 +141,8 @@ fn test_no_trailing_newline() { } fn assert_spans_match(file_content: &str, gomod: &GoMod) { + assert_eq!(&file_content[gomod.module_span.clone()], gomod.module); + for dependency in gomod.require.iter().chain(gomod.exclude.iter()) { let module = &dependency.module; assert_eq!(&file_content[module.path_span.clone()], module.module_path); From 9585504fe20d5c33411fe9dc7461634a4873a6d4 Mon Sep 17 00:00:00 2001 From: Ruslan Semagin Date: Thu, 27 Aug 2026 15:39:28 +0300 Subject: [PATCH 3/3] fix: stop swallowing trailing text into the module path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module directive was parsed with take_till(1.., CRLF), so anything after the path — an end-of-line comment or trailing spaces — ended up in GoMod::module and, since the previous commit, in GoMod::module_span. A language server would then highlight the comment as part of the module path. Tokenize the path with take_till(1.., WHITESPACES) and consume the rest of the line separately, mirroring how the require, tool and ignore parsers already do it. Note that space0 before the optional comment is required: without it `module example.com/m ` fails to parse, because the next directive lookahead stops on the leading space. As a side effect an end-of-line comment on the module directive is now discarded instead of being appended to the path, which is the existing behaviour for require, tool and ignore. --- src/lib.rs | 20 ++++++++++++++++++++ src/parser.rs | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index bd65980..c44972d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -287,6 +287,26 @@ mod tests { assert_eq!(&input[go_mod.module_span.clone()], "github.com/spans"); } + #[test] + fn test_module_trailing_text() { + for (input, expected) in [ + ("module github.com/spans // some comment\n", 7..23), + ("module github.com/spans \n", 7..23), + ("module github.com/spans\r\n", 7..23), + ("module github.com/spans", 7..23), + ] { + let go_mod = GoMod::from_str(input).unwrap(); + + assert_eq!( + go_mod.module, + "github.com/spans".to_string(), + "input: {input:?}" + ); + assert_eq!(go_mod.module_span, expected, "input: {input:?}"); + assert_eq!(&input[go_mod.module_span.clone()], "github.com/spans"); + } + } + #[test] fn test_require_spans() { let input = indoc! {r#" diff --git a/src/parser.rs b/src/parser.rs index 08c92d5..8c5ebe2 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -60,7 +60,10 @@ fn comment<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { fn module<'a>(input: &mut LocatingSlice<&'a str>) -> Result> { let (res, span) = - preceded(("module", space1), take_till(1.., CRLF).with_span()).parse_next(input)?; + preceded(("module", space1), take_till(1.., WHITESPACES).with_span()).parse_next(input)?; + + // remove any comments added to the same line + let _ = (space0, opt(comment)).parse_next(input)?; let _ = take_while(0.., CRLF).parse_next(input)?; Ok(Directive::Module(res, span))