Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,11 @@
"titleBar.inactiveBackground": "#55dbe899",
"titleBar.inactiveForeground": "#15202b99"
},
"peacock.remoteColor": "#55dbe8"
"peacock.remoteColor": "#55dbe8",
"cSpell.words": [
"assemblyinfo",
"buildtransitive",
"contentfiles",
"nugets"
]
}
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Authors>Steven T. Cramer</Authors>
<Product>TimeWarp.SourceGenerators</Product>
<PackageId>TimeWarp.SourceGenerators</PackageId>
<PackageVersion>1.0.0-beta.2</PackageVersion>
<PackageVersion>1.0.0-beta.3</PackageVersion>
<PackageProjectUrl>https://timewarpengineering.github.io/timewarp-source-generators/</PackageProjectUrl>
<PackageTags>TimeWarp; Source Generator;SourceGenerators; Delegate</PackageTags>
<PackageIcon>logo.png</PackageIcon>
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<PackageVersion Include="Microsoft.CodeAnalysis" Version="4.4.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.11.0" />
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="Morris.Moxy" Version="1.1.0" />
<PackageVersion Include="Scriban.Signed" Version="5.5.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Fix TW0003 Analyzer to Ignore Generated Files

## Description
The TW0003 file naming analyzer is incorrectly checking files in build output directories (obj/, bin/) which contain auto-generated build artifacts. The analyzer should skip validation for these directories as they are not user-written source code.

## Problem
The analyzer is reporting errors for generated files such as:
- `.NETCoreApp,Version=v10.0.AssemblyAttributes.cs` in obj/ directory
- `timewarp-code.AssemblyInfo.cs` in obj/ directory

These files are created by the build system and their naming conventions are controlled by the .NET SDK, not the user.

## Acceptance Criteria
- [ ] TW0003 analyzer skips files in `/obj/` directories
- [ ] TW0003 analyzer skips files in `/bin/` directories
- [ ] TW0003 analyzer skips files in other common build output directories
- [ ] User source files continue to be validated correctly
- [ ] Tests verify that generated files are ignored
- [ ] Tests verify that regular source files are still checked

## Technical Details
The fix should be implemented in the TW0003 analyzer by:
1. Checking if the file path contains `/obj/` or `/bin/`
2. Returning early without reporting diagnostics for these paths
3. Consider using normalized path comparison to handle different path separators

## Implementation Location
The fix should be applied in the TW0003 analyzer implementation, likely in the method that determines whether to analyze a given file.

## Test Cases
- Verify files in obj/ directory are ignored
- Verify files in bin/ directory are ignored
- Verify nested obj/bin directories are ignored (e.g., `/src/obj/`)
- Verify regular source files are still validated
- Verify edge cases like files named "obj.cs" in source directories are still checked

## References
- Original issue: `/home/steventcramer/worktrees/github.com/TimeWarpEngineering/timewarp-code/Cramer-2025-07-31-spike/analysis/tw0003-analyzer-issue.md`
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
namespace TimeWarp.SourceGenerators;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FileNameRuleCodeFixProvider)), Shared]
public class FileNameRuleCodeFixProvider : CodeFixProvider
{
private const string Title = "Rename file to kebab-case";

public sealed override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create(FileNameRuleAnalyzer.DiagnosticId);

public sealed override FixAllProvider GetFixAllProvider() =>
WellKnownFixAllProviders.BatchFixer;

public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
{
var diagnostic = context.Diagnostics.First();
var document = context.Document;

// Get the current file name
var filePath = document.FilePath;
if (string.IsNullOrEmpty(filePath))
return;

var currentFileName = Path.GetFileName(filePath);
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(currentFileName);
var extension = Path.GetExtension(currentFileName);

// Convert to kebab-case
var newFileNameWithoutExtension = ConvertToKebabCase(fileNameWithoutExtension);
var newFileName = newFileNameWithoutExtension + extension;

// Only offer fix if the new name is different
if (newFileName == currentFileName)
return;

// Register the code fix
context.RegisterCodeFix(
CodeAction.Create(
title: $"{Title}: '{newFileName}'",
createChangedSolution: c => RenameFileAsync(document, newFileName, c),
equivalenceKey: Title),
diagnostic);

return Task.CompletedTask;
}

private async Task<Solution> RenameFileAsync(
Document document,
string newFileName,
CancellationToken cancellationToken)
{
var solution = document.Project.Solution;

// Get the new document with renamed file
var newDocument = document.WithName(newFileName);

// Remove old document and add new one
var newSolution = solution
.RemoveDocument(document.Id)
.AddDocument(
newDocument.Id,
newFileName,
await document.GetTextAsync(cancellationToken),
document.Folders,
document.FilePath != null ? Path.Combine(Path.GetDirectoryName(document.FilePath)!, newFileName) : null);

return newSolution;
}

private static string ConvertToKebabCase(string input)
{
if (string.IsNullOrEmpty(input))
return input;

// Handle already kebab-case
if (Regex.IsMatch(input, @"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"))
return input;

var result = new StringBuilder();
bool previousWasUpper = false;
bool previousWasNumber = false;

for (int i = 0; i < input.Length; i++)
{
char c = input[i];

if (char.IsUpper(c))
{
// Add hyphen before uppercase letter if:
// - Not at start
// - Previous char was lowercase or number
// - Or this is start of new word in acronym (next char is lowercase)
if (i > 0 && (!previousWasUpper || (i + 1 < input.Length && char.IsLower(input[i + 1]))))
{
result.Append('-');
}

result.Append(char.ToLowerInvariant(c));
previousWasUpper = true;
previousWasNumber = false;
}
else if (char.IsDigit(c))
{
// Add hyphen before number if previous wasn't number and we're not at start
if (i > 0 && !previousWasNumber)
{
result.Append('-');
}

result.Append(c);
previousWasUpper = false;
previousWasNumber = true;
}
else if (char.IsLower(c))
{
result.Append(c);
previousWasUpper = false;
previousWasNumber = false;
}
else if (c == '-' || c == '_')
{
// Replace underscores with hyphens, avoid double hyphens
if (result.Length > 0 && result[result.Length - 1] != '-')
{
result.Append('-');
}
previousWasUpper = false;
previousWasNumber = false;
}
}

// Clean up any double hyphens or trailing hyphens
var cleaned = Regex.Replace(result.ToString(), @"-+", "-");
cleaned = cleaned.Trim('-');

return cleaned;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Create Kebab-Case File Name Code Fix

## Status: Blocked - Requires Separate Assembly

### Issue Discovered
Code fix providers cannot be in the same assembly as source generators due to RS1038 error. The Microsoft.CodeAnalysis.Workspaces assembly (required for code fixes) is not provided during command line compilation scenarios.

## Description
Create a code fix provider for the FileNameRuleAnalyzer (TW0003) that automatically renames files from PascalCase or other naming conventions to kebab-case format.

Expand Down Expand Up @@ -36,4 +41,18 @@ Create a code fix provider for the FileNameRuleAnalyzer (TW0003) that automatica

## Dependencies
- Requires FileNameRuleAnalyzer (TW0003) to be working
- Should follow existing code fix provider patterns in the codebase
- Should follow existing code fix provider patterns in the codebase

## Implementation Progress
- [x] Created FileNameRuleCodeFixProvider class
- [x] Implemented PascalCase to kebab-case conversion logic
- [x] Added file renaming through Roslyn workspace APIs
- [ ] Blocked: Cannot include in same assembly as source generators

## Next Steps
To complete this task, one of the following approaches is needed:
1. Create a separate project for code fix providers (e.g., `timewarp-source-generators.codefixes`)
2. Distribute code fixes as a separate NuGet package
3. Restructure the solution to support both analyzers and code fixes properly

The code fix provider implementation is complete and stored in this folder for when the project structure supports it.
36 changes: 22 additions & 14 deletions source/timewarp-source-generators/file-name-rule-analyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,29 @@ public class FileNameRuleAnalyzer : IIncrementalGenerator
"Directory.Build.props",
"Directory.Build.targets",
"Directory.Packages.props",
"AssemblyInfo.cs",
"*AssemblyInfo.cs",
"*.AssemblyInfo.cs",
"*.AssemblyAttributes.cs",
"*.GlobalUsings.g.cs",
"AnalyzerReleases.Shipped.md",
"AnalyzerReleases.Unshipped.md"
];

public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Create a value provider that provides all syntax trees with config options
var syntaxTreesWithConfig = context.CompilationProvider
IncrementalValuesProvider<(SyntaxTree tree, AnalyzerConfigOptionsProvider configOptions)> syntaxTreesWithConfig = context.CompilationProvider
.Combine(context.AnalyzerConfigOptionsProvider)
.SelectMany((source, _) =>
{
var (compilation, configOptions) = source;
(Compilation compilation, AnalyzerConfigOptionsProvider configOptions) = source;
return compilation.SyntaxTrees.Select(tree => (tree, configOptions));
});

// Register diagnostics for each syntax tree
context.RegisterSourceOutput(syntaxTreesWithConfig, (spc, source) =>
{
var (tree, configOptions) = source;
(SyntaxTree tree, AnalyzerConfigOptionsProvider configOptions) = source;
AnalyzeFileNaming(spc, tree, configOptions);
});
}
Expand All @@ -69,7 +72,7 @@ private void AnalyzeFileNaming(SourceProductionContext context, SyntaxTree tree,
return;

// Get configured exceptions
string[] exceptions = GetConfiguredExceptions(configOptions);
string[] exceptions = GetConfiguredExceptions(configOptions, tree);

// Check if file matches any exception pattern
if (IsFileExcepted(fileName, exceptions))
Expand All @@ -78,28 +81,33 @@ private void AnalyzeFileNaming(SourceProductionContext context, SyntaxTree tree,
// Check if file name follows kebab-case pattern
if (!KebabCasePattern.IsMatch(fileName))
{
Location location = Location.Create(
var location = Location.Create(
tree,
Microsoft.CodeAnalysis.Text.TextSpan.FromBounds(0, 0)
TextSpan.FromBounds(0, 0)
);

Diagnostic diagnostic = Diagnostic.Create(Rule, location, fileName);
var diagnostic = Diagnostic.Create(Rule, location, fileName);
context.ReportDiagnostic(diagnostic);
}
}

private string[] GetConfiguredExceptions(AnalyzerConfigOptionsProvider configOptions)
private string[] GetConfiguredExceptions(AnalyzerConfigOptionsProvider configOptions, SyntaxTree tree)
{
// Get file-specific options
AnalyzerConfigOptions options = configOptions.GetOptions(tree);

// Try to get configured exceptions from .editorconfig
if (configOptions.GlobalOptions.TryGetValue(
if (options.TryGetValue(
"dotnet_diagnostic.TW0003.excluded_files",
out string? configuredExceptions) && !string.IsNullOrEmpty(configuredExceptions))
{
// Split by semicolon and trim whitespace
return configuredExceptions
.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.ToArray();
IEnumerable<string> additionalExceptions = configuredExceptions
.Split([';'], StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim());

// Merge defaults with configured exceptions
return [.. DefaultExceptions, .. additionalExceptions];
}

// Return default exceptions if not configured
Expand Down
3 changes: 3 additions & 0 deletions tests/timewarp-source-generators-test-console/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
root = true

[*.cs]

dotnet_diagnostic.TW0003.severity = error # Set to warning/error to enforce kebab-case file naming
dotnet_diagnostic.TW0003.excluded_files = PascalCaseTest.cs
# Enable TW0004 - XML documentation to markdown analyzer
dotnet_diagnostic.TW0004.severity = suggestion
14 changes: 1 addition & 13 deletions tests/timewarp-source-generators-test-console/packages.lock.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"net9.0": {
"Microsoft.CodeAnalysis.Analyzers": {
"type": "Direct",
"requested": "[3.11.0, )",
Expand All @@ -18,18 +18,6 @@
"Microsoft.CodeAnalysis.Common": "[4.11.0]"
}
},
"Microsoft.DotNet.ILCompiler": {
"type": "Direct",
"requested": "[10.0.0-preview.6.25358.103, )",
"resolved": "10.0.0-preview.6.25358.103",
"contentHash": "+bEoavvMKwx3xRAgUajaFiu3bdRuWCEhf+rkaubJM9jjq6Oj38y9eOlhMNwdnqg6FptLaL4DB79H/Y+A7V5nVw=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.0-preview.6.25358.103, )",
"resolved": "10.0.0-preview.6.25358.103",
"contentHash": "+3mK1T4Y/M+u0fIlw3nDoTgOQGvVt3jPqpI9S8TBXFYJ1WnjCTbvwv+yLML9NyqaXpBg4jWV7EgIH9JWCOpa9Q=="
},
"Microsoft.CodeAnalysis.Common": {
"type": "Transitive",
"resolved": "4.11.0",
Expand Down