Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

using System;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;

namespace UnityVisualStudioSolutionGenerator.Tests
Expand Down Expand Up @@ -118,5 +120,29 @@ public void SourceCodeFilesHandlerMultiFileTest(string testSourceCode, string ex
File.Delete(testSourceCode2FilePath);
}
}

[Test]
public void TestByteOrderMaskHandling([Values] bool withByteOrderMask, [Values] bool alreadyHasNullable)
{
const string testSourceCode1FilePath = "TestSourceCode.cs";
var utf8Encoding = new UTF8Encoding(withByteOrderMask);
try
{
const string contentWithNullable = "#nullable enable\n\npublic class Test\n{\n}\n";
File.WriteAllText(testSourceCode1FilePath, alreadyHasNullable ? contentWithNullable : "public class Test\n{\n}\n", utf8Encoding);
SourceCodeFilesHandler.AddNullableToFile(testSourceCode1FilePath);
var expected = utf8Encoding.GetBytes(contentWithNullable);
if (withByteOrderMask)
{
expected = Encoding.UTF8.GetPreamble().Concat(expected).ToArray();
}

Assert.That(File.ReadAllBytes(testSourceCode1FilePath), Is.EqualTo(expected));
}
finally
{
File.Delete(testSourceCode1FilePath);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,34 @@ public static bool OpenSolutionEnabled()
return GeneratorSettings.IsVisualStudioEditorEnabled();
}

/// <summary>
/// Regenerates the Visual Studio solution file and the C# project files.
/// </summary>
[MenuItem("Visual Studio/Generate Solution", priority = 1)]
public static void SyncSolution()
{
VisualStudioAssetPostprocessor.MarkAsChanged();
CodeEditor.CurrentEditor.SyncAll();
}

/// <summary>
/// Returns whether or not the <see cref="SyncSolution" /> menu item should be enabled.
/// </summary>
/// <returns>True if the menu item should be enabled, False otherwise.</returns>
[MenuItem("Visual Studio/Generate Solution", true)]
public static bool SyncSolutionEnabled()
{
return GeneratorSettings.IsSolutionGeneratorEnabled();
}

/// <summary>
/// Regenerates the Visual Studio solution file and the C# project files as SDK-style projects.
/// </summary>
[MenuItem("Visual Studio/Generate Solution (Sdk-Style)", priority = 1)]
[MenuItem("Visual Studio/Generate Solution (Sdk-Style)", priority = 2)]
public static void SyncSolutionSdkStyle()
{
GeneratorSettings.GenerateSdkStyleProjects = true;
CodeEditor.CurrentEditor.SyncAll();
SyncSolution();
}

/// <summary>
Expand All @@ -53,11 +73,11 @@ public static bool SyncSolutionSdkStyleEnabled()
/// <summary>
/// Regenerates the Visual Studio solution file and the C# project files as Legacy-style projects.
/// </summary>
[MenuItem("Visual Studio/Generate Solution (Legacy-Style)", priority = 2)]
[MenuItem("Visual Studio/Generate Solution (Legacy-Style)", priority = 3)]
public static void SyncSolutionLegacyStyle()
{
GeneratorSettings.GenerateSdkStyleProjects = false;
CodeEditor.CurrentEditor.SyncAll();
SyncSolution();
}

/// <summary>
Expand All @@ -73,7 +93,7 @@ public static bool SyncSolutionLegacyStyleEnabled()
/// <summary>
/// Checks all '.cs' files to contain '#nullable enable' at the start.
/// </summary>
[MenuItem("Visual Studio/Apply enable nullable to all files", priority = 3)]
[MenuItem("Visual Studio/Apply enable nullable to all files", priority = 4)]
public static void EnableNullableOnAllFiles()
{
SourceCodeFilesHandler.EnableNullableOnAllFiles(SolutionFile.CurrentProjectSolution);
Expand All @@ -92,7 +112,7 @@ public static bool EnableNullableOnAllFilesEnabled()
/// <summary>
/// Opens the solution generation preferences page.
/// </summary>
[MenuItem("Visual Studio/Preferences", priority = 4)]
[MenuItem("Visual Studio/Preferences", priority = 5)]
public static void OpenPreferences()
{
SettingsService.OpenProjectSettings(GeneratorSettingsProvider.PreferencesPath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ namespace UnityVisualStudioSolutionGenerator
/// </summary>
public abstract class ProjectFileGeneratorBase : ProjectFileParser
{
private string? assemblyDefinitionFilePath;

/// <summary>
/// Initializes a new instance of the <see cref="ProjectFileGeneratorBase" /> class.
/// </summary>
Expand All @@ -33,45 +31,8 @@ protected ProjectFileGeneratorBase(string filePath)
/// </summary>
protected string ProjectName { get; }

/// <summary>
/// Gets the path of the assembly definition file associated with this project. It is extracted from the project file generated by Unity.
/// </summary>
protected string AssemblyDefinitionFilePath
{
get
{
assemblyDefinitionFilePath ??= ExtractAssemblyDefinitionFilePath();
return assemblyDefinitionFilePath;
}
}

private string ProjectOutputFilePath => Path.ChangeExtension(AssemblyDefinitionFilePath, ".csproj");

/// <summary>
/// Determines whether the project is from the package cache.
/// </summary>
/// <returns>True if the project file is from a package, False otherwise.</returns>
public bool IsProjectFromPackageCache()
{
if (assemblyDefinitionFilePath is not null)
{
return IsProjectFileFromPackageCache(assemblyDefinitionFilePath);
}

var anyProjectFilePath = ProjectElement.Descendants(XmlNamespace + "None")
.Concat(ProjectElement.Descendants(XmlNamespace + "Compile"))
.Select(element => element.Attribute("Include")?.Value)
.FirstOrDefault(itemPath => itemPath is not null);

if (anyProjectFilePath is not null)
{
return IsProjectFileFromPackageCache(Path.GetFullPath(anyProjectFilePath, DirectoryPath));
}

LogHelper.LogWarning($"The project file: {FilePath} has no content so skipping it.");
return true;
}

/// <summary>
/// Writes the project file to disk inside <see cref="ProjectOutputFilePath" />.
/// </summary>
Expand Down Expand Up @@ -114,7 +75,8 @@ internal static ProjectFileGeneratorBase Create(string filePath)
}

/// <summary>
/// Determines the root directory that contains all project files, the directory that contains the <see cref="AssemblyDefinitionFilePath" />.
/// Determines the root directory that contains all project files, the directory that contains the
/// <see cref="ProjectFileParser.AssemblyDefinitionFilePath" />.
/// </summary>
/// <returns>The absolute path to the project root directory.</returns>
internal string GetProjectRootDirectoryPath()
Expand Down Expand Up @@ -223,21 +185,6 @@ private static bool MatchesPattern(string value, IReadOnlyList<string> pattern)
return true;
}

private string ExtractAssemblyDefinitionFilePath()
{
var assemblyDefinitionFilePaths = ProjectElement.Descendants(XmlNamespace + "None")
.Select(noneElement => noneElement.Attribute("Include")?.Value)
.Where(noneItemPath => noneItemPath?.EndsWith(".asmdef", StringComparison.OrdinalIgnoreCase) == true)
.ToList();
if (assemblyDefinitionFilePaths.Count != 1)
{
throw new InvalidOperationException(
$"The csproj file '{FilePath}' need to have exactly one '.asmdef' file but it has ['{string.Join("', '", assemblyDefinitionFilePaths)}']");
}

return Path.GetFullPath(assemblyDefinitionFilePaths[0], DirectoryPath);
}

private void RemoveExcludedAnalyzers()
{
var excludedAnalyzers = GeneratorSettings.ExcludedAnalyzers;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ namespace UnityVisualStudioSolutionGenerator
/// </summary>
public class ProjectFileParser
{
private string? assemblyDefinitionFilePath;

/// <summary>
/// Initializes a new instance of the <see cref="ProjectFileParser" /> class.
/// </summary>
Expand All @@ -34,6 +36,18 @@ public ProjectFileParser(string filePath)
Path.GetDirectoryName(filePath) ?? throw new InvalidOperationException($"Failed to get directory path of '{filePath}'"));
}

/// <summary>
/// Gets the path of the assembly definition file associated with this project. It is extracted from the project file generated by Unity.
/// </summary>
public string AssemblyDefinitionFilePath
{
get
{
assemblyDefinitionFilePath ??= ExtractAssemblyDefinitionFilePath();
return assemblyDefinitionFilePath;
}
}

/// <summary>
/// Gets the absolute path of the project file (.csproj).
/// </summary>
Expand All @@ -54,6 +68,31 @@ public ProjectFileParser(string filePath)
/// </summary>
protected string DirectoryPath { get; }

/// <summary>
/// Determines whether the project is from the package cache.
/// </summary>
/// <returns>True if the project file is from a package, False otherwise.</returns>
public bool IsProjectFromPackageCache()
{
if (assemblyDefinitionFilePath is not null)
{
return IsProjectFileFromPackageCache(assemblyDefinitionFilePath);
}

var anyProjectFilePath = ProjectElement.Descendants(XmlNamespace + "None")
.Concat(ProjectElement.Descendants(XmlNamespace + "Compile"))
.Select(element => element.Attribute("Include")?.Value)
.FirstOrDefault(itemPath => itemPath is not null);

if (anyProjectFilePath is not null)
{
return IsProjectFileFromPackageCache(Path.GetFullPath(anyProjectFilePath, DirectoryPath));
}

LogHelper.LogWarning($"The project file: {FilePath} has no content so skipping it.");
return true;
}

/// <summary>
/// Read the Project file and extract all source code files. But exclude files that are inside a 'PackageCache'.
/// </summary>
Expand Down Expand Up @@ -102,5 +141,20 @@ internal static bool IsProjectFileFromPackageCache(string projectFilePath)
$"{Path.DirectorySeparatorChar}Library{Path.DirectorySeparatorChar}PackageCache{Path.DirectorySeparatorChar}",
StringComparison.OrdinalIgnoreCase);
}

private string ExtractAssemblyDefinitionFilePath()
{
var assemblyDefinitionFilePaths = ProjectElement.Descendants(XmlNamespace + "None")
.Select(noneElement => noneElement.Attribute("Include")?.Value)
.Where(noneItemPath => noneItemPath?.EndsWith(".asmdef", StringComparison.OrdinalIgnoreCase) == true)
.ToList();
if (assemblyDefinitionFilePaths.Count != 1)
{
throw new InvalidOperationException(
$"The csproj file '{FilePath}' need to have exactly one '.asmdef' file but it has ['{string.Join("', '", assemblyDefinitionFilePaths)}']");
}

return Path.GetFullPath(assemblyDefinitionFilePaths[0], DirectoryPath);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable

using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
Expand Down Expand Up @@ -32,7 +33,7 @@ public static void EnableNullableOnAllFiles(SolutionFile solutionFile)
Debug.Assert(Encoding.UTF8.GetBytes("#nullable enable").SequenceEqual(EnableNullableBytes), "Wrong enableNullableBytes detected.");

var (allProjects, _) = SolutionFileParser.Parse(solutionFile, false);
var enableNullableReadBuffer = new byte[EnableNullableBytes.Length + 2];
var enableNullableReadBuffer = new byte[EnableNullableBytes.Length + Utf8BomBytes.Length];

// source code is small so we can read it into memory
var fullFileReadBuffer = new MemoryStream();
Expand All @@ -41,7 +42,14 @@ public static void EnableNullableOnAllFiles(SolutionFile solutionFile)
var originalProjectFilePath = Path.Combine(solutionFile.SolutionDirectoryPath, Path.GetFileName(projectFile.FilePath));
if (!File.Exists(originalProjectFilePath))
{
LogHelper.LogWarning($"Can't find original (Unity) .csproj file at: '{originalProjectFilePath}'.");
var generatedProjectFileParser = new ProjectFileParser(projectFile.FilePath);
var assemblyDefinitionContent =
JsonUtility.FromJson<AssemblyDefinitionContent>(File.ReadAllText(generatedProjectFileParser.AssemblyDefinitionFilePath));
originalProjectFilePath = Path.Combine(solutionFile.SolutionDirectoryPath, $"{assemblyDefinitionContent.name}.csproj");
if (!File.Exists(originalProjectFilePath))
{
LogHelper.LogWarning($"Can't find original (Unity) .csproj file at: '{originalProjectFilePath}'.");
}
}

var projectFileParser = new ProjectFileParser(originalProjectFilePath);
Expand All @@ -58,7 +66,7 @@ public static void EnableNullableOnAllFiles(SolutionFile solutionFile)
/// <param name="sourceCodeFile">The path to the file to add nullable setting to, if it doesn't already has it.</param>
public static void AddNullableToFile(string sourceCodeFile)
{
AddNullableToFile(sourceCodeFile, new byte[EnableNullableBytes.Length + 2], new MemoryStream());
AddNullableToFile(sourceCodeFile, new byte[EnableNullableBytes.Length + Utf8BomBytes.Length], new MemoryStream());
}

private static void AddNullableToFile(string sourceCodeFile, byte[] enableNullableReadBuffer, MemoryStream fullFileReadBuffer)
Expand All @@ -69,7 +77,7 @@ private static void AddNullableToFile(string sourceCodeFile, byte[] enableNullab
var readCount = fileStream.Read(enableNullableReadBuffer);
var readBytes = enableNullableReadBuffer.AsSpan(0, readCount);
var readBytesWithoutBom = readBytes;
var hasUtf8Bom = readBytes.SequenceEqual(Utf8BomBytes);
var hasUtf8Bom = readBytes.StartsWith(Utf8BomBytes);
if (hasUtf8Bom)
{
readBytesWithoutBom = readBytes[Utf8BomBytes.Length..];
Expand All @@ -87,25 +95,32 @@ private static void AddNullableToFile(string sourceCodeFile, byte[] enableNullab
fullFileReadBuffer.Capacity = (int)fileStream.Length;
}

fullFileReadBuffer.Write(readBytes);
fullFileReadBuffer.Write(readBytesWithoutBom);
fileStream.CopyTo(fullFileReadBuffer);

var newLineBytes = DetectNewLineBytes(fullFileReadBuffer);
fullFileReadBuffer.Position = 0;

// reset to start -> write enable nullable -> rest of file
fileStream.Position = 0;

if (hasUtf8Bom)
{
fileStream.Write(Utf8BomBytes);
}

fileStream.Write(EnableNullableBytes);
var firstCharWasNewline = readBytes.StartsWith(newLineBytes);
var firstCharWasNewline = readBytesWithoutBom.StartsWith(newLineBytes);
if (!firstCharWasNewline)
{
fileStream.Write(newLineBytes);
}

var secondCharWasNewLine = readBytes.IsEmpty ||
var secondCharWasNewLine = readBytesWithoutBom.IsEmpty ||
firstCharWasNewline &&
(readBytes[Math.Min(newLineBytes.Length, readBytes.Length)..].StartsWith(newLineBytes) ||
readBytes.Length == newLineBytes.Length);
(readBytesWithoutBom[Math.Min(newLineBytes.Length, readBytesWithoutBom.Length)..]
.StartsWith(newLineBytes) ||
readBytesWithoutBom.Length == newLineBytes.Length);
if (!secondCharWasNewLine)
{
fileStream.Write(newLineBytes);
Expand Down Expand Up @@ -143,5 +158,19 @@ private static byte[] DetectNewLineBytes(MemoryStream memoryStream)
previous = readByte;
}
}

[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Instantiated by json-serializer.")]
private sealed class AssemblyDefinitionContent
{
[SuppressMessage("ReSharper", "FieldCanBeMadeReadOnly.Local", Justification = "Required by serializer.")]
[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Required by serializer.")]
[SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "Required by serializer.")]
[SuppressMessage(
"StyleCop.CSharp.NamingRules",
"SA1307:Accessible fields should begin with upper-case letter",
Justification = "Required by serializer.")]
[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Required by serializer.")]
public string name = string.Empty;
}
}
}
Loading