A C# source generator that provides strongly-typed, compile-time access to project files marked with CopyToOutputDirectory in the .csproj file.
Creates a type-safe API for accessing files that are copied to the projects output directory, eliminating magic strings and providing IntelliSense support for file paths.
See Milestones for release notes.
https://nuget.org/packages/ProjectFiles/
PM> Install-Package ProjectFiles
- Strongly-typed access to project files via generated classes and properties
- Compile-time safety - typos in file paths become compilation errors
- IntelliSense support - discover available files through IDE autocomplete
- Automatic synchronization - regenerates when project files change
- Hierarchical structure - mirrors the project's directory structure
- Glob pattern support - handles wildcard patterns including
**recursive patterns - Smart naming - converts file/directory names to valid C# identifiers
Mark files with CopyToOutputDirectory set to either PreserveNewest or Always:
<ItemGroup>
<None Update="Config\appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<Content Include="RecursiveDirectory\**\*.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<None Include="SpecificDirectory\Dir1\*.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>The files can be consumed via a strong typed API:
[TestFixture]
public class ComsumeTests
{
[Test]
public void Config() =>
IsTrue(File.Exists(ProjectFiles.Config.appsettings_json));
[Test]
public void Recursive() =>
IsTrue(File.Exists(ProjectFiles.RecursiveDirectory.SomeFile_txt));
[Test]
public void Specific()
{
IsTrue(File.Exists(ProjectFiles.SpecificDirectory.Dir1.File1_txt));
IsTrue(File.Exists(ProjectFiles.SpecificDirectory.Dir1.File2_txt));
IsTrue(File.Exists(ProjectFiles.SpecificDirectory.Dir2.File4_txt));
IsTrue(File.Exists(ProjectFiles.SpecificDirectory.File3_txt));
}
The generator creates three files:
ProjectFiles.g.cs- Main entry point with directory structureProjectFiles.ProjectDirectory.g.cs- Base class for directory typesProjectFiles.ProjectFile.g.cs- Base class for file types
Given the following project structure:
Config/
appsettings.json
RecursiveDirectory/
SomeFile.txt
SubDir/
NestedFile.txt
SpecificDirectory/
Dir1/
File1.txt
File2.txt
Dir2/
File4.txt
File3.txt
The generator produces:
// <auto-generated/>
#nullable enable
using ProjectFilesGenerator.Types;
namespace ProjectFilesGenerator
{
/// <summary>Provides strongly-typed access to project files marked with CopyToOutputDirectory.</summary>
static partial class ProjectFiles
{
public static ConfigType Config { get; } = new();
public static RecursiveDirectoryType RecursiveDirectory { get; } = new();
public static SpecificDirectoryType SpecificDirectory { get; } = new();
}
}
namespace ProjectFilesGenerator.Types
{
partial class ConfigType() : ProjectDirectory("Config")
{
public ProjectFile appsettings_json { get; } = new("Config/appsettings.json");
}
partial class RecursiveDirectoryType() : ProjectDirectory("RecursiveDirectory")
{
public SubDirType SubDir { get; } = new();
public partial class SubDirType
{
public ProjectFile NestedFile_txt { get; } = new("RecursiveDirectory/SubDir/NestedFile.txt");
}
public ProjectFile SomeFile_txt { get; } = new("RecursiveDirectory/SomeFile.txt");
}
partial class SpecificDirectoryType() : ProjectDirectory("SpecificDirectory")
{
public Dir1Type Dir1 { get; } = new();
public partial class Dir1Type
{
public ProjectFile File1_txt { get; } = new("SpecificDirectory/Dir1/File1.txt");
public ProjectFile File2_txt { get; } = new("SpecificDirectory/Dir1/File2.txt");
}
public Dir2Type Dir2 { get; } = new();
public partial class Dir2Type
{
public ProjectFile File4_txt { get; } = new("SpecificDirectory/Dir2/File4.txt");
}
public ProjectFile File3_txt { get; } = new("SpecificDirectory/File3.txt");
}
}// Access a file
var configFile = ProjectFiles.Config.appsettings_json;
// Get the file path
string path = configFile.Path; // "Config/appsettings.json"
// Read the file
string json = File.ReadAllText(configFile.Path);// Navigate through nested directories
var nestedFile = ProjectFiles.RecursiveDirectory.SubDir.NestedFile_txt;
// Access directory information
var directory = ProjectFiles.SpecificDirectory;
string dirPath = directory.Path; // "SpecificDirectory"// Access multiple files in the same directory
var dir1 = ProjectFiles.SpecificDirectory.Dir1;
var file1 = dir1.File1_txt;
var file2 = dir1.File2_txt;
// Use in LINQ queries
var allFiles = new[]
{
dir1.File1_txt,
dir1.File2_txt,
ProjectFiles.SpecificDirectory.File3_txt
};
foreach (var file in allFiles)
{
Console.WriteLine($"Processing: {file.Path}");
}The generator follows these rules when converting file and directory names to C# identifiers:
- Valid characters preserved:
MyDirectory→MyDirectory - Invalid characters replaced:
my-directory→my_directory - Leading digits prefixed:
123folder→_123folder - Keywords escaped:
class→@class
- Name converted to identifier:
appsettings.json→appsettings_json - Extension lowercased with underscore:
File.txt→File_txt - Multiple dots preserved:
app.config.json→app_config_json - Special characters replaced:
my-file.xml→my_file_xml
The generator supports standard glob patterns:
<!-- Single directory with wildcards -->
<None Include="Config\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None><!-- All files in directory tree -->
<Content Include="Templates\**\*.html">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content><!-- Specific pattern in subdirectories -->
<None Include="Data\**\schema.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>Directory and File level items types.
Base class for all generated directory types:
namespace ProjectFilesGenerator;
using System.IO;
using System.Collections.Generic;
abstract partial class ProjectDirectory(string path)
{
public string Path { get; } = 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<string> EnumerateDirectories() =>
Directory.EnumerateDirectories(Path);
public IEnumerable<string> EnumerateFiles() =>
Directory.EnumerateFiles(Path);
public IEnumerable<string> GetFiles() =>
Directory.GetFiles(Path);
public IEnumerable<string> GetDirectories() =>
Directory.GetDirectories(Path);
public DirectoryInfo Info => new(Path);
}Class for all generated file instances:
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 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<string> ReadAllTextAsync(CancellationToken cancel = default) =>
File.ReadAllTextAsync(Path, cancel);
public Task<string> ReadAllTextAsync(Encoding encoding, CancellationToken cancel = default) =>
File.ReadAllTextAsync(Path, encoding,cancel);
#else
public Task<string> ReadAllTextAsync(CancellationToken cancel = default) =>
Task.FromResult(File.ReadAllText(Path));
public Task<string> ReadAllTextAsync(Encoding encoding, CancellationToken cancel = default) =>
Task.FromResult(File.ReadAllText(Path, encoding));
#endif
}These base classes can be extended with additional functionality by creating partial class definitions.
namespace ProjectFilesGenerator;
abstract partial class ProjectDirectory
{
/// <summary>
/// Recursively enumerates all files in this directory and subdirectories.
/// </summary>
public IEnumerable<string> EnumerateFilesRecursively(string searchPattern = "*") =>
Directory.EnumerateFiles(Path, searchPattern, SearchOption.AllDirectories);
/// <summary>
/// Combines this directory path with additional path segments.
/// </summary>
public string Combine(params string[] paths) =>
System.IO.Path.Combine([Path, .. paths]);
}- Verify
CopyToOutputDirectoryis set: Only files withPreserveNewestorAlwaysare included - Rebuild the project: Sometimes the generator needs a clean rebuild to detect changes
The generator normalizes all paths to use forward slashes (/) in the generated code, regardless of the platform. This ensures consistent behavior across Windows, Linux, and macOS.
- Minimal runtime overhead: All types are instantiated once as static properties
- No reflection: Direct string property access for maximum performance
- Compile-time generation: Zero runtime code generation or discovery
File designed by Liberus PJ from The Noun Project.