1use crate::error::Result;
15use crate::types::{GoDependency, GoDirective};
16use regex::Regex;
17use tower_lsp_server::ls_types::{Position, Range, Uri};
18
19#[derive(Debug, Clone, serde::Serialize)]
21pub struct GoParseResult {
22 pub dependencies: Vec<GoDependency>,
24 pub module_path: Option<String>,
26 pub go_version: Option<String>,
28 pub uri: Uri,
30}
31
32struct LineOffsetTable {
34 line_starts: Vec<usize>,
35}
36
37impl LineOffsetTable {
38 fn new(content: &str) -> Self {
39 let mut line_starts = vec![0];
40 for (i, c) in content.char_indices() {
41 if c == '\n' {
42 line_starts.push(i + 1);
43 }
44 }
45 Self { line_starts }
46 }
47
48 fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
50 let line = self
51 .line_starts
52 .partition_point(|&start| start <= offset)
53 .saturating_sub(1);
54 let line_start = self.line_starts[line];
55
56 let character = content[line_start..offset]
57 .chars()
58 .map(|c| c.len_utf16() as u32)
59 .sum();
60
61 Position::new(line as u32, character)
62 }
63}
64
65pub fn parse_go_mod(content: &str, doc_uri: &Uri) -> Result<GoParseResult> {
67 tracing::debug!(uri = ?doc_uri, "Parsing go.mod file");
68
69 let line_table = LineOffsetTable::new(content);
70 let mut dependencies = Vec::with_capacity(50);
71 let mut module_path = None;
72 let mut go_version = None;
73
74 static MODULE_PATTERN: std::sync::LazyLock<Regex> =
75 std::sync::LazyLock::new(|| Regex::new(r"^\s*module\s+(\S+)").unwrap());
76 static GO_PATTERN: std::sync::LazyLock<Regex> =
77 std::sync::LazyLock::new(|| Regex::new(r"^\s*go\s+(\S+)").unwrap());
78 static REQUIRE_SINGLE: std::sync::LazyLock<Regex> =
79 std::sync::LazyLock::new(|| Regex::new(r"^\s*require\s+(\S+)\s+(\S+)").unwrap());
80 static REQUIRE_BLOCK_START: std::sync::LazyLock<Regex> =
81 std::sync::LazyLock::new(|| Regex::new(r"^\s*require\s*\(").unwrap());
82 static REPLACE_PATTERN: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
83 Regex::new(r"^\s*replace\s+(\S+)\s+(?:(\S+)\s+)?=>\s+(\S+)\s+(\S+)").unwrap()
84 });
85 static EXCLUDE_PATTERN: std::sync::LazyLock<Regex> =
86 std::sync::LazyLock::new(|| Regex::new(r"^\s*exclude\s+(\S+)\s+(\S+)").unwrap());
87
88 let mut in_require_block = false;
89 let mut line_offset = 0;
90
91 for line in content.lines() {
92 let line_without_comment = strip_line_comment(line);
93 let line_trimmed = line_without_comment.trim();
94
95 if let Some(caps) = MODULE_PATTERN.captures(line_trimmed) {
96 module_path = Some(caps[1].to_string());
97 }
98
99 if let Some(caps) = GO_PATTERN.captures(line_trimmed) {
100 go_version = Some(caps[1].to_string());
101 }
102
103 if REQUIRE_BLOCK_START.is_match(line_trimmed) {
104 in_require_block = true;
105 line_offset += line.len() + 1;
106 continue;
107 }
108
109 if in_require_block && line_trimmed.contains(')') {
110 in_require_block = false;
111 line_offset += line.len() + 1;
112 continue;
113 }
114
115 if (in_require_block || REQUIRE_SINGLE.is_match(line_trimmed))
116 && let Some(dep) = parse_require_line(line, line_offset, content, &line_table)
117 {
118 dependencies.push(dep);
119 }
120
121 if let Some(caps) = REPLACE_PATTERN.captures(line_trimmed) {
122 let module = &caps[1];
123 let version = caps.get(2).map(|m| m.as_str());
124 if let Some(dep) =
125 parse_replace_line(line, line_offset, module, version, content, &line_table)
126 {
127 dependencies.push(dep);
128 }
129 }
130
131 if let Some(caps) = EXCLUDE_PATTERN.captures(line_trimmed) {
132 let module = &caps[1];
133 let version = &caps[2];
134 if let Some(dep) =
135 parse_exclude_line(line, line_offset, module, version, content, &line_table)
136 {
137 dependencies.push(dep);
138 }
139 }
140
141 let line_end = line_offset + line.len();
142 let next_line_start = if line_end < content.len() && content.as_bytes()[line_end] == b'\n' {
143 line_end + 1
144 } else {
145 line_end
146 };
147 line_offset = next_line_start;
148 }
149
150 tracing::debug!(
151 dependencies = %dependencies.len(),
152 module = ?module_path,
153 go_version = ?go_version,
154 "Parsed go.mod successfully"
155 );
156
157 Ok(GoParseResult {
158 dependencies,
159 module_path,
160 go_version,
161 uri: doc_uri.clone(),
162 })
163}
164
165fn strip_line_comment(line: &str) -> &str {
169 let mut in_url = false;
170 for (i, c) in line.char_indices() {
171 if c == ':' && line[i..].starts_with("://") {
172 in_url = true;
173 continue;
174 }
175 if in_url && c.is_whitespace() {
176 in_url = false;
177 }
178 if !in_url && line[i..].starts_with("//") {
179 return &line[..i];
180 }
181 }
182 line
183}
184
185fn parse_require_line(
187 line: &str,
188 line_start_offset: usize,
189 content: &str,
190 line_table: &LineOffsetTable,
191) -> Option<GoDependency> {
192 let parts: Vec<&str> = line.split_whitespace().collect();
193 if parts.is_empty() {
194 return None;
195 }
196
197 let (module_path, version) = if parts[0] == "require" {
198 if parts.len() < 3 {
199 return None;
200 }
201 (parts[1], parts[2])
202 } else {
203 if parts.len() < 2 {
204 return None;
205 }
206 (parts[0], parts[1])
207 };
208
209 let indirect = line.contains("// indirect");
210
211 let module_start = line.find(module_path)?;
212 let module_offset = line_start_offset + module_start;
213 let module_path_range = Range::new(
214 line_table.byte_offset_to_position(content, module_offset),
215 line_table.byte_offset_to_position(content, module_offset + module_path.len()),
216 );
217
218 let version_start = line.find(version)?;
219 let version_offset = line_start_offset + version_start;
220 let version_range = Range::new(
221 line_table.byte_offset_to_position(content, version_offset),
222 line_table.byte_offset_to_position(content, version_offset + version.len()),
223 );
224
225 Some(GoDependency {
226 module_path: module_path.to_string(),
227 module_path_range,
228 version: Some(version.to_string()),
229 version_range: Some(version_range),
230 directive: GoDirective::Require,
231 indirect,
232 })
233}
234
235fn parse_replace_line(
237 line: &str,
238 line_start_offset: usize,
239 module: &str,
240 version: Option<&str>,
241 content: &str,
242 line_table: &LineOffsetTable,
243) -> Option<GoDependency> {
244 let module_start = line.find(module)?;
245 let module_offset = line_start_offset + module_start;
246 let module_path_range = Range::new(
247 line_table.byte_offset_to_position(content, module_offset),
248 line_table.byte_offset_to_position(content, module_offset + module.len()),
249 );
250
251 let (version_str, version_range) = if let Some(ver) = version {
252 let version_start = line.find(ver)?;
253 let version_offset = line_start_offset + version_start;
254 let range = Range::new(
255 line_table.byte_offset_to_position(content, version_offset),
256 line_table.byte_offset_to_position(content, version_offset + ver.len()),
257 );
258 (Some(ver.to_string()), Some(range))
259 } else {
260 (None, None)
261 };
262
263 Some(GoDependency {
264 module_path: module.to_string(),
265 module_path_range,
266 version: version_str,
267 version_range,
268 directive: GoDirective::Replace,
269 indirect: false,
270 })
271}
272
273fn parse_exclude_line(
275 line: &str,
276 line_start_offset: usize,
277 module: &str,
278 version: &str,
279 content: &str,
280 line_table: &LineOffsetTable,
281) -> Option<GoDependency> {
282 let module_start = line.find(module)?;
283 let module_offset = line_start_offset + module_start;
284 let module_path_range = Range::new(
285 line_table.byte_offset_to_position(content, module_offset),
286 line_table.byte_offset_to_position(content, module_offset + module.len()),
287 );
288
289 let version_start = line.find(version)?;
290 let version_offset = line_start_offset + version_start;
291 let version_range = Range::new(
292 line_table.byte_offset_to_position(content, version_offset),
293 line_table.byte_offset_to_position(content, version_offset + version.len()),
294 );
295
296 Some(GoDependency {
297 module_path: module.to_string(),
298 module_path_range,
299 version: Some(version.to_string()),
300 version_range: Some(version_range),
301 directive: GoDirective::Exclude,
302 indirect: false,
303 })
304}
305
306impl deps_core::parser::ParseResultInfo for GoParseResult {
307 type Dependency = GoDependency;
308
309 fn dependencies(&self) -> &[Self::Dependency] {
310 &self.dependencies
311 }
312
313 fn workspace_root(&self) -> Option<&std::path::Path> {
314 None
315 }
316}
317
318deps_core::impl_parse_result!(
319 GoParseResult,
320 GoDependency {
321 dependencies: dependencies,
322 uri: uri,
323 }
324);
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 fn test_uri() -> Uri {
330 use std::str::FromStr;
331 Uri::from_str("file:///test/go.mod").unwrap()
332 }
333
334 #[test]
335 fn test_parse_single_require() {
336 let content = r"module example.com/myapp
337
338go 1.21
339
340require github.com/gin-gonic/gin v1.9.1
341";
342 let result = parse_go_mod(content, &test_uri()).unwrap();
343 assert_eq!(result.dependencies.len(), 1);
344 assert_eq!(
345 result.dependencies[0].module_path,
346 "github.com/gin-gonic/gin"
347 );
348 assert_eq!(result.dependencies[0].version, Some("v1.9.1".to_string()));
349 assert!(!result.dependencies[0].indirect);
350 }
351
352 #[test]
353 fn test_parse_module_directive() {
354 let content = "module example.com/myapp\n";
355 let result = parse_go_mod(content, &test_uri()).unwrap();
356 assert_eq!(result.module_path, Some("example.com/myapp".to_string()));
357 }
358
359 #[test]
360 fn test_parse_go_version() {
361 let content = "go 1.21\n";
362 let result = parse_go_mod(content, &test_uri()).unwrap();
363 assert_eq!(result.go_version, Some("1.21".to_string()));
364 }
365
366 #[test]
367 fn test_parse_require_block() {
368 let content = r"require (
369 github.com/gin-gonic/gin v1.9.1
370 golang.org/x/crypto v0.17.0 // indirect
371)
372";
373 let result = parse_go_mod(content, &test_uri()).unwrap();
374 assert_eq!(result.dependencies.len(), 2);
375 assert!(!result.dependencies[0].indirect);
376 assert!(result.dependencies[1].indirect);
377 }
378
379 #[test]
380 fn test_parse_replace_directive() {
381 let content = "replace github.com/old/module => github.com/new/module v1.2.3\n";
382 let result = parse_go_mod(content, &test_uri()).unwrap();
383 assert_eq!(result.dependencies.len(), 1);
384 assert_eq!(result.dependencies[0].directive, GoDirective::Replace);
385 assert_eq!(result.dependencies[0].module_path, "github.com/old/module");
386 }
387
388 #[test]
389 fn test_parse_exclude_directive() {
390 let content = "exclude github.com/bad/module v0.1.0\n";
391 let result = parse_go_mod(content, &test_uri()).unwrap();
392 assert_eq!(result.dependencies.len(), 1);
393 assert_eq!(result.dependencies[0].directive, GoDirective::Exclude);
394 assert_eq!(result.dependencies[0].module_path, "github.com/bad/module");
395 assert_eq!(result.dependencies[0].version, Some("v0.1.0".to_string()));
396 }
397
398 #[test]
399 fn test_parse_pseudo_version() {
400 let content = "require golang.org/x/crypto v0.0.0-20191109021931-daa7c04131f5\n";
401 let result = parse_go_mod(content, &test_uri()).unwrap();
402 assert_eq!(
403 result.dependencies[0].version,
404 Some("v0.0.0-20191109021931-daa7c04131f5".to_string())
405 );
406 }
407
408 #[test]
409 fn test_position_tracking() {
410 let content = "require github.com/gin-gonic/gin v1.9.1";
411 let result = parse_go_mod(content, &test_uri()).unwrap();
412 let dep = &result.dependencies[0];
413
414 assert_eq!(dep.module_path_range.start.line, 0);
415 assert!(dep.version_range.is_some());
416 }
417
418 #[test]
419 fn test_empty_file() {
420 let content = "";
421 let result = parse_go_mod(content, &test_uri()).unwrap();
422 assert_eq!(result.dependencies.len(), 0);
423 assert_eq!(result.module_path, None);
424 assert_eq!(result.go_version, None);
425 }
426
427 #[test]
428 fn test_comments_stripped() {
429 let content =
430 "// This is a comment\nrequire github.com/pkg/errors v0.9.1 // inline comment\n";
431 let result = parse_go_mod(content, &test_uri()).unwrap();
432 assert_eq!(result.dependencies.len(), 1);
433 assert_eq!(result.dependencies[0].module_path, "github.com/pkg/errors");
434 }
435
436 #[test]
437 fn test_complex_go_mod() {
438 let content = r"module example.com/myapp
439
440go 1.21
441
442require (
443 github.com/gin-gonic/gin v1.9.1
444 golang.org/x/crypto v0.17.0 // indirect
445)
446
447replace github.com/old/module => github.com/new/module v1.2.3
448
449exclude github.com/bad/module v0.1.0
450";
451 let result = parse_go_mod(content, &test_uri()).unwrap();
452 assert_eq!(result.dependencies.len(), 4);
453 assert_eq!(result.module_path, Some("example.com/myapp".to_string()));
454 assert_eq!(result.go_version, Some("1.21".to_string()));
455
456 let require_deps: Vec<_> = result
457 .dependencies
458 .iter()
459 .filter(|d| d.directive == GoDirective::Require)
460 .collect();
461 assert_eq!(require_deps.len(), 2);
462
463 let replace_deps: Vec<_> = result
464 .dependencies
465 .iter()
466 .filter(|d| d.directive == GoDirective::Replace)
467 .collect();
468 assert_eq!(replace_deps.len(), 1);
469
470 let exclude_deps: Vec<_> = result
471 .dependencies
472 .iter()
473 .filter(|d| d.directive == GoDirective::Exclude)
474 .collect();
475 assert_eq!(exclude_deps.len(), 1);
476 }
477
478 #[test]
479 fn test_position_tracking_no_trailing_newline() {
480 let content = "require github.com/gin-gonic/gin v1.9.1";
481 let result = parse_go_mod(content, &test_uri()).unwrap();
482 let dep = &result.dependencies[0];
483
484 assert_eq!(dep.module_path_range.start.character, 8);
485 assert_eq!(dep.module_path_range.end.character, 32);
486 assert_eq!(dep.version_range.as_ref().unwrap().start.character, 33);
487 assert_eq!(dep.version_range.as_ref().unwrap().end.character, 39);
488 }
489
490 #[test]
491 fn test_parse_complex_go_mod() {
492 let content = r"module example.com/myapp
493
494go 1.21
495
496require (
497 github.com/gin-gonic/gin v1.9.1
498 golang.org/x/crypto v0.17.0 // indirect
499)
500
501replace github.com/old/module => github.com/new/module v1.2.3
502
503exclude github.com/bad/module v0.1.0
504";
505 let result = parse_go_mod(content, &test_uri()).unwrap();
506
507 assert_eq!(result.module_path, Some("example.com/myapp".to_string()));
509 assert_eq!(result.go_version, Some("1.21".to_string()));
510
511 assert_eq!(result.dependencies.len(), 4);
513
514 let gin = &result.dependencies[0];
516 assert_eq!(gin.module_path, "github.com/gin-gonic/gin");
517 assert_eq!(gin.version, Some("v1.9.1".to_string()));
518 assert_eq!(gin.directive, GoDirective::Require);
519 assert!(!gin.indirect);
520
521 let crypto = &result.dependencies[1];
523 assert_eq!(crypto.module_path, "golang.org/x/crypto");
524 assert_eq!(crypto.version, Some("v0.17.0".to_string()));
525 assert_eq!(crypto.directive, GoDirective::Require);
526 assert!(crypto.indirect);
527
528 let replace = &result.dependencies[2];
530 assert_eq!(replace.module_path, "github.com/old/module");
531 assert_eq!(replace.version, None);
532 assert_eq!(replace.directive, GoDirective::Replace);
533
534 let exclude = &result.dependencies[3];
536 assert_eq!(exclude.module_path, "github.com/bad/module");
537 assert_eq!(exclude.version, Some("v0.1.0".to_string()));
538 assert_eq!(exclude.directive, GoDirective::Exclude);
539 }
540
541 #[test]
542 fn test_strip_line_comment_with_url() {
543 let line = "replace github.com/old => https://github.com/new // comment";
544 let stripped = strip_line_comment(line);
545 assert_eq!(
546 stripped,
547 "replace github.com/old => https://github.com/new "
548 );
549 }
550}