use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use eyre::{Context, Result, bail};
use super::{
ProjectId, WorkspaceDiscoveryContext, WorkspaceProject, WorkspaceProvenance, WorkspaceProvider,
};
const GO_MOD: &str = "go.mod";
const GO_WORK: &str = "go.work";
#[derive(Debug, Default)]
pub struct GoWorkspaceProvider;
impl WorkspaceProvider for GoWorkspaceProvider {
fn id(&self) -> &str {
"go"
}
fn discover(&self, workspace_root: &Path) -> Result<Vec<WorkspaceProject>> {
let context = WorkspaceDiscoveryContext::new();
self.discover_with_context(workspace_root, &context)
}
fn discover_with_context(
&self,
workspace_root: &Path,
context: &WorkspaceDiscoveryContext,
) -> Result<Vec<WorkspaceProject>> {
let workfile_path = workspace_root.join(GO_WORK);
if !context.is_file(&workfile_path) {
return Ok(Vec::new());
}
let canonical_root = context.canonicalize(workspace_root).wrap_err_with(|| {
format!(
"failed to resolve Go workspace root {}",
workspace_root.display()
)
})?;
let lexical_root = lexical_absolute(workspace_root)?;
let module_directories = read_workfile(context, &workfile_path)?;
let mut modules = BTreeMap::new();
for directory in module_directories {
let candidate = if directory.is_absolute() {
directory
} else {
workspace_root.join(directory)
};
let lexical_candidate = lexical_absolute(&candidate)?;
if !lexical_candidate.starts_with(&lexical_root)
&& !lexical_candidate.starts_with(&canonical_root)
{
continue;
}
let candidate = lexical_candidate;
if !context.exists(&candidate) {
if missing_path_resolves_outside(&candidate, &lexical_root, &canonical_root)? {
continue;
}
bail!(
"Go workspace module {} is missing {GO_MOD}",
candidate.display()
);
}
let canonical_module = context.canonicalize(&candidate).wrap_err_with(|| {
format!(
"failed to resolve Go workspace module {}",
candidate.display()
)
})?;
let Ok(relative) = canonical_module.strip_prefix(&canonical_root) else {
continue;
};
if !context.is_file(&canonical_module.join(GO_MOD)) {
bail!(
"Go workspace module {} is missing {GO_MOD}",
candidate.display()
);
}
let relative = normalize_relative(relative);
let module_path = read_module_path(context, &canonical_module.join(GO_MOD))?;
let id = ProjectId::new(self.id(), &module_path)?;
let source = relative.join(GO_MOD);
let provenance = WorkspaceProvenance {
provider: Some(self.id().to_string()),
source: Some(source),
};
let mut project = WorkspaceProject::new(id, relative);
project.provenance = provenance;
project
.metadata
.insert("workspace_source".to_string(), GO_MOD.to_string());
modules.insert(canonical_module, project);
}
Ok(modules.into_values().collect())
}
}
fn read_workfile(context: &WorkspaceDiscoveryContext, path: &Path) -> Result<Vec<PathBuf>> {
let contents = context
.read_to_string(path)
.wrap_err_with(|| format!("failed to read Go workspace metadata {}", path.display()))?;
parse_workfile(&contents)
.wrap_err_with(|| format!("failed to parse Go workspace metadata {}", path.display()))
}
fn parse_workfile(contents: &str) -> Result<Vec<PathBuf>> {
let mut directories = Vec::new();
let mut in_use_block = false;
for (index, raw_line) in contents.lines().enumerate() {
let line_number = index + 1;
let line = strip_comment(raw_line).trim();
if line.is_empty() {
continue;
}
if in_use_block {
if line == ")" {
in_use_block = false;
continue;
}
directories.push(PathBuf::from(parse_argument(line).wrap_err_with(|| {
format!("invalid use directive on line {line_number}")
})?));
continue;
}
let Some(arguments) = directive_arguments(line, "use") else {
continue;
};
if arguments == "(" {
in_use_block = true;
} else {
directories.push(PathBuf::from(parse_argument(arguments).wrap_err_with(
|| format!("invalid use directive on line {line_number}"),
)?));
}
}
if in_use_block {
bail!("unterminated use block");
}
Ok(directories)
}
fn read_module_path(context: &WorkspaceDiscoveryContext, path: &Path) -> Result<String> {
let contents = context
.read_to_string(path)
.wrap_err_with(|| format!("failed to read Go module metadata {}", path.display()))?;
let mut module_path = None;
for (index, raw_line) in contents.lines().enumerate() {
let line = strip_comment(raw_line).trim();
let Some(arguments) = directive_arguments(line, "module") else {
continue;
};
let parsed = parse_argument(arguments)
.wrap_err_with(|| format!("invalid module directive on line {}", index + 1))?;
if module_path.replace(parsed).is_some() {
bail!("Go module metadata contains multiple module directives");
}
}
module_path.ok_or_else(|| eyre::eyre!("Go module metadata is missing a module directive"))
}
fn directive_arguments<'a>(line: &'a str, directive: &str) -> Option<&'a str> {
let rest = line.strip_prefix(directive)?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
Some(rest.trim())
}
fn parse_argument(value: &str) -> Result<String> {
if value.is_empty() {
bail!("directive requires one argument");
}
if let Some(value) = value.strip_prefix('"') {
let Some(value) = value.strip_suffix('"') else {
bail!("unterminated interpreted Go string");
};
return unescape_go_string(value);
}
if let Some(value) = value.strip_prefix('`') {
let Some(value) = value.strip_suffix('`') else {
bail!("unterminated raw Go string");
};
if value.contains('`') {
bail!("unexpected raw Go string delimiter");
}
return Ok(value.to_string());
}
if value.split_whitespace().count() != 1 {
bail!("directive requires exactly one argument");
}
Ok(value.to_string())
}
fn unescape_go_string(value: &str) -> Result<String> {
let mut characters = value.chars();
let mut unescaped = String::new();
while let Some(character) = characters.next() {
if character == '"' {
bail!("unexpected interpreted Go string delimiter");
}
if character != '\\' {
unescaped.push(character);
continue;
}
let escape = characters
.next()
.ok_or_else(|| eyre::eyre!("unterminated Go string escape"))?;
let escaped = match escape {
'a' => '\u{0007}',
'b' => '\u{0008}',
'f' => '\u{000c}',
'n' => '\n',
'r' => '\r',
't' => '\t',
'v' => '\u{000b}',
'\\' => '\\',
'\'' => '\'',
'"' => '"',
'x' => escape_character(&mut characters, 2, 16)?,
'u' => escape_character(&mut characters, 4, 16)?,
'U' => escape_character(&mut characters, 8, 16)?,
digit @ '0'..='7' => {
let mut digits = digit.to_string();
for _ in 0..2 {
digits.push(
characters
.next()
.ok_or_else(|| eyre::eyre!("incomplete octal Go string escape"))?,
);
}
decoded_character(&digits, 8)?
}
_ => bail!("unsupported Go string escape \\{escape}"),
};
unescaped.push(escaped);
}
Ok(unescaped)
}
fn escape_character(
characters: &mut std::str::Chars<'_>,
length: usize,
radix: u32,
) -> Result<char> {
let digits = characters.take(length).collect::<String>();
if digits.chars().count() != length {
bail!("incomplete Go string escape");
}
decoded_character(&digits, radix)
}
fn decoded_character(digits: &str, radix: u32) -> Result<char> {
let value = u32::from_str_radix(digits, radix)
.wrap_err_with(|| format!("invalid Go string escape {digits:?}"))?;
char::from_u32(value).ok_or_else(|| eyre::eyre!("invalid Go character escape {digits:?}"))
}
fn strip_comment(line: &str) -> &str {
let mut quote = None;
let mut escaped = false;
for (index, character) in line.char_indices() {
match quote {
Some('"') if escaped => escaped = false,
Some('"') if character == '\\' => escaped = true,
Some(delimiter) if character == delimiter => quote = None,
Some(_) => {}
None if matches!(character, '"' | '`') => quote = Some(character),
None if character == '/' && line[index..].starts_with("//") => {
return &line[..index];
}
None => {}
}
}
line
}
fn normalize_relative(path: &Path) -> PathBuf {
if path.as_os_str().is_empty() {
PathBuf::from(".")
} else {
path.to_path_buf()
}
}
fn lexical_absolute(path: &Path) -> Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.wrap_err("failed to resolve the current directory")?
.join(path)
};
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if normalized.parent().is_some() {
normalized.pop();
}
}
component => normalized.push(component.as_os_str()),
}
}
Ok(normalized)
}
fn missing_path_resolves_outside(
candidate: &Path,
lexical_root: &Path,
canonical_root: &Path,
) -> Result<bool> {
let mut candidate = candidate.to_path_buf();
let mut followed = BTreeSet::new();
loop {
if !candidate.starts_with(lexical_root) && !candidate.starts_with(canonical_root) {
return Ok(true);
}
let mut next = None;
for ancestor in candidate.ancestors() {
if !ancestor.starts_with(lexical_root) && !ancestor.starts_with(canonical_root) {
break;
}
if let Ok(target) = std::fs::read_link(ancestor) {
if !followed.insert(ancestor.to_path_buf()) {
return Ok(false);
}
let target = if target.is_absolute() {
target
} else {
ancestor.parent().unwrap_or(lexical_root).join(target)
};
let suffix = candidate.strip_prefix(ancestor)?;
next = Some(lexical_absolute(&target.join(suffix))?);
break;
}
if ancestor == lexical_root || ancestor == canonical_root {
break;
}
}
match next {
Some(next) => candidate = next,
None => return Ok(false),
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
fn write(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
fn module(path: &Path, module_path: &str) {
write(
&path.join(GO_MOD),
&format!("module {module_path}\n\ngo 1.25\n"),
);
}
#[test]
fn discovers_single_and_block_use_directives() {
let temp = tempdir().unwrap();
write(
&temp.path().join(GO_WORK),
"go 1.25\nuse ./cmd/api // app\nuse (\n ./lib\n \"./tools/code\\x20gen\"\n)\n",
);
module(&temp.path().join("cmd/api"), "example.com/api");
module(&temp.path().join("lib"), "example.com/lib");
module(&temp.path().join("tools/code gen"), "example.com/codegen");
let projects = GoWorkspaceProvider.discover(temp.path()).unwrap();
let summary = projects
.iter()
.map(|project| (project.id.as_str(), project.root.as_path()))
.collect::<Vec<_>>();
assert_eq!(
summary,
vec![
("go:example.com/api", Path::new("cmd/api")),
("go:example.com/lib", Path::new("lib")),
("go:example.com/codegen", Path::new("tools/code gen")),
]
);
assert!(
projects
.iter()
.all(|project| project.dependencies.is_empty())
);
}
#[test]
fn supports_root_modules_and_quoted_module_paths() {
let temp = tempdir().unwrap();
write(&temp.path().join(GO_WORK), "go 1.25\nuse .\n");
write(
&temp.path().join(GO_MOD),
"module \"example.com/root\" // root module\n",
);
let projects = GoWorkspaceProvider.discover(temp.path()).unwrap();
assert_eq!(projects[0].id.as_str(), "go:example.com/root");
assert_eq!(projects[0].root, Path::new("."));
assert_eq!(projects[0].provenance.source, Some("./go.mod".into()));
}
#[test]
fn interpreted_strings_require_three_digit_octal_escapes() {
assert_eq!(unescape_go_string(r"code\040gen").unwrap(), "code gen");
assert!(unescape_go_string(r"code\0gen").is_err());
assert!(unescape_go_string(r"code\04gen").is_err());
}
#[test]
fn cleans_use_paths_before_filesystem_access() {
let temp = tempdir().unwrap();
write(
&temp.path().join(GO_WORK),
"go 1.25\nuse ./missing/../app\n",
);
module(&temp.path().join("app"), "example.com/app");
let projects = GoWorkspaceProvider.discover(temp.path()).unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id.as_str(), "go:example.com/app");
}
#[test]
fn skips_modules_outside_the_monorepo_root() {
let temp = tempdir().unwrap();
let workspace = temp.path().join("workspace");
let external = temp.path().join("external");
write(
&workspace.join(GO_WORK),
"go 1.25\nuse ./app\nuse ../external\nuse ../not-a-module\nuse ../missing\n",
);
module(&workspace.join("app"), "example.com/app");
module(&external, "example.com/external");
fs::create_dir_all(temp.path().join("not-a-module")).unwrap();
let projects = GoWorkspaceProvider.discover(&workspace).unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id.as_str(), "go:example.com/app");
}
#[test]
fn reports_invalid_workspace_modules() {
let temp = tempdir().unwrap();
write(&temp.path().join(GO_WORK), "go 1.25\nuse ./missing\n");
assert!(
GoWorkspaceProvider
.discover(temp.path())
.unwrap_err()
.to_string()
.contains("missing go.mod")
);
write(&temp.path().join(GO_WORK), "go 1.25\nuse (\n");
let error = GoWorkspaceProvider.discover(temp.path()).unwrap_err();
assert!(format!("{error:#}").contains("unterminated use block"));
}
#[cfg(unix)]
#[test]
fn skips_missing_paths_through_external_symlinks() {
let temp = tempdir().unwrap();
let workspace = temp.path().join("workspace");
let external = temp.path().join("external");
fs::create_dir_all(&external).unwrap();
write(
&workspace.join(GO_WORK),
"go 1.25\nuse ./app\nuse ./dangling-link\nuse ./external-link/missing\nuse ./chained-link/missing\n",
);
module(&workspace.join("app"), "example.com/app");
std::os::unix::fs::symlink(external.join("missing"), workspace.join("dangling-link"))
.unwrap();
std::os::unix::fs::symlink(&external, workspace.join("external-link")).unwrap();
std::os::unix::fs::symlink("external-link", workspace.join("chained-link")).unwrap();
let projects = GoWorkspaceProvider.discover(&workspace).unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id.as_str(), "go:example.com/app");
}
#[test]
fn ignores_roots_without_a_workfile() {
let temp = tempdir().unwrap();
module(temp.path(), "example.com/standalone");
assert!(
GoWorkspaceProvider
.discover(temp.path())
.unwrap()
.is_empty()
);
}
}