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 ®istry_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}