Skip to content

A C# source generator that provides strongly-typed, compile-time access to project files marked with CopyToOutputDirectory in the .csproj file.

License

Notifications You must be signed in to change notification settings

SimonCropp/ProjectFiles

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

74 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ProjectFiles Source Generator

Build status NuGet Status

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.

NuGet package

https://nuget.org/packages/ProjectFiles/

PM> Install-Package ProjectFiles

Features

  • 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

Setup

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>

Strong typed file access

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

Generated Code Structure

The generator creates three files:

  1. ProjectFiles.g.cs - Main entry point with directory structure
  2. ProjectFiles.ProjectDirectory.g.cs - Base class for directory types
  3. ProjectFiles.ProjectFile.g.cs - Base class for file types

Generated API Example

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

Usage

Basic File Access

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

Navigating Directories

// Navigate through nested directories
var nestedFile = ProjectFiles.RecursiveDirectory.SubDir.NestedFile_txt;

// Access directory information
var directory = ProjectFiles.SpecificDirectory;
string dirPath = directory.Path;  // "SpecificDirectory"

Working with Multiple Files

// 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}");
}

Naming Conventions

The generator follows these rules when converting file and directory names to C# identifiers:

Directory Names → Class Names (PascalCase)

  • Valid characters preserved: MyDirectoryMyDirectory
  • Invalid characters replaced: my-directorymy_directory
  • Leading digits prefixed: 123folder_123folder
  • Keywords escaped: class@class

File Names → Property Names (PascalCase with extension)

  • Name converted to identifier: appsettings.jsonappsettings_json
  • Extension lowercased with underscore: File.txtFile_txt
  • Multiple dots preserved: app.config.jsonapp_config_json
  • Special characters replaced: my-file.xmlmy_file_xml

Glob Pattern Support

The generator supports standard glob patterns:

Wildcards

<!-- Single directory with wildcards -->
<None Include="Config\*.json">
  <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

Recursive Patterns

<!-- All files in directory tree -->
<Content Include="Templates\**\*.html">
  <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>

Mixed Patterns

<!-- Specific pattern in subdirectories -->
<None Include="Data\**\schema.json">
  <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

Base Classes

Directory and File level items types.

ProjectDirectory

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

snippet source | anchor

ProjectFile

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
}

snippet source | anchor

Extending base class

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

snippet source | anchor

Troubleshooting

Files Not Appearing

  1. Verify CopyToOutputDirectory is set: Only files with PreserveNewest or Always are included
  2. Rebuild the project: Sometimes the generator needs a clean rebuild to detect changes

Path Separators

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.

Performance Considerations

  • 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

Icon

File designed by Liberus PJ from The Noun Project.

About

A C# source generator that provides strongly-typed, compile-time access to project files marked with CopyToOutputDirectory in the .csproj file.

Resources

License

Code of conduct

Stars

Watchers

Forks

Languages