cabin_build/link_diagnostics.rs
1//! Post-link-failure diagnostics.
2//!
3//! When ninja fails because the linker couldn't resolve a symbol,
4//! cabin has structured information the raw linker output lacks:
5//! which target failed (from ninja's `FAILED:` line), which package
6//! owns it, what that package's `[dependencies]` declares, and what
7//! the target's own `deps =` list links. The mismatch between
8//! "declared at the package level" and "linked at the target level"
9//! is the most common newcomer gotcha: `[dependencies]` makes a
10//! dep *available*; `[target.X.deps]` is what gets passed
11//! to the linker. Cabin can spot the gap precisely.
12//!
13//! This module is the parser + matcher. The CLI calls
14//! [`diagnose`] with captured ninja stderr and a closure that
15//! resolves a `(package, target)` pair to its dependency picture;
16//! the resulting [`LinkDiagnostic`] is rendered into a hint.
17
18use std::collections::BTreeSet;
19
20/// Parsed picture of one failing link action. Only the *first*
21/// failure ninja reports is extracted; secondary failures in a
22/// parallel build are not surfaced (the user fixes one, re-runs,
23/// gets the next).
24#[derive(Debug, Clone, Eq, PartialEq)]
25pub struct LinkFailure {
26 /// Package name owning the target, derived from the
27 /// `/packages/<package>/` segment cabin's planner embeds in
28 /// every output path.
29 pub package: String,
30 /// Target name - the binary or library that failed to link.
31 pub target: String,
32}
33
34/// What the dep walk in the loaded workspace tells us about a
35/// failing target. Built by the CLI from the loaded `PackageGraph`
36/// and passed to [`diagnose`] via the lookup closure.
37#[derive(Debug, Clone)]
38pub struct TargetDepInfo {
39 /// Names appearing in the package's `[dependencies]` table.
40 pub package_deps: BTreeSet<String>,
41 /// Names appearing in `[target.<name>].deps` for the failing
42 /// target.
43 pub target_deps: BTreeSet<String>,
44}
45
46/// Diagnostic shape. The CLI calls [`render`] to format it.
47#[derive(Debug, Clone, Eq, PartialEq)]
48pub enum LinkDiagnostic {
49 /// `[dependencies]` declares one or more names the failing
50 /// target's `deps =` doesn't link. The #1 newcomer gotcha:
51 /// "I added it to `[dependencies]`, why isn't it linking?"
52 DeclaredButUnlinked {
53 package: String,
54 target: String,
55 /// Every declared name absent from `target.deps`, sorted.
56 unlinked: Vec<String>,
57 },
58}
59
60/// Top-level entry point. Parses `stderr`, walks the captured
61/// failure(s), and consults `lookup_deps` to compute the gap.
62/// Returns the first applicable diagnostic, or `None` if the
63/// stderr does not look like a recognizable link failure.
64pub fn diagnose<F>(stderr: &str, lookup_deps: F) -> Option<LinkDiagnostic>
65where
66 F: Fn(&str, &str) -> Option<TargetDepInfo>,
67{
68 let failure = parse_link_failure(stderr)?;
69 let info = lookup_deps(&failure.package, &failure.target)?;
70
71 let unlinked: Vec<String> = info
72 .package_deps
73 .difference(&info.target_deps)
74 .cloned()
75 .collect();
76
77 if unlinked.is_empty() {
78 return None;
79 }
80 Some(LinkDiagnostic::DeclaredButUnlinked {
81 package: failure.package,
82 target: failure.target,
83 unlinked,
84 })
85}
86
87/// Render a diagnostic as the multi-line text cabin emits to
88/// stderr. The CLI's reporter prepends the styled `help:` lead-in
89/// and indents continuation lines, so this body is intentionally
90/// plain: one logical paragraph per blank-line-separated block,
91/// each line short enough to live under a six-column indent
92/// without re-wrapping on an 80-column terminal.
93///
94/// Code blocks (TOML snippets) are indented four spaces inside
95/// the body; combined with the reporter's six-space indent that
96/// puts them at column ten, matching the Rust compiler's spacing
97/// for help-attached suggestions.
98pub fn render(diag: &LinkDiagnostic) -> String {
99 use std::fmt::Write as _;
100
101 let mut out = String::new();
102 let LinkDiagnostic::DeclaredButUnlinked {
103 package,
104 target,
105 unlinked,
106 } = diag;
107 let primary = unlinked.join("`, `");
108 let first = &unlinked[0];
109 let _ = write!(
110 out,
111 "package `{package}` declares `{primary}` in `[dependencies]`,\n\
112 but `[target.{target}]` does not link it.\n\
113 \n\
114 `[dependencies]` makes a package available; each target's\n\
115 `deps =` list is what actually gets linked.\n\
116 \n\
117 Add the dep to the target:\n\
118 \n\
119 \x20 [target.{target}]\n\
120 \x20 # ...existing fields...\n\
121 \x20 deps = [\"{first}\"]\n"
122 );
123 out
124}
125
126// -------------------------------------------------------------
127// Parsing
128// -------------------------------------------------------------
129
130/// Parse `stderr` for the first recognizable link failure.
131/// Returns the failing target's identity (package + target name).
132pub fn parse_link_failure(stderr: &str) -> Option<LinkFailure> {
133 let (package, target) = find_failed_target(stderr)?;
134 Some(LinkFailure { package, target })
135}
136
137/// Recover the declared target name from a link-action output
138/// filename. Cabin's planner emits exactly two forms:
139///
140/// * `lib<name>.a` for static libraries
141/// * `<name>` (no extension) for every executable kind
142///
143/// Stripping the library wrapper is therefore *only* safe when
144/// the filename matches the full `lib…a` shape - a literal target
145/// named `tool.a` or `tool.exe` would otherwise be misread as
146/// `tool` and the workspace-graph lookup would miss it, silently
147/// suppressing the link hint. Names that do not match the
148/// library pattern round-trip unchanged.
149fn extract_target_name(filename: &str) -> &str {
150 if let Some(stem) = filename
151 .strip_prefix("lib")
152 .and_then(|s| s.strip_suffix(".a"))
153 && !stem.is_empty()
154 {
155 return stem;
156 }
157 filename
158}
159
160/// Find the first `FAILED:` line in ninja stderr and pull
161/// `(package, target)` out of the path it points at.
162///
163/// Cabin's planner emits link-action outputs under
164/// `<build_dir>/<profile>/packages/<package>/<target>`, so the
165/// `/packages/<package>/<target>` segment is the load-bearing
166/// signal. Anything before `/packages/` is platform-dependent
167/// build-root noise; anything after is the target executable
168/// or library file.
169fn find_failed_target(stderr: &str) -> Option<(String, String)> {
170 for line in stderr.lines() {
171 let trimmed = line.trim_start();
172 if !trimmed.starts_with("FAILED:") {
173 continue;
174 }
175 // The rest of the line is one or more space-separated
176 // output paths (ninja's `FAILED:` lists every output of
177 // the failing edge). The link action has exactly one
178 // output - the binary or static archive - and that's
179 // the only path under `/packages/`.
180 let rest = trimmed.trim_start_matches("FAILED:").trim_start();
181 for token in rest.split_whitespace() {
182 // Normalize Windows-style separators so a single
183 // `/packages/` probe anchors the parse on every
184 // platform. Cabin's planner emits the same logical
185 // layout regardless of OS; only ninja's stderr
186 // separator differs.
187 let normalized = token.replace('\\', "/");
188 // Anchor on the *last* `/packages/` segment.
189 // `--build-dir` can itself contain a `packages`
190 // component (e.g. `/tmp/packages/out`), and `find`
191 // would lock onto that prefix and split off the
192 // wrong package/target pair. The planner-owned
193 // suffix is always the last `packages` segment, so
194 // `rfind` is the load-bearing anchor.
195 if let Some(idx) = normalized.rfind("/packages/") {
196 let tail = &normalized[idx + "/packages/".len()..];
197 let mut parts = tail.splitn(3, '/');
198 let pkg = parts.next()?;
199 let target = parts.next()?;
200 if pkg.is_empty() || target.is_empty() {
201 continue;
202 }
203 // Recover the declared target name from the
204 // linker's output filename. Only the
205 // `lib<name>.a` wrapper cabin's planner produces
206 // for static libraries is unwrapped; every other
207 // shape - including target names that happen to
208 // end in `.a`, `.exe`, `.so`, etc. - is left
209 // alone so the workspace-graph lookup hits.
210 let target = extract_target_name(target);
211 return Some((pkg.to_owned(), target.to_owned()));
212 }
213 }
214 }
215 None
216}
217
218// -------------------------------------------------------------
219// Tests
220// -------------------------------------------------------------
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn deps(package: &[&str], target: &[&str]) -> TargetDepInfo {
227 TargetDepInfo {
228 package_deps: package.iter().map(|s| (*s).to_owned()).collect(),
229 target_deps: target.iter().map(|s| (*s).to_owned()).collect(),
230 }
231 }
232
233 // ---- parser ---------------------------------------------------
234
235 #[test]
236 fn parses_macos_failed_line() {
237 let stderr = "FAILED: [code=1] /abs/build/dev/packages/mytest/mytest\n\
238 /usr/bin/c++ ...\n";
239 let failure = parse_link_failure(stderr).unwrap();
240 assert_eq!(failure.package, "mytest");
241 assert_eq!(failure.target, "mytest");
242 }
243
244 /// A static-library archive failure emits the wrapped
245 /// `lib<name>.a` filename cabin's planner produces. The parser
246 /// must unwrap that exact shape so the workspace-graph lookup
247 /// sees `<name>` - the form the user declared in
248 /// `[target.<name>]`.
249 #[test]
250 fn parses_linux_failed_line_with_library_wrapper() {
251 let stderr = "FAILED: build/dev/packages/mylib/libmylib.a\n/usr/bin/ar ...\n";
252 let failure = parse_link_failure(stderr).unwrap();
253 assert_eq!(failure.package, "mylib");
254 assert_eq!(failure.target, "mylib");
255 }
256
257 /// Ninja on Windows emits backslash-separated paths. The
258 /// parser must still find `\packages\<package>\<target>`
259 /// and recover the same identity it would on POSIX, so the
260 /// downstream "declared-but-unlinked" hint reaches the
261 /// Windows user too. Cabin's planner declares the executable
262 /// output filename without an extension on every platform, so
263 /// the FAILED path mirrors that.
264 #[test]
265 fn parses_windows_failed_line_with_backslashes() {
266 let stderr = "FAILED: build\\dev\\packages\\mytest\\mytest\n\
267 link.exe ...\n";
268 let failure = parse_link_failure(stderr).unwrap();
269 assert_eq!(failure.package, "mytest");
270 assert_eq!(failure.target, "mytest");
271 }
272
273 /// `--build-dir` may itself contain a `packages` segment
274 /// (e.g. `/tmp/packages/out`). The parser must anchor on the
275 /// planner-owned *trailing* `/packages/` segment, not the
276 /// first occurrence in the path - otherwise the recovered
277 /// `(package, target)` pair points at the wrong directory
278 /// and the link hint is silently lost.
279 #[test]
280 fn anchors_on_last_packages_segment() {
281 let stderr = "FAILED: /tmp/packages/out/dev/packages/realpkg/realtarget\nld: ...\n";
282 let failure = parse_link_failure(stderr).unwrap();
283 assert_eq!(failure.package, "realpkg");
284 assert_eq!(failure.target, "realtarget");
285 }
286
287 /// A target spelled with a literal dot (e.g. `tool.v2`) is
288 /// not an extension and must round-trip through the parser
289 /// unchanged; the parser only unwraps the `lib…a` library
290 /// shape, so anything else (including names that happen to
291 /// look like an extension) survives verbatim.
292 #[test]
293 fn preserves_target_names_with_internal_dots() {
294 let stderr = "FAILED: build/dev/packages/mypkg/tool.v2\nld: ...\n";
295 let failure = parse_link_failure(stderr).unwrap();
296 assert_eq!(failure.package, "mypkg");
297 assert_eq!(failure.target, "tool.v2");
298 }
299
300 /// Target names ending in `.a`, `.exe`, `.so`, `.dylib`,
301 /// `.dll`, or `.lib` must round-trip unchanged. Cabin's
302 /// planner only produces `lib<name>.a` (libraries) and
303 /// `<name>` (executables, no extension), so any dot in the
304 /// FAILED path is part of the user's declared target name -
305 /// stripping it would silently drop the link hint for
306 /// projects whose targets happen to be spelled that way.
307 #[test]
308 fn preserves_target_names_ending_in_non_emitted_extensions() {
309 for name in [
310 "tool.a",
311 "tool.exe",
312 "tool.so",
313 "plugin.dll",
314 "filter.dylib",
315 "shim.lib",
316 ] {
317 let stderr = format!("FAILED: build/dev/packages/mypkg/{name}\nld: ...\n");
318 let failure = parse_link_failure(&stderr).unwrap();
319 assert_eq!(failure.package, "mypkg");
320 assert_eq!(failure.target, name);
321 }
322 }
323
324 #[test]
325 fn no_failed_line_returns_none() {
326 assert!(parse_link_failure("ninja: no work to do\n").is_none());
327 }
328
329 // ---- diagnose ------------------------------------------------
330
331 fn ninja_link_failure() -> &'static str {
332 "FAILED: [code=1] /abs/build/dev/packages/mytest/mytest\n\
333 /usr/bin/c++ obj/main.cc.o -o /abs/.../mytest\n\
334 ld: symbol(s) not found for architecture arm64\n"
335 }
336
337 #[test]
338 fn flags_declared_but_unlinked() {
339 let diag = diagnose(ninja_link_failure(), |pkg, target| {
340 assert_eq!(pkg, "mytest");
341 assert_eq!(target, "mytest");
342 // User declared `zlib` at the package level but
343 // forgot to add it to the binary's `deps =`.
344 Some(deps(&["zlib"], &[]))
345 })
346 .unwrap();
347 let LinkDiagnostic::DeclaredButUnlinked {
348 package,
349 target,
350 unlinked,
351 } = diag;
352 assert_eq!(package, "mytest");
353 assert_eq!(target, "mytest");
354 assert_eq!(unlinked, vec!["zlib"]);
355 }
356
357 #[test]
358 fn returns_none_when_target_already_links_dep() {
359 // `zlib` is both declared AND linked - the failure must
360 // be something else (a real bug in the user's code, a
361 // missing system lib, etc.). Cabin has no useful hint;
362 // surface nothing.
363 let diag = diagnose(ninja_link_failure(), |_, _| {
364 Some(deps(&["zlib"], &["zlib"]))
365 });
366 assert!(diag.is_none());
367 }
368
369 #[test]
370 fn returns_none_when_nothing_is_declared() {
371 // No deps declared at the package level - the gap-based
372 // diagnostic has nothing to say.
373 let diag = diagnose(ninja_link_failure(), |_, _| Some(deps(&[], &[])));
374 assert!(diag.is_none());
375 }
376
377 #[test]
378 fn returns_none_when_package_lookup_fails() {
379 // Closure returns None - the failing target isn't in the
380 // graph. Don't pretend to know anything.
381 let diag = diagnose(ninja_link_failure(), |_, _| None);
382 assert!(diag.is_none());
383 }
384
385 #[test]
386 fn returns_none_on_unparsable_stderr() {
387 let diag = diagnose("just some random output\n", |_, _| {
388 Some(deps(&["zlib"], &[]))
389 });
390 assert!(diag.is_none());
391 }
392
393 #[test]
394 fn lists_every_unlinked_dep_when_multiple_declared() {
395 // Package declares zlib + fmt + spdlog; target links
396 // none. All three appear in `unlinked`.
397 let diag = diagnose(ninja_link_failure(), |_, _| {
398 Some(deps(&["fmt", "spdlog", "zlib"], &[]))
399 })
400 .unwrap();
401 let LinkDiagnostic::DeclaredButUnlinked { unlinked, .. } = &diag;
402 assert_eq!(unlinked, &["fmt", "spdlog", "zlib"]);
403 }
404
405 // ---- render --------------------------------------------------
406
407 #[test]
408 fn render_declared_but_unlinked_names_target_and_dep() {
409 let diag = LinkDiagnostic::DeclaredButUnlinked {
410 package: "mytest".into(),
411 target: "mytest".into(),
412 unlinked: vec!["zlib".into()],
413 };
414 let out = render(&diag);
415 assert!(out.contains("`mytest`"));
416 assert!(out.contains("`zlib`"));
417 assert!(out.contains("[target.mytest]"));
418 assert!(out.contains("deps = [\"zlib\"]"));
419 }
420}