use std::collections::BTreeSet;
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct LinkFailure {
pub package: String,
pub target: String,
}
#[derive(Debug, Clone)]
pub struct TargetDepInfo {
pub package_deps: BTreeSet<String>,
pub target_deps: BTreeSet<String>,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum LinkDiagnostic {
DeclaredButUnlinked {
package: String,
target: String,
unlinked: Vec<String>,
},
}
pub fn diagnose<F>(stderr: &str, lookup_deps: F) -> Option<LinkDiagnostic>
where
F: Fn(&str, &str) -> Option<TargetDepInfo>,
{
let failure = parse_link_failure(stderr)?;
let info = lookup_deps(&failure.package, &failure.target)?;
let unlinked: Vec<String> = info
.package_deps
.difference(&info.target_deps)
.cloned()
.collect();
if unlinked.is_empty() {
return None;
}
Some(LinkDiagnostic::DeclaredButUnlinked {
package: failure.package,
target: failure.target,
unlinked,
})
}
pub fn render(diag: &LinkDiagnostic) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let LinkDiagnostic::DeclaredButUnlinked {
package,
target,
unlinked,
} = diag;
let primary = unlinked.join("`, `");
let first = &unlinked[0];
let _ = write!(
out,
"package `{package}` declares `{primary}` in `[dependencies]`,\n\
but `[target.{target}]` does not link it.\n\
\n\
`[dependencies]` makes a package available; each target's\n\
`deps =` list is what actually gets linked.\n\
\n\
Add the dep to the target:\n\
\n\
\x20 [target.{target}]\n\
\x20 # ...existing fields...\n\
\x20 deps = [\"{first}\"]\n"
);
out
}
pub fn parse_link_failure(stderr: &str) -> Option<LinkFailure> {
let (package, target) = find_failed_target(stderr)?;
Some(LinkFailure { package, target })
}
fn extract_target_name(filename: &str) -> &str {
if let Some(stem) = filename
.strip_prefix("lib")
.and_then(|s| s.strip_suffix(".a"))
&& !stem.is_empty()
{
return stem;
}
filename
}
fn find_failed_target(stderr: &str) -> Option<(String, String)> {
for line in stderr.lines() {
let trimmed = line.trim_start();
if !trimmed.starts_with("FAILED:") {
continue;
}
let rest = trimmed.trim_start_matches("FAILED:").trim_start();
for token in rest.split_whitespace() {
let normalized = token.replace('\\', "/");
if let Some(idx) = normalized.rfind("/packages/") {
let tail = &normalized[idx + "/packages/".len()..];
let mut parts = tail.splitn(3, '/');
let pkg = parts.next()?;
let target = parts.next()?;
if pkg.is_empty() || target.is_empty() {
continue;
}
let target = extract_target_name(target);
return Some((pkg.to_owned(), target.to_owned()));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn deps(package: &[&str], target: &[&str]) -> TargetDepInfo {
TargetDepInfo {
package_deps: package.iter().map(|s| (*s).to_owned()).collect(),
target_deps: target.iter().map(|s| (*s).to_owned()).collect(),
}
}
#[test]
fn parses_macos_failed_line() {
let stderr = "FAILED: [code=1] /abs/build/dev/packages/mytest/mytest\n\
/usr/bin/c++ ...\n";
let failure = parse_link_failure(stderr).unwrap();
assert_eq!(failure.package, "mytest");
assert_eq!(failure.target, "mytest");
}
#[test]
fn parses_linux_failed_line_with_library_wrapper() {
let stderr = "FAILED: build/dev/packages/mylib/libmylib.a\n/usr/bin/ar ...\n";
let failure = parse_link_failure(stderr).unwrap();
assert_eq!(failure.package, "mylib");
assert_eq!(failure.target, "mylib");
}
#[test]
fn parses_windows_failed_line_with_backslashes() {
let stderr = "FAILED: build\\dev\\packages\\mytest\\mytest\n\
link.exe ...\n";
let failure = parse_link_failure(stderr).unwrap();
assert_eq!(failure.package, "mytest");
assert_eq!(failure.target, "mytest");
}
#[test]
fn anchors_on_last_packages_segment() {
let stderr = "FAILED: /tmp/packages/out/dev/packages/realpkg/realtarget\nld: ...\n";
let failure = parse_link_failure(stderr).unwrap();
assert_eq!(failure.package, "realpkg");
assert_eq!(failure.target, "realtarget");
}
#[test]
fn preserves_target_names_with_internal_dots() {
let stderr = "FAILED: build/dev/packages/mypkg/tool.v2\nld: ...\n";
let failure = parse_link_failure(stderr).unwrap();
assert_eq!(failure.package, "mypkg");
assert_eq!(failure.target, "tool.v2");
}
#[test]
fn preserves_target_names_ending_in_non_emitted_extensions() {
for name in [
"tool.a",
"tool.exe",
"tool.so",
"plugin.dll",
"filter.dylib",
"shim.lib",
] {
let stderr = format!("FAILED: build/dev/packages/mypkg/{name}\nld: ...\n");
let failure = parse_link_failure(&stderr).unwrap();
assert_eq!(failure.package, "mypkg");
assert_eq!(failure.target, name);
}
}
#[test]
fn no_failed_line_returns_none() {
assert!(parse_link_failure("ninja: no work to do\n").is_none());
}
fn ninja_link_failure() -> &'static str {
"FAILED: [code=1] /abs/build/dev/packages/mytest/mytest\n\
/usr/bin/c++ obj/main.cc.o -o /abs/.../mytest\n\
ld: symbol(s) not found for architecture arm64\n"
}
#[test]
fn flags_declared_but_unlinked() {
let diag = diagnose(ninja_link_failure(), |pkg, target| {
assert_eq!(pkg, "mytest");
assert_eq!(target, "mytest");
Some(deps(&["zlib"], &[]))
})
.unwrap();
let LinkDiagnostic::DeclaredButUnlinked {
package,
target,
unlinked,
} = diag;
assert_eq!(package, "mytest");
assert_eq!(target, "mytest");
assert_eq!(unlinked, vec!["zlib"]);
}
#[test]
fn returns_none_when_target_already_links_dep() {
let diag = diagnose(ninja_link_failure(), |_, _| {
Some(deps(&["zlib"], &["zlib"]))
});
assert!(diag.is_none());
}
#[test]
fn returns_none_when_nothing_is_declared() {
let diag = diagnose(ninja_link_failure(), |_, _| Some(deps(&[], &[])));
assert!(diag.is_none());
}
#[test]
fn returns_none_when_package_lookup_fails() {
let diag = diagnose(ninja_link_failure(), |_, _| None);
assert!(diag.is_none());
}
#[test]
fn returns_none_on_unparsable_stderr() {
let diag = diagnose("just some random output\n", |_, _| {
Some(deps(&["zlib"], &[]))
});
assert!(diag.is_none());
}
#[test]
fn lists_every_unlinked_dep_when_multiple_declared() {
let diag = diagnose(ninja_link_failure(), |_, _| {
Some(deps(&["fmt", "spdlog", "zlib"], &[]))
})
.unwrap();
let LinkDiagnostic::DeclaredButUnlinked { unlinked, .. } = &diag;
assert_eq!(unlinked, &["fmt", "spdlog", "zlib"]);
}
#[test]
fn render_declared_but_unlinked_names_target_and_dep() {
let diag = LinkDiagnostic::DeclaredButUnlinked {
package: "mytest".into(),
target: "mytest".into(),
unlinked: vec!["zlib".into()],
};
let out = render(&diag);
assert!(out.contains("`mytest`"));
assert!(out.contains("`zlib`"));
assert!(out.contains("[target.mytest]"));
assert!(out.contains("deps = [\"zlib\"]"));
}
}