#pragma warning disable SA1512,SA1124 // Single-line comments should not be followed by blank line
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using JetBrains.Annotations;
using NugetForUnity.Configuration;
using NugetForUnity.Helper;
using NugetForUnity.Models;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
#region No ReShaper
// ReSharper disable All
// needed because 'JetBrains.Annotations.NotNull' and 'System.Diagnostics.CodeAnalysis.NotNull' collide if this file is compiled with a never version of Unity / C#
using SuppressMessageAttribute = System.Diagnostics.CodeAnalysis.SuppressMessageAttribute;
// ReSharper restore All
#endregion
#pragma warning restore SA1512,SA1124 // Single-line comments should not be followed by blank line
namespace NugetForUnity
{
///
/// Hook into the import pipeline to change NuGet package related assets after / before they are imported.
///
public class NugetAssetPostprocessor : AssetPostprocessor
{
///
/// Folder used to store Roslyn-Analyzers inside NuGet packages.
///
private const string AnalyzersFolderName = "analyzers";
///
/// Used to mark an asset as already processed by this class.
///
private const string ProcessedLabel = "NuGetForUnity";
///
/// Used to let unity know an asset is a Roslyn-Analyzer.
///
private const string RoslynAnalyzerLabel = "RoslynAnalyzer";
///
/// Name of the root folder containing dotnet analyzers.
///
private static readonly string AnalyzersRoslynVersionsFolderName = Path.Combine(AnalyzersFolderName, "dotnet");
///
/// Prefix for the path of dll's of roslyn analyzers.
///
private static readonly string AnalyzersRoslynVersionSubFolderPrefix = Path.Combine(AnalyzersRoslynVersionsFolderName, "roslyn");
private static readonly List NonObsoleteBuildTargets = typeof(BuildTarget).GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(fieldInfo => fieldInfo.GetCustomAttribute(typeof(ObsoleteAttribute)) == null)
.Select(fieldInfo => (BuildTarget)fieldInfo.GetValue(null))
.ToList();
private static readonly PropertyInfo PluginImporterIsExplicitlyReferencedProperty =
typeof(PluginImporter).GetProperty("IsExplicitlyReferenced", BindingFlags.Instance | BindingFlags.NonPublic) ??
throw new InvalidOperationException("Can't find property 'IsExplicitlyReferenced'.");
///
/// Called when the asset database finishes importing assets.
/// We use it to check if packages.config has been changed and if so, we want to restore packages.
///
/// The list of assets that were imported.
/// The list of assets that were deleted.
/// The list of assets that were moved.
/// The list of paths of assets that were moved.
internal static void OnPostprocessAllAssets(
string[] importedAssets,
string[] deletedAssets,
string[] movedAssets,
string[] movedFromAssetPaths)
{
// currently only importedAssets are important for us, so if there are none, we do nothing
if (importedAssets.Length == 0)
{
return;
}
var packagesConfigFilePath = ConfigurationManager.NugetConfigFile.PackagesConfigFilePath;
var foundPackagesConfigAsset = importedAssets.Any(
importedAsset => Path.GetFullPath(importedAsset).Equals(packagesConfigFilePath, StringComparison.Ordinal));
if (!foundPackagesConfigAsset)
{
return;
}
InstalledPackagesManager.ReloadPackagesConfig();
PackageRestorer.Restore(ConfigurationManager.NugetConfigFile.SlimRestore);
}
[NotNull]
private static IEnumerable<(string AssetType, string AssetPath, ResultStatus Status)> HandleAsset(
[NotNull] string projectRelativeAssetPath,
[NotNull] string absoluteRepositoryPath,
bool reimport)
{
var assetFileName = Path.GetFileName(projectRelativeAssetPath);
if (assetFileName.Equals(NugetConfigFile.FileName, StringComparison.OrdinalIgnoreCase) ||
assetFileName.Equals(PackagesConfigFile.FileName, StringComparison.OrdinalIgnoreCase))
{
// Not sure why but for .config files we need to re-import always. I think this is because they are treated as native plug-ins.
var result = ModifyImportSettingsOfConfigurationFile(projectRelativeAssetPath, true);
yield return ("ConfigurationFile", projectRelativeAssetPath, result);
yield break;
}
var absoluteAssetPath = Path.GetFullPath(Path.Combine(UnityPathHelper.AbsoluteProjectPath, projectRelativeAssetPath));
if (!AssetIsDllInsideNuGetRepository(absoluteAssetPath, absoluteRepositoryPath))
{
yield break;
}
var assetPathRelativeToRepository = absoluteAssetPath.Substring(absoluteRepositoryPath.Length);
// the first component is the package name with version number
var assetPathComponents = GetPathComponents(assetPathRelativeToRepository);
var packageNameParts = assetPathComponents.Length > 0 ? assetPathComponents[0].Split('.') : Array.Empty();
var packageName = string.Join(".", packageNameParts.TakeWhile(part => !part.All(char.IsDigit)));
var packageConfig = InstalledPackagesManager.PackagesConfigFile.GetPackageConfigurationById(packageName);
if (!GetPluginImporter(projectRelativeAssetPath, out var plugin))
{
yield return ("GetPluginImporter", projectRelativeAssetPath, ResultStatus.Failure);
yield break;
}
if (AlreadyProcessed(plugin))
{
yield return ("AlreadyProcessed", projectRelativeAssetPath, ResultStatus.AlreadyProcessed);
yield break;
}
if (packageConfig != null)
{
ModifyImportSettingsOfGeneralPlugin(packageConfig, plugin, reimport);
yield return ("GeneralSetting", projectRelativeAssetPath, ResultStatus.Success);
}
if (assetPathComponents.Length > 1 && assetPathComponents[1].Equals(AnalyzersFolderName, StringComparison.OrdinalIgnoreCase))
{
ModifyImportSettingsOfRoslynAnalyzer(plugin, reimport);
yield return ("RoslynAnalyzer", projectRelativeAssetPath, ResultStatus.Success);
yield break;
}
if (assetPathComponents.Length > 0 &&
UnityPreImportedLibraryResolver.GetAlreadyImportedEditorOnlyLibraries()
.Contains(Path.GetFileNameWithoutExtension(assetPathComponents[assetPathComponents.Length - 1])))
{
ModifyImportSettingsOfPlayerOnly(plugin, reimport);
yield return ("PlayerOnly", projectRelativeAssetPath, ResultStatus.Success);
}
}
[NotNull]
private static string[] GetPathComponents([NotNull] string path)
{
return path.Split(Path.DirectorySeparatorChar);
}
private static bool AssetIsDllInsideNuGetRepository([NotNull] string absoluteAssetPath, [NotNull] string absoluteRepositoryPath)
{
return absoluteAssetPath.StartsWith(absoluteRepositoryPath, PathHelper.PathComparisonType) &&
absoluteAssetPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) &&
File.Exists(absoluteAssetPath);
}
///
/// Gets the absolute path where NuGetForUnity restores NuGet packages, with trailing directory separator.
///
/// The absolute path where NuGetForUnity restores NuGet packages, with trailing directory separator.
[NotNull]
private static string GetNuGetRepositoryPath()
{
return ConfigurationManager.NugetConfigFile.RepositoryPath + Path.DirectorySeparatorChar;
}
[CanBeNull]
private static NugetPackageVersion GetMaxSupportedRoslynVersion()
{
var unityVersion = UnityVersion.Current;
if (unityVersion >= new UnityVersion(2022, 3, 12, 'f', 1))
{
return new NugetPackageVersion("4.3.0");
}
if (unityVersion >= new UnityVersion(2022, 2, 1, 'f', 1))
{
return new NugetPackageVersion("4.1.0");
}
if (unityVersion >= new UnityVersion(2021, 2, 1, 'f', 1))
{
return new NugetPackageVersion("3.8.0");
}
return null;
}
[CanBeNull]
private static NugetPackageVersion GetRoslynVersionNumberFromAnalyzerPath(string analyzerAssetPath)
{
var versionPrefixStartIndex = analyzerAssetPath.IndexOf(AnalyzersRoslynVersionSubFolderPrefix, StringComparison.Ordinal);
if (versionPrefixStartIndex < 0)
{
return null;
}
var versionStartIndex = versionPrefixStartIndex + AnalyzersRoslynVersionSubFolderPrefix.Length;
var separatorIndex = analyzerAssetPath.IndexOf(Path.DirectorySeparatorChar, versionStartIndex);
var versionLength = separatorIndex >= 0 ? separatorIndex - versionStartIndex : analyzerAssetPath.Length - versionStartIndex;
var versionString = analyzerAssetPath.Substring(versionStartIndex, versionLength);
return string.IsNullOrEmpty(versionString) ? null : new NugetPackageVersion(versionString);
}
private static void ModifyImportSettingsOfRoslynAnalyzer([NotNull] PluginImporter plugin, bool reimport)
{
plugin.SetCompatibleWithAnyPlatform(false);
plugin.SetCompatibleWithEditor(false);
foreach (var platform in NonObsoleteBuildTargets)
{
plugin.SetExcludeFromAnyPlatform(platform, false);
}
var enableRoslynAnalyzer = true;
// The nuget package can contain analyzers for multiple Roslyn versions.
// In that case, for the same package, the most recent version must be chosen out of those available for the current Unity version.
var assetPath = Path.GetFullPath(plugin.assetPath);
var assetRoslynVersion = GetRoslynVersionNumberFromAnalyzerPath(assetPath);
if (assetRoslynVersion != null)
{
var maxSupportedRoslynVersion = GetMaxSupportedRoslynVersion();
if (maxSupportedRoslynVersion == null)
{
// the current unity version doesn't support roslyn analyzers
enableRoslynAnalyzer = false;
}
else
{
var versionPrefixIndex = assetPath.IndexOf(AnalyzersRoslynVersionsFolderName, StringComparison.Ordinal);
var analyzerVersionsRootDirectoryPath = Path.Combine(
assetPath.Substring(0, versionPrefixIndex),
AnalyzersRoslynVersionsFolderName);
var analyzersFolders = Directory.EnumerateDirectories(analyzerVersionsRootDirectoryPath);
var allEnabledRoslynVersions = analyzersFolders.Select(GetRoslynVersionNumberFromAnalyzerPath)
.Where(version => version != null && version.CompareTo(maxSupportedRoslynVersion) <= 0)
.ToArray();
// If most recent valid analyzers exist elsewhere, don't add label `RoslynAnalyzer`
var maxMatchingVersion = allEnabledRoslynVersions.Max();
if (!allEnabledRoslynVersions.Contains(assetRoslynVersion) || assetRoslynVersion < maxMatchingVersion)
{
enableRoslynAnalyzer = false;
}
}
}
AssetDatabase.SetLabels(plugin, enableRoslynAnalyzer ? new[] { RoslynAnalyzerLabel, ProcessedLabel } : new[] { ProcessedLabel });
if (reimport)
{
// Persist and reload the change to the meta file
plugin.SaveAndReimport();
}
NugetLogger.LogVerbose("Configured asset '{0}' as a Roslyn-Analyzer.", plugin.assetPath);
}
private static void ModifyImportSettingsOfGeneralPlugin([NotNull] PackageConfig packageConfig, [NotNull] PluginImporter plugin, bool reimport)
{
PluginImporterIsExplicitlyReferencedProperty.SetValue(plugin, !packageConfig.AutoReferenced);
AssetDatabase.SetLabels(plugin, new[] { ProcessedLabel });
if (reimport)
{
// Persist and reload the change to the meta file
plugin.SaveAndReimport();
}
}
///
/// Changes the importer settings to exclude it from editor.
/// This is needed for assemblies that are imported by unity but only in edit-mode so we can only import it in play-mode.
/// .
///
/// The asset to edit.
/// Whether or not to save and re-import the file.
private static void ModifyImportSettingsOfPlayerOnly([NotNull] PluginImporter plugin, bool reimport)
{
plugin.SetCompatibleWithAnyPlatform(true);
plugin.SetExcludeEditorFromAnyPlatform(true);
AssetDatabase.SetLabels(plugin, new[] { ProcessedLabel });
if (reimport)
{
// Persist and reload the change to the meta file
plugin.SaveAndReimport();
}
NugetLogger.LogVerbose("Configured asset '{0}' as a Player Only.", plugin.assetPath);
}
///
/// Changes the importer settings to disables the export to WSA Platform setting.
///
/// The path to the .config file.
/// Whether or not to save and re-import the file.
private static ResultStatus ModifyImportSettingsOfConfigurationFile([NotNull] string analyzerAssetPath, bool reimport)
{
if (!GetPluginImporter(analyzerAssetPath, out var plugin))
{
return ResultStatus.Failure;
}
if (AlreadyProcessed(plugin))
{
return ResultStatus.AlreadyProcessed;
}
plugin.SetCompatibleWithPlatform(BuildTarget.WSAPlayer, false);
AssetDatabase.SetLabels(plugin, new[] { ProcessedLabel });
if (reimport)
{
// Persist and reload the change to the meta file
plugin.SaveAndReimport();
}
NugetLogger.LogVerbose("Disabling WSA platform on asset settings for {0}", plugin.assetPath);
return ResultStatus.Success;
}
///
/// Logs the aggregated result of the processing of the assets.
///
/// The aggregated result status.
private static void LogResults([NotNull] IEnumerable<(string AssetType, string AssetPath, ResultStatus Status)> results)
{
var grouped = results.GroupBy(result => result.Status);
foreach (var groupEntry in grouped)
{
if (groupEntry.Key == ResultStatus.Success && ConfigurationManager.NugetConfigFile.Verbose)
{
NugetLogger.LogVerbose(
"NuGetForUnity: successfully processed: {0}",
string.Join(",", groupEntry.Select(asset => $"'{asset.AssetPath}' ({asset.AssetType})")));
}
if (groupEntry.Key == ResultStatus.Failure)
{
Debug.LogError(
$"NuGetForUnity: failed to process: {string.Join(",", groupEntry.Select(asset => $"'{asset.AssetPath}' ({asset.AssetType})"))}");
}
}
}
///
/// Given the load the for the asset it's associated
/// with.
///
/// Path to the assets.
/// The loaded PluginImporter.
/// True if the plug-in importer for the asset could be loaded.
private static bool GetPluginImporter([NotNull] string assetFilePath, out PluginImporter plugin)
{
var assetPath = assetFilePath;
plugin = AssetImporter.GetAtPath(assetPath) as PluginImporter;
if (plugin == null)
{
Debug.LogWarning($"Failed to import plug-in at {assetPath}");
return false;
}
NugetLogger.LogVerbose("Plug-in loaded for file: {0}", assetPath);
return true;
}
///
/// Check the labels on the asset to determine if we've already processed it. Calls to
/// trigger call backs to this class to occur so not checking
/// if an asset has already been processed triggers an infinite loop that Unity detects and logs as an error.
///
/// Asset to check.
/// True if already processed.
private static bool AlreadyProcessed([NotNull] Object asset)
{
return AssetDatabase.GetLabels(asset).Contains(ProcessedLabel);
}
///
/// Get informed about a new asset that is added.
/// This is called before unity tried to import a asset but the is already created
/// so we can change the import settings before unity throws errors about incompatibility etc..
///
/// Currently we change the import settings of:
///
///
/// -
/// Roslyn-Analyzers: are marked so unity knows that the *.dll's are analyzers and treats them accordingly.
///
/// -
/// NuGetForUnity config files: so they are not exported to WSA
///
/// -
/// PlayerOnly assemblies: configure the assemblies to be excluded form edit-mode
///
/// -
/// Normal assemblies (*.dll): apply the IsExplicitlyReferenced setting read from the package.config.
///
///
///
[UsedImplicitly]
[SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Called by Unity.")]
private void OnPreprocessAsset()
{
var absoluteRepositoryPath = GetNuGetRepositoryPath();
var results = HandleAsset(assetPath, absoluteRepositoryPath, false);
LogResults(results);
}
[SuppressMessage(
"StyleCop.CSharp.OrderingRules",
"SA1201:Elements should appear in the correct order",
Justification = "We like private enums at the botom of the file.")]
private enum ResultStatus
{
Success,
Failure,
AlreadyProcessed,
}
}
}