Skip to main content

anchor_cli/debugger/
cargo_deps.rs

1//! Discover dependency source trees so the TUI can read files whose DWARF
2//! paths were emitted relative to each crate's compile dir (cargo strips
3//! `DW_AT_comp_dir` on some builds, leaving entries like `src/cpi.rs`).
4//!
5//! Strategy: parse the workspace `Cargo.lock`, locate each registry or git
6//! dep in the local cargo cache, and hand back those directories. The
7//! resolver in [`super::tui::resolve_src_path`] tries each as a join-root
8//! and uses the first match.
9//!
10//! **Ambiguity note.** Relative paths like `src/lib.rs` exist in almost
11//! every crate. When multiple deps could match, the first one in
12//! `Cargo.lock` wins — deterministic but not always semantically correct.
13//! Unique paths like `src/sysvars/rent.rs` disambiguate cleanly in
14//! practice. We accept this tradeoff because the alternative — routing
15//! each relative path through PC-and-symbol-aware dep selection — is
16//! disproportionate effort for the mostly-cosmetic win.
17
18use {
19    serde::Deserialize,
20    std::{
21        collections::BTreeSet,
22        path::{Path, PathBuf},
23    },
24};
25
26/// Minimal Cargo.lock schema — ignores anything we don't care about.
27#[derive(Deserialize)]
28struct CargoLock {
29    #[serde(default)]
30    package: Vec<Package>,
31}
32
33#[derive(Deserialize)]
34struct Package {
35    name: String,
36    version: String,
37    #[serde(default)]
38    source: Option<String>,
39}
40
41/// Return a list of directories to try when joining a relative DWARF path.
42/// Each entry is a crate source root under `$CARGO_HOME/registry/src/` or
43/// `$CARGO_HOME/git/checkouts/`. Empty vec if `Cargo.lock` or the cargo
44/// cache isn't available.
45///
46/// Order: registry deps first (most predictable layout), then git
47/// checkouts (best-effort), sorted within each group by
48/// `Cargo.lock` order. No dedup across packages — upstream dedup happens
49/// because we only add candidates whose directory actually exists.
50pub fn discover_dep_src_roots(workspace_root: &Path) -> Vec<PathBuf> {
51    let lock_path = workspace_root.join("Cargo.lock");
52    let Ok(contents) = std::fs::read_to_string(&lock_path) else {
53        return Vec::new();
54    };
55    let Ok(parsed) = toml::from_str::<CargoLock>(&contents) else {
56        return Vec::new();
57    };
58
59    let Some(cargo_home) = cargo_home() else {
60        return Vec::new();
61    };
62
63    // Enumerate every `registry/src/<registry-hash>/` subtree so we can
64    // resolve deps regardless of which index they came from (crates.io,
65    // custom registries, sparse-index vs git-index, etc.).
66    let registry_roots: Vec<PathBuf> = std::fs::read_dir(cargo_home.join("registry/src"))
67        .into_iter()
68        .flatten()
69        .flatten()
70        .map(|e| e.path())
71        .filter(|p| p.is_dir())
72        .collect();
73
74    // `git/checkouts/` holds `<repo-name>-<hash>/<rev-prefix>/`. We only
75    // want rev-prefix dirs that contain a crate — not their parent dirs —
76    // since those are what cargo compiles against.
77    let git_checkouts = cargo_home.join("git/checkouts");
78
79    let mut roots: Vec<PathBuf> = Vec::new();
80    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
81
82    for pkg in &parsed.package {
83        let Some(source) = pkg.source.as_deref() else {
84            // Path deps have no `source`. Their source lives in the
85            // workspace itself, which is already a src_root.
86            continue;
87        };
88
89        if source.starts_with("registry+") {
90            let dirname = format!("{}-{}", pkg.name, pkg.version);
91            for registry in &registry_roots {
92                let candidate = registry.join(&dirname);
93                if candidate.is_dir() && seen.insert(candidate.clone()) {
94                    roots.push(candidate);
95                    break;
96                }
97            }
98        } else if source.starts_with("git+") {
99            // Git deps: `git+https://github.com/foo/bar.git?branch=x#<hash>`.
100            // Cargo clones to `git/checkouts/<repo>-<hash>/<rev-prefix>/`
101            // where rev-prefix is the first ~7 chars of the commit hash
102            // after `#`. Pull that suffix out and find the checkout.
103            let Some(hash) = source.rsplit('#').next() else {
104                continue;
105            };
106            if hash.len() < 7 {
107                continue;
108            }
109            let rev_prefix = &hash[..7];
110            // `read_dir` twice: outer is `<repo>-<hash>` dirs, inner is
111            // rev-prefix subdirs. The repo name isn't trivially derivable
112            // from the url (cargo hashes and abbreviates), so we scan.
113            let entries = match std::fs::read_dir(&git_checkouts) {
114                Ok(e) => e,
115                Err(_) => continue,
116            };
117            for outer in entries.flatten() {
118                let candidate = outer.path().join(rev_prefix);
119                // The checkout may be the repo root, not the crate — walk
120                // down to find a `Cargo.toml` whose `[package] name` matches.
121                if let Some(crate_dir) =
122                    find_crate_dir_in_checkout(&candidate, &pkg.name, &pkg.version)
123                {
124                    if seen.insert(crate_dir.clone()) {
125                        roots.push(crate_dir);
126                    }
127                    break;
128                }
129            }
130        }
131    }
132
133    roots
134}
135
136/// Discover path dependencies from a crate's `Cargo.toml` and return their
137/// resolved absolute directories. These are local deps like
138/// `anchor-lang-v2 = { path = "../../../../lang-v2" }` — their source
139/// root is the resolved path itself, which needs to be in `src_roots` so
140/// relative DWARF paths like `src/lib.rs` resolve to the right crate
141/// instead of colliding with a same-named file at the workspace root.
142pub fn discover_path_dep_roots(crate_dir: &Path) -> Vec<PathBuf> {
143    let manifest = crate_dir.join("Cargo.toml");
144    let Ok(contents) = std::fs::read_to_string(&manifest) else {
145        return Vec::new();
146    };
147    let Ok(parsed) = toml::from_str::<toml::Value>(&contents) else {
148        return Vec::new();
149    };
150
151    let mut roots = Vec::new();
152    for section in ["dependencies", "dev-dependencies"] {
153        let Some(deps) = parsed.get(section).and_then(|v| v.as_table()) else {
154            continue;
155        };
156        for (_name, spec) in deps {
157            let path_str = match spec {
158                toml::Value::Table(t) => t.get("path").and_then(|v| v.as_str()),
159                _ => None,
160            };
161            if let Some(p) = path_str {
162                let resolved = crate_dir.join(p);
163                if let Ok(canonical) = resolved.canonicalize() {
164                    if canonical.is_dir() {
165                        roots.push(canonical);
166                    }
167                }
168            }
169        }
170    }
171    roots
172}
173
174/// Locate `$CARGO_HOME`. Mirrors cargo's own resolution: env var first,
175/// then `~/.cargo`.
176fn cargo_home() -> Option<PathBuf> {
177    if let Some(p) = std::env::var_os("CARGO_HOME") {
178        return Some(PathBuf::from(p));
179    }
180    dirs::home_dir().map(|h| h.join(".cargo"))
181}
182
183/// Depth-limited scan of a git checkout for the `Cargo.toml` that
184/// actually declares `<name>@<version>`. Needed because a git dep may
185/// live in a subdir (e.g. multi-crate repos where `foo = { git = ..., package = "foo-sub" }`).
186///
187/// Returns the first matching crate directory — or `None` if no
188/// `Cargo.toml` in the checkout declares the package. Bounded to 4 levels
189/// of descent to keep this cheap.
190fn find_crate_dir_in_checkout(root: &Path, name: &str, version: &str) -> Option<PathBuf> {
191    fn recurse(dir: &Path, name: &str, version: &str, depth: u8) -> Option<PathBuf> {
192        if depth > 4 {
193            return None;
194        }
195        let manifest = dir.join("Cargo.toml");
196        if manifest.is_file() {
197            if let Ok(contents) = std::fs::read_to_string(&manifest) {
198                // Crude but avoids a full toml parse per checkout subdir.
199                // We look for both `name = "<name>"` and `version = "<version>"`
200                // within a window — good enough to distinguish the right
201                // [package] section from any [dependencies.*] entries,
202                // which wouldn't have `version` + `name` together.
203                if contents.contains(&format!("name = \"{name}\"")) {
204                    // Version check: optional because path/workspace deps
205                    // sometimes omit version in the manifest.
206                    if version.is_empty() || contents.contains(&format!("version = \"{version}\""))
207                    {
208                        return Some(dir.to_path_buf());
209                    }
210                }
211            }
212        }
213        let entries = std::fs::read_dir(dir).ok()?;
214        for entry in entries.flatten() {
215            let path = entry.path();
216            if !path.is_dir() {
217                continue;
218            }
219            let Some(fname) = path.file_name().and_then(|s| s.to_str()) else {
220                continue;
221            };
222            // Skip typical noise that never holds a crate manifest.
223            if fname.starts_with('.') || fname == "target" || fname == "tests" {
224                continue;
225            }
226            if let Some(hit) = recurse(&path, name, version, depth + 1) {
227                return Some(hit);
228            }
229        }
230        None
231    }
232
233    recurse(root, name, version, 0)
234}