Skip to main content

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::config::{GoParseContext, GoProxyChain};
15use crate::types::{GoDependency, GoDirective};
16use deps_core::Result;
17use deps_core::lsp_helpers::LineOffsetTable;
18use regex::Regex;
19use tower_lsp_server::ls_types::{Range, Uri};
20
21/// Result of parsing a go.mod file.
22#[derive(Debug, Clone)]
23pub struct GoParseResult {
24    /// All dependencies found in the file
25    pub dependencies: Vec<GoDependency>,
26    /// Module path declared in `module` directive
27    pub module_path: Option<String>,
28    /// Minimum Go version from `go` directive
29    pub go_version: Option<String>,
30    /// Document URI
31    pub uri: Uri,
32    /// Every `$GOENV`-resolved `GOPROXY`/`GOPRIVATE`-bypass chain this parse implies (spec
33    /// 034), ready for `GoRegistry::register_chain`. Empty when `$GOENV` declares no
34    /// override (US-005).
35    pub resolved_chains: Vec<GoProxyChain>,
36}
37
38/// Parses a go.mod file and extracts all dependencies with positions, using a fresh, default
39/// [`GoParseContext`].
40///
41/// No live `$GOENV` policy handle — every dependency resolves to plain
42/// [`deps_core::parser::DependencySource::Registry`], byte-identical to pre-#519 behavior.
43/// Production parsing goes through [`parse_go_mod_with_context`] instead.
44pub fn parse_go_mod(content: &str, doc_uri: &Uri) -> Result<GoParseResult> {
45    parse_go_mod_with_context(content, doc_uri, &GoParseContext::default())
46}
47
48/// Parses a go.mod file and extracts all dependencies with positions.
49///
50/// Resolves each dependency's [`deps_core::parser::DependencySource`] against `ctx`'s
51/// `$GOENV`-derived `GOPROXY`/`GOPRIVATE` configuration (spec 034 FR-002/FR-007/FR-008/FR-009).
52///
53/// # Errors
54///
55/// Same as [`parse_go_mod`].
56pub fn parse_go_mod_with_context(
57    content: &str,
58    doc_uri: &Uri,
59    ctx: &GoParseContext,
60) -> Result<GoParseResult> {
61    tracing::debug!(uri = ?doc_uri, "Parsing go.mod file");
62
63    let line_table = LineOffsetTable::new(content);
64    let mut dependencies = Vec::with_capacity(50);
65    let mut module_path = None;
66    let mut go_version = None;
67
68    static MODULE_PATTERN: std::sync::LazyLock<Regex> =
69        std::sync::LazyLock::new(|| Regex::new(r"^\s*module\s+(\S+)").unwrap());
70    static GO_PATTERN: std::sync::LazyLock<Regex> =
71        std::sync::LazyLock::new(|| Regex::new(r"^\s*go\s+(\S+)").unwrap());
72    static REQUIRE_SINGLE: std::sync::LazyLock<Regex> =
73        std::sync::LazyLock::new(|| Regex::new(r"^\s*require\s+(\S+)\s+(\S+)").unwrap());
74    static REQUIRE_BLOCK_START: std::sync::LazyLock<Regex> =
75        std::sync::LazyLock::new(|| Regex::new(r"^\s*require\s*\(").unwrap());
76    static REPLACE_PATTERN: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
77        Regex::new(r"^\s*replace\s+(\S+)\s+(?:(\S+)\s+)?=>\s+(\S+)\s+(\S+)").unwrap()
78    });
79    static EXCLUDE_PATTERN: std::sync::LazyLock<Regex> =
80        std::sync::LazyLock::new(|| Regex::new(r"^\s*exclude\s+(\S+)\s+(\S+)").unwrap());
81
82    let mut in_require_block = false;
83    let mut line_offset = 0;
84
85    for line in content.lines() {
86        let line_without_comment = strip_line_comment(line);
87        let line_trimmed = line_without_comment.trim();
88
89        if let Some(caps) = MODULE_PATTERN.captures(line_trimmed) {
90            module_path = Some(caps[1].to_string());
91        }
92
93        if let Some(caps) = GO_PATTERN.captures(line_trimmed) {
94            go_version = Some(caps[1].to_string());
95        }
96
97        if REQUIRE_BLOCK_START.is_match(line_trimmed) {
98            in_require_block = true;
99            line_offset += line.len() + 1;
100            continue;
101        }
102
103        if in_require_block && line_trimmed.contains(')') {
104            in_require_block = false;
105            line_offset += line.len() + 1;
106            continue;
107        }
108
109        if (in_require_block || REQUIRE_SINGLE.is_match(line_trimmed))
110            && let Some(dep) = parse_require_line(line, line_offset, content, &line_table)
111        {
112            dependencies.push(dep);
113        }
114
115        if let Some(caps) = REPLACE_PATTERN.captures(line_trimmed) {
116            let module = &caps[1];
117            let version = caps.get(2).map(|m| m.as_str());
118            if let Some(dep) =
119                parse_replace_line(line, line_offset, module, version, content, &line_table)
120            {
121                dependencies.push(dep);
122            }
123        }
124
125        if let Some(caps) = EXCLUDE_PATTERN.captures(line_trimmed) {
126            let module = &caps[1];
127            let version = &caps[2];
128            if let Some(dep) =
129                parse_exclude_line(line, line_offset, module, version, content, &line_table)
130            {
131                dependencies.push(dep);
132            }
133        }
134
135        let line_end = line_offset + line.len();
136        let next_line_start = if line_end < content.len() && content.as_bytes()[line_end] == b'\n' {
137            line_end + 1
138        } else {
139            line_end
140        };
141        line_offset = next_line_start;
142    }
143
144    tracing::debug!(
145        dependencies = %dependencies.len(),
146        module = ?module_path,
147        go_version = ?go_version,
148        "Parsed go.mod successfully"
149    );
150
151    let go_config = crate::config::resolve_with_context(ctx);
152    for dep in &mut dependencies {
153        dep.source = go_config.resolve_source_for(dep.module_path.as_str());
154    }
155
156    Ok(GoParseResult {
157        dependencies,
158        module_path,
159        go_version,
160        uri: doc_uri.clone(),
161        resolved_chains: go_config.resolved_chains(),
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.into(),
227        module_path_range,
228        version: Some(version.into()),
229        version_range: Some(version_range),
230        directive: GoDirective::Require,
231        indirect,
232        source: deps_core::parser::DependencySource::Registry,
233    })
234}
235
236/// Parses a replace directive line.
237fn parse_replace_line(
238    line: &str,
239    line_start_offset: usize,
240    module: &str,
241    version: Option<&str>,
242    content: &str,
243    line_table: &LineOffsetTable,
244) -> Option<GoDependency> {
245    let module_start = line.find(module)?;
246    let module_offset = line_start_offset + module_start;
247    let module_path_range = Range::new(
248        line_table.byte_offset_to_position(content, module_offset),
249        line_table.byte_offset_to_position(content, module_offset + module.len()),
250    );
251
252    let (version_str, version_range) = if let Some(ver) = version {
253        let version_start = line.find(ver)?;
254        let version_offset = line_start_offset + version_start;
255        let range = Range::new(
256            line_table.byte_offset_to_position(content, version_offset),
257            line_table.byte_offset_to_position(content, version_offset + ver.len()),
258        );
259        (Some(ver.to_string()), Some(range))
260    } else {
261        (None, None)
262    };
263
264    Some(GoDependency {
265        module_path: module.into(),
266        module_path_range,
267        version: version_str.map(Into::into),
268        version_range,
269        directive: GoDirective::Replace,
270        indirect: false,
271        source: deps_core::parser::DependencySource::Registry,
272    })
273}
274
275/// Parses an exclude directive line.
276fn parse_exclude_line(
277    line: &str,
278    line_start_offset: usize,
279    module: &str,
280    version: &str,
281    content: &str,
282    line_table: &LineOffsetTable,
283) -> Option<GoDependency> {
284    let module_start = line.find(module)?;
285    let module_offset = line_start_offset + module_start;
286    let module_path_range = Range::new(
287        line_table.byte_offset_to_position(content, module_offset),
288        line_table.byte_offset_to_position(content, module_offset + module.len()),
289    );
290
291    let version_start = line.find(version)?;
292    let version_offset = line_start_offset + version_start;
293    let version_range = Range::new(
294        line_table.byte_offset_to_position(content, version_offset),
295        line_table.byte_offset_to_position(content, version_offset + version.len()),
296    );
297
298    Some(GoDependency {
299        module_path: module.into(),
300        module_path_range,
301        version: Some(version.into()),
302        version_range: Some(version_range),
303        directive: GoDirective::Exclude,
304        indirect: false,
305        source: deps_core::parser::DependencySource::Registry,
306    })
307}
308
309deps_core::impl_parse_result!(
310    GoParseResult,
311    GoDependency {
312        dependencies: dependencies,
313        uri: uri,
314    }
315);
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    fn test_uri() -> Uri {
321        use std::str::FromStr;
322        Uri::from_str("file:///test/go.mod").unwrap()
323    }
324
325    #[test]
326    fn test_parse_single_require() {
327        let content = r"module example.com/myapp
328
329go 1.21
330
331require github.com/gin-gonic/gin v1.9.1
332";
333        let result = parse_go_mod(content, &test_uri()).unwrap();
334        assert_eq!(result.dependencies.len(), 1);
335        assert_eq!(
336            result.dependencies[0].module_path,
337            "github.com/gin-gonic/gin"
338        );
339        assert_eq!(
340            result.dependencies[0]
341                .version
342                .as_ref()
343                .map(deps_core::VersionReq::as_str),
344            Some("v1.9.1")
345        );
346        assert!(!result.dependencies[0].indirect);
347    }
348
349    #[test]
350    fn test_parse_module_directive() {
351        let content = "module example.com/myapp\n";
352        let result = parse_go_mod(content, &test_uri()).unwrap();
353        assert_eq!(result.module_path, Some("example.com/myapp".to_string()));
354    }
355
356    #[test]
357    fn test_parse_go_version() {
358        let content = "go 1.21\n";
359        let result = parse_go_mod(content, &test_uri()).unwrap();
360        assert_eq!(result.go_version, Some("1.21".to_string()));
361    }
362
363    #[test]
364    fn test_parse_require_block() {
365        let content = r"require (
366    github.com/gin-gonic/gin v1.9.1
367    golang.org/x/crypto v0.17.0 // indirect
368)
369";
370        let result = parse_go_mod(content, &test_uri()).unwrap();
371        assert_eq!(result.dependencies.len(), 2);
372        assert!(!result.dependencies[0].indirect);
373        assert!(result.dependencies[1].indirect);
374    }
375
376    #[test]
377    fn test_parse_replace_directive() {
378        let content = "replace github.com/old/module => github.com/new/module v1.2.3\n";
379        let result = parse_go_mod(content, &test_uri()).unwrap();
380        assert_eq!(result.dependencies.len(), 1);
381        assert_eq!(result.dependencies[0].directive, GoDirective::Replace);
382        assert_eq!(result.dependencies[0].module_path, "github.com/old/module");
383    }
384
385    #[test]
386    fn test_parse_exclude_directive() {
387        let content = "exclude github.com/bad/module v0.1.0\n";
388        let result = parse_go_mod(content, &test_uri()).unwrap();
389        assert_eq!(result.dependencies.len(), 1);
390        assert_eq!(result.dependencies[0].directive, GoDirective::Exclude);
391        assert_eq!(result.dependencies[0].module_path, "github.com/bad/module");
392        assert_eq!(
393            result.dependencies[0]
394                .version
395                .as_ref()
396                .map(deps_core::VersionReq::as_str),
397            Some("v0.1.0")
398        );
399    }
400
401    #[test]
402    fn test_parse_pseudo_version() {
403        let content = "require golang.org/x/crypto v0.0.0-20191109021931-daa7c04131f5\n";
404        let result = parse_go_mod(content, &test_uri()).unwrap();
405        assert_eq!(
406            result.dependencies[0].version,
407            Some(deps_core::VersionReq::new(
408                "v0.0.0-20191109021931-daa7c04131f5"
409            ))
410        );
411    }
412
413    #[test]
414    fn test_position_tracking() {
415        let content = "require github.com/gin-gonic/gin v1.9.1";
416        let result = parse_go_mod(content, &test_uri()).unwrap();
417        let dep = &result.dependencies[0];
418
419        assert_eq!(dep.module_path_range.start.line, 0);
420        assert!(dep.version_range.is_some());
421    }
422
423    #[test]
424    fn test_empty_file() {
425        let content = "";
426        let result = parse_go_mod(content, &test_uri()).unwrap();
427        assert_eq!(result.dependencies.len(), 0);
428        assert_eq!(result.module_path, None);
429        assert_eq!(result.go_version, None);
430    }
431
432    #[test]
433    fn test_comments_stripped() {
434        let content =
435            "// This is a comment\nrequire github.com/pkg/errors v0.9.1 // inline comment\n";
436        let result = parse_go_mod(content, &test_uri()).unwrap();
437        assert_eq!(result.dependencies.len(), 1);
438        assert_eq!(result.dependencies[0].module_path, "github.com/pkg/errors");
439    }
440
441    #[test]
442    fn test_complex_go_mod() {
443        let content = r"module example.com/myapp
444
445go 1.21
446
447require (
448    github.com/gin-gonic/gin v1.9.1
449    golang.org/x/crypto v0.17.0 // indirect
450)
451
452replace github.com/old/module => github.com/new/module v1.2.3
453
454exclude github.com/bad/module v0.1.0
455";
456        let result = parse_go_mod(content, &test_uri()).unwrap();
457        assert_eq!(result.dependencies.len(), 4);
458        assert_eq!(result.module_path, Some("example.com/myapp".to_string()));
459        assert_eq!(result.go_version, Some("1.21".to_string()));
460
461        let require_deps: Vec<_> = result
462            .dependencies
463            .iter()
464            .filter(|d| d.directive == GoDirective::Require)
465            .collect();
466        assert_eq!(require_deps.len(), 2);
467
468        let replace_deps: Vec<_> = result
469            .dependencies
470            .iter()
471            .filter(|d| d.directive == GoDirective::Replace)
472            .collect();
473        assert_eq!(replace_deps.len(), 1);
474
475        let exclude_deps: Vec<_> = result
476            .dependencies
477            .iter()
478            .filter(|d| d.directive == GoDirective::Exclude)
479            .collect();
480        assert_eq!(exclude_deps.len(), 1);
481    }
482
483    #[test]
484    fn test_position_tracking_no_trailing_newline() {
485        let content = "require github.com/gin-gonic/gin v1.9.1";
486        let result = parse_go_mod(content, &test_uri()).unwrap();
487        let dep = &result.dependencies[0];
488
489        assert_eq!(dep.module_path_range.start.character, 8);
490        assert_eq!(dep.module_path_range.end.character, 32);
491        assert_eq!(dep.version_range.as_ref().unwrap().start.character, 33);
492        assert_eq!(dep.version_range.as_ref().unwrap().end.character, 39);
493    }
494
495    #[test]
496    fn test_parse_complex_go_mod() {
497        let content = r"module example.com/myapp
498
499go 1.21
500
501require (
502    github.com/gin-gonic/gin v1.9.1
503    golang.org/x/crypto v0.17.0 // indirect
504)
505
506replace github.com/old/module => github.com/new/module v1.2.3
507
508exclude github.com/bad/module v0.1.0
509";
510        let result = parse_go_mod(content, &test_uri()).unwrap();
511
512        // Check module metadata
513        assert_eq!(result.module_path, Some("example.com/myapp".to_string()));
514        assert_eq!(result.go_version, Some("1.21".to_string()));
515
516        // Check dependencies count
517        assert_eq!(result.dependencies.len(), 4);
518
519        // Check gin-gonic (require, direct)
520        let gin = &result.dependencies[0];
521        assert_eq!(gin.module_path, "github.com/gin-gonic/gin");
522        assert_eq!(
523            gin.version.as_ref().map(deps_core::VersionReq::as_str),
524            Some("v1.9.1")
525        );
526        assert_eq!(gin.directive, GoDirective::Require);
527        assert!(!gin.indirect);
528
529        // Check crypto (require, indirect)
530        let crypto = &result.dependencies[1];
531        assert_eq!(crypto.module_path, "golang.org/x/crypto");
532        assert_eq!(
533            crypto.version.as_ref().map(deps_core::VersionReq::as_str),
534            Some("v0.17.0")
535        );
536        assert_eq!(crypto.directive, GoDirective::Require);
537        assert!(crypto.indirect);
538
539        // Check replace directive
540        let replace = &result.dependencies[2];
541        assert_eq!(replace.module_path, "github.com/old/module");
542        assert_eq!(replace.version, None);
543        assert_eq!(replace.directive, GoDirective::Replace);
544
545        // Check exclude directive
546        let exclude = &result.dependencies[3];
547        assert_eq!(exclude.module_path, "github.com/bad/module");
548        assert_eq!(
549            exclude.version.as_ref().map(deps_core::VersionReq::as_str),
550            Some("v0.1.0")
551        );
552        assert_eq!(exclude.directive, GoDirective::Exclude);
553    }
554
555    #[test]
556    fn test_strip_line_comment_with_url() {
557        let line = "replace github.com/old => https://github.com/new // comment";
558        let stripped = strip_line_comment(line);
559        assert_eq!(
560            stripped,
561            "replace github.com/old => https://github.com/new "
562        );
563    }
564
565    /// Integration test (issue #559 follow-up): the full parse -> resolve -> `register_chain`
566    /// -> `get_versions_from` path, exercised end-to-end against a real fixture `$GOENV` file
567    /// via [`GoParseContext::goenv_path`] rather than the real host environment.
568    #[tokio::test]
569    async fn test_integration_parse_resolve_register_chain_get_versions() {
570        use crate::registry::GoRegistry;
571        use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
572        use deps_core::{FreshnessSettings, HttpCache, Registry};
573        use std::sync::Arc;
574
575        let mut alt_server = mockito::Server::new_async().await;
576        alt_server
577            .mock("GET", "/github.com/gin-gonic/gin/@v/list")
578            .with_status(200)
579            .with_body("v1.9.1\n")
580            .create_async()
581            .await;
582
583        let dir = tempfile::tempdir().unwrap();
584        let goenv_path = dir.path().join("env");
585        std::fs::write(
586            &goenv_path,
587            format!("GOPROXY={},direct\n", alt_server.url()),
588        )
589        .unwrap();
590
591        let cache = Arc::new(HttpCache::new());
592        cache.set_registry_policy(WorkspaceRegistryAccess::All);
593        let ctx = GoParseContext {
594            policy: Arc::new(RegistryAccessPolicy::new(WorkspaceRegistryAccess::All)),
595            goenv_path: Some(goenv_path),
596            ..Default::default()
597        };
598
599        let content = "module example.com/myapp\n\nrequire github.com/gin-gonic/gin v1.9.0\n";
600        let result = parse_go_mod_with_context(content, &test_uri(), &ctx).unwrap();
601        assert_eq!(result.resolved_chains.len(), 1);
602
603        let registry = Arc::new(GoRegistry::new(Arc::clone(&cache)));
604        for chain in &result.resolved_chains {
605            GoRegistry::register_chain(&registry, chain);
606        }
607
608        let source = result.dependencies[0].source.clone();
609        let versions = registry
610            .get_versions_from(
611                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
612                &source,
613                FreshnessSettings::default(),
614            )
615            .await
616            .unwrap();
617        assert_eq!(versions.len(), 1);
618    }
619}