From 97b42e6a758ba446a890bd8652f31b328c9be5c2 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 28 Jan 2026 17:02:24 +1100 Subject: [PATCH 1/3] Handle dotfiles and empty strings in identifier generation Added support for files that start with a dot (e.g., ".txt") in Generator, ensuring correct identifier generation. Updated Identifier.Build to return '_' for empty strings instead of throwing an exception. Added and updated tests to verify correct handling of dotfiles and empty string identifiers. --- ...rojectFiles.ProjectDirectory.g.verified.cs | 34 ++++++++++ ...oot#ProjectFiles.ProjectFile.g.verified.cs | 50 ++++++++++++++ ...t.DotFileAtRoot#ProjectFiles.g.verified.cs | 18 +++++ src/Tests/GeneratorTest.cs | 67 +++++++++++++++++++ .../IdentifierTest.EmptyString.verified.txt | 1 + src/Tests/IdentifierTest.cs | 7 +- 6 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.ProjectDirectory.g.verified.cs create mode 100644 src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.ProjectFile.g.verified.cs create mode 100644 src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.g.verified.cs create mode 100644 src/Tests/IdentifierTest.EmptyString.verified.txt diff --git a/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.ProjectDirectory.g.verified.cs b/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.ProjectDirectory.g.verified.cs new file mode 100644 index 0000000..4f4b361 --- /dev/null +++ b/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.ProjectDirectory.g.verified.cs @@ -0,0 +1,34 @@ +//HintName: ProjectFiles.ProjectDirectory.g.cs +namespace ProjectFilesGenerator; + +using System.IO; +using System.Collections.Generic; + +partial class ProjectDirectory(string path) +{ + public string Path { get; } = path; + + public string FullPath => System.IO.Path.GetFullPath(Path); + + public override string ToString() => Path; + + public static implicit operator string(ProjectDirectory temp) => + temp.Path; + + public static implicit operator FileInfo(ProjectDirectory temp) => + new(temp.Path); + + public IEnumerable EnumerateDirectories() => + Directory.EnumerateDirectories(Path); + + public IEnumerable EnumerateFiles() => + Directory.EnumerateFiles(Path); + + public IEnumerable GetFiles() => + Directory.GetFiles(Path); + + public IEnumerable GetDirectories() => + Directory.GetDirectories(Path); + + public DirectoryInfo Info => new(Path); +} \ No newline at end of file diff --git a/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.ProjectFile.g.verified.cs b/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.ProjectFile.g.verified.cs new file mode 100644 index 0000000..ecec5ac --- /dev/null +++ b/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.ProjectFile.g.verified.cs @@ -0,0 +1,50 @@ +//HintName: ProjectFiles.ProjectFile.g.cs +namespace ProjectFilesGenerator; + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +partial class ProjectFile(string path) +{ + public string Path { get; } = path; + + public string FullPath => System.IO.Path.GetFullPath(Path); + + public override string ToString() => Path; + + public static implicit operator string(ProjectFile temp) => + temp.Path; + + public static implicit operator FileInfo(ProjectFile temp) => + new(temp.Path); + + public FileStream OpenRead() => + File.OpenRead(Path); + + public StreamReader OpenText() => + File.OpenText(Path); + + public string ReadAllText() => + File.ReadAllText(Path); + + public string ReadAllText(Encoding encoding) => + File.ReadAllText(Path, encoding); + + public FileInfo Info => new(Path); + +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0_OR_GREATER + public Task ReadAllTextAsync(CancellationToken cancel = default) => + File.ReadAllTextAsync(Path, cancel); + + public Task ReadAllTextAsync(Encoding encoding, CancellationToken cancel = default) => + File.ReadAllTextAsync(Path, encoding,cancel); +#else + public Task ReadAllTextAsync(CancellationToken cancel = default) => + Task.FromResult(File.ReadAllText(Path)); + + public Task ReadAllTextAsync(Encoding encoding, CancellationToken cancel = default) => + Task.FromResult(File.ReadAllText(Path, encoding)); +#endif +} \ No newline at end of file diff --git a/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.g.verified.cs b/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.g.verified.cs new file mode 100644 index 0000000..8d78c11 --- /dev/null +++ b/src/Tests/GeneratorTest.DotFileAtRoot#ProjectFiles.g.verified.cs @@ -0,0 +1,18 @@ +//HintName: ProjectFiles.g.cs +// +#nullable enable + +namespace ProjectFilesGenerator +{ + using ProjectFilesGenerator.Types; + + /// Provides strongly-typed access to project files marked with CopyToOutputDirectory. + static partial class ProjectFiles + { + public static ProjectFile _txt { get; } = new(".txt"); + } +} + +namespace ProjectFilesGenerator.Types +{ +} diff --git a/src/Tests/GeneratorTest.cs b/src/Tests/GeneratorTest.cs index ac3e6a1..3bd4782 100644 --- a/src/Tests/GeneratorTest.cs +++ b/src/Tests/GeneratorTest.cs @@ -1498,6 +1498,73 @@ public Task PF0001_CSharp12Required() return Verify(driver); } + [Test] + public Task DotFileAtRoot() + { + var additionalFiles = new[] + { + CreateAdditionalText(".txt", "content") + }; + + var metadata = new Dictionary> + { + [".txt"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = ".txt" + } + }; + + return Verify(RunGenerator(additionalFiles, metadata)); + } + + [Test] + public Task MultipleDotFilesAtRoot() + { + var additionalFiles = new[] + { + CreateAdditionalText(".gitignore", "content"), + CreateAdditionalText(".env", "content"), + CreateAdditionalText(".editorconfig", "content") + }; + + var metadata = new Dictionary> + { + [".gitignore"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = ".gitignore" + }, + [".env"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = ".env" + }, + [".editorconfig"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = ".editorconfig" + } + }; + + return Verify(RunGenerator(additionalFiles, metadata)); + } + + [Test] + public Task DotFileInDirectory() + { + var additionalFiles = new[] + { + CreateAdditionalText("Config/.env", "content") + }; + + var metadata = new Dictionary> + { + ["Config/.env"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = "Config/.env" + } + }; + + return Verify(RunGenerator(additionalFiles, metadata)); + } + static AdditionalText CreateAdditionalText(string path, string content) => new MockAdditionalText(path, content); diff --git a/src/Tests/IdentifierTest.EmptyString.verified.txt b/src/Tests/IdentifierTest.EmptyString.verified.txt new file mode 100644 index 0000000..eb610d0 --- /dev/null +++ b/src/Tests/IdentifierTest.EmptyString.verified.txt @@ -0,0 +1 @@ +_ \ No newline at end of file diff --git a/src/Tests/IdentifierTest.cs b/src/Tests/IdentifierTest.cs index ebe6b29..be01c37 100644 --- a/src/Tests/IdentifierTest.cs +++ b/src/Tests/IdentifierTest.cs @@ -46,11 +46,8 @@ public Task MixedCaseKeyword() => Verify(Identifier.Build("Class")); [Test] - public Task EmptyString() - { - Assert.Throws(() => Identifier.Build("")); - return Task.CompletedTask; - } + public Task EmptyString() => + Verify(Identifier.Build("")); [Test] public Task SingleCharacter() => From 60c520b682ff86e41840cbdf7408886b0a753a6c Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 28 Jan 2026 17:03:30 +1100 Subject: [PATCH 2/3] . --- ...rojectFiles.ProjectDirectory.g.verified.cs | 34 +++++++++++++ ...ory#ProjectFiles.ProjectFile.g.verified.cs | 50 +++++++++++++++++++ ...FileInDirectory#ProjectFiles.g.verified.cs | 22 ++++++++ ...rojectFiles.ProjectDirectory.g.verified.cs | 34 +++++++++++++ ...oot#ProjectFiles.ProjectFile.g.verified.cs | 50 +++++++++++++++++++ ...eDotFilesAtRoot#ProjectFiles.g.verified.cs | 20 ++++++++ src/Tests/GeneratorTest.cs | 4 +- 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.ProjectDirectory.g.verified.cs create mode 100644 src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.ProjectFile.g.verified.cs create mode 100644 src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.g.verified.cs create mode 100644 src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.ProjectDirectory.g.verified.cs create mode 100644 src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.ProjectFile.g.verified.cs create mode 100644 src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.g.verified.cs diff --git a/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.ProjectDirectory.g.verified.cs b/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.ProjectDirectory.g.verified.cs new file mode 100644 index 0000000..4f4b361 --- /dev/null +++ b/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.ProjectDirectory.g.verified.cs @@ -0,0 +1,34 @@ +//HintName: ProjectFiles.ProjectDirectory.g.cs +namespace ProjectFilesGenerator; + +using System.IO; +using System.Collections.Generic; + +partial class ProjectDirectory(string path) +{ + public string Path { get; } = path; + + public string FullPath => System.IO.Path.GetFullPath(Path); + + public override string ToString() => Path; + + public static implicit operator string(ProjectDirectory temp) => + temp.Path; + + public static implicit operator FileInfo(ProjectDirectory temp) => + new(temp.Path); + + public IEnumerable EnumerateDirectories() => + Directory.EnumerateDirectories(Path); + + public IEnumerable EnumerateFiles() => + Directory.EnumerateFiles(Path); + + public IEnumerable GetFiles() => + Directory.GetFiles(Path); + + public IEnumerable GetDirectories() => + Directory.GetDirectories(Path); + + public DirectoryInfo Info => new(Path); +} \ No newline at end of file diff --git a/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.ProjectFile.g.verified.cs b/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.ProjectFile.g.verified.cs new file mode 100644 index 0000000..ecec5ac --- /dev/null +++ b/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.ProjectFile.g.verified.cs @@ -0,0 +1,50 @@ +//HintName: ProjectFiles.ProjectFile.g.cs +namespace ProjectFilesGenerator; + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +partial class ProjectFile(string path) +{ + public string Path { get; } = path; + + public string FullPath => System.IO.Path.GetFullPath(Path); + + public override string ToString() => Path; + + public static implicit operator string(ProjectFile temp) => + temp.Path; + + public static implicit operator FileInfo(ProjectFile temp) => + new(temp.Path); + + public FileStream OpenRead() => + File.OpenRead(Path); + + public StreamReader OpenText() => + File.OpenText(Path); + + public string ReadAllText() => + File.ReadAllText(Path); + + public string ReadAllText(Encoding encoding) => + File.ReadAllText(Path, encoding); + + public FileInfo Info => new(Path); + +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0_OR_GREATER + public Task ReadAllTextAsync(CancellationToken cancel = default) => + File.ReadAllTextAsync(Path, cancel); + + public Task ReadAllTextAsync(Encoding encoding, CancellationToken cancel = default) => + File.ReadAllTextAsync(Path, encoding,cancel); +#else + public Task ReadAllTextAsync(CancellationToken cancel = default) => + Task.FromResult(File.ReadAllText(Path)); + + public Task ReadAllTextAsync(Encoding encoding, CancellationToken cancel = default) => + Task.FromResult(File.ReadAllText(Path, encoding)); +#endif +} \ No newline at end of file diff --git a/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.g.verified.cs b/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.g.verified.cs new file mode 100644 index 0000000..115aed9 --- /dev/null +++ b/src/Tests/GeneratorTest.DotFileInDirectory#ProjectFiles.g.verified.cs @@ -0,0 +1,22 @@ +//HintName: ProjectFiles.g.cs +// +#nullable enable + +namespace ProjectFilesGenerator +{ + using ProjectFilesGenerator.Types; + + /// Provides strongly-typed access to project files marked with CopyToOutputDirectory. + static partial class ProjectFiles + { + public static ConfigType Config { get; } = new(); + } +} + +namespace ProjectFilesGenerator.Types +{ +partial class ConfigType() : ProjectDirectory("Config") +{ + public ProjectFile _env { get; } = new("Config/.env"); +} +} diff --git a/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.ProjectDirectory.g.verified.cs b/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.ProjectDirectory.g.verified.cs new file mode 100644 index 0000000..4f4b361 --- /dev/null +++ b/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.ProjectDirectory.g.verified.cs @@ -0,0 +1,34 @@ +//HintName: ProjectFiles.ProjectDirectory.g.cs +namespace ProjectFilesGenerator; + +using System.IO; +using System.Collections.Generic; + +partial class ProjectDirectory(string path) +{ + public string Path { get; } = path; + + public string FullPath => System.IO.Path.GetFullPath(Path); + + public override string ToString() => Path; + + public static implicit operator string(ProjectDirectory temp) => + temp.Path; + + public static implicit operator FileInfo(ProjectDirectory temp) => + new(temp.Path); + + public IEnumerable EnumerateDirectories() => + Directory.EnumerateDirectories(Path); + + public IEnumerable EnumerateFiles() => + Directory.EnumerateFiles(Path); + + public IEnumerable GetFiles() => + Directory.GetFiles(Path); + + public IEnumerable GetDirectories() => + Directory.GetDirectories(Path); + + public DirectoryInfo Info => new(Path); +} \ No newline at end of file diff --git a/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.ProjectFile.g.verified.cs b/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.ProjectFile.g.verified.cs new file mode 100644 index 0000000..ecec5ac --- /dev/null +++ b/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.ProjectFile.g.verified.cs @@ -0,0 +1,50 @@ +//HintName: ProjectFiles.ProjectFile.g.cs +namespace ProjectFilesGenerator; + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +partial class ProjectFile(string path) +{ + public string Path { get; } = path; + + public string FullPath => System.IO.Path.GetFullPath(Path); + + public override string ToString() => Path; + + public static implicit operator string(ProjectFile temp) => + temp.Path; + + public static implicit operator FileInfo(ProjectFile temp) => + new(temp.Path); + + public FileStream OpenRead() => + File.OpenRead(Path); + + public StreamReader OpenText() => + File.OpenText(Path); + + public string ReadAllText() => + File.ReadAllText(Path); + + public string ReadAllText(Encoding encoding) => + File.ReadAllText(Path, encoding); + + public FileInfo Info => new(Path); + +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0_OR_GREATER + public Task ReadAllTextAsync(CancellationToken cancel = default) => + File.ReadAllTextAsync(Path, cancel); + + public Task ReadAllTextAsync(Encoding encoding, CancellationToken cancel = default) => + File.ReadAllTextAsync(Path, encoding,cancel); +#else + public Task ReadAllTextAsync(CancellationToken cancel = default) => + Task.FromResult(File.ReadAllText(Path)); + + public Task ReadAllTextAsync(Encoding encoding, CancellationToken cancel = default) => + Task.FromResult(File.ReadAllText(Path, encoding)); +#endif +} \ No newline at end of file diff --git a/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.g.verified.cs b/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.g.verified.cs new file mode 100644 index 0000000..217167e --- /dev/null +++ b/src/Tests/GeneratorTest.MultipleDotFilesAtRoot#ProjectFiles.g.verified.cs @@ -0,0 +1,20 @@ +//HintName: ProjectFiles.g.cs +// +#nullable enable + +namespace ProjectFilesGenerator +{ + using ProjectFilesGenerator.Types; + + /// Provides strongly-typed access to project files marked with CopyToOutputDirectory. + static partial class ProjectFiles + { + public static ProjectFile _editorconfig { get; } = new(".editorconfig"); + public static ProjectFile _env { get; } = new(".env"); + public static ProjectFile _gitignore { get; } = new(".gitignore"); + } +} + +namespace ProjectFilesGenerator.Types +{ +} diff --git a/src/Tests/GeneratorTest.cs b/src/Tests/GeneratorTest.cs index 3bd4782..500640d 100644 --- a/src/Tests/GeneratorTest.cs +++ b/src/Tests/GeneratorTest.cs @@ -309,7 +309,7 @@ public Task PartialMetadataMatch() { ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = "has-metadata.json" }, - ["no-metadata.json"] = new(), // Empty metadata + ["no-metadata.json"] = [], // Empty metadata ["also-has-metadata.txt"] = new() { ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = "also-has-metadata.txt" @@ -1590,4 +1590,4 @@ static CSharpCompilation CreateCompilation() => [], [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], new(OutputKind.DynamicallyLinkedLibrary)); -} \ No newline at end of file +} From f178574e1a0e2f3c4a909a4781b0c87a5f8822d7 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 28 Jan 2026 17:05:35 +1100 Subject: [PATCH 3/3] . --- src/Directory.Build.props | 4 ++-- src/ProjectFiles/Generator.cs | 7 +++++++ src/ProjectFiles/Identifier.cs | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index de0ced3..d48b65b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU1608;NU1109;RS2008;RS1041 - 0.4.0 + 0.5.0 preview 1.0.0 true @@ -11,4 +11,4 @@ true A C# source generator that provides strongly-typed, compile-time access to project files marked with CopyToOutputDirectory in the .csproj file. - \ No newline at end of file + diff --git a/src/ProjectFiles/Generator.cs b/src/ProjectFiles/Generator.cs index 1d2159e..9068b30 100644 --- a/src/ProjectFiles/Generator.cs +++ b/src/ProjectFiles/Generator.cs @@ -339,6 +339,13 @@ static string ToFilePropertyName(string filePath) var nameWithoutExtension = Path.GetFileNameWithoutExtension(filePath); var extension = Path.GetExtension(filePath); + // Handle files that start with a dot (like ".txt") + if (string.IsNullOrEmpty(nameWithoutExtension) && !string.IsNullOrEmpty(extension)) + { + var fileName = Path.GetFileName(filePath); + return Identifier.Build(fileName); + } + var propertyName = Identifier.Build(nameWithoutExtension); if (!string.IsNullOrEmpty(extension)) diff --git a/src/ProjectFiles/Identifier.cs b/src/ProjectFiles/Identifier.cs index 32a6d84..a61bd88 100644 --- a/src/ProjectFiles/Identifier.cs +++ b/src/ProjectFiles/Identifier.cs @@ -2,6 +2,11 @@ public static class Identifier { public static string Build(string name) { + if (name.Length == 0) + { + return "_"; + } + var builder = new StringBuilder(name.Length + 1); var first = name[0]; if (char.IsLetter(first) || first == '_')