// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Core.Helper
{
using System;
using System.IO;
using System.Runtime.CompilerServices;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
///
/// Helpers for creating and resolving directories in Unity projects.
///
///
/// Editor paths are expected to be under the Assets/ folder. Provides conversions between absolute and Unity-relative paths.
///
public static class DirectoryHelper
{
///
/// Ensures a directory exists in the project. In the editor, the directory must be inside Assets/ and is created via AssetDatabase.
///
/// Unity relative path (e.g., Assets/MyFolder/Sub).
/// Thrown if attempting to create a directory outside of Assets/ in the editor.
public static void EnsureDirectoryExists(string relativeDirectoryPath)
{
if (string.IsNullOrWhiteSpace(relativeDirectoryPath))
{
return;
}
// Normalize path separators to forward slashes for cross-platform consistency
relativeDirectoryPath = relativeDirectoryPath.SanitizePath();
#if UNITY_EDITOR
// Case-insensitive check for Assets/ prefix to handle Windows paths
if (!relativeDirectoryPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
if (relativeDirectoryPath.Equals("Assets", StringComparison.OrdinalIgnoreCase))
{
return;
}
Debug.LogError(
$"Attempted to create directory outside of Assets: '{relativeDirectoryPath}'"
);
throw new ArgumentException(
"Cannot create directories outside the Assets folder using AssetDatabase.",
nameof(relativeDirectoryPath)
);
}
// First, ensure the folder exists on disk. This prevents Unity's internal
// "Moving file failed" modal dialog when CreateAsset tries to move a temp file
// to a destination folder that doesn't exist.
string projectRoot = Path.GetDirectoryName(Application.dataPath);
string absoluteDirectory = null;
if (!string.IsNullOrEmpty(projectRoot))
{
absoluteDirectory = Path.Combine(projectRoot, relativeDirectoryPath);
try
{
if (!Directory.Exists(absoluteDirectory))
{
Directory.CreateDirectory(absoluteDirectory);
}
}
catch (Exception e)
{
Debug.LogWarning(
$"DirectoryHelper: Failed to create directory on disk '{absoluteDirectory}': {e.Message}"
);
}
}
// Check both AssetDatabase and disk to avoid creating duplicate folders.
// AssetDatabase.IsValidFolder may not immediately reflect folders created
// via Directory.CreateDirectory until a refresh, but AssetDatabase.CreateFolder
// will create a duplicate folder (e.g., "tree 1") if the folder exists on disk.
if (AssetDatabase.IsValidFolder(relativeDirectoryPath))
{
return;
}
// If the directory already exists on disk, we need to make the AssetDatabase aware
// of it to prevent duplicate folder creation (e.g., "tree 1", "tree 2").
bool directoryExistsOnDisk =
!string.IsNullOrEmpty(absoluteDirectory) && Directory.Exists(absoluteDirectory);
if (directoryExistsOnDisk)
{
// Refresh the parent folder to make Unity aware of the new folder on disk.
// This is more targeted than a full AssetDatabase.Refresh().
string parentForRefresh = Path.GetDirectoryName(relativeDirectoryPath)
.SanitizePath();
if (!string.IsNullOrWhiteSpace(parentForRefresh))
{
AssetDatabase.ImportAsset(
parentForRefresh,
ImportAssetOptions.ForceSynchronousImport
| ImportAssetOptions.ImportRecursive
);
}
// Re-check after import
if (AssetDatabase.IsValidFolder(relativeDirectoryPath))
{
return;
}
}
string parentPath = Path.GetDirectoryName(relativeDirectoryPath).SanitizePath();
if (
string.IsNullOrWhiteSpace(parentPath)
|| parentPath.Equals("Assets", StringComparison.OrdinalIgnoreCase)
)
{
string folderNameToCreate = Path.GetFileName(relativeDirectoryPath);
if (
!string.IsNullOrWhiteSpace(folderNameToCreate)
&& !AssetDatabase.IsValidFolder(relativeDirectoryPath)
&& !directoryExistsOnDisk
)
{
AssetDatabase.CreateFolder("Assets", folderNameToCreate);
}
return;
}
EnsureDirectoryExists(parentPath);
string currentFolderName = Path.GetFileName(relativeDirectoryPath);
if (
!string.IsNullOrWhiteSpace(currentFolderName)
&& !AssetDatabase.IsValidFolder(relativeDirectoryPath)
&& !directoryExistsOnDisk
)
{
AssetDatabase.CreateFolder(parentPath, currentFolderName);
Debug.Log($"Created folder: {relativeDirectoryPath}");
}
#else
Directory.CreateDirectory(relativeDirectoryPath);
#endif
}
///
/// Gets the directory of the calling source file (useful for locating package-relative content).
///
public static string GetCallerScriptDirectory([CallerFilePath] string sourceFilePath = "")
{
return string.IsNullOrWhiteSpace(sourceFilePath)
? string.Empty
: Path.GetDirectoryName(sourceFilePath);
}
///
/// Walks up the directory tree until a folder containing package.json is found.
///
public static string FindPackageRootPath(string startDirectory)
{
return FindRootPath(
startDirectory,
path => File.Exists(Path.Combine(path, "package.json"))
);
}
///
/// Walks up the directory tree until returns true.
///
public static string FindRootPath(
string startDirectory,
Func terminalCondition
)
{
string currentPath = startDirectory;
while (!string.IsNullOrWhiteSpace(currentPath))
{
try
{
if (terminalCondition(currentPath))
{
DirectoryInfo directoryInfo = new(currentPath);
if (!directoryInfo.Exists)
{
return currentPath;
}
return directoryInfo.FullName;
}
}
catch
{
return currentPath;
}
try
{
string parentPath = Path.GetDirectoryName(currentPath);
if (string.Equals(parentPath, currentPath, StringComparison.OrdinalIgnoreCase))
{
break;
}
currentPath = parentPath;
}
catch
{
return currentPath;
}
}
return string.Empty;
}
///
/// Resolves an absolute path to a directory relative to the package root and returns a Unity-relative path.
///
public static string FindAbsolutePathToDirectory(string directory)
{
string scriptDirectory = GetCallerScriptDirectory();
if (string.IsNullOrEmpty(scriptDirectory))
{
return string.Empty;
}
string packageRootAbsolute = FindPackageRootPath(scriptDirectory);
if (string.IsNullOrEmpty(packageRootAbsolute))
{
return string.Empty;
}
string targetPathAbsolute = Path.Combine(
packageRootAbsolute,
directory.Replace('/', Path.DirectorySeparatorChar)
);
return AbsoluteToUnityRelativePath(targetPathAbsolute);
}
///
/// Converts an absolute OS path to a Unity-relative path (e.g., Assets/...), or empty string if outside the project.
///
public static string AbsoluteToUnityRelativePath(string absolutePath)
{
if (string.IsNullOrWhiteSpace(absolutePath))
{
return string.Empty;
}
absolutePath = absolutePath.SanitizePath();
string projectRoot = Application.dataPath.SanitizePath();
projectRoot = Path.GetDirectoryName(projectRoot)?.SanitizePath();
if (string.IsNullOrWhiteSpace(projectRoot))
{
return string.Empty;
}
if (absolutePath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
{
// +1 to remove the leading slash only if projectRoot doesn't end with one
int startIndex = projectRoot.EndsWith("/", StringComparison.OrdinalIgnoreCase)
? projectRoot.Length
: projectRoot.Length + 1;
return absolutePath.Length > startIndex ? absolutePath[startIndex..] : string.Empty;
}
return string.Empty;
}
///
/// Converts an absolute OS path to a Unity-loadable path that works for Assets, Packages, and Library/PackageCache.
/// This method handles:
///
/// - Assets/ - Returns the relative path as-is
/// - Packages/ - Returns the path prefixed with "Packages/"
/// - Library/PackageCache/ - Converts to "Packages/{packageId}/" format
///
///
/// The absolute path to convert.
/// The package identifier (e.g., "com.wallstop-studios.unity-helpers") used for Library/PackageCache resolution.
/// A Unity-loadable path, or empty string if the path cannot be resolved.
public static string AbsoluteToUnityLoadablePath(string absolutePath, string packageId)
{
if (string.IsNullOrWhiteSpace(absolutePath))
{
return string.Empty;
}
absolutePath = absolutePath.SanitizePath();
// First try the standard Unity relative path (works for Assets/)
string relativePath = AbsoluteToUnityRelativePath(absolutePath);
if (!string.IsNullOrEmpty(relativePath))
{
return relativePath;
}
// Check if path is in Library/PackageCache
const string packageCacheMarker = "Library/PackageCache/";
int packageCacheIndex = absolutePath.IndexOf(
packageCacheMarker,
StringComparison.OrdinalIgnoreCase
);
if (packageCacheIndex >= 0)
{
// Extract the portion after "Library/PackageCache/{packageFolder}/"
string afterCache = absolutePath[(packageCacheIndex + packageCacheMarker.Length)..];
// The package folder may have version suffix like "com.package@1.0.0"
// Find the first separator after the package folder name
int firstSlash = afterCache.IndexOf('/');
if (firstSlash > 0)
{
string pathInsidePackage = afterCache[(firstSlash + 1)..];
if (!string.IsNullOrEmpty(packageId))
{
return $"Packages/{packageId}/{pathInsidePackage}";
}
}
return string.Empty;
}
// Check if path already contains "Packages/" segment (embedded packages, local packages)
const string packagesMarker = "/Packages/";
int packagesIndex = absolutePath.IndexOf(
packagesMarker,
StringComparison.OrdinalIgnoreCase
);
if (packagesIndex >= 0)
{
return "Packages/" + absolutePath[(packagesIndex + packagesMarker.Length)..];
}
return string.Empty;
}
///
/// Resolves a path relative to the package root (identified by package.json) to a Unity-loadable path.
/// This method works regardless of where the package is installed (Assets, Packages, or Library/PackageCache).
///
/// The path relative to the package root (e.g., "Editor/Styles/MyStyle.uss").
/// Leave as default to use the calling script's path. This parameter is automatically filled by the compiler.
/// A Unity-loadable path that can be used with AssetDatabase.LoadAssetAtPath.
public static string ResolvePackageAssetPath(
string relativePath,
[CallerFilePath] string sourceFilePath = ""
)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return string.Empty;
}
string scriptDirectory = string.IsNullOrWhiteSpace(sourceFilePath)
? string.Empty
: Path.GetDirectoryName(sourceFilePath);
if (string.IsNullOrEmpty(scriptDirectory))
{
return string.Empty;
}
string packageRootAbsolute = FindPackageRootPath(scriptDirectory);
if (string.IsNullOrEmpty(packageRootAbsolute))
{
return string.Empty;
}
// Read package.json to get the package ID
string packageId = ReadPackageIdFromRoot(packageRootAbsolute);
string targetPathAbsolute = Path.Combine(
packageRootAbsolute,
relativePath.Replace('/', Path.DirectorySeparatorChar)
)
.SanitizePath();
return AbsoluteToUnityLoadablePath(targetPathAbsolute, packageId);
}
///
/// Reads the package ID ("name" field) from a package.json file in the specified directory.
///
/// The absolute path to the package root containing package.json.
/// The package ID, or empty string if not found or could not be read.
public static string ReadPackageIdFromRoot(string packageRootPath)
{
if (string.IsNullOrWhiteSpace(packageRootPath))
{
return string.Empty;
}
string packageJsonPath = Path.Combine(packageRootPath, "package.json");
if (!File.Exists(packageJsonPath))
{
return string.Empty;
}
try
{
string json = File.ReadAllText(packageJsonPath);
// Simple parsing - look for "name": "value"
// This avoids dependency on JSON libraries for runtime code
const string nameKey = "\"name\"";
int nameIndex = json.IndexOf(nameKey, StringComparison.Ordinal);
if (nameIndex < 0)
{
return string.Empty;
}
int colonIndex = json.IndexOf(':', nameIndex + nameKey.Length);
if (colonIndex < 0)
{
return string.Empty;
}
int firstQuote = json.IndexOf('"', colonIndex + 1);
if (firstQuote < 0)
{
return string.Empty;
}
int secondQuote = json.IndexOf('"', firstQuote + 1);
if (secondQuote < 0)
{
return string.Empty;
}
return json.Substring(firstQuote + 1, secondQuote - firstQuote - 1);
}
catch
{
return string.Empty;
}
}
}
}