Files
cursebreaker-parser-rust/unity-parser/src/parser/guid_resolver.rs
2026-01-03 14:51:31 +00:00

502 lines
15 KiB
Rust

//! Unity GUID resolution for MonoBehaviour scripts
//!
//! This module resolves Unity GUIDs to their corresponding MonoBehaviour class names
//! by scanning .meta files and parsing C# scripts.
//!
//! # How Unity GUID Resolution Works
//!
//! 1. Every asset has a `.meta` file with a unique GUID
//! 2. MonoBehaviour components reference scripts via GUID in `m_Script`
//! 3. We scan `.cs.meta` files to build a GUID → Script Path mapping
//! 4. We parse the `.cs` files to extract the class name
//! 5. Result: GUID → Class Name mapping for component registration
//!
//! # Example
//!
//! ```no_run
//! use unity_parser::parser::GuidResolver;
//! use std::path::Path;
//!
//! // Build resolver from Unity project directory
//! let project_path = Path::new("path/to/UnityProject");
//! let resolver = GuidResolver::from_project(project_path)?;
//!
//! // Resolve a GUID to class name
//! let guid = "091c537484687e9419460cdcd7038234";
//! if let Some(class_name) = resolver.resolve_class_name(guid) {
//! println!("GUID {} → {}", guid, class_name);
//! }
//! # Ok::<(), unity_parser::Error>(())
//! ```
use crate::parser::meta::MetaFile;
use crate::types::Guid;
use crate::{Error, Result};
use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Resolves Unity GUIDs to MonoBehaviour class names
///
/// This struct builds a mapping from GUID to class name by scanning
/// a Unity project's `.cs.meta` files and parsing the corresponding
/// C# scripts to extract class names.
///
/// Uses a 128-bit `Guid` type for efficient storage and fast comparisons.
#[derive(Debug, Clone)]
pub struct GuidResolver {
/// Map from GUID to MonoBehaviour class name
guid_to_class: HashMap<Guid, String>,
}
impl GuidResolver {
/// Create a new empty GuidResolver
pub fn new() -> Self {
Self {
guid_to_class: HashMap::new(),
}
}
/// Build a GuidResolver by scanning a Unity project directory
///
/// This scans for all `.cs.meta` files (MonoBehaviour scripts),
/// parses them to extract GUIDs, then parses the corresponding
/// C# files to extract class names.
///
/// # Arguments
///
/// * `project_path` - Path to the Unity project root (containing Assets/ folder)
///
/// # Examples
///
/// ```no_run
/// use unity_parser::parser::GuidResolver;
/// use std::path::Path;
///
/// let resolver = GuidResolver::from_project(Path::new("MyUnityProject"))?;
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn from_project(project_path: impl AsRef<Path>) -> Result<Self> {
let project_path = project_path.as_ref();
// Verify this looks like a Unity project - check for Assets/ or _GameAssets/
let assets_dir = if project_path.join("Assets").exists() {
project_path.join("Assets")
} else if project_path.join("_GameAssets").exists() {
project_path.join("_GameAssets")
} else {
return Err(Error::invalid_format(format!(
"Not a Unity project: missing Assets/ or _GameAssets/ directory at {}",
project_path.display()
)));
};
let mut resolver = Self::new();
// Walk the Assets directory looking for .cs.meta files
for entry in WalkDir::new(&assets_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
// Only process .cs.meta files (MonoBehaviour scripts)
if !is_cs_meta_file(path) {
continue;
}
// Parse the .meta file to get the GUID
let meta = match MetaFile::from_path(path) {
Ok(meta) => meta,
Err(e) => {
eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
continue;
}
};
// Get the corresponding .cs file path
let cs_path = path.with_file_name(
path.file_name()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_suffix(".meta"))
.unwrap_or(""),
);
// Extract the class name from the .cs file
let class_name = match extract_class_name(&cs_path) {
Ok(Some(name)) => name,
Ok(None) => {
// No MonoBehaviour class found in this file
continue;
}
Err(e) => {
eprintln!(
"Warning: Failed to extract class name from {}: {}",
cs_path.display(),
e
);
continue;
}
};
// Parse the GUID string to Guid type
let guid = match Guid::from_hex(meta.guid()) {
Ok(g) => g,
Err(e) => {
eprintln!(
"Warning: Invalid GUID in {}: {}",
path.display(),
e
);
continue;
}
};
// Store the mapping
resolver.guid_to_class.insert(guid, class_name);
}
Ok(resolver)
}
/// Resolve a GUID to its class name
///
/// Accepts either a `&Guid` or a string that can be parsed as a GUID.
///
/// # Arguments
///
/// * `guid` - The Unity GUID (e.g., "091c537484687e9419460cdcd7038234" or a `Guid`)
///
/// # Returns
///
/// The class name if the GUID is found, otherwise None
///
/// # Examples
///
/// ```no_run
/// # use unity_parser::{GuidResolver, Guid};
/// # use std::path::Path;
/// # let resolver = GuidResolver::from_project(Path::new("."))?;
/// // Resolve by string
/// if let Some(class_name) = resolver.resolve_class_name("091c537484687e9419460cdcd7038234") {
/// println!("Found class: {}", class_name);
/// }
///
/// // Resolve by Guid
/// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234")?;
/// if let Some(class_name) = resolver.resolve_class_name(&guid) {
/// println!("Found class: {}", class_name);
/// }
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn resolve_class_name<G: AsGuid>(&self, guid: G) -> Option<&str> {
guid.as_guid()
.and_then(|g| self.guid_to_class.get(&g))
.map(|s| s.as_str())
}
/// Get the number of GUID mappings
pub fn len(&self) -> usize {
self.guid_to_class.len()
}
/// Check if the resolver is empty
pub fn is_empty(&self) -> bool {
self.guid_to_class.is_empty()
}
/// Insert a GUID → class name mapping manually
///
/// Useful for testing or adding custom mappings
///
/// # Examples
///
/// ```
/// use unity_parser::{GuidResolver, Guid};
///
/// let mut resolver = GuidResolver::new();
/// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
/// resolver.insert(guid, "PlaySFX".to_string());
/// ```
pub fn insert(&mut self, guid: Guid, class_name: String) {
self.guid_to_class.insert(guid, class_name);
}
/// Get all resolved GUIDs
///
/// Returns an iterator over all GUIDs in the resolver.
pub fn guids(&self) -> impl Iterator<Item = Guid> + '_ {
self.guid_to_class.keys().copied()
}
}
impl Default for GuidResolver {
fn default() -> Self {
Self::new()
}
}
/// Trait for types that can be converted to a GUID for lookup
///
/// This allows the `resolve_class_name` method to accept both `&Guid` and `&str`.
pub trait AsGuid {
/// Convert to a GUID, returning None if conversion fails
fn as_guid(&self) -> Option<Guid>;
}
impl AsGuid for Guid {
fn as_guid(&self) -> Option<Guid> {
Some(*self)
}
}
impl AsGuid for &Guid {
fn as_guid(&self) -> Option<Guid> {
Some(**self)
}
}
impl AsGuid for str {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
impl AsGuid for &str {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
impl AsGuid for String {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
/// Check if a path points to a C# .meta file
fn is_cs_meta_file(path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.map(|ext| ext == "meta")
.unwrap_or(false)
&& path
.file_name()
.and_then(|s| s.to_str())
.map(|name| name.ends_with(".cs.meta"))
.unwrap_or(false)
}
/// Extract the MonoBehaviour class name from a C# script file
///
/// This looks for patterns like:
/// - `public class ClassName : MonoBehaviour`
/// - `class ClassName : MonoBehaviour`
/// - `public class ClassName:MonoBehaviour`
///
/// # Arguments
///
/// * `cs_path` - Path to the .cs file
///
/// # Returns
///
/// Ok(Some(class_name)) if a MonoBehaviour class was found
/// Ok(None) if no MonoBehaviour class was found
/// Err if the file couldn't be read
fn extract_class_name(cs_path: &Path) -> Result<Option<String>> {
let content = std::fs::read_to_string(cs_path)?;
// Regex to match MonoBehaviour class declarations (direct or indirect inheritance)
// Matches: public class ClassName : MonoBehaviour
// Also matches: public class ClassName : SomeBaseClass (which may inherit from MonoBehaviour)
// Captures the class name in group 1
//
// We match any class with inheritance (: BaseClass) because in Unity,
// scripts can inherit from MonoBehaviour indirectly through base classes.
// The component registration system will filter for actual MonoBehaviours.
let class_regex = Regex::new(
r"(?:public\s+)?class\s+(\w+)\s*:\s*\w+"
).unwrap();
// Find the first class with inheritance in the file
// Unity typically has one main class per script file
if let Some(captures) = class_regex.captures(&content) {
if let Some(class_name) = captures.get(1) {
return Ok(Some(class_name.as_str().to_string()));
}
}
Ok(None)
}
/// Find the Unity project root from any path within the project
///
/// Searches upward from the given path until it finds a directory
/// containing an "Assets" folder.
///
/// # Arguments
///
/// * `path` - Any path within the Unity project (file or directory)
///
/// # Returns
///
/// The project root path if found, otherwise an error
///
/// # Examples
///
/// ```no_run
/// use unity_parser::parser::find_project_root;
/// use std::path::Path;
///
/// let scene_path = Path::new("MyProject/Assets/Scenes/Main.unity");
/// let project_root = find_project_root(scene_path)?;
/// assert_eq!(project_root.file_name().unwrap(), "MyProject");
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn find_project_root(path: impl AsRef<Path>) -> Result<PathBuf> {
let path = path.as_ref();
// Start from the file's directory (or the directory itself)
let mut current = if path.is_file() {
path.parent().ok_or_else(|| {
Error::invalid_format("Cannot get parent directory")
})?
} else {
path
};
// Search upward for Assets/ or _GameAssets/ directory
loop {
let assets_dir = current.join("Assets");
let game_assets_dir = current.join("_GameAssets");
if (assets_dir.exists() && assets_dir.is_dir()) || (game_assets_dir.exists() && game_assets_dir.is_dir()) {
return Ok(current.to_path_buf());
}
// Move up one directory
current = current.parent().ok_or_else(|| {
Error::invalid_format(format!(
"Could not find Unity project root from {}",
path.display()
))
})?;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_cs_meta_file() {
assert!(is_cs_meta_file(Path::new("PlaySFX.cs.meta")));
assert!(is_cs_meta_file(Path::new("Assets/Scripts/PlaySFX.cs.meta")));
assert!(!is_cs_meta_file(Path::new("PlaySFX.cs")));
assert!(!is_cs_meta_file(Path::new("Scene.unity.meta")));
assert!(!is_cs_meta_file(Path::new("texture.png.meta")));
}
#[test]
fn test_extract_class_name() {
// Test standard MonoBehaviour class
let content = r#"
using UnityEngine;
public class PlaySFX : MonoBehaviour
{
public float volume = 1.0f;
}
"#;
let temp_file = std::env::temp_dir().join("test_playsfx.cs");
std::fs::write(&temp_file, content).unwrap();
let result = extract_class_name(&temp_file).unwrap();
assert_eq!(result, Some("PlaySFX".to_string()));
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_extract_class_name_no_public() {
// Test without public modifier
let content = r#"
using UnityEngine;
class MyBehaviour : MonoBehaviour
{
void Start() {}
}
"#;
let temp_file = std::env::temp_dir().join("test_mybehaviour.cs");
std::fs::write(&temp_file, content).unwrap();
let result = extract_class_name(&temp_file).unwrap();
assert_eq!(result, Some("MyBehaviour".to_string()));
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_extract_class_name_no_space() {
// Test with no space after colon
let content = r#"
using UnityEngine;
public class TestScript:MonoBehaviour
{
void Update() {}
}
"#;
let temp_file = std::env::temp_dir().join("test_testscript.cs");
std::fs::write(&temp_file, content).unwrap();
let result = extract_class_name(&temp_file).unwrap();
assert_eq!(result, Some("TestScript".to_string()));
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_extract_class_name_not_monobehaviour() {
// Test class that doesn't inherit from MonoBehaviour
let content = r#"
using UnityEngine;
public class HelperClass
{
public int value;
}
"#;
let temp_file = std::env::temp_dir().join("test_helper.cs");
std::fs::write(&temp_file, content).unwrap();
let result = extract_class_name(&temp_file).unwrap();
assert_eq!(result, None);
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_guid_resolver_manual() {
let mut resolver = GuidResolver::new();
assert!(resolver.is_empty());
let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
resolver.insert(guid, "PlaySFX".to_string());
assert_eq!(resolver.len(), 1);
// Test resolving by string
assert_eq!(
resolver.resolve_class_name("091c537484687e9419460cdcd7038234"),
Some("PlaySFX")
);
// Test resolving by Guid
assert_eq!(
resolver.resolve_class_name(&guid),
Some("PlaySFX")
);
// Test nonexistent GUID
assert_eq!(
resolver.resolve_class_name("00000000000000000000000000000000"),
None
);
}
}