Skip to main content

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}