unity_solution_generator/
lockfile_scanner.rs1use 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
19pub struct ScannedLockfile {
23 pub lockfile: Lockfile,
24 pub contributing_paths_relative: Vec<String>,
27 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 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 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 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 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
287fn 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
335fn 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 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 hits.sort();
388 hits
389}
390
391#[derive(Debug)]
396struct MissingPackage {
397 name: String,
398}
399
400fn compute_missing_packages(project_root: &str) -> Vec<MissingPackage> {
401 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 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