deps_go/
parser.rs

1//! go.mod parser with position tracking.
2//!
3//! Parses go.mod files using regex patterns and line-by-line parsing.
4//! Critical for LSP features like hover, completion, and inlay hints.
5//!
6//! # Key Features
7//!
8//! - Position-preserving parsing with byte-to-LSP conversion
9//! - Handles go.mod directives: module, go, require, replace, exclude
10//! - Supports multi-line blocks and inline/block comments
11//! - Extracts indirect dependency markers (// indirect)
12//! - Note: retract directive is defined in types but not yet parsed
13
14use crate::error::Result;
15use crate::types::{GoDependency, GoDirective};
16use regex::Regex;
17use tower_lsp_server::ls_types::{Position, Range, Uri};
18
19/// Result of parsing a go.mod file.
20#[derive(Debug, Clone, serde::Serialize)]
21pub struct GoParseResult {
22    /// All dependencies found in the file
23    pub dependencies: Vec<GoDependency>,
24    /// Module path declared in `module` directive
25    pub module_path: Option<String>,
26    /// Minimum Go version from `go` directive
27    pub go_version: Option<String>,
28    /// Document URI
29    pub uri: Uri,
30}
31
32/// Pre-computed line start byte offsets for O(log n) position lookups.
33struct 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    /// Converts byte offset to LSP Position (line, UTF-16 character).
49    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
65/// Parses a go.mod file and extracts all dependencies with positions.
66pub 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
165/// Strips line comments from a line (everything after //).
166///
167/// Handles URL schemes (e.g., https://) to avoid stripping URL paths.
168fn 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
185/// Parses a single require line.
186fn 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
235/// Parses a replace directive line.
236fn 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
273/// Parses an exclude directive line.
274fn 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        // Check module metadata
508        assert_eq!(result.module_path, Some("example.com/myapp".to_string()));
509        assert_eq!(result.go_version, Some("1.21".to_string()));
510
511        // Check dependencies count
512        assert_eq!(result.dependencies.len(), 4);
513
514        // Check gin-gonic (require, direct)
515        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        // Check crypto (require, indirect)
522        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        // Check replace directive
529        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        // Check exclude directive
535        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}