Skip to main content

unity_solution_generator/
lockfile_scanner.rs

1//! Lockfile scanner: walks the Unity installation + project to materialise
2//! every DLL reference, analyzer, and define needed for a `.csproj`.
3
4use std::collections::BTreeSet;
5use std::path::Path;
6
7use ignore::{WalkBuilder, WalkState};
8use walkdir::WalkDir;
9
10use crate::defines::{DEFAULT_FEATURE_DEFINES, generate_version_defines, parse_scripting_defines};
11use crate::error::{LockfileError, Result};
12use crate::io::{file_exists, list_directory, read_file};
13use crate::lockfile::{DllRef, Lockfile, RefCategory};
14use crate::paths::{join_path, resolve_real_path};
15use crate::project_scanner::parse_version_defines;
16
17pub struct LockfileScanner;
18
19/// Output of [`LockfileScanner::scan_with_artifacts`]: the lockfile plus the
20/// concrete `.dll`/`.asmdef` paths that contributed to it. The caller (lock-cache)
21/// uses the path list to build a fingerprint of contributing directories.
22pub struct ScannedLockfile {
23    pub lockfile: Lockfile,
24    /// Paths relative to `project_root`, of every project-side `.dll` and `.asmdef`
25    /// that the scan ingested.
26    pub contributing_paths_relative: Vec<String>,
27    /// Absolute paths outside `project_root` that the scan ingested (Unity
28    /// `BuiltInPackages/`, the per-user tarball-extract cache). Watched by
29    /// `lock-fingerprint` so post-Unity-install changes invalidate the lockfile.
30    pub contributing_external_absolute: Vec<String>,
31}
32
33impl LockfileScanner {
34    pub fn scan(project_root: &str) -> Result<Lockfile> {
35        Self::scan_with_artifacts(project_root).map(|s| s.lockfile)
36    }
37
38    pub fn scan_with_artifacts(project_root: &str) -> Result<ScannedLockfile> {
39        let _span = tracing::info_span!("lockfile_scanner.scan").entered();
40        let (version, unity_path) = resolve_unity_path(project_root)?;
41        let app_contents = join_path(&unity_path, "Unity.app/Contents");
42        let _unity_span = tracing::info_span!("lockfile_scanner.unity_install").entered();
43
44        let managed_engine_dir = join_path(&app_contents, "Managed/UnityEngine");
45        let mut engine_refs: Vec<DllRef> = Vec::new();
46        let mut editor_refs: Vec<DllRef> = Vec::new();
47        let mut managed_dlls: Vec<String> = list_directory(&managed_engine_dir)
48            .into_iter()
49            .filter(|n| n.ends_with(".dll"))
50            .collect();
51        managed_dlls.sort();
52        for dll in &managed_dlls {
53            let name = &dll[..dll.len() - 4];
54            if !(name.starts_with("UnityEngine") || name.starts_with("UnityEditor")) {
55                continue;
56            }
57            let path = format!(
58                "$(UnityPath)/Unity.app/Contents/Managed/UnityEngine/{}",
59                dll
60            );
61            if name.starts_with("UnityEditor") {
62                editor_refs.push(DllRef::new(name, path));
63            } else {
64                engine_refs.push(DllRef::new(name, path));
65            }
66        }
67
68        // Lives one level up from Managed/UnityEngine/.
69        let graphs_dll = join_path(&app_contents, "Managed/UnityEditor.Graphs.dll");
70        if file_exists(&graphs_dll) {
71            editor_refs.push(DllRef::new(
72                "UnityEditor.Graphs",
73                "$(UnityPath)/Unity.app/Contents/Managed/UnityEditor.Graphs.dll",
74            ));
75        }
76
77        let netstd_base = join_path(&app_contents, "NetStandard");
78        let mut netstd_refs: Vec<DllRef> = Vec::new();
79        walk_files(&netstd_base, &netstd_base, &[".dll"], false, |rel, name| {
80            let n = &name[..name.len() - 4];
81            netstd_refs.push(DllRef::new(
82                n,
83                format!("$(UnityPath)/Unity.app/Contents/NetStandard/{}", rel),
84            ));
85        });
86        netstd_refs.sort_by(|a, b| a.name.cmp(&b.name));
87
88        let playback_base = join_path(&unity_path, "PlaybackEngines");
89        let ios_refs = scan_playback_dlls(
90            &join_path(&playback_base, "iOSSupport"),
91            "PlaybackEngines/iOSSupport",
92        );
93        let android_refs = scan_playback_dlls(
94            &join_path(&playback_base, "AndroidPlayer"),
95            "PlaybackEngines/AndroidPlayer",
96        );
97        let standalone_dir = join_path(&app_contents, "PlaybackEngines/MacStandaloneSupport");
98        let standalone_refs = scan_playback_dlls(
99            &standalone_dir,
100            "Unity.app/Contents/PlaybackEngines/MacStandaloneSupport",
101        );
102
103        let source_gen_dir = join_path(&app_contents, "Tools/Unity.SourceGenerators");
104        let mut analyzers: Vec<String> = Vec::new();
105        let mut sg_dlls: Vec<String> = list_directory(&source_gen_dir)
106            .into_iter()
107            .filter(|n| n.ends_with(".dll"))
108            .collect();
109        sg_dlls.sort();
110        for dll in sg_dlls {
111            analyzers.push(format!(
112                "$(UnityPath)/Unity.app/Contents/Tools/Unity.SourceGenerators/{}",
113                dll
114            ));
115        }
116
117        // Deduplicate by assembly name (first wins across Assets > Packages > PackageCache).
118        // We walk each root in parallel (via `ignore`), collect the per-root hits into a
119        // `Vec<(rel_path, file_name)>`, then iterate sequentially across roots in order
120        // to preserve "first wins" semantics. The hot cost is reading `Library/PackageCache`,
121        // which is heavily parallelisable.
122        drop(_unity_span);
123        let _proj_span = tracing::info_span!("lockfile_scanner.project_walk").entered();
124        let mut project_refs: Vec<DllRef> = Vec::new();
125        let mut seen_project_dlls: BTreeSet<String> = BTreeSet::new();
126        let mut seen_analyzers: BTreeSet<String> = BTreeSet::new();
127        let mut asmdef_paths: Vec<String> = Vec::new();
128        let mut contributing: Vec<String> = Vec::new();
129        let mut contributing_external: Vec<String> = Vec::new();
130        for root in ["Assets", "Packages", "Library/PackageCache"] {
131            let root_dir = join_path(project_root, root);
132            let hits = parallel_walk_dlls_and_asmdefs(&root_dir, project_root);
133            for (rel, file_name) in hits {
134                contributing.push(rel.clone());
135                if file_name.ends_with(".dll") {
136                    let name = &file_name[..file_name.len() - 4];
137                    let path = format!("$(ProjectRoot)/{}", rel);
138                    if is_analyzer_dll(name) {
139                        if seen_analyzers.insert(name.to_string()) {
140                            analyzers.push(path);
141                        }
142                    } else if seen_project_dlls.insert(name.to_string()) {
143                        project_refs.push(DllRef::new(name, path));
144                    }
145                } else {
146                    asmdef_paths.push(join_path(project_root, &rel));
147                }
148            }
149        }
150
151        // Targeted fallback for packages that `Library/PackageCache/` doesn't
152        // cover — typically a fresh worktree where Unity hasn't run yet, so
153        // registry/builtin packages haven't been resolved into the per-project
154        // cache. We read `packages-lock.json` to find what *should* be there;
155        // for each gap we look up the package in `BuiltInPackages/` (already
156        // extracted, lives in the Unity install) or extract from
157        // `PackageManager/Editor/<name>-<version>.tgz` into a per-user cache.
158        // Walking those sources in bulk would drown the cold-lock path
159        // (~28 s on meow-tower); the missing-package set is usually empty in
160        // practice, so the gated approach reverts to the original ~30 ms.
161        let missing_packages = compute_missing_packages(project_root);
162        if !missing_packages.is_empty() {
163            tracing::info!(
164                "lockfile_scanner: {} package(s) missing from PackageCache; falling back to BuiltInPackages + tgz extract",
165                missing_packages.len()
166            );
167        }
168        // Each fallback source roots its walk at the per-package directory and
169        // emits ref paths using a single placeholder + prefix. Keeping `rel`
170        // relative to the package dir (not the install/cache root) means the
171        // emitted ref looks like `$(VAR)/<package-prefix>/<rel>` regardless of
172        // source — no asymmetry between BuiltInPackages and the tgz cache.
173        let mut ingest = |pkg_dir: &str, ref_prefix: &str| {
174            contributing_external.push(pkg_dir.to_string());
175            for (rel, file_name) in parallel_walk_dlls_and_asmdefs(pkg_dir, pkg_dir) {
176                if file_name.ends_with(".dll") {
177                    let name = &file_name[..file_name.len() - 4];
178                    let path = format!("{}/{}", ref_prefix, rel);
179                    if is_analyzer_dll(name) {
180                        if seen_analyzers.insert(name.to_string()) {
181                            analyzers.push(path);
182                        }
183                    } else if seen_project_dlls.insert(name.to_string()) {
184                        project_refs.push(DllRef::new(name, path));
185                    }
186                } else {
187                    asmdef_paths.push(format!("{}/{}", pkg_dir, rel));
188                }
189            }
190        };
191        for entry in &missing_packages {
192            let builtin = format!(
193                "{}/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/{}",
194                unity_path, entry.name
195            );
196            if Path::new(&builtin).exists() {
197                let prefix = format!(
198                    "$(UnityPath)/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/{}",
199                    entry.name
200                );
201                ingest(&builtin, &prefix);
202                continue;
203            }
204            let Some(extract_root) = crate::package_cache::ensure_extracted_for_package(
205                &unity_path,
206                &version,
207                &entry.name,
208            ) else {
209                tracing::warn!(
210                    "lockfile_scanner: package '{}' missing from PackageCache, BuiltInPackages, and Editor/*.tgz",
211                    entry.name
212                );
213                continue;
214            };
215            let prefix = format!("$(UsgCache)/{}", entry.name);
216            ingest(&extract_root, &prefix);
217        }
218
219        analyzers.sort();
220        project_refs.sort_by(|a, b| a.name.cmp(&b.name));
221
222        drop(_proj_span);
223        let _defines_span = tracing::info_span!("lockfile_scanner.defines").entered();
224        let version_defines = generate_version_defines(&version);
225        let asmdef_defines = collect_asmdef_version_defines(project_root, &asmdef_paths);
226        let mut all_defines = version_defines;
227        all_defines.extend(DEFAULT_FEATURE_DEFINES.iter().map(|s| s.to_string()));
228        all_defines.extend(asmdef_defines);
229        let scripting_defines = parse_scripting_defines(project_root);
230
231        let mut refs = std::collections::BTreeMap::new();
232        refs.insert(RefCategory::Engine, engine_refs);
233        refs.insert(RefCategory::Editor, editor_refs);
234        refs.insert(RefCategory::Netstandard, netstd_refs);
235        refs.insert(RefCategory::PlaybackIos, ios_refs);
236        refs.insert(RefCategory::PlaybackAndroid, android_refs);
237        refs.insert(RefCategory::PlaybackStandalone, standalone_refs);
238        refs.insert(RefCategory::Project, project_refs);
239
240        let lockfile = Lockfile {
241            unity_version: version,
242            unity_path,
243            lang_version: "9.0".to_string(),
244            analyzers,
245            refs,
246            defines: all_defines,
247            defines_scripting: scripting_defines,
248        };
249        Ok(ScannedLockfile {
250            lockfile,
251            contributing_paths_relative: contributing,
252            contributing_external_absolute: contributing_external,
253        })
254    }
255}
256
257fn resolve_unity_path(project_root: &str) -> Result<(String, String)> {
258    let version_file = join_path(project_root, "ProjectSettings/ProjectVersion.txt");
259    if !file_exists(&version_file) {
260        return Err(LockfileError::NoProjectVersion(project_root.to_string()).into());
261    }
262    let content = read_file(&version_file)?;
263    let Some(colon) = content.find(':') else {
264        return Err(LockfileError::NoProjectVersion(project_root.to_string()).into());
265    };
266    let bytes = content.as_bytes();
267    let mut i = colon + 1;
268    while i < bytes.len() && bytes[i] == b' ' {
269        i += 1;
270    }
271    let mut end = i;
272    while end < bytes.len() && bytes[end] != b'\n' && bytes[end] != b'\r' {
273        end += 1;
274    }
275    let version = content[i..end].to_string();
276    if version.is_empty() {
277        return Err(LockfileError::NoProjectVersion(project_root.to_string()).into());
278    }
279
280    let unity_path = format!("/Applications/Unity/Hub/Editor/{}", version);
281    if !Path::new(&unity_path).exists() {
282        return Err(LockfileError::UnityNotFound(unity_path).into());
283    }
284    Ok((version, resolve_real_path(&unity_path)))
285}
286
287/// Recursively walk `directory` (using walkdir), invoking `handler(relative_to_base, file_name)`
288/// for each file with a matching extension. Skips dotfiles, tilde-suffixed entries, and
289/// (optionally) native-plugin subdirs.
290fn walk_files(
291    directory: &str,
292    base_path: &str,
293    extensions: &[&str],
294    skip_native_plugin_dirs: bool,
295    mut handler: impl FnMut(&str, &str),
296) {
297    if !Path::new(directory).exists() {
298        return;
299    }
300    let base = Path::new(base_path);
301    let mut iter = WalkDir::new(directory)
302        .follow_links(false)
303        .into_iter()
304        .filter_entry(|e| {
305            let name = e.file_name().to_string_lossy();
306            if name.starts_with('.') || name.ends_with('~') {
307                return false;
308            }
309            if e.file_type().is_dir() && skip_native_plugin_dirs && is_native_plugin_dir(&name) {
310                return false;
311            }
312            true
313        });
314    while let Some(entry) = iter.next() {
315        let Ok(entry) = entry else {
316            continue;
317        };
318        if !entry.file_type().is_file() {
319            continue;
320        }
321        let name_owned = entry.file_name().to_string_lossy().into_owned();
322        if !extensions.iter().any(|ext| name_owned.ends_with(ext)) {
323            continue;
324        }
325        let Ok(rel_path) = entry.path().strip_prefix(base) else {
326            continue;
327        };
328        let Some(rel) = rel_path.to_str() else {
329            continue;
330        };
331        handler(rel, &name_owned);
332    }
333}
334
335/// Walk `directory` in parallel using `ignore::WalkBuilder::build_parallel` and return
336/// every file ending in `.dll` or `.asmdef`, as `(relative_to_strip_base, file_name)`.
337/// Skips dotfiles, tilde-suffixed entries, and native-plugin directories.
338fn parallel_walk_dlls_and_asmdefs(directory: &str, strip_base: &str) -> Vec<(String, String)> {
339    if !Path::new(directory).exists() {
340        return Vec::new();
341    }
342    // Component-aware prefix strip — see project_scanner.rs for rationale.
343    let project_root_path = Path::new(strip_base);
344    let mut builder = WalkBuilder::new(directory);
345    builder
346        .standard_filters(false)
347        .hidden(false)
348        .ignore(false)
349        .git_ignore(false)
350        .git_global(false)
351        .git_exclude(false)
352        .parents(false)
353        .follow_links(false);
354
355    let mut hits = crate::walk::parallel_walk(builder, |local: &mut Vec<(String, String)>, entry| {
356        let name = entry.file_name().to_string_lossy();
357        if name.starts_with('.') || name.ends_with('~') {
358            return WalkState::Skip;
359        }
360        let Some(ft) = entry.file_type() else {
361            return WalkState::Continue;
362        };
363        if ft.is_dir() {
364            if is_native_plugin_dir(&name) {
365                return WalkState::Skip;
366            }
367            return WalkState::Continue;
368        }
369        if !ft.is_file() {
370            return WalkState::Continue;
371        }
372        let n: &str = name.as_ref();
373        if !(n.ends_with(".dll") || n.ends_with(".asmdef")) {
374            return WalkState::Continue;
375        }
376        let Ok(rel) = entry.path().strip_prefix(project_root_path) else {
377            return WalkState::Continue;
378        };
379        let Some(rel_str) = rel.to_str() else {
380            return WalkState::Continue;
381        };
382        local.push((rel_str.to_string(), n.to_string()));
383        WalkState::Continue
384    });
385    // Stable order across the roots so the "first wins" dedupe pass is deterministic
386    // even though the parallel walker fans out non-deterministically per thread.
387    hits.sort();
388    hits
389}
390
391/// Package entry that `Library/PackageCache/` is expected to cover but doesn't.
392/// Driven by `Packages/packages-lock.json`: an entry is "expected" when its
393/// `source` is something other than `embedded`/`local` (those live under
394/// `Packages/` directly and are picked up by the project walk already).
395#[derive(Debug)]
396struct MissingPackage {
397    name: String,
398}
399
400fn compute_missing_packages(project_root: &str) -> Vec<MissingPackage> {
401    // Snapshot of currently-resolved packages by canonical name. Unity uses
402    // `<name>@<hash>` for PackageCache directories; we strip the suffix.
403    let pc_dir = join_path(project_root, "Library/PackageCache");
404    let mut resolved: BTreeSet<String> = BTreeSet::new();
405    for entry in list_directory(&pc_dir) {
406        let name = match entry.find('@') {
407            Some(i) => entry[..i].to_string(),
408            None => entry,
409        };
410        resolved.insert(name);
411    }
412
413    let lock_path = join_path(project_root, "Packages/packages-lock.json");
414    let Ok(content) = read_file(&lock_path) else {
415        return Vec::new();
416    };
417    let v: serde_json::Value = match serde_json::from_str(&content) {
418        Ok(v) => v,
419        Err(e) => {
420            tracing::warn!(
421                "lockfile_scanner: malformed packages-lock.json ({}); skipping missing-package fallback",
422                e
423            );
424            return Vec::new();
425        }
426    };
427    let Some(deps) = v.get("dependencies").and_then(|x| x.as_object()) else {
428        return Vec::new();
429    };
430
431    let mut missing = Vec::new();
432    for (name, meta) in deps {
433        let source = meta
434            .get("source")
435            .and_then(|s| s.as_str())
436            .unwrap_or("");
437        // `embedded` lives in `Packages/<name>/`; `local` is a `file:` path —
438        // both are scanned by the project walk. Everything else (registry,
439        // builtin, git) lands in `Library/PackageCache/` after Unity resolves.
440        if matches!(source, "embedded" | "local") {
441            continue;
442        }
443        if resolved.contains(name) {
444            continue;
445        }
446        missing.push(MissingPackage {
447            name: name.clone(),
448        });
449    }
450    missing
451}
452
453fn is_native_plugin_dir(name: &str) -> bool {
454    matches!(
455        name,
456        "x86" | "x86_64" | "arm64-v8a" | "armeabi-v7a" | "ARM64" | "x64"
457    ) || name.ends_with(".framework")
458        || name.ends_with(".bundle")
459}
460
461fn scan_playback_dlls(directory: &str, prefix: &str) -> Vec<DllRef> {
462    let mut dlls: Vec<String> = list_directory(directory)
463        .into_iter()
464        .filter(|n| n.ends_with(".dll"))
465        .collect();
466    dlls.sort();
467    dlls.into_iter()
468        .filter_map(|dll| {
469            let name = dll[..dll.len() - 4].to_string();
470            if name.starts_with("UnityEditor.") || name.starts_with("Unity.Android.") {
471                Some(DllRef::new(name, format!("$(UnityPath)/{}/{}", prefix, dll)))
472            } else {
473                None
474            }
475        })
476        .collect()
477}
478
479fn is_analyzer_dll(name: &str) -> bool {
480    let lower = name.to_ascii_lowercase();
481    lower.contains("analyzer") || lower.contains("sourcegenerator")
482}
483
484fn collect_asmdef_version_defines(project_root: &str, asmdef_paths: &[String]) -> Vec<String> {
485    let mut installed_packages: BTreeSet<String> = BTreeSet::new();
486    installed_packages.insert("Unity".to_string());
487
488    let manifest_path = join_path(project_root, "Packages/manifest.json");
489    if let Ok(manifest) = read_file(&manifest_path) {
490        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&manifest) {
491            if let Some(deps) = v.get("dependencies").and_then(|x| x.as_object()) {
492                for pkg in deps.keys() {
493                    installed_packages.insert(pkg.clone());
494                }
495            }
496        }
497    }
498    for entry in list_directory(&join_path(project_root, "Packages")) {
499        if entry.ends_with(".json") || entry.starts_with('.') {
500            continue;
501        }
502        installed_packages.insert(entry);
503    }
504
505    let mut all: BTreeSet<String> = BTreeSet::new();
506    for path in asmdef_paths {
507        let Ok(content) = read_file(path) else {
508            continue;
509        };
510        let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) else {
511            continue;
512        };
513        for vd in parse_version_defines(&v) {
514            if installed_packages.contains(&vd.package_name) {
515                all.insert(vd.define);
516            }
517        }
518    }
519    all.into_iter().collect()
520}
521