code_moniker_core/lang/go/
build.rs1#[derive(Clone, Debug, Eq, PartialEq)]
2pub struct Dep {
3 pub name: String,
4 pub version: Option<String>,
5 pub dep_kind: String,
6 pub import_root: String,
7}
8
9#[derive(Debug)]
10pub enum GoModError {
11 Schema(String),
12}
13
14impl std::fmt::Display for GoModError {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 Self::Schema(s) => write!(f, "go.mod schema error: {s}"),
18 }
19 }
20}
21
22impl std::error::Error for GoModError {}
23
24pub fn parse(content: &str) -> Result<Vec<Dep>, GoModError> {
25 let mut out = Vec::new();
26 let mut block: Option<Block> = None;
27
28 for raw_line in content.lines() {
29 let (clean, comment) = split_comment(raw_line);
30 let trimmed = clean.trim();
31 if trimmed.is_empty() {
32 continue;
33 }
34
35 if let Some(b) = block {
36 if trimmed == ")" {
37 block = None;
38 continue;
39 }
40 match b {
41 Block::Require => {
42 if let Some(dep) = parse_require_entry(trimmed, comment) {
43 out.push(dep);
44 }
45 }
46 Block::Other => {}
47 }
48 block = Some(b);
49 continue;
50 }
51
52 if let Some(rest) = trimmed.strip_prefix("module") {
53 let path = rest.trim().trim_matches('"');
54 if !path.is_empty() {
55 out.push(Dep {
56 name: path.into(),
57 version: None,
58 dep_kind: "package".into(),
59 import_root: path.into(),
60 });
61 }
62 continue;
63 }
64
65 if let Some(rest) = trimmed.strip_prefix("require") {
66 let rest = rest.trim();
67 if rest == "(" {
68 block = Some(Block::Require);
69 continue;
70 }
71 if let Some(dep) = parse_require_entry(rest, comment) {
72 out.push(dep);
73 }
74 continue;
75 }
76
77 if trimmed.starts_with("replace")
78 || trimmed.starts_with("exclude")
79 || trimmed.starts_with("retract")
80 {
81 let rest = trimmed.split_whitespace().nth(1).unwrap_or("");
82 if rest == "(" {
83 block = Some(Block::Other);
84 }
85 continue;
86 }
87
88 if trimmed.starts_with("go ") || trimmed.starts_with("toolchain ") {
89 continue;
90 }
91 }
92
93 Ok(out)
94}
95
96#[derive(Clone, Copy)]
97enum Block {
98 Require,
99 Other,
100}
101
102fn parse_require_entry(line: &str, comment: &str) -> Option<Dep> {
103 let mut parts = line.split_whitespace();
104 let path = parts.next()?.trim_matches('"');
105 let version = parts.next()?.trim_matches('"');
106 if path.is_empty() || version.is_empty() {
107 return None;
108 }
109 let dep_kind = if comment.trim() == "indirect" {
110 "indirect"
111 } else {
112 "normal"
113 };
114 Some(Dep {
115 name: path.into(),
116 version: Some(version.into()),
117 dep_kind: dep_kind.into(),
118 import_root: path.into(),
119 })
120}
121
122fn split_comment(line: &str) -> (&str, &str) {
123 if let Some(idx) = line.find("//") {
124 (&line[..idx], &line[idx + 2..])
125 } else {
126 (line, "")
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn parse_empty_returns_empty_vec() {
136 assert!(parse("").unwrap().is_empty());
137 }
138
139 #[test]
140 fn parse_module_only_emits_package_dep() {
141 let src = "module github.com/foo/bar\n\ngo 1.21\n";
142 let deps = parse(src).unwrap();
143 assert_eq!(
144 deps,
145 vec![Dep {
146 name: "github.com/foo/bar".into(),
147 version: None,
148 dep_kind: "package".into(),
149 import_root: "github.com/foo/bar".into(),
150 }]
151 );
152 }
153
154 #[test]
155 fn parse_single_line_require() {
156 let src = "module foo\n\nrequire gopkg.in/x v1.0.0\n";
157 let deps = parse(src).unwrap();
158 let req = deps.iter().find(|d| d.name == "gopkg.in/x").unwrap();
159 assert_eq!(req.version.as_deref(), Some("v1.0.0"));
160 assert_eq!(req.dep_kind, "normal");
161 assert_eq!(req.import_root, "gopkg.in/x");
162 }
163
164 #[test]
165 fn parse_block_require_multiple_entries() {
166 let src = "module foo\n\nrequire (\n\tgithub.com/x/y v1.2.3\n\tgithub.com/a/b v0.5.0\n)\n";
167 let deps = parse(src).unwrap();
168 assert!(
169 deps.iter()
170 .any(|d| d.name == "github.com/x/y" && d.version.as_deref() == Some("v1.2.3"))
171 );
172 assert!(
173 deps.iter()
174 .any(|d| d.name == "github.com/a/b" && d.version.as_deref() == Some("v0.5.0"))
175 );
176 }
177
178 #[test]
179 fn parse_indirect_marker_sets_dep_kind() {
180 let src = "module foo\n\nrequire (\n\tgithub.com/x/y v1.2.3 // indirect\n)\n";
181 let deps = parse(src).unwrap();
182 let req = deps.iter().find(|d| d.name == "github.com/x/y").unwrap();
183 assert_eq!(req.dep_kind, "indirect");
184 }
185
186 #[test]
187 fn parse_inline_indirect_on_single_line_require() {
188 let src = "module foo\n\nrequire github.com/x/y v1.0.0 // indirect\n";
189 let deps = parse(src).unwrap();
190 let req = deps.iter().find(|d| d.name == "github.com/x/y").unwrap();
191 assert_eq!(req.dep_kind, "indirect");
192 }
193
194 #[test]
195 fn parse_skips_replace_block() {
196 let src = "module foo\n\nrequire github.com/x v1.0.0\n\nreplace (\n\tgithub.com/old => github.com/new v2.0.0\n)\n\nrequire github.com/z v3.0.0\n";
197 let deps = parse(src).unwrap();
198 let names: Vec<&str> = deps.iter().map(|d| d.name.as_str()).collect();
199 assert!(names.contains(&"github.com/x"));
200 assert!(names.contains(&"github.com/z"));
201 assert!(!names.contains(&"github.com/old"));
202 assert!(!names.contains(&"github.com/new"));
203 }
204
205 #[test]
206 fn parse_skips_replace_single_line() {
207 let src = "module foo\n\nreplace github.com/old => github.com/new v2.0.0\n\nrequire github.com/x v1.0.0\n";
208 let deps = parse(src).unwrap();
209 assert!(deps.iter().any(|d| d.name == "github.com/x"));
210 assert!(!deps.iter().any(|d| d.name == "github.com/old"));
211 }
212
213 #[test]
214 fn parse_skips_go_and_toolchain_directives() {
215 let src = "module foo\n\ngo 1.21\ntoolchain go1.22.0\n";
216 let deps = parse(src).unwrap();
217 assert_eq!(deps.len(), 1);
218 assert_eq!(deps[0].name, "foo");
219 }
220
221 #[test]
222 fn parse_strips_inline_comments_outside_indirect_marker() {
223 let src = "module foo // some comment\n\nrequire github.com/x v1.0.0 // some other text\n";
224 let deps = parse(src).unwrap();
225 let req = deps.iter().find(|d| d.name == "github.com/x").unwrap();
226 assert_eq!(req.dep_kind, "normal");
227 }
228}