Skip to main content

kdo_resolver/
go.rs

1//! Parser for Go modules (`go.mod`).
2
3use crate::ManifestParser;
4use kdo_core::{DepKind, Dependency, KdoError, Language, Project};
5use std::path::Path;
6
7/// Parser for Go `go.mod` manifest files.
8pub struct GoParser;
9
10impl ManifestParser for GoParser {
11    fn manifest_name(&self) -> &str {
12        "go.mod"
13    }
14
15    fn can_parse(&self, manifest_path: &Path) -> bool {
16        manifest_path
17            .file_name()
18            .map(|f| f == "go.mod")
19            .unwrap_or(false)
20    }
21
22    fn parse(
23        &self,
24        manifest_path: &Path,
25        workspace_root: &Path,
26    ) -> Result<(Project, Vec<Dependency>), KdoError> {
27        let content = std::fs::read_to_string(manifest_path)?;
28
29        let name = parse_module_name(&content).unwrap_or_else(|| {
30            manifest_path
31                .parent()
32                .and_then(|p| p.file_name())
33                .map(|n| n.to_string_lossy().to_string())
34                .unwrap_or_else(|| "unknown".to_string())
35        });
36
37        let project_path = manifest_path
38            .parent()
39            .unwrap_or(manifest_path)
40            .to_path_buf();
41
42        // Parse `require` block for dependencies
43        let deps = parse_requires(&content);
44
45        // Determine if this is a workspace-local dependency
46        let workspace_deps = deps
47            .into_iter()
48            .filter_map(|(dep_name, version)| {
49                // Only keep deps that resolve to local paths via `replace` directives
50                let local_path = find_replace_path(&content, &dep_name)?;
51                let resolved = workspace_root.join(&local_path);
52                if resolved.exists() {
53                    Some(Dependency {
54                        name: dep_name
55                            .split('/')
56                            .next_back()
57                            .unwrap_or(&dep_name)
58                            .to_string(),
59                        version_req: version,
60                        kind: DepKind::Source,
61                        is_workspace: true,
62                        resolved_path: Some(resolved),
63                    })
64                } else {
65                    None
66                }
67            })
68            .collect();
69
70        let project = Project {
71            name: short_name(&name),
72            path: project_path,
73            language: Language::Go,
74            manifest_path: manifest_path.to_path_buf(),
75            context_summary: None,
76            public_api_files: Vec::new(),
77            internal_files: Vec::new(),
78            content_hash: [0u8; 32],
79        };
80
81        Ok((project, workspace_deps))
82    }
83}
84
85/// Extract the module name from `module <name>` line.
86fn parse_module_name(content: &str) -> Option<String> {
87    for line in content.lines() {
88        let trimmed = line.trim();
89        if let Some(rest) = trimmed.strip_prefix("module ") {
90            let name = rest.trim().to_string();
91            if !name.is_empty() {
92                return Some(name);
93            }
94        }
95    }
96    None
97}
98
99/// Parse `require` blocks: returns (module_path, version) pairs.
100fn parse_requires(content: &str) -> Vec<(String, String)> {
101    let mut deps = Vec::new();
102    let mut in_require_block = false;
103
104    for line in content.lines() {
105        let trimmed = line.trim();
106
107        if trimmed == "require (" {
108            in_require_block = true;
109            continue;
110        }
111        if in_require_block && trimmed == ")" {
112            in_require_block = false;
113            continue;
114        }
115
116        if in_require_block {
117            // Line format: <module> <version> [// indirect]
118            let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
119            if parts.len() == 2 {
120                let module = parts[0].trim().to_string();
121                let version = parts[1].split("//").next().unwrap_or("").trim().to_string();
122                if !module.is_empty() && !version.is_empty() {
123                    deps.push((module, version));
124                }
125            }
126        } else if let Some(rest) = trimmed.strip_prefix("require ") {
127            // Single-line require
128            let parts: Vec<&str> = rest.splitn(2, ' ').collect();
129            if parts.len() == 2 {
130                deps.push((parts[0].trim().to_string(), parts[1].trim().to_string()));
131            }
132        }
133    }
134
135    deps
136}
137
138/// Look for a `replace` directive for the given module path.
139fn find_replace_path(content: &str, module_path: &str) -> Option<String> {
140    for line in content.lines() {
141        let trimmed = line.trim();
142        // replace <module> => <path>
143        if let Some(rest) = trimmed.strip_prefix("replace ") {
144            if rest.contains(module_path) {
145                if let Some(arrow_pos) = rest.find("=>") {
146                    let path = rest[arrow_pos + 2..].trim().to_string();
147                    // If it starts with ./ or ../ it's a local path
148                    if path.starts_with("./") || path.starts_with("../") {
149                        return Some(path);
150                    }
151                }
152            }
153        }
154    }
155    None
156}
157
158/// Get a short human-readable name from a Go module path (last component).
159fn short_name(module_path: &str) -> String {
160    module_path
161        .split('/')
162        .next_back()
163        .unwrap_or(module_path)
164        .to_string()
165}