diff --git a/readme.md b/readme.md index cfc543a..2a86c67 100644 --- a/readme.md +++ b/readme.md @@ -402,6 +402,36 @@ string envVars = File.ReadAllText(ProjectFiles._env); - `.editorconfig` → `_editorconfig` - `.txt` → `_txt` +**Important:** Avoid naming conflicts. The generator detects and reports duplicate property names: + +**Example 1: Dot file conflict** +```xml + + + + PreserveNewest + + + PreserveNewest + + +``` + +**Example 2: Dots vs underscores conflict** +```xml + + + + PreserveNewest + + + PreserveNewest + + +``` + +Error: `PROJFILES004: Files 'config.json' and 'config_json' both generate the same property name 'config_json'. Rename one of the files to avoid the conflict.` + ## Glob Pattern Support diff --git a/src/ProjectFiles/Diagnostics.cs b/src/ProjectFiles/Diagnostics.cs index 6543499..36797a6 100644 --- a/src/ProjectFiles/Diagnostics.cs +++ b/src/ProjectFiles/Diagnostics.cs @@ -24,4 +24,12 @@ public static class Diagnostics category: "ProjectFiles", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DuplicatePropertyName = new( + id: "PROJFILES004", + title: "Duplicate property name generated", + messageFormat: "Files '{0}' and '{1}' both generate the same property name '{2}'. Rename one of the files to avoid the conflict.", + category: "ProjectFiles", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } \ No newline at end of file diff --git a/src/ProjectFiles/Generator.cs b/src/ProjectFiles/Generator.cs index 9068b30..f4acfbb 100644 --- a/src/ProjectFiles/Generator.cs +++ b/src/ProjectFiles/Generator.cs @@ -98,6 +98,21 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.ReportDiagnostic(diagnostic); } + // Check for duplicate property names + var duplicateConflicts = FindDuplicatePropertyNames(fileList); + foreach (var (file1, file2, propertyName) in duplicateConflicts) + { + conflictingFiles.Add(file1); + conflictingFiles.Add(file2); + var diagnostic = Diagnostic.Create( + Diagnostics.DuplicatePropertyName, + Location.None, + file1, + file2, + propertyName); + context.ReportDiagnostic(diagnostic); + } + // Filter out conflicting files before generating source var filteredFiles = fileList.Where(_ => !conflictingFiles.Contains(_)).ToImmutableArray(); @@ -152,6 +167,48 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return conflicts; } + static List<(string File1, string File2, string PropertyName)> FindDuplicatePropertyNames(ImmutableArray files) + { + var conflicts = new List<(string, string, string)>(); + + // Group files by their directory (same scope) + var filesByDirectory = new Dictionary>(); + + foreach (var file in files) + { + var directory = Path.GetDirectoryName(file) ?? string.Empty; + if (!filesByDirectory.TryGetValue(directory, out var filesInDir)) + { + filesInDir = []; + filesByDirectory[directory] = filesInDir; + } + + filesInDir.Add(file); + } + + // Check for duplicates within each directory + foreach (var filesInDir in filesByDirectory.Values) + { + var propertyToFile = new Dictionary(); + + foreach (var file in filesInDir) + { + var propertyName = ToFilePropertyName(file); + + if (propertyToFile.TryGetValue(propertyName, out var existingFile)) + { + conflicts.Add((existingFile, file, propertyName)); + } + else + { + propertyToFile[propertyName] = file; + } + } + } + + return conflicts; + } + static string GenerateSource(ImmutableArray files, MsBuildProperties properties, Cancel cancel) { var (tree, rootFiles) = BuildFileTree(files, cancel); @@ -417,4 +474,4 @@ static string ToFilePropertyName(string filePath) return (topLevelDirectories.Values, rootFiles); } -} \ No newline at end of file +} diff --git a/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile#ProjectFiles.ProjectDirectory.g.verified.cs b/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile#ProjectFiles.ProjectDirectory.g.verified.cs new file mode 100644 index 0000000..4f4b361 --- /dev/null +++ b/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile#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.ConflictBetweenDotFileAndRegularFile#ProjectFiles.ProjectFile.g.verified.cs b/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile#ProjectFiles.ProjectFile.g.verified.cs new file mode 100644 index 0000000..ecec5ac --- /dev/null +++ b/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile#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.ConflictBetweenDotFileAndRegularFile#ProjectFiles.g.verified.cs b/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile#ProjectFiles.g.verified.cs new file mode 100644 index 0000000..9386c41 --- /dev/null +++ b/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile#ProjectFiles.g.verified.cs @@ -0,0 +1,17 @@ +//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 + { + } +} + +namespace ProjectFilesGenerator.Types +{ +} diff --git a/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile.verified.txt b/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile.verified.txt new file mode 100644 index 0000000..e8653ba --- /dev/null +++ b/src/Tests/GeneratorTest.ConflictBetweenDotFileAndRegularFile.verified.txt @@ -0,0 +1,16 @@ +{ + Diagnostics: [ + { + Message: Files '.txt' and '_txt' both generate the same property name '_txt'. Rename one of the files to avoid the conflict., + Severity: Error, + Descriptor: { + Id: PROJFILES004, + Title: Duplicate property name generated, + MessageFormat: Files '{0}' and '{1}' both generate the same property name '{2}'. Rename one of the files to avoid the conflict., + Category: ProjectFiles, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores#ProjectFiles.ProjectDirectory.g.verified.cs b/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores#ProjectFiles.ProjectDirectory.g.verified.cs new file mode 100644 index 0000000..4f4b361 --- /dev/null +++ b/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores#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.ConflictBetweenFileWithDotsAndUnderscores#ProjectFiles.ProjectFile.g.verified.cs b/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores#ProjectFiles.ProjectFile.g.verified.cs new file mode 100644 index 0000000..ecec5ac --- /dev/null +++ b/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores#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.ConflictBetweenFileWithDotsAndUnderscores#ProjectFiles.g.verified.cs b/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores#ProjectFiles.g.verified.cs new file mode 100644 index 0000000..9386c41 --- /dev/null +++ b/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores#ProjectFiles.g.verified.cs @@ -0,0 +1,17 @@ +//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 + { + } +} + +namespace ProjectFilesGenerator.Types +{ +} diff --git a/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores.verified.txt b/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores.verified.txt new file mode 100644 index 0000000..51e6f4a --- /dev/null +++ b/src/Tests/GeneratorTest.ConflictBetweenFileWithDotsAndUnderscores.verified.txt @@ -0,0 +1,16 @@ +{ + Diagnostics: [ + { + Message: Files 'config.json' and 'config_json' both generate the same property name 'config_json'. Rename one of the files to avoid the conflict., + Severity: Error, + Descriptor: { + Id: PROJFILES004, + Title: Duplicate property name generated, + MessageFormat: Files '{0}' and '{1}' both generate the same property name '{2}'. Rename one of the files to avoid the conflict., + Category: ProjectFiles, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/GeneratorTest.SameFileNameInDifferentDirectories#ProjectFiles.ProjectDirectory.g.verified.cs b/src/Tests/GeneratorTest.SameFileNameInDifferentDirectories#ProjectFiles.ProjectDirectory.g.verified.cs new file mode 100644 index 0000000..4f4b361 --- /dev/null +++ b/src/Tests/GeneratorTest.SameFileNameInDifferentDirectories#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.SameFileNameInDifferentDirectories#ProjectFiles.ProjectFile.g.verified.cs b/src/Tests/GeneratorTest.SameFileNameInDifferentDirectories#ProjectFiles.ProjectFile.g.verified.cs new file mode 100644 index 0000000..ecec5ac --- /dev/null +++ b/src/Tests/GeneratorTest.SameFileNameInDifferentDirectories#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.SameFileNameInDifferentDirectories#ProjectFiles.g.verified.cs b/src/Tests/GeneratorTest.SameFileNameInDifferentDirectories#ProjectFiles.g.verified.cs new file mode 100644 index 0000000..dbbe606 --- /dev/null +++ b/src/Tests/GeneratorTest.SameFileNameInDifferentDirectories#ProjectFiles.g.verified.cs @@ -0,0 +1,27 @@ +//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 Dir1Type Dir1 { get; } = new(); + public static Dir2Type Dir2 { get; } = new(); + } +} + +namespace ProjectFilesGenerator.Types +{ +partial class Dir1Type() : ProjectDirectory("Dir1") +{ + public ProjectFile config_json { get; } = new("Dir1/config.json"); +} +partial class Dir2Type() : ProjectDirectory("Dir2") +{ + public ProjectFile config_json { get; } = new("Dir2/config.json"); +} +} diff --git a/src/Tests/GeneratorTest.cs b/src/Tests/GeneratorTest.cs index 500640d..1f515b3 100644 --- a/src/Tests/GeneratorTest.cs +++ b/src/Tests/GeneratorTest.cs @@ -1565,6 +1565,78 @@ public Task DotFileInDirectory() return Verify(RunGenerator(additionalFiles, metadata)); } + [Test] + public Task ConflictBetweenDotFileAndRegularFile() + { + var additionalFiles = new[] + { + CreateAdditionalText(".txt", "content"), + CreateAdditionalText("_txt", "content") + }; + + var metadata = new Dictionary> + { + [".txt"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = ".txt" + }, + ["_txt"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = "_txt" + } + }; + + return Verify(RunGenerator(additionalFiles, metadata)); + } + + [Test] + public Task ConflictBetweenFileWithDotsAndUnderscores() + { + var additionalFiles = new[] + { + CreateAdditionalText("config.json", "content"), + CreateAdditionalText("config_json", "content") + }; + + var metadata = new Dictionary> + { + ["config.json"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = "config.json" + }, + ["config_json"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = "config_json" + } + }; + + return Verify(RunGenerator(additionalFiles, metadata)); + } + + [Test] + public Task SameFileNameInDifferentDirectories() + { + var additionalFiles = new[] + { + CreateAdditionalText("Dir1/config.json", "content"), + CreateAdditionalText("Dir2/config.json", "content") + }; + + var metadata = new Dictionary> + { + ["Dir1/config.json"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = "Dir1/config.json" + }, + ["Dir2/config.json"] = new() + { + ["build_metadata.AdditionalFiles.ProjectFilesGenerator"] = "Dir2/config.json" + } + }; + + return Verify(RunGenerator(additionalFiles, metadata)); + } + static AdditionalText CreateAdditionalText(string path, string content) => new MockAdditionalText(path, content);