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);