Skip to main content

gen_gomod/
gomod.rs

1//! `go.mod` → [`ModuleSpec`] parser.
2//!
3//! Pure text parse — no network, no `go` invocation. Handles the
4//! directives the encoder needs: `module`, `go`, `toolchain`,
5//! `require` (single + block), `replace` (single + block). The heavy
6//! semantic work — transitive closure, build-constraint resolution,
7//! vendor/replace rewriting — is `go list`'s job (see [`crate::golist`]);
8//! this parser only produces the module-level [`ModuleSpec`] scalars.
9
10use crate::build_spec::{DepMode, ModuleSpec};
11
12/// One `replace` directive: `old[@version] => new[@version]`. Carried
13/// for audit/provenance; `go list` already resolves the effect (it
14/// points a replaced package's `Dir` at the replacement).
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ReplaceDirective {
17    pub old_path: String,
18    pub old_version: Option<String>,
19    pub new_path: String,
20    pub new_version: Option<String>,
21}
22
23/// The parsed shape of a `go.mod`, before target resolution.
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
25pub struct GoMod {
26    pub module_path: String,
27    pub go_version: String,
28    pub toolchain: Option<String>,
29    /// `(path, version)` per require edge.
30    pub requires: Vec<(String, String)>,
31    pub replaces: Vec<ReplaceDirective>,
32}
33
34impl GoMod {
35    /// Parse `go.mod` text. Tolerant of comments, blank lines, and both
36    /// single-line + parenthesized-block forms for `require`/`replace`.
37    #[must_use]
38    pub fn parse(text: &str) -> Self {
39        let mut m = GoMod::default();
40        // `None` = top-level; `Some(Block::Require|Replace)` = inside a
41        // parenthesized block whose entries omit the leading keyword.
42        let mut block: Option<Block> = None;
43
44        for raw in text.lines() {
45            let line = strip_comment(raw).trim();
46            if line.is_empty() {
47                continue;
48            }
49
50            // Close a block.
51            if let Some(_b) = block {
52                if line == ")" {
53                    block = None;
54                    continue;
55                }
56            }
57
58            match block {
59                Some(Block::Require) => {
60                    if let Some((p, v)) = parse_require_entry(line) {
61                        m.requires.push((p, v));
62                    }
63                    continue;
64                }
65                Some(Block::Replace) => {
66                    if let Some(r) = parse_replace_entry(line) {
67                        m.replaces.push(r);
68                    }
69                    continue;
70                }
71                None => {}
72            }
73
74            // Top-level directives.
75            if let Some(rest) = line.strip_prefix("module ") {
76                m.module_path = rest.trim().to_string();
77            } else if let Some(rest) = line.strip_prefix("go ") {
78                m.go_version = rest.trim().to_string();
79            } else if let Some(rest) = line.strip_prefix("toolchain ") {
80                m.toolchain = Some(rest.trim().to_string());
81            } else if line == "require (" || line == "require(" {
82                block = Some(Block::Require);
83            } else if let Some(rest) = line.strip_prefix("require ") {
84                if let Some((p, v)) = parse_require_entry(rest.trim()) {
85                    m.requires.push((p, v));
86                }
87            } else if line == "replace (" || line == "replace(" {
88                block = Some(Block::Replace);
89            } else if let Some(rest) = line.strip_prefix("replace ") {
90                if let Some(r) = parse_replace_entry(rest.trim()) {
91                    m.replaces.push(r);
92                }
93            }
94            // `exclude`/`retract` are irrelevant to the build graph — ignored.
95        }
96
97        m
98    }
99
100    /// Lower the parsed go.mod into the spec's [`ModuleSpec`]. `dep_mode`
101    /// is the caller's decision (M1 ⇒ `Vendored`); `vendor_hash` is the
102    /// coarse-path fallback (always `None` on the incremental path).
103    #[must_use]
104    pub fn to_module_spec(&self, dep_mode: DepMode, vendor_hash: Option<String>) -> ModuleSpec {
105        ModuleSpec {
106            module_path: self.module_path.clone(),
107            go_version: self.go_version.clone(),
108            toolchain: self.toolchain.clone(),
109            has_external_deps: !self.requires.is_empty(),
110            dep_mode,
111            vendor_hash,
112        }
113    }
114}
115
116#[derive(Clone, Copy)]
117enum Block {
118    Require,
119    Replace,
120}
121
122/// Drop a trailing `// …` comment, respecting nothing fancy — go.mod
123/// has no string literals so a bare `//` split is correct.
124fn strip_comment(line: &str) -> &str {
125    match line.find("//") {
126        Some(i) => &line[..i],
127        None => line,
128    }
129}
130
131/// `<path> <version>` (block entries and single-line `require` tail).
132fn parse_require_entry(s: &str) -> Option<(String, String)> {
133    let mut it = s.split_whitespace();
134    let path = it.next()?.to_string();
135    let version = it.next()?.to_string();
136    Some((path, version))
137}
138
139/// `<old>[ <oldver>] => <new>[ <newver>]`.
140fn parse_replace_entry(s: &str) -> Option<ReplaceDirective> {
141    let (lhs, rhs) = s.split_once("=>")?;
142    let (old_path, old_version) = split_path_version(lhs.trim());
143    let (new_path, new_version) = split_path_version(rhs.trim());
144    if old_path.is_empty() || new_path.is_empty() {
145        return None;
146    }
147    Some(ReplaceDirective { old_path, old_version, new_path, new_version })
148}
149
150/// `<path>` or `<path> <version>`. A local filesystem replacement
151/// (`./foo`, `../bar`) has no version → `(path, None)`.
152fn split_path_version(s: &str) -> (String, Option<String>) {
153    let mut it = s.split_whitespace();
154    let path = it.next().unwrap_or_default().to_string();
155    let version = it.next().map(str::to_string);
156    (path, version)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    const AKEYLESS_SHAPED: &str = r#"
164module akeyless.io/akeyless-main-repo
165
166go 1.26
167
168toolchain go1.26.4
169
170require (
171	github.com/foo/bar v1.2.3
172	github.com/baz/qux v0.4.0 // indirect
173)
174
175require github.com/single/dep v2.0.0
176
177replace github.com/akeylesslabs/akeyless-go/v3 => ./go/src/client/sdktest/akeyless-go
178
179replace (
180	github.com/old/mod v1.0.0 => github.com/new/mod v1.1.0
181)
182"#;
183
184    #[test]
185    fn parses_akeyless_shaped_go_mod() {
186        let m = GoMod::parse(AKEYLESS_SHAPED);
187        assert_eq!(m.module_path, "akeyless.io/akeyless-main-repo");
188        assert_eq!(m.go_version, "1.26");
189        assert_eq!(m.toolchain.as_deref(), Some("go1.26.4"));
190        assert_eq!(m.requires.len(), 3);
191        assert!(m.requires.contains(&("github.com/single/dep".into(), "v2.0.0".into())));
192        // in-tree filesystem replace (no version on the RHS).
193        let fs_replace = m
194            .replaces
195            .iter()
196            .find(|r| r.old_path == "github.com/akeylesslabs/akeyless-go/v3")
197            .expect("in-tree replace present");
198        assert_eq!(fs_replace.new_path, "./go/src/client/sdktest/akeyless-go");
199        assert_eq!(fs_replace.new_version, None);
200        // block replace with versions on both sides.
201        let mod_replace = m.replaces.iter().find(|r| r.old_path == "github.com/old/mod").unwrap();
202        assert_eq!(mod_replace.old_version.as_deref(), Some("v1.0.0"));
203        assert_eq!(mod_replace.new_version.as_deref(), Some("v1.1.0"));
204    }
205
206    #[test]
207    fn dep_free_module_has_no_external_deps() {
208        let m = GoMod::parse("module example.com/x\n\ngo 1.25\n");
209        assert!(m.requires.is_empty());
210        let spec = m.to_module_spec(DepMode::Vendored, None);
211        assert!(!spec.has_external_deps);
212        assert_eq!(spec.module_path, "example.com/x");
213        assert_eq!(spec.go_version, "1.25");
214        assert!(spec.toolchain.is_none());
215    }
216
217    #[test]
218    fn to_module_spec_carries_dep_mode_and_require_presence() {
219        let m = GoMod::parse(AKEYLESS_SHAPED);
220        let spec = m.to_module_spec(DepMode::Vendored, None);
221        assert!(spec.has_external_deps);
222        assert!(spec.dep_mode.is_vendored());
223        assert!(spec.vendor_hash.is_none());
224    }
225}