502 lines
15 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|