/*
Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.
*/
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using UnityEditor;
using UnityEditor.AssetImporters;
using UnityEngine;
using Yarn.Compiler;
using Yarn.Utility;
#if USE_UNITY_LOCALIZATION
using UnityEditor.Localization;
using UnityEngine.Localization.Tables;
#endif
#nullable enable
namespace Yarn.Unity.Editor
{
///
/// Imports a .yarnproject file and produces a
/// asset.
///
[ScriptedImporter(7, new[] { "yarnproject" }, 1000), HelpURL("https://docs.yarnspinner.dev/using-yarnspinner-with-unity/importing-yarn-files/yarn-projects")]
[InitializeOnLoad]
public class YarnProjectImporter : ScriptedImporter
{
///
/// A regular expression that matches characters following the start of
/// the string or an underscore.
///
///
/// Used as part of converting variable names from snake_case to
/// CamelCase when generating C# variable source code.
///
private static readonly System.Text.RegularExpressions.Regex SnakeCaseToCamelCase = new System.Text.RegularExpressions.Regex(@"(^|_)(\w)");
///
/// Stores information about a variable declaration found in a compiled
/// Yarn Project.
///
[System.Serializable]
public class SerializedDeclaration
{
internal static List BuiltInTypesList = new List {
Yarn.Types.String,
Yarn.Types.Boolean,
Yarn.Types.Number,
};
///
/// The name of the variable.
///
public string name = "$variable";
///
/// The type of the variable.
///
[UnityEngine.Serialization.FormerlySerializedAs("type")]
public string typeName = Yarn.Types.String.Name;
///
/// The description of the variable.
///
public string? description;
///
/// Whether the variable was explicitly declared (i.e. using a
/// <<declare>> statement), or whether it was
/// implicitly declared through usage.
///
public bool isImplicit;
///
/// A reference to the source .yarn file in which the
/// variable was declared (either implicitly or explicitly.)
///
public TextAsset sourceYarnAsset;
///
/// Initialises a new instance of the SerializedDeclaration class
/// using a .
///
/// A containing
/// information about a Yarn variable.
public SerializedDeclaration(Declaration decl)
{
this.name = decl.Name;
this.typeName = decl.Type.Name;
this.description = decl.Description;
this.isImplicit = decl.IsImplicit;
string sourceScriptPath = GetRelativePath(decl.SourceFileName);
sourceYarnAsset = AssetDatabase.LoadAssetAtPath(sourceScriptPath);
}
}
private class FunctionDeclarationReceiver : IActionRegistration
{
public List FunctionDeclarations = new();
public void AddCommandHandler(string commandName, System.Delegate handler) { }
public void AddCommandHandler(string commandName, MethodInfo methodInfo) { }
public void AddFunction(string name, System.Delegate implementation) { }
public void RegisterFunctionDeclaration(string name, System.Type returnType, System.Type[] parameterTypes)
{
if (Types.TypeMappings.TryGetValue(returnType, out var returnYarnType) == false)
{
Debug.LogError($"Can't register function {name}: can't convert return type {returnType} to a Yarn type");
return;
}
var typeBuilder = new FunctionTypeBuilder().WithReturnType(returnYarnType);
for (int i = 0; i < parameterTypes.Length; i++)
{
System.Type? parameter = parameterTypes[i];
bool isParamsArray = false;
if (i == parameterTypes.Length - 1 && parameter.IsArray)
{
// If this is the last parameter and it is an array,
// treat it as though it were a params array and use the
// type of the array
parameter = parameter.GetElementType();
isParamsArray = true;
}
if (Types.TypeMappings.TryGetValue(parameter, out var parameterYarnType) == false)
{
Debug.LogError($"Can't register function {name}: can't convert parameter {i} type {parameterYarnType} to a Yarn type");
return;
}
if (isParamsArray)
{
typeBuilder = typeBuilder.WithVariadicParameterType(parameterYarnType);
}
else
{
typeBuilder = typeBuilder.WithParameter(parameterYarnType);
}
}
var decl = new DeclarationBuilder()
.WithName(name)
.WithType(typeBuilder.FunctionType)
.Declaration;
this.FunctionDeclarations.Add(decl);
}
public void RemoveCommandHandler(string commandName) { }
public void RemoveFunction(string name) { }
}
///
/// Whether to generate a C# file that contains properties for each variable.
///
///
///
///
public bool generateVariablesSourceFile = false;
///
/// The name of the generated variables storage class.
///
///
///
///
public string variablesClassName = "YarnVariables";
///
/// The namespace of the generated variables storage class.
///
///
///
///
public string? variablesClassNamespace = null;
///
/// The parent class of the generated variables storage class.
///
///
///
///
public string variablesClassParent = typeof(InMemoryVariableStorage).FullName;
///
/// Whether or not this Yarn project's built-in Localizations will use
/// Addressable Assets.
///
/// This value is only used when the project is not configured
/// to use Unity Localization.
public bool useAddressableAssets;
internal static string UnityProjectRootPath => Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
// Scripted importers can't have direct references to scriptable
// objects, so we'll store the reference as a string containing the
// GUID. This is also used to store a reference to a string table if
// Unity Localisation is not installed.
public string? unityLocalisationStringTableCollectionGUID;
public bool UseUnityLocalisationSystem = false;
#if USE_UNITY_LOCALIZATION
private StringTableCollection? _cachedStringTableCollection;
///
/// Gets or sets the Unity Localization string table collection
/// associated with this importer.
///
public StringTableCollection? UnityLocalisationStringTableCollection
{
get
{
if (_cachedStringTableCollection == null)
{
if (!string.IsNullOrEmpty(unityLocalisationStringTableCollectionGUID))
{
var assetPath = AssetDatabase.GUIDToAssetPath(unityLocalisationStringTableCollectionGUID);
if (!string.IsNullOrEmpty(assetPath))
{
_cachedStringTableCollection = AssetDatabase.LoadAssetAtPath(assetPath);
}
}
}
return _cachedStringTableCollection;
}
set
{
if (value == null)
{
unityLocalisationStringTableCollectionGUID = string.Empty;
_cachedStringTableCollection = null;
return;
}
if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(value, out var guid, out long _))
{
throw new System.InvalidOperationException($"String table collection {value.name} has no GUID - is it not an asset stored on disk?");
}
unityLocalisationStringTableCollectionGUID = guid;
_cachedStringTableCollection = value;
}
}
#endif
///
/// Gets a loaded from this importer's asset file,
/// or if an error is encountered.
///
/// A loaded representing the data from
/// the file that this asset importer represents, or .
public Project? GetProject()
{
try
{
return Project.LoadFromFile(this.assetPath);
}
catch (System.Exception)
{
return null;
}
}
///
/// Gets the created the last time that
/// this Yarn Project was imported, if available.
///
public ProjectImportData? ImportData => AssetDatabase.LoadAssetAtPath(this.assetPath);
///
/// Gets a value indicating whether this Yarn Project includes a Yarn
/// script as part of its compilation.
///
/// The importer for a Yarn script.
/// if this Yarn Project uses the file
/// represented by yarnImporter;
/// otherwise.
public bool GetProjectReferencesYarnFile(YarnImporter yarnImporter)
{
try
{
var project = Project.LoadFromFile(this.assetPath);
var scriptFile = yarnImporter.assetPath;
var scriptFileWithEnvironmentSeparators = string.Join(System.IO.Path.DirectorySeparatorChar, scriptFile.Split('/'));
var projectRelativeSourceFiles = project.SourceFiles.Select(GetRelativePath);
return projectRelativeSourceFiles.Contains(scriptFileWithEnvironmentSeparators);
}
catch
{
return false;
}
}
///
/// Gets the Yarn string table produced as a result of compiling the Yarn
/// Project, or if no string table could be produced.
///
private Dictionary? GetYarnStringTable()
{
Project project;
try
{
project = Project.LoadFromFile(this.assetPath);
}
catch (System.Exception)
{
return null;
}
var job = CompilationJob.CreateFromFiles(project.SourceFiles);
job.LanguageVersion = project.FileVersion;
job.CompilationType = CompilationJob.Type.StringsOnly;
try
{
var compilationResult = Compiler.Compiler.Compile(job);
return new(compilationResult.StringTable);
}
catch (System.Exception)
{
return null;
}
}
///
/// Called by Unity to import an asset.
///
/// The context for the asset import
/// operation.
public override void OnImportAsset(AssetImportContext ctx)
{
#if YARNSPINNER_DEBUG
UnityEngine.Profiling.Profiler.enabled = true;
#endif
var projectAsset = ScriptableObject.CreateInstance();
projectAsset.name = Path.GetFileNameWithoutExtension(ctx.assetPath);
// Start by creating the asset - no matter what, we need to
// produce an asset, even if it doesn't contain valid Yarn
// bytecode, so that other assets don't lose their references.
ctx.AddObjectToAsset("Project", projectAsset);
ctx.SetMainObject(projectAsset);
var importData = ScriptableObject.CreateInstance();
importData.name = "Project Import Data";
ctx.AddObjectToAsset("ImportData", importData);
// Attempt to load the JSON project file.
Project project;
try
{
project = Yarn.Compiler.Project.LoadFromFile(ctx.assetPath);
}
catch (System.Exception)
{
var text = File.ReadAllText(ctx.assetPath);
if (text.StartsWith("title:"))
{
// This is an old-style project that needs to be upgraded.
importData.ImportStatus = ProjectImportData.ImportStatusCode.NeedsUpgradeFromV1;
// Log to notify the user that this needs to be done.
ctx.LogImportError($"Yarn Project {ctx.assetPath} is a version 1 Yarn Project, and needs to be upgraded. Select it in the Inspector, and click Upgrade Yarn project.", this);
}
else
{
// We don't know what's going on.
importData.ImportStatus = ProjectImportData.ImportStatusCode.Unknown;
}
// Either way, we can't continue.
return;
}
importData.sourceFilePatterns = new();
importData.sourceFilePatterns.AddRange(project.SourceFilePatterns);
importData.baseLanguageName = project.BaseLanguage;
foreach (var loc in project.Localisation)
{
ProjectImportData.LocalizationEntry locInfo;
// am force unwrapping the strings due to a bug
// the IsNullOrEmpty check on this version of dotnet doesn't propogate it's understanding that the value isn't null
// in a future version this will go away as a concern.
if (string.IsNullOrEmpty(loc.Value.Strings) == false && loc.Value.Strings!.StartsWith("unity:"))
{
// This is an external Localization asset.
locInfo = new ProjectImportData.LocalizationEntry
{
languageID = loc.Key,
isExternal = true,
externalLocalization = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(loc.Value.Strings.Substring("unity:".Length)))
};
}
else
{
var hasStringsFile = project.TryGetStringsPath(loc.Key, out var stringsFilePath);
var hasAssetsFolder = project.TryGetAssetsPath(loc.Key, out var assetsFolderPath);
// This is a reference to a strings table file and a folder
// containing assets.
locInfo = new ProjectImportData.LocalizationEntry
{
languageID = loc.Key,
isExternal = false,
stringsFile = hasStringsFile ? AssetDatabase.LoadAssetAtPath(stringsFilePath) : null,
assetsFolder = hasAssetsFolder ? AssetDatabase.LoadAssetAtPath(assetsFolderPath) : null
};
}
importData.localizations.Add(locInfo);
}
if (project.Localisation.ContainsKey(project.BaseLanguage) == false)
{
importData.localizations.Add(new ProjectImportData.LocalizationEntry
{
languageID = project.BaseLanguage,
});
}
var projectRelativeSourceFiles = project.SourceFiles.Select(GetRelativePath);
CompilationResult compilationResult;
if (projectRelativeSourceFiles.Any())
{
// This project depends upon this script
foreach (var scriptPath in projectRelativeSourceFiles)
{
string guid = AssetDatabase.AssetPathToGUID(scriptPath);
ctx.DependsOnSourceAsset(scriptPath);
importData.yarnFiles.Add(AssetDatabase.LoadAssetAtPath(scriptPath));
}
// Get all function declarations found in the Unity project
var functionDeclarationReceiver = new FunctionDeclarationReceiver();
foreach (var registrationAction in Actions.ActionRegistrationMethods)
{
registrationAction(functionDeclarationReceiver, RegistrationType.Compilation);
}
// Now to compile the scripts associated with this project.
var job = CompilationJob.CreateFromFiles(project.SourceFiles);
job.LanguageVersion = project.FileVersion;
job.Declarations = functionDeclarationReceiver.FunctionDeclarations;
try
{
compilationResult = Compiler.Compiler.Compile(job);
}
catch (System.Exception e)
{
var errorMessage = $"Encountered an unhandled exception during compilation: {e.Message}";
Debug.LogException(e);
ctx.LogImportError(errorMessage, null);
importData.diagnostics.Add(new ProjectImportData.DiagnosticEntry
{
yarnFile = null,
errorMessages = new List { errorMessage },
});
importData.ImportStatus = ProjectImportData.ImportStatusCode.CompilationFailed;
return;
}
var errors = compilationResult.Diagnostics.Where(d => d.Severity == Diagnostic.DiagnosticSeverity.Error);
if (errors.Count() > 0)
{
var errorGroups = errors.GroupBy(e => e.FileName);
foreach (var errorGroup in errorGroups)
{
if (errorGroup.Key == null)
{
// ok so we have no file for some reason
// so these are errors currently not tied to a file
// so we instead need to just log the errors and move on
foreach (var error in errorGroup)
{
ctx.LogImportError($"Error compiling project: {error.Message}");
}
importData.diagnostics.Add(new ProjectImportData.DiagnosticEntry
{
yarnFile = null,
errorMessages = errorGroup.Select(e => e.Message).ToList(),
});
continue;
}
// the compiler currently returns (unknown) for situations where an error is defined in a file that the compiler can't access
// this is not ideal and something to fix, but for now we will handle it by reporting the issue in a different way
if (errorGroup.Key == "(unknown)")
{
foreach (var error in errorGroup)
{
ctx.LogImportError($"Error compiling the project: {error.Message}");
}
var errorMessages = errorGroup.Select(e => e.ToString());
importData.diagnostics.Add(new ProjectImportData.DiagnosticEntry
{
yarnFile = null,
errorMessages = errorMessages.ToList(),
});
continue;
}
try
{
var relativePath = GetRelativePath(errorGroup.Key);
var asset = AssetDatabase.LoadAssetAtPath