diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6212cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore SharpLinter.slnx + + - name: Build Solution + run: dotnet build SharpLinter.slnx --configuration Release --no-restore + + - name: Run Tests + run: dotnet test SharpLinter.slnx --configuration Release --no-build --verbosity normal + + - name: Pack NuGet Packages + run: dotnet pack SharpLinter.slnx --configuration Release --no-build -o artifacts + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: artifacts/*.nupkg diff --git a/.sharplinter.json b/.sharplinter.json new file mode 100644 index 0000000..8e5f0e4 --- /dev/null +++ b/.sharplinter.json @@ -0,0 +1,16 @@ +{ + "preset": "recommended", + "customRulesFile": ".sharplinter.rules.yaml", + "exclude": [ + "**/bin/**", + "**/obj/**", + "**/artifacts/**", + "**/nupkg/**" + ], + "formatting": { + "enabled": true, + "indentSize": 4, + "useTabs": false, + "newLineForBraces": true + } +} diff --git a/.sharplinter.rules.yaml b/.sharplinter.rules.yaml new file mode 100644 index 0000000..f759a28 --- /dev/null +++ b/.sharplinter.rules.yaml @@ -0,0 +1,37 @@ +# SharpLinter Custom Rules +# Define your own rules here without writing C# analyzer code. + +rules: + # Example 1: Flag TODO or HACK comments in code + - id: "CUSTOM001" + title: "Clean up temporary comments" + description: "HACK, TODO, or FIXME comments should be resolved before committing code." + category: "Maintainability" + severity: "warning" + type: "pattern" + pattern: + kind: "comment" + match: "TODO|HACK|FIXME" + + # Example 2: Limit method length to 40 lines + - id: "CUSTOM002" + title: "Method exceeds line limit" + description: "Methods should not exceed 40 lines. Consider decomposing complex logic." + category: "Maintainability" + severity: "suggestion" + type: "metric" + metric: + target: "method" + measure: "lines" + max: 40 + + # Example 3: Ban public fields + - id: "CUSTOM003" + title: "Encapsulation violation" + description: "Public instance fields should be refactored to properties." + category: "Design" + severity: "warning" + type: "naming" + naming: + target: "field" + pattern: "^[a-z_][a-zA-Z0-9]*$" # camelCase fields should be private/protected diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..093289f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rahu619 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LinterProject.API/Controllers/CodeAnalysisController.cs b/LinterProject.API/Controllers/CodeAnalysisController.cs deleted file mode 100644 index 305e4fe..0000000 --- a/LinterProject.API/Controllers/CodeAnalysisController.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using OnlineLinter.Interfaces; -using System.Net; -using System.Net.Mime; -using System.Text; - -namespace OnlineLinter.Controllers -{ - [Route("api/v1/[controller]")] - [ApiController] - public class CodeAnalysisController : ControllerBase - { - private readonly ICodeAnalysisService _codeAnalysisService; - - public CodeAnalysisController(ICodeAnalysisService linterService) - { - this._codeAnalysisService = linterService; - } - - /// - /// Formats and returns a c sharp code snippet - /// - /// - /// - /// A newly formatted code - [HttpPost()] - [Produces(MediaTypeNames.Text.Plain)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> FormatCode(CancellationToken cancellationToken) - { - string? content; - using (var reader = new StreamReader(Request.Body, Encoding.UTF8)) - { - content = await reader.ReadToEndAsync(); - } - ArgumentNullException.ThrowIfNull(content, "Error reading request body"); - - var formattedCode = await this._codeAnalysisService.GetFormattedCode(content, cancellationToken); - - return Ok(formattedCode); - } - } -} diff --git a/LinterProject.API/ExceptionHandlingMiddleware.cs b/LinterProject.API/ExceptionHandlingMiddleware.cs deleted file mode 100644 index 12b4996..0000000 --- a/LinterProject.API/ExceptionHandlingMiddleware.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Net; -using System.Net.Mime; - -namespace OnlineLinter -{ - public class ExceptionHandlingMiddleware - { - private readonly ILogger _logger; - private readonly RequestDelegate _next; - - public ExceptionHandlingMiddleware(ILogger logger, - RequestDelegate next) - { - this._logger = logger; - this._next = next; - } - - public async Task InvokeAsync(HttpContext httpContext) - { - try - { - await _next(httpContext); - } - catch (Exception ex) - { - httpContext.Response.ContentType = MediaTypeNames.Application.Json; - httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - - if (ex is ArgumentException) - { - httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; - } - - await httpContext.Response.WriteAsync($"An error occurred while processing your request. Found exception: {ex.Message}"); - } - } - } -} diff --git a/LinterProject.API/Interfaces/ICodeAnalysisService.cs b/LinterProject.API/Interfaces/ICodeAnalysisService.cs deleted file mode 100644 index 49bdc7e..0000000 --- a/LinterProject.API/Interfaces/ICodeAnalysisService.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace OnlineLinter.Interfaces -{ - /// - /// The interface for the code analyis service. - /// - public interface ICodeAnalysisService - { - /// - /// Formats and indents the passed in - /// - /// The incoming code - /// The cancellation token - /// - Task GetFormattedCode(string codeSnippet, CancellationToken cancellationToken = default); - } -} diff --git a/LinterProject.API/LinterProject.API.csproj b/LinterProject.API/LinterProject.API.csproj deleted file mode 100644 index d46dc22..0000000 --- a/LinterProject.API/LinterProject.API.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net6.0 - enable - enable - - - - - - - - - - - diff --git a/LinterProject.API/Program.cs b/LinterProject.API/Program.cs deleted file mode 100644 index de7516d..0000000 --- a/LinterProject.API/Program.cs +++ /dev/null @@ -1,39 +0,0 @@ -using OnlineLinter; -using OnlineLinter.Interfaces; -using OnlineLinter.Services; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. - -builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(c => -{ - c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo { Title = "Linter.API", Version = "v1", Description = "An API to format c# code snippets." }); -}); - -builder.Services.AddRouting(options => options.LowercaseUrls = true); -builder.Services.AddSingleton(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(c => - { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "Linter.API"); - }); -} - -//Adding custom middlewares -app.UseMiddleware(); - -app.UseHttpsRedirection(); -app.UseAuthorization(); -app.MapControllers(); - -app.Run(); diff --git a/LinterProject.API/Properties/launchSettings.json b/LinterProject.API/Properties/launchSettings.json deleted file mode 100644 index f5ea29e..0000000 --- a/LinterProject.API/Properties/launchSettings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:52916", - "sslPort": 44302 - } - }, - "profiles": { - "OnlineLinter": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7295;http://localhost:5035", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/LinterProject.API/Services/CodeAnalysisService.cs b/LinterProject.API/Services/CodeAnalysisService.cs deleted file mode 100644 index 7bb1b7a..0000000 --- a/LinterProject.API/Services/CodeAnalysisService.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Formatting; -using Microsoft.CodeAnalysis.Formatting; -using OnlineLinter.Interfaces; -using OnlineLinter.SyntaxRewriters; - -namespace OnlineLinter.Services -{ - public class CodeAnalysisService : ICodeAnalysisService - { - private readonly ILogger _logger; - - public CodeAnalysisService(ILogger logger) - { - this._logger = logger; - } - - /// - public async Task GetFormattedCode(string codeSnippet, CancellationToken cancellationToken = default) - { - //TODO: Upgrade to .NET 7 - //https://learn.microsoft.com/en-us/dotnet/api/system.argumentexception.throwifnullorempty?view=net-7.0 - - if (string.IsNullOrWhiteSpace(codeSnippet)) - { - throw new ArgumentNullException(nameof(codeSnippet)); - } - - if (!ValidateCode(codeSnippet)) - { - throw new ArgumentException("Error parsing code snippet"); - } - - var syntaxTree = CSharpSyntaxTree.ParseText(codeSnippet); - var root = await syntaxTree.GetRootAsync(cancellationToken); - var rewriter = new AddBracesRewriter(); - var modifiedRoot = rewriter.Visit(root); //Applying syntax rewriters - - var workspace = new AdhocWorkspace(); - - var options = workspace.Options - .WithChangedOption(CSharpFormattingOptions.IndentBlock, true) - .WithChangedOption(CSharpFormattingOptions.IndentBraces, false) - .WithChangedOption(CSharpFormattingOptions.SpaceAfterColonInBaseTypeDeclaration, true) - .WithChangedOption(CSharpFormattingOptions.SpaceBeforeColonInBaseTypeDeclaration, false) - .WithChangedOption(CSharpFormattingOptions.NewLineForCatch, true) - .WithChangedOption(CSharpFormattingOptions.NewLineForFinally, true) - .WithChangedOption(CSharpFormattingOptions.NewLineForMembersInObjectInit, true) - .WithChangedOption(CSharpFormattingOptions.NewLineForMembersInAnonymousTypes, true) - .WithChangedOption(CSharpFormattingOptions.NewLineForClausesInQuery, true) - .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInControlBlocks, true) - .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInMethods, true) - .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInProperties, true) - .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInTypes, true) - .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInControlBlocks, true) - ; - - - var formattedSyntaxNode = Formatter.Format(modifiedRoot, workspace, options, cancellationToken); - - return formattedSyntaxNode.ToFullString(); - } - - private bool ValidateCode(string codeSnippet) - { - var syntaxTree = CSharpSyntaxTree.ParseText(codeSnippet); - if (syntaxTree is null) - { - _logger.LogError("Error parsing syntax : {codeSnippet}", codeSnippet); - return false; - } - - foreach (var diagnostic in syntaxTree.GetDiagnostics()) - { - if (diagnostic.Severity == DiagnosticSeverity.Error) - { - _logger.LogError("Error parsing syntax : {codeSnippet}", codeSnippet); - return false; - } - } - - return true; - } - } -} diff --git a/LinterProject.API/SyntaxRewriters/AddBracesRewriter.cs b/LinterProject.API/SyntaxRewriters/AddBracesRewriter.cs deleted file mode 100644 index a61c665..0000000 --- a/LinterProject.API/SyntaxRewriters/AddBracesRewriter.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace OnlineLinter.SyntaxRewriters -{ - public class AddBracesRewriter : CSharpSyntaxRewriter - { - public override SyntaxNode VisitIfStatement(IfStatementSyntax node) - { - var newIfStatement = node.WithStatement(AddBracesToStatement(node.Statement)); - - if (newIfStatement.Else != null) - { - var newElseClause = newIfStatement.Else.WithStatement(AddBracesToStatement(newIfStatement.Else.Statement)); - newIfStatement = newIfStatement.WithElse(newElseClause); - } - - return base.VisitIfStatement(newIfStatement); - } - - public override SyntaxNode VisitWhileStatement(WhileStatementSyntax node) - { - return node.WithStatement(AddBracesToStatement(node.Statement)); - } - - public override SyntaxNode VisitForStatement(ForStatementSyntax node) - { - return node.WithStatement(AddBracesToStatement(node.Statement)); - } - - public override SyntaxNode VisitForEachStatement(ForEachStatementSyntax node) - { - return node.WithStatement(AddBracesToStatement(node.Statement)); - } - - public override SyntaxNode VisitDoStatement(DoStatementSyntax node) - { - return node.WithStatement(AddBracesToStatement(node.Statement)); - } - - private StatementSyntax AddBracesToStatement(StatementSyntax statement) - { - if (statement is BlockSyntax) - { - return statement; - } - - return SyntaxFactory.Block(statement); - } - } -} diff --git a/LinterProject.API/appsettings.Development.json b/LinterProject.API/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/LinterProject.API/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/LinterProject.API/appsettings.json b/LinterProject.API/appsettings.json deleted file mode 100644 index 10f68b8..0000000 --- a/LinterProject.API/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/LinterProject.Tests/CodeAnalysisServiceTests.cs b/LinterProject.Tests/CodeAnalysisServiceTests.cs deleted file mode 100644 index e1815ba..0000000 --- a/LinterProject.Tests/CodeAnalysisServiceTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using NUnit.Framework; -using OnlineLinter.Interfaces; -using OnlineLinter.Services; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; - -namespace OnlineLinter.Test -{ - [TestFixture] - public class CodeAnalysisServiceTests - { - [TestCaseSource(nameof(CodeSnippetTestData))] - public async Task ShouldFormatValidCode(string expectedCode, string inputCode) - { - //Arrange - //Act - string actualCode = await new CodeAnalysisService(NullLogger.Instance).GetFormattedCode(inputCode); - - //Assert - actualCode.Should().NotBeEmpty().And.BeEquivalentTo(expectedCode); - } - - private static IEnumerable CodeSnippetTestData() - { - var expectedFilePaths = Directory.EnumerateFiles(@"Resources\Expected").ToList(); - var inputFilePaths = Directory.EnumerateFiles(@"Resources\Input").ToList(); - - if (expectedFilePaths.Count != inputFilePaths.Count) - { - throw new InvalidOperationException("The directories must contain the same number of files."); - } - - for (int i = 0; i < expectedFilePaths.Count; i++) - { - var expectedFileContent = File.ReadAllText(expectedFilePaths[i]) ?? throw new Exception($"Error retrieving expected file path: {expectedFilePaths[i]}"); - var inputFileContent = File.ReadAllText(inputFilePaths[i]) ?? throw new Exception($"Error retrieving input file path: {inputFilePaths[i]}"); - - yield return new object[] { expectedFileContent, inputFileContent }; - } - } - } -} \ No newline at end of file diff --git a/LinterProject.Tests/LinterProject - Backup.Test.csproj b/LinterProject.Tests/LinterProject - Backup.Test.csproj deleted file mode 100644 index 938ef88..0000000 --- a/LinterProject.Tests/LinterProject - Backup.Test.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - net6.0 - enable - false - - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/LinterProject.Tests/LinterProject.Test.csproj b/LinterProject.Tests/LinterProject.Test.csproj deleted file mode 100644 index 938ef88..0000000 --- a/LinterProject.Tests/LinterProject.Test.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - net6.0 - enable - false - - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/LinterProject.Tests/Resources/Expected/SampleCode_1.txt b/LinterProject.Tests/Resources/Expected/SampleCode_1.txt deleted file mode 100644 index 45fc847..0000000 --- a/LinterProject.Tests/Resources/Expected/SampleCode_1.txt +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OnlineLinter.Test -{ - class SampleCode_1 - { - } -} diff --git a/LinterProject.Tests/Resources/Expected/SampleCode_2.txt b/LinterProject.Tests/Resources/Expected/SampleCode_2.txt deleted file mode 100644 index 071559c..0000000 --- a/LinterProject.Tests/Resources/Expected/SampleCode_2.txt +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OnlineLinter.Test -{ - class SampleCode_2 - { - public static void Main(string[] args) - { - Console.WriteLine("Starting application"); - - if (args.Any()) - { - Console.WriteLine("No args found!"); - } - else - { - Console.WriteLine(string.Join(",", args)); - } - } - } -} diff --git a/LinterProject.Tests/Resources/Input/SampleCode_1.txt b/LinterProject.Tests/Resources/Input/SampleCode_1.txt deleted file mode 100644 index 35e0b30..0000000 --- a/LinterProject.Tests/Resources/Input/SampleCode_1.txt +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - using System.Text; -using System.Threading.Tasks; - -namespace OnlineLinter.Test -{ - class SampleCode_1 - { - } -} diff --git a/LinterProject.Tests/Resources/Input/SampleCode_2.txt b/LinterProject.Tests/Resources/Input/SampleCode_2.txt deleted file mode 100644 index 57c96f4..0000000 --- a/LinterProject.Tests/Resources/Input/SampleCode_2.txt +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace OnlineLinter.Test -{ - class SampleCode_2 - { - public static void Main(string[] args) - { - Console.WriteLine("Starting application"); - - if (args.Any()) Console.WriteLine("No args found!"); - else Console.WriteLine(string.Join(",", args)); - } - } -} diff --git a/LinterProject.sln b/LinterProject.sln deleted file mode 100644 index 5dbf814..0000000 --- a/LinterProject.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32407.343 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinterProject.API", "LinterProject.API\LinterProject.API.csproj", "{D9715C49-F2C0-4E55-87FC-483E879FC0F9}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinterProject.Test", "LinterProject.Tests\LinterProject.Test.csproj", "{1F8D0A79-4B5A-45D0-9605-EC2F0844425B}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {D9715C49-F2C0-4E55-87FC-483E879FC0F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D9715C49-F2C0-4E55-87FC-483E879FC0F9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D9715C49-F2C0-4E55-87FC-483E879FC0F9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D9715C49-F2C0-4E55-87FC-483E879FC0F9}.Release|Any CPU.Build.0 = Release|Any CPU - {1F8D0A79-4B5A-45D0-9605-EC2F0844425B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1F8D0A79-4B5A-45D0-9605-EC2F0844425B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1F8D0A79-4B5A-45D0-9605-EC2F0844425B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1F8D0A79-4B5A-45D0-9605-EC2F0844425B}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {3E33B9E4-41C3-4914-9F71-7BB2F1153113} - EndGlobalSection -EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..5183fbf --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +# SharpLinter + +[![Build Status](https://img.shields.io/github/actions/workflow/status/rahu619/SharpLinter/ci.yml?branch=main)](https://github.com/rahu619/SharpLinter/actions) +[![NuGet Version](https://img.shields.io/nuget/v/SharpLinter.svg)](https://www.nuget.org/packages/SharpLinter) +[![License](https://img.shields.io/github/license/rahu619/SharpLinter.svg)](LICENSE) + +SharpLinter is a free, lightweight, syntax-tree-first C# linter and formatter. It runs fully offline with zero configuration required, but is highly customizable. + +Unlike heavy build-time Roslyn analyzers or expensive enterprise tools like SonarQube, SharpLinter is built for speed and flexibility. It can run in pre-commit hooks, CI/CD pipelines, or as a standalone CLI tool. + +--- + +## Key Features + +- ⚡ **Lightning Fast:** Analyzes code structures using syntax-tree-only matching without full compilation overhead. +- 📦 **Pluggable & Distributable:** Available as both a reusable NuGet library (`SharpLinter`) and a dotnet CLI tool (`SharpLinter.Cli`). +- 🤖 **Online/Offline Rule Sync:** Syncs the latest Microsoft Code Analysis (CA/IDE) rules from Microsoft Learn, caching them locally. +- 🛠️ **YAML-Based Custom Rules:** Create custom rules (regex patterns, metrics like method length/complexity, naming styles) with zero C# coding. +- 📊 **Multi-Format Output:** Get results in human-readable console colors, JSON, standard **SARIF v2.1.0**, or **MSBuild native warning format**. + +--- + +## 🚀 Installation & Setup + +### For CLI usage: +```bash +dotnet tool install --global SharpLinter.Cli +``` + +### For programmatic C# Library usage: +Add the NuGet package dependency: +```xml + +``` + +--- + +## 🛠️ CLI How-To & Commands + +### 1. Initialize Configuration +Set up the default configuration file `.sharplinter.json` and a custom rules YAML template in your project root: +```bash +sharplinter init --custom-rules +``` + +### 2. Analyze Code files and folders +Scan your workspace for style and code violations: +```bash +# Print colored, grouped console reports (default) +sharplinter analyze ./src + +# Generate a machine-readable JSON report +sharplinter analyze ./src --format json + +# Export a SARIF report for GitHub Code Scanning/Security alerts +sharplinter analyze ./src --format sarif > results.sarif +``` + +### 3. Automatically Format & Fix Issues +Auto-fix fixable issues (like adding missing control flow braces or stripping trailing whitespaces): +```bash +# Fix files in place +sharplinter format ./src + +# Verify formatting without altering files (non-zero exit code if issues exist — perfect for pull requests) +sharplinter format ./src --check +``` + +### 4. Sync Rule Databases Offline +Synchronize local caches with the latest Microsoft Learn rules database: +```bash +sharplinter sync +``` + +--- + +## ⚡ MSBuild & IDE Integration How-To + +You can trigger analysis automatically whenever anyone runs `dotnet build` (or builds within Visual Studio, Rider, or VS Code). Adding the following targets block to your `.csproj` automatically flags `SharpLinter` findings as native IDE compiler warnings: + +```xml + + + + + + + + +``` + +When building, warnings will display highlighted with `[SharpLinter]` in cyan: +```bash +$ dotnet build +/Users/user/Program.cs(6,11): warning SL1005: [SharpLinter] Class 'my_bad_class' should use PascalCase [/Users/user/Project.csproj] +``` + +--- + +## 📦 Programmatic NuGet API How-To + +Consume the `SharpLinter.Core` library directly in your C# code to build custom linting pipelines: + +```csharp +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +// 1. Load configuration (discover .sharplinter.json automatically or load recommended defaults) +var config = Presets.GetPreset("recommended"); +var engine = new LintEngine(config); + +// 2. Perform static analysis on code string +string code = "class bad_name {}"; +LintResult result = await engine.AnalyzeCodeAsync(code, "Temp.cs"); + +// 3. Inspect diagnostics +Console.WriteLine($"Found {result.Diagnostics.Count} issue(s):"); +foreach (var diag in result.Diagnostics) +{ + Console.WriteLine($"[{diag.RuleId}] Line {diag.Line}: {diag.Message}"); +} + +// 4. Auto-formatted code output (if formatting is enabled) +string formattedCode = result.FormattedCode; +``` + +--- + +## ✍️ Defining Custom Rules (`.sharplinter.rules.yaml`) + +Add custom validation rules declaratively using YAML without writing C# compilation code: + +```yaml +rules: + # Example 1: Regexp pattern checks on comments + - id: "CUSTOM001" + title: "Avoid temporary comment markers" + description: "HACK, TODO, or FIXME comments should be cleaned up before merging." + category: "Maintainability" + severity: "warning" + type: "pattern" + pattern: + kind: "comment" + match: "HACK|TODO|FIXME" + + # Example 2: Metric boundaries on method lines + - id: "CUSTOM002" + title: "Method is too long" + description: "Keep methods under 40 lines to maintain clean architecture." + category: "Maintainability" + severity: "suggestion" + type: "metric" + metric: + target: "method" + measure: "lines" + max: 40 + + # Example 3: Private field prefix validation + - id: "CUSTOM003" + title: "Private fields prefix rule" + category: "Naming" + severity: "warning" + type: "naming" + naming: + target: "field" + pattern: "^_[a-z][a-zA-Z0-9]*$" +``` + +--- + +## ⚙️ Configuration Reference (`.sharplinter.json`) + +Configure rule behaviors, formatting constraints, and file filters: + +```json +{ + "preset": "recommended", + "rules": { + "SL1001": "error", + "SL1004": { "severity": "warning", "maxLines": 60 }, + "SL1007": { "severity": "suggestion", "maxComplexity": 15 }, + "SL1010": "off" + }, + "exclude": [ + "**/bin/**", + "**/obj/**", + "**/Generated/**" + ], + "formatting": { + "enabled": true, + "indentSize": 4, + "useTabs": false, + "newLineForBraces": true + } +} +``` + +--- + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/SharpLinter.slnx b/SharpLinter.slnx new file mode 100644 index 0000000..863cfc2 --- /dev/null +++ b/SharpLinter.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/custom-rules.md b/docs/custom-rules.md new file mode 100644 index 0000000..aa55938 --- /dev/null +++ b/docs/custom-rules.md @@ -0,0 +1,112 @@ +# SharpLinter Custom Rules Reference + +Custom rules are defined in a simple YAML configuration file (by default `.sharplinter.rules.yaml` in your project root). They allow teams to enforce codebase-specific constraints, deprecate APIs, or prevent pattern anti-patterns without writing compiled Roslyn extensions. + +--- + +## Configuration Schema + +A custom rule requires: +- `id`: A unique string starting with `CUSTOM` (e.g., `CUSTOM001`). +- `title`: Short summary message shown to developers. +- `description`: Detailed explanation of the rule's rationale. +- `category`: `Style`, `Naming`, `Design`, `Performance`, `Security`, or `Maintainability`. +- `severity`: `error`, `warning`, `suggestion`, or `none`. +- `type`: The engine to run: `pattern`, `metric`, or `naming`. + +--- + +## 1. Pattern Rules (`type: "pattern"`) + +Pattern rules use regex matching against specified syntax elements. + +### Supported Fields +- `kind`: The syntax element to target: + - `comment`: Both single-line (`//`) and multi-line (`/* */`) comments. + - `invocation`: Method invocations (e.g., `Console.WriteLine`, `Thread.Sleep`). + - `numeric-literal`: Numeric constants. + - `string-literal`: Hardcoded string variables. + - `identifier`: Variable, class, or method names. +- `match`: Regex pattern to match against target text. +- `exclude`: List of values to ignore (for `numeric-literal`). +- `scope`: Set to `method-body` to restrict scanning (optional). + +### Examples + +**Flag temporary comment tags:** +```yaml +- id: "CUSTOM001" + title: "Avoid HACK comments" + category: "Maintainability" + severity: "warning" + type: "pattern" + pattern: + kind: "comment" + match: "HACK|TODO|FIXME" +``` + +**Ban synchronous threading APIs:** +```yaml +- id: "CUSTOM002" + title: "Avoid Thread.Sleep" + category: "Performance" + severity: "error" + type: "pattern" + pattern: + kind: "invocation" + match: "Thread\\.Sleep" +``` + +--- + +## 2. Metric Rules (`type: "metric"`) + +Metric rules enforce quantitative thresholds. + +### Supported Fields +- `target`: What element to count: `method`, `class`, or `file`. +- `measure`: What unit to measure: + - `lines`: Line count of the target block. + - `parameters`: Parameter count (for `method`). + - `methods`: Method count (for `class`). + - `fields`: Field count (for `class`). +- `max`: The maximum allowed value before triggering a warning. + +### Examples + +**Enforce maximum parameters:** +```yaml +- id: "CUSTOM003" + title: "Too many parameters" + category: "Design" + severity: "warning" + type: "metric" + metric: + target: "method" + measure: "parameters" + max: 5 +``` + +--- + +## 3. Naming Rules (`type: "naming"`) + +Naming rules enforce regex-based naming constraints on structural tokens. + +### Supported Fields +- `target`: Target syntax element: `class`, `interface`, `method`, `property`, `field`, `parameter`, or `variable`. +- `pattern`: Regex pattern that names must match. + +### Examples + +**Enforce private field prefix conventions:** +```yaml +- id: "CUSTOM004" + title: "Private fields must start with underscore" + category: "Naming" + severity: "warning" + type: "naming" + naming: + target: "field" + pattern: "^_[a-z][a-zA-Z0-9]*$" +``` diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..cc0da62 --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,180 @@ +# SharpLinter Built-in Rules Catalog + +Here is the complete catalog of the 12 built-in rules that ship with SharpLinter. All rules run fully offline and are implemented as fast syntax-tree analyzers. + +--- + +## Style Rules + +### SL1001: Add braces to control flow statements +- **Default Severity:** Warning +- **Auto-Fixable:** Yes +- **Inspired By:** IDE0011, SA1503 +- **Description:** Control flow statements (`if`, `else`, `for`, `foreach`, `while`, `do`) should always use braces, even for single-line bodies. This prevents errors during code modification. + +**Non-compliant code:** +```csharp +if (condition) + Console.WriteLine("Violating"); +``` + +**Compliant code:** +```csharp +if (condition) +{ + Console.WriteLine("Compliant"); +} +``` + +--- + +### SL1006: Potentially unused using directives +- **Default Severity:** Suggestion +- **Auto-Fixable:** Yes (via format command) +- **Inspired By:** IDE0005 +- **Description:** Unused `using` directives clutter the file header. Clean them up automatically. + +--- + +### SL1008: Consistent brace placement +- **Default Severity:** Suggestion +- **Options:** `style: "newLine" | "sameLine"` +- **Description:** Enforces consistent brace placement. Default style is Allman (opening brace on a new line). + +**Allman Style (Default):** +```csharp +void Run() +{ +} +``` + +**K&R Style:** +```csharp +void Run() { +} +``` + +--- + +### SL1009: Trailing whitespace +- **Default Severity:** Suggestion +- **Auto-Fixable:** Yes +- **Description:** Trailing whitespace at the end of lines creates messy diffs. + +--- + +## Naming Rules + +### SL1005: Naming conventions +- **Default Severity:** Warning +- **Description:** Enforces C# coding conventions: + - Classes, Structs, Enums, Records, Interfaces, Methods, Properties: `PascalCase` + - Local variables, Parameters: `camelCase` + - Interfaces: Must start with prefix `I` + - Constants: `PascalCase` or `UPPER_SNAKE_CASE` + +--- + +## Design Rules + +### SL1002: Avoid empty catch blocks +- **Default Severity:** Warning +- **Inspired By:** CA1031 +- **Description:** Empty catch blocks silently swallow exceptions. A comment explaining why the exception is intentionally ignored will satisfy this rule. + +**Non-compliant:** +```csharp +try { + DoWork(); +} catch (Exception ex) { +} +``` + +**Compliant:** +```csharp +try { + DoWork(); +} catch (Exception ex) { + // Intentionally ignored because... +} +``` + +--- + +### SL1003: Avoid public fields +- **Default Severity:** Warning +- **Inspired By:** CA1051 +- **Description:** Public fields violate encapsulation. Use properties instead. `const` and `static readonly` fields are exempt. + +--- + +## Maintainability Rules + +### SL1004: Method is too long +- **Default Severity:** Suggestion +- **Options:** `maxLines: 50` +- **Description:** Long methods are hard to read. Extract logic into smaller helpers. + +--- + +### SL1007: Cyclomatic complexity +- **Default Severity:** Suggestion +- **Options:** `maxComplexity: 10` +- **Description:** Limits the number of decision paths (loops, branches) within a single method. + +--- + +### SL1010: File is too long +- **Default Severity:** Suggestion +- **Options:** `maxLines: 500` +- **Description:** Files should be modular. Split large classes into smaller files. + +--- + +## Performance Rules + +### SL1011: Pattern matching suggestions +- **Default Severity:** Suggestion +- **Inspired By:** IDE0019, IDE0020 +- **Description:** Prefer modern pattern matching checks over type checking and type casting. + +**Non-compliant:** +```csharp +if (obj is Person) +{ + var p = (Person)obj; +} +``` + +**Compliant:** +```csharp +if (obj is Person p) +{ +} +``` + +--- + +### SL1012: Avoid string concatenation in loops +- **Default Severity:** Warning +- **Inspired By:** CA1845 +- **Description:** String concatenation in a loop generates new objects on each iteration. Use `StringBuilder` for O(n) performance. + +**Non-compliant:** +```csharp +string result = ""; +foreach (var item in list) +{ + result += item; +} +``` + +**Compliant:** +```csharp +var sb = new StringBuilder(); +foreach (var item in list) +{ + sb.Append(item); +} +string result = sb.ToString(); +``` diff --git a/src/SharpLinter.Cli/Commands/AnalyzeCommand.cs b/src/SharpLinter.Cli/Commands/AnalyzeCommand.cs new file mode 100644 index 0000000..1590195 --- /dev/null +++ b/src/SharpLinter.Cli/Commands/AnalyzeCommand.cs @@ -0,0 +1,96 @@ +using System.CommandLine; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; +using SharpLinter.Core.Output; + +namespace SharpLinter.Cli.Commands; + +/// +/// `sharplinter analyze` — Analyze C# files for lint violations. +/// +public static class AnalyzeCommand +{ + public static Command Create() + { + var pathArg = new Argument("path", () => ".", "Path to a file or directory to analyze"); + var severityOpt = new Option("--severity", () => "suggestion", "Minimum severity to report (suggestion, warning, error)"); + var formatOpt = new Option("--format", () => "console", "Output format (console, json, sarif)"); + var configOpt = new Option("--config", "Path to .sharplinter.json config file"); + var noCacheOpt = new Option("--no-cache", "Skip rule cache, use only built-in rules"); + + var command = new Command("analyze", "Analyze C# files for lint violations") + { + pathArg, + severityOpt, + formatOpt, + configOpt, + noCacheOpt + }; + + command.SetHandler(async (path, severity, format, configPath, noCache) => + { + var config = configPath != null + ? LintConfiguration.LoadFromFile(configPath) + : LintConfiguration.Discover(Path.GetFullPath(path)); + + if (noCache) + { + config.RuleSync = new RuleSyncConfig { Enabled = false }; + } + + var engine = new LintEngine(config); + var results = new List(); + + if (File.Exists(path)) + { + var result = await engine.AnalyzeFileAsync(Path.GetFullPath(path)); + results.Add(result); + } + else if (Directory.Exists(path)) + { + var dirResults = await engine.AnalyzeDirectoryAsync(Path.GetFullPath(path)); + results.AddRange(dirResults); + } + else + { + Console.Error.WriteLine($"Error: Path '{path}' does not exist."); + Environment.ExitCode = 1; + return; + } + + // Filter by minimum severity + var minSeverity = ParseSeverity(severity); + var filteredResults = results.Select(r => new LintResult( + r.Diagnostics.Where(d => d.Severity >= minSeverity).ToList(), + r.Duration + )).ToList(); + + // Format output + IOutputFormatter formatter = format.ToLowerInvariant() switch + { + "json" => new JsonOutputFormatter(), + "sarif" => new SarifOutputFormatter(), + "msbuild" => new MsBuildOutputFormatter(), + _ => new ConsoleOutputFormatter() + }; + + var output = formatter.Format(filteredResults); + Console.Write(output); + + // Set exit code + var hasErrors = filteredResults.Any(r => r.HasErrors); + Environment.ExitCode = hasErrors ? 1 : 0; + + }, pathArg, severityOpt, formatOpt, configOpt, noCacheOpt); + + return command; + } + + private static LintSeverity ParseSeverity(string severity) => severity.ToLowerInvariant() switch + { + "error" => LintSeverity.Error, + "warning" => LintSeverity.Warning, + "suggestion" or "info" => LintSeverity.Suggestion, + _ => LintSeverity.Suggestion + }; +} diff --git a/src/SharpLinter.Cli/Commands/FormatCommand.cs b/src/SharpLinter.Cli/Commands/FormatCommand.cs new file mode 100644 index 0000000..0680aad --- /dev/null +++ b/src/SharpLinter.Cli/Commands/FormatCommand.cs @@ -0,0 +1,99 @@ +using System.CommandLine; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Cli.Commands; + +/// +/// `sharplinter format` — Format C# files according to configuration. +/// +public static class FormatCommand +{ + public static Command Create() + { + var pathArg = new Argument("path", () => ".", "Path to a file or directory to format"); + var dryRunOpt = new Option("--dry-run", "Show what would be changed without modifying files"); + var checkOpt = new Option("--check", "Exit with code 1 if any files need formatting (CI mode)"); + var configOpt = new Option("--config", "Path to .sharplinter.json config file"); + + var command = new Command("format", "Format C# files according to configuration") + { + pathArg, + dryRunOpt, + checkOpt, + configOpt + }; + + command.SetHandler(async (path, dryRun, check, configPath) => + { + var config = configPath != null + ? LintConfiguration.LoadFromFile(configPath) + : LintConfiguration.Discover(Path.GetFullPath(path)); + + var engine = new LintEngine(config); + var fullPath = Path.GetFullPath(path); + var files = new List(); + + if (File.Exists(fullPath)) + { + files.Add(fullPath); + } + else if (Directory.Exists(fullPath)) + { + files.AddRange(Directory.EnumerateFiles(fullPath, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains("/obj/") && !f.Contains("/bin/") && !f.Contains("\\obj\\") && !f.Contains("\\bin\\"))); + } + else + { + Console.Error.WriteLine($"Error: Path '{path}' does not exist."); + Environment.ExitCode = 1; + return; + } + + int changedCount = 0; + + foreach (var file in files) + { + var original = await File.ReadAllTextAsync(file); + var formatted = engine.FormatCode(original); + + if (original != formatted) + { + changedCount++; + var relative = Path.GetRelativePath(Directory.GetCurrentDirectory(), file); + + if (dryRun || check) + { + Console.WriteLine($" ⚡ {relative} (needs formatting)"); + } + else + { + await File.WriteAllTextAsync(file, formatted); + Console.WriteLine($" ✅ {relative} (formatted)"); + } + } + } + + if (changedCount == 0) + { + Console.WriteLine("✅ All files are properly formatted."); + } + else if (dryRun) + { + Console.WriteLine($"\n {changedCount} file(s) would be changed."); + } + else if (check) + { + Console.WriteLine($"\n {changedCount} file(s) need formatting."); + Environment.ExitCode = 1; + } + else + { + Console.WriteLine($"\n {changedCount} file(s) formatted."); + } + + }, pathArg, dryRunOpt, checkOpt, configOpt); + + return command; + } +} diff --git a/src/SharpLinter.Cli/Commands/InitCommand.cs b/src/SharpLinter.Cli/Commands/InitCommand.cs new file mode 100644 index 0000000..8454b46 --- /dev/null +++ b/src/SharpLinter.Cli/Commands/InitCommand.cs @@ -0,0 +1,101 @@ +using System.CommandLine; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Cli.Commands; + +/// +/// `sharplinter init` — Generate configuration files. +/// +public static class InitCommand +{ + public static Command Create() + { + var presetOpt = new Option("--preset", () => "recommended", "Preset to use (recommended, strict, minimal)"); + var customRulesOpt = new Option("--custom-rules", "Also generate a .sharplinter.rules.yaml template"); + + var command = new Command("init", "Generate SharpLinter configuration files") + { + presetOpt, + customRulesOpt + }; + + command.SetHandler((preset, includeCustomRules) => + { + // Generate .sharplinter.json + var configPath = Path.Combine(Directory.GetCurrentDirectory(), ".sharplinter.json"); + if (File.Exists(configPath)) + { + Console.WriteLine($"⚠️ {configPath} already exists. Skipping."); + } + else + { + var config = Presets.GetPreset(preset); + if (includeCustomRules) + { + config.CustomRulesFile = ".sharplinter.rules.yaml"; + } + File.WriteAllText(configPath, config.ToJson()); + Console.WriteLine($"✅ Created .sharplinter.json (preset: {preset})"); + } + + // Generate custom rules template + if (includeCustomRules) + { + var rulesPath = Path.Combine(Directory.GetCurrentDirectory(), ".sharplinter.rules.yaml"); + if (File.Exists(rulesPath)) + { + Console.WriteLine($"⚠️ {rulesPath} already exists. Skipping."); + } + else + { + File.WriteAllText(rulesPath, GetCustomRulesTemplate()); + Console.WriteLine("✅ Created .sharplinter.rules.yaml (custom rules template)"); + } + } + + }, presetOpt, customRulesOpt); + + return command; + } + + private static string GetCustomRulesTemplate() => """ + # SharpLinter Custom Rules + # Define your own lint rules using simple YAML syntax. + # See: https://github.com/rahu619/SharpLinter/docs/custom-rules.md + + rules: + # Example: Flag TODO/HACK/FIXME comments + - id: "CUSTOM001" + title: "Avoid TODO comments in production code" + description: "TODO comments indicate incomplete work" + category: "Maintainability" + severity: "warning" + type: "pattern" + pattern: + kind: "comment" + match: "TODO|HACK|FIXME" + + # Example: Limit method length + - id: "CUSTOM002" + title: "Method exceeds maximum length" + description: "Methods should not exceed the configured line count" + category: "Maintainability" + severity: "warning" + type: "metric" + metric: + target: "method" + measure: "lines" + max: 40 + + # Example: Ban specific API usage + # - id: "CUSTOM003" + # title: "Avoid Thread.Sleep" + # description: "Use Task.Delay instead of Thread.Sleep" + # category: "Performance" + # severity: "error" + # type: "pattern" + # pattern: + # kind: "invocation" + # match: "Thread\\.Sleep" + """; +} diff --git a/src/SharpLinter.Cli/Commands/RulesCommand.cs b/src/SharpLinter.Cli/Commands/RulesCommand.cs new file mode 100644 index 0000000..787851b --- /dev/null +++ b/src/SharpLinter.Cli/Commands/RulesCommand.cs @@ -0,0 +1,80 @@ +using System.CommandLine; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; +using SharpLinter.Core.Providers; + +namespace SharpLinter.Cli.Commands; + +/// +/// `sharplinter rules` — List all available rules. +/// +public static class RulesCommand +{ + public static Command Create() + { + var categoryOpt = new Option("--category", "Filter by category (style, naming, design, performance, security, maintainability)"); + var sourceOpt = new Option("--source", "Filter by source (builtin, microsoft-learn, custom)"); + + var command = new Command("rules", "List all available lint rules") + { + categoryOpt, + sourceOpt + }; + + command.SetHandler(async (category, source) => + { + var config = LintConfiguration.Discover(Directory.GetCurrentDirectory()); + var engine = new LintEngine(config); + await engine.InitializeAsync(); + + var analyzers = engine.GetLoadedAnalyzers(); + + // Also load cached/bundled provider rules for display + var cacheManager = new RuleCacheManager(config.RuleSync.CachePath, config.RuleSync.CacheExpiryDays); + var cachedRules = await cacheManager.LoadFromCacheAsync(); + cachedRules ??= await new BundledRuleCatalog().GetRulesAsync(); + + // Combine built-in analyzer metadata with provider rules + var allRules = analyzers.Select(a => a.Metadata) + .Concat(cachedRules.Where(cr => !analyzers.Any(a => a.Metadata.RuleId == cr.RuleId))) + .ToList(); + + // Apply filters + if (!string.IsNullOrEmpty(category)) + { + allRules = allRules.Where(r => + r.Category.ToString().Equals(category, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + if (!string.IsNullOrEmpty(source)) + { + allRules = allRules.Where(r => + r.Source.Equals(source, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + // Display + Console.WriteLine($"\n {"Rule ID",-12} {"Category",-16} {"Severity",-12} {"Source",-16} Title"); + Console.WriteLine($" {new string('─', 90)}"); + + foreach (var rule in allRules.OrderBy(r => r.RuleId)) + { + var severity = config.GetEffectiveSeverity(rule.RuleId, rule.DefaultSeverity); + var severityIcon = severity switch + { + LintSeverity.Error => "✖", + LintSeverity.Warning => "⚠", + LintSeverity.Suggestion => "ℹ", + _ => "○" + }; + + Console.WriteLine($" {rule.RuleId,-12} {rule.Category,-16} {severityIcon} {severity.ToString().ToLower(),-10} {rule.Source,-16} {rule.Title}"); + } + + Console.WriteLine($"\n Total: {allRules.Count} rule(s)"); + Console.WriteLine($" Built-in: {allRules.Count(r => r.Source == "builtin")} | Fetched: {allRules.Count(r => r.Source == "microsoft-learn")} | Custom: {allRules.Count(r => r.Source == "custom")}"); + + }, categoryOpt, sourceOpt); + + return command; + } +} diff --git a/src/SharpLinter.Cli/Commands/SyncCommand.cs b/src/SharpLinter.Cli/Commands/SyncCommand.cs new file mode 100644 index 0000000..42c70b6 --- /dev/null +++ b/src/SharpLinter.Cli/Commands/SyncCommand.cs @@ -0,0 +1,65 @@ +using System.CommandLine; +using SharpLinter.Core.Configuration; +using SharpLinter.Core.Providers; + +namespace SharpLinter.Cli.Commands; + +/// +/// `sharplinter sync` — Fetch rules from online sources and cache locally. +/// +public static class SyncCommand +{ + public static Command Create() + { + var forceOpt = new Option("--force", "Force refresh even if cache is fresh"); + + var command = new Command("sync", "Fetch latest rules from Microsoft Learn and cache locally") + { + forceOpt + }; + + command.SetHandler(async (force) => + { + var config = LintConfiguration.Discover(Directory.GetCurrentDirectory()); + var cacheManager = new RuleCacheManager(config.RuleSync.CachePath, config.RuleSync.CacheExpiryDays); + + if (!force && cacheManager.IsCacheValid()) + { + Console.WriteLine("✅ Rule cache is up to date."); + Console.WriteLine($" Cache location: {cacheManager.CacheFilePath}"); + Console.WriteLine(" Use --force to refresh anyway."); + return; + } + + Console.WriteLine("🔄 Fetching rules from Microsoft Learn..."); + + try + { + var provider = new MicrosoftLearnRuleProvider(); + var rules = await provider.GetRulesAsync(); + + if (rules.Count > 0) + { + await cacheManager.SaveToCacheAsync(rules); + Console.WriteLine($"✅ Synced {rules.Count} rules to local cache."); + Console.WriteLine($" Cache location: {cacheManager.CacheFilePath}"); + Console.WriteLine($" Expires in {config.RuleSync.CacheExpiryDays} days."); + } + else + { + Console.WriteLine("⚠️ No rules fetched. The page structure may have changed."); + Console.WriteLine(" Built-in rules remain available."); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"❌ Failed to fetch rules: {ex.Message}"); + Console.Error.WriteLine(" Built-in rules remain available for offline use."); + Environment.ExitCode = 1; + } + + }, forceOpt); + + return command; + } +} diff --git a/src/SharpLinter.Cli/Program.cs b/src/SharpLinter.Cli/Program.cs new file mode 100644 index 0000000..845089a --- /dev/null +++ b/src/SharpLinter.Cli/Program.cs @@ -0,0 +1,24 @@ +using System.CommandLine; +using SharpLinter.Cli.Commands; + +namespace SharpLinter.Cli; + +/// +/// SharpLinter CLI entry point. +/// +public static class Program +{ + public static async Task Main(string[] args) + { + var rootCommand = new RootCommand("SharpLinter — A free, customizable C# linter") + { + AnalyzeCommand.Create(), + FormatCommand.Create(), + InitCommand.Create(), + RulesCommand.Create(), + SyncCommand.Create() + }; + + return await rootCommand.InvokeAsync(args); + } +} diff --git a/src/SharpLinter.Cli/SharpLinter.Cli.csproj b/src/SharpLinter.Cli/SharpLinter.Cli.csproj new file mode 100644 index 0000000..5acc819 --- /dev/null +++ b/src/SharpLinter.Cli/SharpLinter.Cli.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + enable + enable + SharpLinter.Cli + + + true + sharplinter + SharpLinter.Cli + 1.0.0 + rahu619 + CLI tool for SharpLinter — a free, customizable C# linter + csharp;linter;cli;dotnet-tool + MIT + https://github.com/rahu619/SharpLinter + ./nupkg + + + + + + + + diff --git a/src/SharpLinter.Core/Analysis/LintDiagnostic.cs b/src/SharpLinter.Core/Analysis/LintDiagnostic.cs new file mode 100644 index 0000000..0aca9a1 --- /dev/null +++ b/src/SharpLinter.Core/Analysis/LintDiagnostic.cs @@ -0,0 +1,23 @@ +namespace SharpLinter.Core.Analysis; + +/// +/// Represents a single lint diagnostic — a specific issue found at a specific location. +/// +/// The rule that triggered this diagnostic (e.g., "SL1001"). +/// Human-readable description of the specific issue found. +/// The severity level of this diagnostic. +/// Absolute or relative path to the file containing the issue. +/// 1-based line number where the issue starts. +/// 1-based column number where the issue starts. +/// 1-based line number where the issue ends. +/// 1-based column number where the issue ends. +public record LintDiagnostic( + string RuleId, + string Message, + LintSeverity Severity, + string FilePath, + int Line, + int Column, + int EndLine, + int EndColumn +); diff --git a/src/SharpLinter.Core/Analysis/LintEngine.cs b/src/SharpLinter.Core/Analysis/LintEngine.cs new file mode 100644 index 0000000..89c1164 --- /dev/null +++ b/src/SharpLinter.Core/Analysis/LintEngine.cs @@ -0,0 +1,235 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis.CSharp; +using SharpLinter.Core.Configuration; +using SharpLinter.Core.Formatting; +using SharpLinter.Core.Providers; +using SharpLinter.Core.Rules; +using SharpLinter.Core.Rules.BuiltIn; +using SharpLinter.Core.Rules.Custom; + +namespace SharpLinter.Core.Analysis; + +/// +/// The main orchestrator for SharpLinter analysis. +/// Coordinates rule discovery, configuration loading, file parsing, and diagnostic collection. +/// +public sealed class LintEngine +{ + private readonly LintConfiguration _config; + private readonly List _analyzers = []; + private readonly CodeFormatter _formatter = new(); + private bool _initialized; + + public LintEngine(LintConfiguration? config = null) + { + _config = config ?? Presets.GetPreset("recommended"); + } + + /// + /// Initializes the engine by loading all rule analyzers. + /// Called automatically on first analysis, but can be called explicitly for eager loading. + /// + public async Task InitializeAsync(CancellationToken ct = default) + { + if (_initialized) return; + + // Tier 1: Built-in rules (always loaded) + LoadBuiltInRules(); + + // Tier 3: Custom rules (if configured) + LoadCustomRules(); + + // Tier 2: Fetched/cached rules (for awareness catalog — these don't have analyzers) + await LoadProviderRulesAsync(ct); + + _initialized = true; + } + + /// + /// Analyzes a single C# code string. + /// + public async Task AnalyzeCodeAsync(string code, string filePath = "", CancellationToken ct = default) + { + await InitializeAsync(ct); + + var stopwatch = Stopwatch.StartNew(); + var tree = CSharpSyntaxTree.ParseText(code); + var diagnostics = new List(); + + foreach (var analyzer in _analyzers) + { + var severity = _config.GetEffectiveSeverity(analyzer.Metadata.RuleId, analyzer.Metadata.DefaultSeverity); + if (severity == LintSeverity.None) continue; + + try + { + var results = analyzer.Analyze(tree, filePath, _config); + diagnostics.AddRange(results); + } + catch (Exception) + { + // Individual rule failures should not break the entire analysis + } + } + + stopwatch.Stop(); + + // Apply formatting if enabled + string? formattedCode = null; + if (_config.Formatting.Enabled) + { + try + { + formattedCode = _formatter.Format(code, _config); + } + catch (Exception) + { + // Formatting failure is non-fatal + } + } + + return new LintResult( + diagnostics.OrderBy(d => d.FilePath).ThenBy(d => d.Line).ThenBy(d => d.Column).ToList(), + stopwatch.Elapsed, + formattedCode + ); + } + + /// + /// Analyzes a single C# file. + /// + public async Task AnalyzeFileAsync(string filePath, CancellationToken ct = default) + { + var code = await File.ReadAllTextAsync(filePath, ct); + return await AnalyzeCodeAsync(code, filePath, ct); + } + + /// + /// Analyzes all C# files in a directory (recursively). + /// Respects include/exclude patterns from configuration. + /// + public async Task> AnalyzeDirectoryAsync(string directoryPath, CancellationToken ct = default) + { + await InitializeAsync(ct); + + var files = DiscoverFiles(directoryPath); + var results = new List(); + + foreach (var file in files) + { + ct.ThrowIfCancellationRequested(); + var result = await AnalyzeFileAsync(file, ct); + results.Add(result); + } + + return results; + } + + /// + /// Gets all loaded rule analyzers. + /// + public IReadOnlyList GetLoadedAnalyzers() + { + return _analyzers.AsReadOnly(); + } + + /// + /// Formats a C# code string according to configuration. + /// + public string FormatCode(string code) + { + return _formatter.Format(code, _config); + } + + private void LoadBuiltInRules() + { + _analyzers.AddRange( + [ + new SL1001_AddBracesAnalyzer(), + new SL1002_EmptyCatchBlockAnalyzer(), + new SL1003_PublicFieldAnalyzer(), + new SL1004_MethodLengthAnalyzer(), + new SL1005_NamingConventionAnalyzer(), + new SL1006_UnusedUsingAnalyzer(), + new SL1007_CyclomaticComplexityAnalyzer(), + new SL1008_ConsistentBracePlacementAnalyzer(), + new SL1009_TrailingWhitespaceAnalyzer(), + new SL1010_FileLengthAnalyzer(), + new SL1011_PatternMatchingAnalyzer(), + new SL1012_StringConcatInLoopAnalyzer() + ]); + } + + private void LoadCustomRules() + { + if (!string.IsNullOrEmpty(_config.CustomRulesFile)) + { + var customAnalyzers = CustomRuleLoader.LoadFromFile(_config.CustomRulesFile); + _analyzers.AddRange(customAnalyzers); + } + } + + private async Task LoadProviderRulesAsync(CancellationToken ct) + { + if (!_config.RuleSync.Enabled) return; + + var cacheManager = new RuleCacheManager(_config.RuleSync.CachePath, _config.RuleSync.CacheExpiryDays); + + // Try cache first + if (cacheManager.IsCacheValid()) + { + // Cache exists and is fresh — no need to fetch + return; + } + + // Try to fetch and cache (non-blocking — failure is OK) + try + { + var provider = new MicrosoftLearnRuleProvider(); + var rules = await provider.GetRulesAsync(ct); + if (rules.Count > 0) + { + await cacheManager.SaveToCacheAsync(rules, ct); + } + } + catch (Exception) + { + // Network failure — continue with built-in rules only + } + } + + private IReadOnlyList DiscoverFiles(string directoryPath) + { + if (!Directory.Exists(directoryPath)) + { + // Maybe it's a single file + if (File.Exists(directoryPath) && directoryPath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + return [directoryPath]; + } + return []; + } + + var allCsFiles = Directory.EnumerateFiles(directoryPath, "*.cs", SearchOption.AllDirectories); + + return allCsFiles + .Where(f => !ShouldExclude(f, directoryPath)) + .ToList(); + } + + private bool ShouldExclude(string filePath, string basePath) + { + var relativePath = Path.GetRelativePath(basePath, filePath).Replace('\\', '/'); + + foreach (var pattern in _config.Exclude) + { + var normalizedPattern = pattern.Replace("**/", ""); + if (relativePath.Contains(normalizedPattern.Trim('*', '/'))) + { + return true; + } + } + + return false; + } +} diff --git a/src/SharpLinter.Core/Analysis/LintResult.cs b/src/SharpLinter.Core/Analysis/LintResult.cs new file mode 100644 index 0000000..a4baae7 --- /dev/null +++ b/src/SharpLinter.Core/Analysis/LintResult.cs @@ -0,0 +1,44 @@ +namespace SharpLinter.Core.Analysis; + +/// +/// The result of running lint analysis on one or more files. +/// +public sealed class LintResult +{ + /// All diagnostics found during analysis. + public IReadOnlyList Diagnostics { get; } + + /// Number of diagnostics with Error severity. + public int ErrorCount { get; } + + /// Number of diagnostics with Warning severity. + public int WarningCount { get; } + + /// Number of diagnostics with Suggestion severity. + public int SuggestionCount { get; } + + /// Time taken for the analysis. + public TimeSpan Duration { get; } + + /// Optionally formatted/fixed code (when auto-fix is enabled). + public string? FormattedCode { get; } + + public LintResult( + IReadOnlyList diagnostics, + TimeSpan duration, + string? formattedCode = null) + { + Diagnostics = diagnostics; + Duration = duration; + FormattedCode = formattedCode; + ErrorCount = diagnostics.Count(d => d.Severity == LintSeverity.Error); + WarningCount = diagnostics.Count(d => d.Severity == LintSeverity.Warning); + SuggestionCount = diagnostics.Count(d => d.Severity == LintSeverity.Suggestion); + } + + /// True if any diagnostics with Error severity were found. + public bool HasErrors => ErrorCount > 0; + + /// True if any diagnostics were found at any severity level. + public bool HasIssues => Diagnostics.Count > 0; +} diff --git a/src/SharpLinter.Core/Analysis/LintSeverity.cs b/src/SharpLinter.Core/Analysis/LintSeverity.cs new file mode 100644 index 0000000..f042b71 --- /dev/null +++ b/src/SharpLinter.Core/Analysis/LintSeverity.cs @@ -0,0 +1,19 @@ +namespace SharpLinter.Core.Analysis; + +/// +/// Represents the severity level of a lint diagnostic. +/// +public enum LintSeverity +{ + /// Rule is disabled. + None = 0, + + /// Informational suggestion — will not cause CI failure. + Suggestion = 1, + + /// Warning — may cause CI failure depending on configuration. + Warning = 2, + + /// Error — will cause CI failure (non-zero exit code). + Error = 3 +} diff --git a/src/SharpLinter.Core/Configuration/LintConfiguration.cs b/src/SharpLinter.Core/Configuration/LintConfiguration.cs new file mode 100644 index 0000000..2d49beb --- /dev/null +++ b/src/SharpLinter.Core/Configuration/LintConfiguration.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Configuration; + +/// +/// Represents the complete SharpLinter configuration loaded from .sharplinter.json. +/// +public sealed class LintConfiguration +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + /// Preset name to use as a base: "recommended", "strict", or "minimal". + public string Preset { get; set; } = "recommended"; + + /// Per-rule severity overrides. Key is rule ID (e.g., "SL1001"). + public Dictionary Rules { get; set; } = new(); + + /// Glob patterns for files/directories to exclude from analysis. + public List Exclude { get; set; } = ["**/obj/**", "**/bin/**"]; + + /// Glob patterns for files to include in analysis. + public List Include { get; set; } = ["**/*.cs"]; + + /// Path to custom rules YAML file (relative to config location). + public string? CustomRulesFile { get; set; } + + /// Formatting options. + public FormattingConfig Formatting { get; set; } = new(); + + /// Rule sync/cache options. + public RuleSyncConfig RuleSync { get; set; } = new(); + + /// + /// Gets the effective severity for a given rule, applying overrides on top of defaults. + /// + public LintSeverity GetEffectiveSeverity(string ruleId, LintSeverity defaultSeverity) + { + if (Rules.TryGetValue(ruleId, out var ruleOverride)) + { + return ruleOverride.Severity; + } + + return defaultSeverity; + } + + /// + /// Gets a rule-specific option value, falling back to a default. + /// + public T GetRuleOption(string ruleId, string optionName, T defaultValue) + { + if (Rules.TryGetValue(ruleId, out var ruleOverride) + && ruleOverride.Options.TryGetValue(optionName, out var value)) + { + if (value is JsonElement element) + { + return element.Deserialize(JsonOptions) ?? defaultValue; + } + + if (value is T typedValue) + { + return typedValue; + } + } + + return defaultValue; + } + + /// + /// Loads configuration from a .sharplinter.json file. + /// Returns default configuration if file does not exist. + /// + public static LintConfiguration LoadFromFile(string filePath) + { + if (!File.Exists(filePath)) + { + return Presets.GetPreset("recommended"); + } + + var json = File.ReadAllText(filePath); + var config = JsonSerializer.Deserialize(json, JsonOptions); + return config ?? Presets.GetPreset("recommended"); + } + + /// + /// Loads configuration from the given directory by searching for .sharplinter.json. + /// Walks up parent directories if not found in the given directory. + /// + public static LintConfiguration Discover(string startDirectory) + { + var dir = new DirectoryInfo(startDirectory); + while (dir != null) + { + var configPath = Path.Combine(dir.FullName, ".sharplinter.json"); + if (File.Exists(configPath)) + { + return LoadFromFile(configPath); + } + dir = dir.Parent; + } + + return Presets.GetPreset("recommended"); + } + + /// + /// Serializes this configuration to JSON. + /// + public string ToJson() + { + var options = new JsonSerializerOptions(JsonOptions) + { + WriteIndented = true + }; + return JsonSerializer.Serialize(this, options); + } +} + +/// +/// Formatting-specific configuration options. +/// +public sealed class FormattingConfig +{ + public bool Enabled { get; set; } = true; + public int IndentSize { get; set; } = 4; + public bool UseTabs { get; set; } = false; + public bool NewLineForBraces { get; set; } = true; +} + +/// +/// Configuration for rule sync (online fetching and caching). +/// +public sealed class RuleSyncConfig +{ + public bool Enabled { get; set; } = true; + public int CacheExpiryDays { get; set; } = 30; + public string CachePath { get; set; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".sharplinter", "rules-cache"); +} diff --git a/src/SharpLinter.Core/Configuration/Presets.cs b/src/SharpLinter.Core/Configuration/Presets.cs new file mode 100644 index 0000000..a3a8c39 --- /dev/null +++ b/src/SharpLinter.Core/Configuration/Presets.cs @@ -0,0 +1,81 @@ +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Configuration; + +/// +/// Built-in configuration presets that provide sensible defaults. +/// +public static class Presets +{ + /// + /// Gets a preset configuration by name. + /// + /// Preset name: "recommended", "strict", or "minimal". + /// A pre-configured . + public static LintConfiguration GetPreset(string name) => name.ToLowerInvariant() switch + { + "strict" => CreateStrictPreset(), + "minimal" => CreateMinimalPreset(), + _ => CreateRecommendedPreset() + }; + + private static LintConfiguration CreateRecommendedPreset() => new() + { + Preset = "recommended", + Rules = new Dictionary + { + ["SL1001"] = new() { Severity = LintSeverity.Warning }, + ["SL1002"] = new() { Severity = LintSeverity.Warning }, + ["SL1003"] = new() { Severity = LintSeverity.Warning }, + ["SL1004"] = new() { Severity = LintSeverity.Suggestion, Options = new() { ["maxLines"] = 50 } }, + ["SL1005"] = new() { Severity = LintSeverity.Warning }, + ["SL1006"] = new() { Severity = LintSeverity.Suggestion }, + ["SL1007"] = new() { Severity = LintSeverity.Suggestion, Options = new() { ["maxComplexity"] = 10 } }, + ["SL1008"] = new() { Severity = LintSeverity.Suggestion }, + ["SL1009"] = new() { Severity = LintSeverity.Suggestion }, + ["SL1010"] = new() { Severity = LintSeverity.Suggestion, Options = new() { ["maxLines"] = 500 } }, + ["SL1011"] = new() { Severity = LintSeverity.Suggestion }, + ["SL1012"] = new() { Severity = LintSeverity.Warning } + } + }; + + private static LintConfiguration CreateStrictPreset() => new() + { + Preset = "strict", + Rules = new Dictionary + { + ["SL1001"] = new() { Severity = LintSeverity.Error }, + ["SL1002"] = new() { Severity = LintSeverity.Error }, + ["SL1003"] = new() { Severity = LintSeverity.Error }, + ["SL1004"] = new() { Severity = LintSeverity.Warning, Options = new() { ["maxLines"] = 30 } }, + ["SL1005"] = new() { Severity = LintSeverity.Error }, + ["SL1006"] = new() { Severity = LintSeverity.Warning }, + ["SL1007"] = new() { Severity = LintSeverity.Warning, Options = new() { ["maxComplexity"] = 8 } }, + ["SL1008"] = new() { Severity = LintSeverity.Warning }, + ["SL1009"] = new() { Severity = LintSeverity.Warning }, + ["SL1010"] = new() { Severity = LintSeverity.Warning, Options = new() { ["maxLines"] = 300 } }, + ["SL1011"] = new() { Severity = LintSeverity.Warning }, + ["SL1012"] = new() { Severity = LintSeverity.Error } + } + }; + + private static LintConfiguration CreateMinimalPreset() => new() + { + Preset = "minimal", + Rules = new Dictionary + { + ["SL1001"] = new() { Severity = LintSeverity.Suggestion }, + ["SL1002"] = new() { Severity = LintSeverity.Warning }, + ["SL1003"] = new() { Severity = LintSeverity.Suggestion }, + ["SL1004"] = new() { Severity = LintSeverity.None }, + ["SL1005"] = new() { Severity = LintSeverity.Suggestion }, + ["SL1006"] = new() { Severity = LintSeverity.None }, + ["SL1007"] = new() { Severity = LintSeverity.None }, + ["SL1008"] = new() { Severity = LintSeverity.None }, + ["SL1009"] = new() { Severity = LintSeverity.None }, + ["SL1010"] = new() { Severity = LintSeverity.None }, + ["SL1011"] = new() { Severity = LintSeverity.None }, + ["SL1012"] = new() { Severity = LintSeverity.Suggestion } + } + }; +} diff --git a/src/SharpLinter.Core/Configuration/RuleOverride.cs b/src/SharpLinter.Core/Configuration/RuleOverride.cs new file mode 100644 index 0000000..ad6fb9b --- /dev/null +++ b/src/SharpLinter.Core/Configuration/RuleOverride.cs @@ -0,0 +1,15 @@ +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Configuration; + +/// +/// Represents a per-rule configuration override. +/// +public sealed class RuleOverride +{ + /// The severity to apply for this rule. + public LintSeverity Severity { get; set; } = LintSeverity.Warning; + + /// Rule-specific options (e.g., maxLines, maxComplexity). + public Dictionary Options { get; set; } = new(); +} diff --git a/src/SharpLinter.Core/Formatting/CodeFormatter.cs b/src/SharpLinter.Core/Formatting/CodeFormatter.cs new file mode 100644 index 0000000..77fcf07 --- /dev/null +++ b/src/SharpLinter.Core/Formatting/CodeFormatter.cs @@ -0,0 +1,40 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Formatting; +using Microsoft.CodeAnalysis.Formatting; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Formatting; + +/// +/// Wraps Roslyn's code formatter with configurable options from SharpLinter configuration. +/// +public sealed class CodeFormatter +{ + /// + /// Formats C# code according to the given configuration. + /// + public string Format(string code, LintConfiguration config) + { + var tree = CSharpSyntaxTree.ParseText(code); + var root = tree.GetRoot(); + + var workspace = new Microsoft.CodeAnalysis.AdhocWorkspace(); + var options = workspace.Options + .WithChangedOption(CSharpFormattingOptions.IndentBlock, true) + .WithChangedOption(CSharpFormattingOptions.IndentBraces, false) + .WithChangedOption(CSharpFormattingOptions.NewLineForCatch, true) + .WithChangedOption(CSharpFormattingOptions.NewLineForFinally, true) + .WithChangedOption(CSharpFormattingOptions.NewLineForMembersInObjectInit, true) + .WithChangedOption(CSharpFormattingOptions.NewLineForMembersInAnonymousTypes, true) + .WithChangedOption(CSharpFormattingOptions.NewLineForClausesInQuery, true) + .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInControlBlocks, config.Formatting.NewLineForBraces) + .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInMethods, config.Formatting.NewLineForBraces) + .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInProperties, config.Formatting.NewLineForBraces) + .WithChangedOption(CSharpFormattingOptions.NewLinesForBracesInTypes, config.Formatting.NewLineForBraces) + .WithChangedOption(CSharpFormattingOptions.SpaceAfterColonInBaseTypeDeclaration, true) + .WithChangedOption(CSharpFormattingOptions.SpaceBeforeColonInBaseTypeDeclaration, true); + + var formatted = Formatter.Format(root, workspace, options); + return formatted.ToFullString(); + } +} diff --git a/src/SharpLinter.Core/Output/ConsoleOutputFormatter.cs b/src/SharpLinter.Core/Output/ConsoleOutputFormatter.cs new file mode 100644 index 0000000..a4c0593 --- /dev/null +++ b/src/SharpLinter.Core/Output/ConsoleOutputFormatter.cs @@ -0,0 +1,79 @@ +using System.Text; +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Output; + +/// +/// Formats lint results for terminal display with colored, ESLint-style output. +/// Groups diagnostics by file with severity-based coloring. +/// +public sealed class ConsoleOutputFormatter : IOutputFormatter +{ + public string Format(LintResult result) + { + return Format([result]); + } + + public string Format(IReadOnlyList results) + { + var sb = new StringBuilder(); + var allDiagnostics = results.SelectMany(r => r.Diagnostics).ToList(); + + if (allDiagnostics.Count == 0) + { + sb.AppendLine("✅ No issues found."); + return sb.ToString(); + } + + // Group by file + var grouped = allDiagnostics.GroupBy(d => d.FilePath); + + foreach (var fileGroup in grouped) + { + sb.AppendLine(); + sb.AppendLine($"📄 {fileGroup.Key}"); + + foreach (var diagnostic in fileGroup.OrderBy(d => d.Line).ThenBy(d => d.Column)) + { + var icon = GetSeverityIcon(diagnostic.Severity); + var color = GetSeverityColor(diagnostic.Severity); + + sb.AppendLine($" {color}{icon} {diagnostic.Line}:{diagnostic.Column} {diagnostic.Severity.ToString().ToLowerInvariant()} {diagnostic.Message} [{diagnostic.RuleId}]{ResetColor()}"); + } + } + + // Summary + sb.AppendLine(); + var totalErrors = allDiagnostics.Count(d => d.Severity == LintSeverity.Error); + var totalWarnings = allDiagnostics.Count(d => d.Severity == LintSeverity.Warning); + var totalSuggestions = allDiagnostics.Count(d => d.Severity == LintSeverity.Suggestion); + + var parts = new List(); + if (totalErrors > 0) parts.Add($"❌ {totalErrors} error(s)"); + if (totalWarnings > 0) parts.Add($"⚠️ {totalWarnings} warning(s)"); + if (totalSuggestions > 0) parts.Add($"💡 {totalSuggestions} suggestion(s)"); + + sb.AppendLine($" {string.Join(" ", parts)}"); + sb.AppendLine($" Found {allDiagnostics.Count} issue(s) in {grouped.Count()} file(s)"); + + return sb.ToString(); + } + + private static string GetSeverityIcon(LintSeverity severity) => severity switch + { + LintSeverity.Error => "✖", + LintSeverity.Warning => "⚠", + LintSeverity.Suggestion => "ℹ", + _ => " " + }; + + private static string GetSeverityColor(LintSeverity severity) => severity switch + { + LintSeverity.Error => "\u001b[31m", // Red + LintSeverity.Warning => "\u001b[33m", // Yellow + LintSeverity.Suggestion => "\u001b[36m", // Cyan + _ => "" + }; + + private static string ResetColor() => "\u001b[0m"; +} diff --git a/src/SharpLinter.Core/Output/IOutputFormatter.cs b/src/SharpLinter.Core/Output/IOutputFormatter.cs new file mode 100644 index 0000000..c0b5981 --- /dev/null +++ b/src/SharpLinter.Core/Output/IOutputFormatter.cs @@ -0,0 +1,19 @@ +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Output; + +/// +/// Interface for formatting lint results into human/machine-readable output. +/// +public interface IOutputFormatter +{ + /// + /// Formats a single lint result into a string. + /// + string Format(LintResult result); + + /// + /// Formats multiple lint results into a string. + /// + string Format(IReadOnlyList results); +} diff --git a/src/SharpLinter.Core/Output/JsonOutputFormatter.cs b/src/SharpLinter.Core/Output/JsonOutputFormatter.cs new file mode 100644 index 0000000..c61e24d --- /dev/null +++ b/src/SharpLinter.Core/Output/JsonOutputFormatter.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Output; + +/// +/// Formats lint results as machine-readable JSON. +/// +public sealed class JsonOutputFormatter : IOutputFormatter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public string Format(LintResult result) => Format([result]); + + public string Format(IReadOnlyList results) + { + var output = new JsonOutput + { + TotalIssues = results.Sum(r => r.Diagnostics.Count), + ErrorCount = results.Sum(r => r.ErrorCount), + WarningCount = results.Sum(r => r.WarningCount), + SuggestionCount = results.Sum(r => r.SuggestionCount), + Duration = results.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.Duration).ToString(), + Diagnostics = results.SelectMany(r => r.Diagnostics).ToList() + }; + + return JsonSerializer.Serialize(output, JsonOptions); + } + + private sealed class JsonOutput + { + public int TotalIssues { get; set; } + public int ErrorCount { get; set; } + public int WarningCount { get; set; } + public int SuggestionCount { get; set; } + public string Duration { get; set; } = ""; + public List Diagnostics { get; set; } = []; + } +} diff --git a/src/SharpLinter.Core/Output/MsBuildOutputFormatter.cs b/src/SharpLinter.Core/Output/MsBuildOutputFormatter.cs new file mode 100644 index 0000000..931ee2d --- /dev/null +++ b/src/SharpLinter.Core/Output/MsBuildOutputFormatter.cs @@ -0,0 +1,38 @@ +using System.Text; +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Output; + +/// +/// Formats lint results in the standard MSBuild error/warning format: +/// {FilePath}({Line},{Column}): {Severity} {Code}: {Message} +/// This allows MSBuild to natively parse the output and display them as actual IDE compiler warnings. +/// +public sealed class MsBuildOutputFormatter : IOutputFormatter +{ + public string Format(LintResult result) => Format([result]); + + public string Format(IReadOnlyList results) + { + var sb = new StringBuilder(); + var allDiagnostics = results.SelectMany(r => r.Diagnostics).ToList(); + + foreach (var diagnostic in allDiagnostics.OrderBy(d => d.FilePath).ThenBy(d => d.Line).ThenBy(d => d.Column)) + { + var severity = MapSeverity(diagnostic.Severity); + + // Format: FilePath(Line,Column): Severity Code: [SharpLinter] Message (colored cyan) + sb.AppendLine($"{diagnostic.FilePath}({diagnostic.Line},{diagnostic.Column}): {severity} {diagnostic.RuleId}: \u001b[36m[SharpLinter]\u001b[0m {diagnostic.Message}"); + } + + return sb.ToString(); + } + + private static string MapSeverity(LintSeverity severity) => severity switch + { + LintSeverity.Error => "error", + LintSeverity.Warning => "warning", + LintSeverity.Suggestion => "warning", // MSBuild doesn't support 'suggestion', map to warning or info + _ => "info" + }; +} diff --git a/src/SharpLinter.Core/Output/SarifOutputFormatter.cs b/src/SharpLinter.Core/Output/SarifOutputFormatter.cs new file mode 100644 index 0000000..d52ed0b --- /dev/null +++ b/src/SharpLinter.Core/Output/SarifOutputFormatter.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Output; + +/// +/// Formats lint results as SARIF v2.1.0 (Static Analysis Results Interchange Format). +/// Compatible with GitHub Code Scanning, Azure DevOps, and other CI tools. +/// +public sealed class SarifOutputFormatter : IOutputFormatter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public string Format(LintResult result) => Format([result]); + + public string Format(IReadOnlyList results) + { + var allDiagnostics = results.SelectMany(r => r.Diagnostics).ToList(); + + var sarif = new SarifLog + { + Schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + Version = "2.1.0", + Runs = + [ + new SarifRun + { + Tool = new SarifTool + { + Driver = new SarifDriver + { + Name = "SharpLinter", + Version = "1.0.0", + InformationUri = "https://github.com/rahu619/SharpLinter", + Rules = allDiagnostics + .Select(d => d.RuleId) + .Distinct() + .Select(id => new SarifRule + { + Id = id, + ShortDescription = new SarifMessage + { + Text = allDiagnostics.First(d => d.RuleId == id).Message + } + }) + .ToList() + } + }, + Results = allDiagnostics.Select(d => new SarifResult + { + RuleId = d.RuleId, + Level = MapSeverity(d.Severity), + Message = new SarifMessage { Text = d.Message }, + Locations = + [ + new SarifLocation + { + PhysicalLocation = new SarifPhysicalLocation + { + ArtifactLocation = new SarifArtifactLocation + { + Uri = d.FilePath.Replace('\\', '/') + }, + Region = new SarifRegion + { + StartLine = d.Line, + StartColumn = d.Column, + EndLine = d.EndLine, + EndColumn = d.EndColumn + } + } + } + ] + }).ToList() + } + ] + }; + + return JsonSerializer.Serialize(sarif, JsonOptions); + } + + private static string MapSeverity(LintSeverity severity) => severity switch + { + LintSeverity.Error => "error", + LintSeverity.Warning => "warning", + LintSeverity.Suggestion => "note", + _ => "none" + }; + + // SARIF v2.1.0 data model (minimal) + private sealed class SarifLog + { + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + public string? Version { get; set; } + public List? Runs { get; set; } + } + + private sealed class SarifRun + { + public SarifTool? Tool { get; set; } + public List? Results { get; set; } + } + + private sealed class SarifTool + { + public SarifDriver? Driver { get; set; } + } + + private sealed class SarifDriver + { + public string? Name { get; set; } + public string? Version { get; set; } + public string? InformationUri { get; set; } + public List? Rules { get; set; } + } + + private sealed class SarifRule + { + public string? Id { get; set; } + public SarifMessage? ShortDescription { get; set; } + } + + private sealed class SarifResult + { + public string? RuleId { get; set; } + public string? Level { get; set; } + public SarifMessage? Message { get; set; } + public List? Locations { get; set; } + } + + private sealed class SarifMessage + { + public string? Text { get; set; } + } + + private sealed class SarifLocation + { + public SarifPhysicalLocation? PhysicalLocation { get; set; } + } + + private sealed class SarifPhysicalLocation + { + public SarifArtifactLocation? ArtifactLocation { get; set; } + public SarifRegion? Region { get; set; } + } + + private sealed class SarifArtifactLocation + { + public string? Uri { get; set; } + } + + private sealed class SarifRegion + { + public int StartLine { get; set; } + public int StartColumn { get; set; } + public int EndLine { get; set; } + public int EndColumn { get; set; } + } +} diff --git a/src/SharpLinter.Core/Providers/BundledRuleCatalog.cs b/src/SharpLinter.Core/Providers/BundledRuleCatalog.cs new file mode 100644 index 0000000..cfb75fd --- /dev/null +++ b/src/SharpLinter.Core/Providers/BundledRuleCatalog.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using System.Text.Json; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Rules; + +namespace SharpLinter.Core.Providers; + +/// +/// Provides a bundled snapshot of the most common Microsoft CA/IDE rules. +/// This is embedded in the NuGet package and ensures SharpLinter works +/// fully offline even on first run without any cache. +/// +public sealed class BundledRuleCatalog : IRuleProvider +{ + public bool IsAvailableOffline => true; + + public Task> GetRulesAsync(CancellationToken ct = default) + { + var rules = LoadEmbeddedRules(); + return Task.FromResult(rules); + } + + private static IReadOnlyList LoadEmbeddedRules() + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "SharpLinter.Core.Providers.bundled-rules.json"; + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + // Fallback: return a hardcoded minimal set + return GetFallbackRules(); + } + + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + var rules = JsonSerializer.Deserialize>(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return rules?.Select(r => new RuleMetadata( + RuleId: r.RuleId ?? "", + Title: r.Title ?? "", + Description: r.Description ?? r.Title ?? "", + Category: Enum.TryParse(r.Category, true, out var cat) ? cat : RuleCategory.Design, + DefaultSeverity: LintSeverity.Warning, + Source: "microsoft-learn", + HasAnalyzer: false, + DocumentationUrl: r.DocumentationUrl + )).ToList() ?? GetFallbackRules(); + } + + private static List GetFallbackRules() => + [ + new("CA1001", "Types that own disposable fields should be disposable", "Types that own disposable fields should be disposable", RuleCategory.Design, LintSeverity.Warning, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1001"), + new("CA1031", "Do not catch general exception types", "Do not catch general exception types", RuleCategory.Design, LintSeverity.Warning, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1031"), + new("CA1051", "Do not declare visible instance fields", "Do not declare visible instance fields", RuleCategory.Design, LintSeverity.Warning, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1051"), + new("CA1062", "Validate arguments of public methods", "Validate arguments of public methods", RuleCategory.Design, LintSeverity.Warning, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1062"), + new("CA1715", "Identifiers should have correct prefix", "Identifiers should have correct prefix", RuleCategory.Naming, LintSeverity.Warning, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1715"), + new("CA1822", "Mark members as static", "Mark members as static", RuleCategory.Performance, LintSeverity.Suggestion, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1822"), + new("CA1845", "Use span-based string.Concat", "Use span-based string.Concat", RuleCategory.Performance, LintSeverity.Suggestion, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1845"), + new("CA2000", "Dispose objects before losing scope", "Dispose objects before losing scope", RuleCategory.Design, LintSeverity.Warning, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2000"), + new("CA2007", "Do not directly await a Task", "Do not directly await a Task without ConfigureAwait", RuleCategory.Design, LintSeverity.Suggestion, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2007"), + new("CA2227", "Collection properties should be read only", "Collection properties should be read only", RuleCategory.Design, LintSeverity.Warning, "microsoft-learn", false, "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2227"), + ]; + + private sealed class BundledRule + { + public string? RuleId { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? Category { get; set; } + public string? DocumentationUrl { get; set; } + } +} diff --git a/src/SharpLinter.Core/Providers/IRuleProvider.cs b/src/SharpLinter.Core/Providers/IRuleProvider.cs new file mode 100644 index 0000000..52a8de5 --- /dev/null +++ b/src/SharpLinter.Core/Providers/IRuleProvider.cs @@ -0,0 +1,19 @@ +using SharpLinter.Core.Rules; + +namespace SharpLinter.Core.Providers; + +/// +/// Interface for rule sources that can provide rule metadata. +/// +public interface IRuleProvider +{ + /// + /// Gets all rules from this provider. + /// + Task> GetRulesAsync(CancellationToken ct = default); + + /// + /// Whether this provider can serve rules without network access. + /// + bool IsAvailableOffline { get; } +} diff --git a/src/SharpLinter.Core/Providers/MicrosoftLearnRuleProvider.cs b/src/SharpLinter.Core/Providers/MicrosoftLearnRuleProvider.cs new file mode 100644 index 0000000..4c93e24 --- /dev/null +++ b/src/SharpLinter.Core/Providers/MicrosoftLearnRuleProvider.cs @@ -0,0 +1,111 @@ +using AngleSharp; +using AngleSharp.Dom; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Rules; + +namespace SharpLinter.Core.Providers; + +/// +/// Fetches C# code analysis rule metadata from Microsoft Learn documentation pages. +/// Parses the public HTML tables to extract rule IDs, titles, and categories. +/// +public sealed class MicrosoftLearnRuleProvider : IRuleProvider +{ + private const string QualityRulesUrl = "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/"; + private const string StyleRulesUrl = "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/"; + + public bool IsAvailableOffline => false; + + /// + /// Fetches rule metadata from Microsoft Learn CA and IDE rule pages. + /// + public async Task> GetRulesAsync(CancellationToken ct = default) + { + var rules = new List(); + + try + { + var browsingConfig = AngleSharp.Configuration.Default.WithDefaultLoader(); + var context = BrowsingContext.New(browsingConfig); + + // Fetch quality rules (CA####) + var qualityRules = await FetchRulesFromPage(context, QualityRulesUrl, "microsoft-learn", ct); + rules.AddRange(qualityRules); + + // Fetch style rules (IDE####) + var styleRules = await FetchRulesFromPage(context, StyleRulesUrl, "microsoft-learn", ct); + rules.AddRange(styleRules); + } + catch (Exception) + { + // Network failure — return empty, caller should fall back to cache/bundled + } + + return rules; + } + + private static async Task> FetchRulesFromPage( + IBrowsingContext context, string url, string source, CancellationToken ct) + { + var rules = new List(); + + var document = await context.OpenAsync(url, ct); + if (document == null) return rules; + + // Find all table rows containing rule data + var rows = document.QuerySelectorAll("table tbody tr"); + + foreach (var row in rows) + { + var cells = row.QuerySelectorAll("td").ToList(); + if (cells.Count < 2) continue; + + var linkElement = cells[0].QuerySelector("a"); + var ruleText = linkElement?.TextContent?.Trim() ?? cells[0].TextContent?.Trim(); + if (string.IsNullOrEmpty(ruleText)) continue; + + // Parse "CA1001: Types that own disposable fields should be disposable" + var colonIndex = ruleText.IndexOf(':'); + if (colonIndex < 0) continue; + + var ruleId = ruleText[..colonIndex].Trim(); + var title = ruleText[(colonIndex + 1)..].Trim(); + + // Category is typically in the second column + var category = cells.Count > 1 ? cells[1].TextContent?.Trim() : null; + + var href = linkElement?.GetAttribute("href"); + var docUrl = href != null && !href.StartsWith("http") + ? new Uri(new Uri(url), href).ToString() + : href; + + rules.Add(new RuleMetadata( + RuleId: ruleId, + Title: title, + Description: title, // Full description would need individual page fetch + Category: ParseCategory(category), + DefaultSeverity: LintSeverity.Warning, + Source: source, + HasAnalyzer: false, // Fetched rules don't have built-in analyzers + DocumentationUrl: docUrl + )); + } + + return rules; + } + + private static RuleCategory ParseCategory(string? category) => category?.ToLowerInvariant() switch + { + "design" => RuleCategory.Design, + "naming" => RuleCategory.Naming, + "style" => RuleCategory.Style, + "performance" => RuleCategory.Performance, + "security" => RuleCategory.Security, + "maintainability" => RuleCategory.Maintainability, + "reliability" => RuleCategory.Design, + "usage" => RuleCategory.Design, + "globalization" => RuleCategory.Design, + "interoperability" => RuleCategory.Design, + _ => RuleCategory.Design + }; +} diff --git a/src/SharpLinter.Core/Providers/RuleCacheManager.cs b/src/SharpLinter.Core/Providers/RuleCacheManager.cs new file mode 100644 index 0000000..4631eed --- /dev/null +++ b/src/SharpLinter.Core/Providers/RuleCacheManager.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using SharpLinter.Core.Rules; + +namespace SharpLinter.Core.Providers; + +/// +/// Manages a local JSON cache of fetched rules for offline use. +/// Cache is stored at ~/.sharplinter/rules-cache/rules.json. +/// +public sealed class RuleCacheManager +{ + private readonly string _cachePath; + private readonly int _expiryDays; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + public RuleCacheManager(string cachePath, int expiryDays = 30) + { + _cachePath = cachePath; + _expiryDays = expiryDays; + } + + /// + /// Gets the path to the cache file. + /// + public string CacheFilePath => Path.Combine(_cachePath, "rules.json"); + + /// + /// Returns true if the cache exists and has not expired. + /// + public bool IsCacheValid() + { + if (!File.Exists(CacheFilePath)) return false; + + var cacheInfo = new FileInfo(CacheFilePath); + return (DateTime.UtcNow - cacheInfo.LastWriteTimeUtc).TotalDays < _expiryDays; + } + + /// + /// Saves rules to the local cache. + /// + public async Task SaveToCacheAsync(IReadOnlyList rules, CancellationToken ct = default) + { + Directory.CreateDirectory(_cachePath); + + var cacheData = new RuleCacheData + { + CachedAt = DateTime.UtcNow, + Rules = rules.ToList() + }; + + var json = JsonSerializer.Serialize(cacheData, JsonOptions); + await File.WriteAllTextAsync(CacheFilePath, json, ct); + } + + /// + /// Loads rules from the local cache. + /// Returns null if cache doesn't exist or is invalid JSON. + /// + public async Task?> LoadFromCacheAsync(CancellationToken ct = default) + { + if (!File.Exists(CacheFilePath)) return null; + + try + { + var json = await File.ReadAllTextAsync(CacheFilePath, ct); + var cacheData = JsonSerializer.Deserialize(json, JsonOptions); + return cacheData?.Rules; + } + catch (JsonException) + { + return null; + } + } + + /// + /// Deletes the cache file. + /// + public void ClearCache() + { + if (File.Exists(CacheFilePath)) + { + File.Delete(CacheFilePath); + } + } + + private sealed class RuleCacheData + { + public DateTime CachedAt { get; set; } + public List Rules { get; set; } = []; + } +} diff --git a/src/SharpLinter.Core/Providers/bundled-rules.json b/src/SharpLinter.Core/Providers/bundled-rules.json new file mode 100644 index 0000000..0eaeb57 --- /dev/null +++ b/src/SharpLinter.Core/Providers/bundled-rules.json @@ -0,0 +1,32 @@ +[ + { "ruleId": "CA1001", "title": "Types that own disposable fields should be disposable", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1001" }, + { "ruleId": "CA1010", "title": "Collections should implement generic interface", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1010" }, + { "ruleId": "CA1031", "title": "Do not catch general exception types", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1031" }, + { "ruleId": "CA1032", "title": "Implement standard exception constructors", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1032" }, + { "ruleId": "CA1051", "title": "Do not declare visible instance fields", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1051" }, + { "ruleId": "CA1062", "title": "Validate arguments of public methods", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1062" }, + { "ruleId": "CA1063", "title": "Implement IDisposable correctly", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1063" }, + { "ruleId": "CA1068", "title": "CancellationToken parameters must come last", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1068" }, + { "ruleId": "CA1304", "title": "Specify CultureInfo", "category": "Globalization", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304" }, + { "ruleId": "CA1707", "title": "Identifiers should not contain underscores", "category": "Naming", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1707" }, + { "ruleId": "CA1715", "title": "Identifiers should have correct prefix", "category": "Naming", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1715" }, + { "ruleId": "CA1720", "title": "Identifiers should not contain type names", "category": "Naming", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1720" }, + { "ruleId": "CA1805", "title": "Do not initialize unnecessarily", "category": "Performance", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1805" }, + { "ruleId": "CA1822", "title": "Mark members as static", "category": "Performance", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1822" }, + { "ruleId": "CA1845", "title": "Use span-based string.Concat", "category": "Performance", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1845" }, + { "ruleId": "CA1852", "title": "Seal internal types", "category": "Performance", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1852" }, + { "ruleId": "CA2000", "title": "Dispose objects before losing scope", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2000" }, + { "ruleId": "CA2007", "title": "Do not directly await a Task", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2007" }, + { "ruleId": "CA2100", "title": "Review SQL queries for security vulnerabilities", "category": "Security", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2100" }, + { "ruleId": "CA2227", "title": "Collection properties should be read only", "category": "Design", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2227" }, + { "ruleId": "IDE0003", "title": "Remove this or Me qualification", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0003-ide0009" }, + { "ruleId": "IDE0005", "title": "Remove unnecessary using directives", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005" }, + { "ruleId": "IDE0011", "title": "Add braces", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0011" }, + { "ruleId": "IDE0019", "title": "Use pattern matching to avoid as followed by null check", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0019" }, + { "ruleId": "IDE0020", "title": "Use pattern matching to avoid is check followed by cast", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0020-ide0038" }, + { "ruleId": "IDE0040", "title": "Add accessibility modifiers", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0040" }, + { "ruleId": "IDE0055", "title": "Fix formatting", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0055" }, + { "ruleId": "IDE0060", "title": "Remove unused parameter", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0060" }, + { "ruleId": "IDE0065", "title": "using directive placement", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0065" }, + { "ruleId": "IDE0161", "title": "Use file-scoped namespace", "category": "Style", "documentationUrl": "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0161" } +] diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1001_AddBracesAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1001_AddBracesAnalyzer.cs new file mode 100644 index 0000000..562b20a --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1001_AddBracesAnalyzer.cs @@ -0,0 +1,93 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1001: Control flow statements (if, else, for, foreach, while, do) should use braces. +/// Inspired by IDE0011, SA1503. +/// +public sealed class SL1001_AddBracesAnalyzer : IRuleAnalyzer +{ + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1001", + Title: "Add braces to control flow statements", + Description: "Control flow statements (if, else, for, foreach, while, do) should always use braces, even for single-line bodies. This prevents bugs from accidental mis-indentation.", + Category: RuleCategory.Style, + DefaultSeverity: LintSeverity.Warning, + Source: "builtin", + HasAnalyzer: true, + DocumentationUrl: "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0011" + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var root = tree.GetRoot(); + var walker = new BracesWalker(filePath, severity); + walker.Visit(root); + return walker.Diagnostics; + } + + private sealed class BracesWalker(string filePath, LintSeverity severity) : CSharpSyntaxWalker + { + public List Diagnostics { get; } = []; + + public override void VisitIfStatement(IfStatementSyntax node) + { + CheckStatement(node.Statement, "if"); + if (node.Else?.Statement is not IfStatementSyntax and not null) + { + CheckStatement(node.Else.Statement, "else"); + } + base.VisitIfStatement(node); + } + + public override void VisitForStatement(ForStatementSyntax node) + { + CheckStatement(node.Statement, "for"); + base.VisitForStatement(node); + } + + public override void VisitForEachStatement(ForEachStatementSyntax node) + { + CheckStatement(node.Statement, "foreach"); + base.VisitForEachStatement(node); + } + + public override void VisitWhileStatement(WhileStatementSyntax node) + { + CheckStatement(node.Statement, "while"); + base.VisitWhileStatement(node); + } + + public override void VisitDoStatement(DoStatementSyntax node) + { + CheckStatement(node.Statement, "do"); + base.VisitDoStatement(node); + } + + private void CheckStatement(StatementSyntax statement, string keyword) + { + if (statement is not BlockSyntax) + { + var span = statement.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1001", + Message: $"'{keyword}' statement should use braces", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1002_EmptyCatchBlockAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1002_EmptyCatchBlockAnalyzer.cs new file mode 100644 index 0000000..1b4b3d0 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1002_EmptyCatchBlockAnalyzer.cs @@ -0,0 +1,70 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1002: Catch blocks should not be empty — they silently swallow exceptions. +/// Inspired by CA1031. +/// +public sealed class SL1002_EmptyCatchBlockAnalyzer : IRuleAnalyzer +{ + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1002", + Title: "Avoid empty catch blocks", + Description: "Empty catch blocks silently swallow exceptions, hiding potential bugs. At minimum, log the exception or add a comment explaining why it is intentionally ignored.", + Category: RuleCategory.Design, + DefaultSeverity: LintSeverity.Warning, + Source: "builtin", + HasAnalyzer: true, + DocumentationUrl: "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1031" + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var root = tree.GetRoot(); + var walker = new EmptyCatchWalker(filePath, severity); + walker.Visit(root); + return walker.Diagnostics; + } + + private sealed class EmptyCatchWalker(string filePath, LintSeverity severity) : CSharpSyntaxWalker + { + public List Diagnostics { get; } = []; + + public override void VisitCatchClause(CatchClauseSyntax node) + { + if (node.Block.Statements.Count == 0) + { + // Check if there's at least a comment explaining the empty catch + var hasComment = node.Block.CloseBraceToken.LeadingTrivia + .Concat(node.Block.OpenBraceToken.TrailingTrivia) + .Any(t => t.IsKind(SyntaxKind.SingleLineCommentTrivia) + || t.IsKind(SyntaxKind.MultiLineCommentTrivia)); + + if (!hasComment) + { + var span = node.CatchKeyword.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1002", + Message: "Empty catch block — exceptions should not be silently swallowed", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } + + base.VisitCatchClause(node); + } + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1003_PublicFieldAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1003_PublicFieldAnalyzer.cs new file mode 100644 index 0000000..2b59537 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1003_PublicFieldAnalyzer.cs @@ -0,0 +1,78 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1003: Public fields should be properties instead for proper encapsulation. +/// Inspired by CA1051. +/// +public sealed class SL1003_PublicFieldAnalyzer : IRuleAnalyzer +{ + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1003", + Title: "Avoid public fields — use properties instead", + Description: "Public instance fields break encapsulation. Use properties instead to allow validation, change notification, and binary compatibility.", + Category: RuleCategory.Design, + DefaultSeverity: LintSeverity.Warning, + Source: "builtin", + HasAnalyzer: true, + DocumentationUrl: "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1051" + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var root = tree.GetRoot(); + var walker = new PublicFieldWalker(filePath, severity); + walker.Visit(root); + return walker.Diagnostics; + } + + private sealed class PublicFieldWalker(string filePath, LintSeverity severity) : CSharpSyntaxWalker + { + public List Diagnostics { get; } = []; + + public override void VisitFieldDeclaration(FieldDeclarationSyntax node) + { + // Skip const and static readonly fields — those are fine as public + var isConst = node.Modifiers.Any(SyntaxKind.ConstKeyword); + var isStaticReadonly = node.Modifiers.Any(SyntaxKind.StaticKeyword) + && node.Modifiers.Any(SyntaxKind.ReadOnlyKeyword); + + if (isConst || isStaticReadonly) + { + base.VisitFieldDeclaration(node); + return; + } + + var isPublic = node.Modifiers.Any(SyntaxKind.PublicKeyword); + var isProtected = node.Modifiers.Any(SyntaxKind.ProtectedKeyword); + + if (isPublic || isProtected) + { + foreach (var variable in node.Declaration.Variables) + { + var span = variable.Identifier.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1003", + Message: $"Public field '{variable.Identifier.Text}' should be a property", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } + + base.VisitFieldDeclaration(node); + } + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1004_MethodLengthAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1004_MethodLengthAnalyzer.cs new file mode 100644 index 0000000..1c7be12 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1004_MethodLengthAnalyzer.cs @@ -0,0 +1,84 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1004: Methods should not exceed a configurable line count. +/// Default: 50 lines. +/// +public sealed class SL1004_MethodLengthAnalyzer : IRuleAnalyzer +{ + private const int DefaultMaxLines = 50; + + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1004", + Title: "Method is too long", + Description: "Long methods are hard to understand and maintain. Consider extracting logic into smaller, well-named helper methods. Default threshold: 50 lines.", + Category: RuleCategory.Maintainability, + DefaultSeverity: LintSeverity.Suggestion, + Source: "builtin", + HasAnalyzer: true + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var maxLines = config.GetRuleOption(Metadata.RuleId, "maxLines", DefaultMaxLines); + var root = tree.GetRoot(); + var walker = new MethodLengthWalker(filePath, severity, maxLines); + walker.Visit(root); + return walker.Diagnostics; + } + + private sealed class MethodLengthWalker(string filePath, LintSeverity severity, int maxLines) : CSharpSyntaxWalker + { + public List Diagnostics { get; } = []; + + public override void VisitMethodDeclaration(MethodDeclarationSyntax node) + { + CheckBody(node.Body, node.Identifier.Text, node.Identifier); + base.VisitMethodDeclaration(node); + } + + public override void VisitConstructorDeclaration(ConstructorDeclarationSyntax node) + { + CheckBody(node.Body, node.Identifier.Text, node.Identifier); + base.VisitConstructorDeclaration(node); + } + + public override void VisitLocalFunctionStatement(LocalFunctionStatementSyntax node) + { + CheckBody(node.Body, node.Identifier.Text, node.Identifier); + base.VisitLocalFunctionStatement(node); + } + + private void CheckBody(BlockSyntax? body, string name, SyntaxToken identifier) + { + if (body == null) return; + + var bodySpan = body.GetLocation().GetLineSpan(); + var lineCount = bodySpan.EndLinePosition.Line - bodySpan.StartLinePosition.Line + 1; + + if (lineCount > maxLines) + { + var identSpan = identifier.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1004", + Message: $"Method '{name}' is {lineCount} lines long (max: {maxLines})", + Severity: severity, + FilePath: filePath, + Line: identSpan.StartLinePosition.Line + 1, + Column: identSpan.StartLinePosition.Character + 1, + EndLine: identSpan.EndLinePosition.Line + 1, + EndColumn: identSpan.EndLinePosition.Character + 1 + )); + } + } + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1005_NamingConventionAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1005_NamingConventionAnalyzer.cs new file mode 100644 index 0000000..3e88135 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1005_NamingConventionAnalyzer.cs @@ -0,0 +1,157 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Text.RegularExpressions; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1005: Naming convention enforcement. +/// - Types (classes, structs, enums, records): PascalCase +/// - Interfaces: Must start with 'I' followed by PascalCase +/// - Local variables and parameters: camelCase +/// - Constants: PascalCase or UPPER_SNAKE_CASE +/// Inspired by CA1715, SA1300, SA1302, SA1312. +/// +public sealed class SL1005_NamingConventionAnalyzer : IRuleAnalyzer +{ + private static readonly Regex PascalCaseRegex = new(@"^[A-Z][a-zA-Z0-9]*$", RegexOptions.Compiled); + private static readonly Regex CamelCaseRegex = new(@"^[a-z_][a-zA-Z0-9]*$", RegexOptions.Compiled); + private static readonly Regex InterfacePrefixRegex = new(@"^I[A-Z][a-zA-Z0-9]*$", RegexOptions.Compiled); + private static readonly Regex ConstantRegex = new(@"^([A-Z][a-zA-Z0-9]*|[A-Z][A-Z0-9_]*)$", RegexOptions.Compiled); + + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1005", + Title: "Naming convention violation", + Description: "Enforces .NET naming conventions: PascalCase for types, camelCase for locals/parameters, I-prefix for interfaces, PascalCase or UPPER_SNAKE_CASE for constants.", + Category: RuleCategory.Naming, + DefaultSeverity: LintSeverity.Warning, + Source: "builtin", + HasAnalyzer: true, + DocumentationUrl: "https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions" + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var root = tree.GetRoot(); + var walker = new NamingWalker(filePath, severity); + walker.Visit(root); + return walker.Diagnostics; + } + + private sealed class NamingWalker(string filePath, LintSeverity severity) : CSharpSyntaxWalker + { + public List Diagnostics { get; } = []; + + public override void VisitClassDeclaration(ClassDeclarationSyntax node) + { + CheckPascalCase(node.Identifier, "Class"); + base.VisitClassDeclaration(node); + } + + public override void VisitStructDeclaration(StructDeclarationSyntax node) + { + CheckPascalCase(node.Identifier, "Struct"); + base.VisitStructDeclaration(node); + } + + public override void VisitRecordDeclaration(RecordDeclarationSyntax node) + { + CheckPascalCase(node.Identifier, "Record"); + base.VisitRecordDeclaration(node); + } + + public override void VisitEnumDeclaration(EnumDeclarationSyntax node) + { + CheckPascalCase(node.Identifier, "Enum"); + base.VisitEnumDeclaration(node); + } + + public override void VisitInterfaceDeclaration(InterfaceDeclarationSyntax node) + { + var name = node.Identifier.Text; + if (!InterfacePrefixRegex.IsMatch(name)) + { + AddDiagnostic(node.Identifier, $"Interface '{name}' should start with 'I' followed by PascalCase"); + } + base.VisitInterfaceDeclaration(node); + } + + public override void VisitMethodDeclaration(MethodDeclarationSyntax node) + { + CheckPascalCase(node.Identifier, "Method"); + base.VisitMethodDeclaration(node); + } + + public override void VisitPropertyDeclaration(PropertyDeclarationSyntax node) + { + CheckPascalCase(node.Identifier, "Property"); + base.VisitPropertyDeclaration(node); + } + + public override void VisitParameter(ParameterSyntax node) + { + var name = node.Identifier.Text; + if (!string.IsNullOrEmpty(name) && !CamelCaseRegex.IsMatch(name)) + { + AddDiagnostic(node.Identifier, $"Parameter '{name}' should use camelCase"); + } + base.VisitParameter(node); + } + + public override void VisitVariableDeclarator(VariableDeclaratorSyntax node) + { + // Only check local variables, not fields (fields have different rules) + if (node.Parent?.Parent is LocalDeclarationStatementSyntax) + { + var name = node.Identifier.Text; + if (!CamelCaseRegex.IsMatch(name)) + { + AddDiagnostic(node.Identifier, $"Local variable '{name}' should use camelCase"); + } + } + + // Check constants + if (node.Parent?.Parent is FieldDeclarationSyntax field + && field.Modifiers.Any(SyntaxKind.ConstKeyword)) + { + var name = node.Identifier.Text; + if (!ConstantRegex.IsMatch(name)) + { + AddDiagnostic(node.Identifier, $"Constant '{name}' should use PascalCase or UPPER_SNAKE_CASE"); + } + } + + base.VisitVariableDeclarator(node); + } + + private void CheckPascalCase(SyntaxToken identifier, string kind) + { + var name = identifier.Text; + if (!string.IsNullOrEmpty(name) && !PascalCaseRegex.IsMatch(name)) + { + AddDiagnostic(identifier, $"{kind} '{name}' should use PascalCase"); + } + } + + private void AddDiagnostic(SyntaxToken identifier, string message) + { + var span = identifier.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1005", + Message: message, + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1006_UnusedUsingAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1006_UnusedUsingAnalyzer.cs new file mode 100644 index 0000000..efc0292 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1006_UnusedUsingAnalyzer.cs @@ -0,0 +1,85 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1006: Detect potentially unused using directives. +/// Uses a lightweight syntax-only heuristic: checks if any identifier in the file +/// matches a plausible type from the imported namespace. +/// Inspired by IDE0005. +/// +public sealed class SL1006_UnusedUsingAnalyzer : IRuleAnalyzer +{ + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1006", + Title: "Potentially unused using directive", + Description: "Using directives that are not referenced anywhere in the file add unnecessary clutter. Remove unused usings to keep the file clean.", + Category: RuleCategory.Style, + DefaultSeverity: LintSeverity.Suggestion, + Source: "builtin", + HasAnalyzer: true, + DocumentationUrl: "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005" + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var root = tree.GetRoot(); + var diagnostics = new List(); + + // Get all using directives + var usings = root.DescendantNodes().OfType() + .Where(u => u.Alias == null && u.StaticKeyword.IsKind(SyntaxKind.None)) + .ToList(); + + if (usings.Count == 0) return []; + + // Collect all identifiers used in the file (excluding the usings themselves) + var allIdentifiers = root.DescendantTokens() + .Where(t => t.IsKind(SyntaxKind.IdentifierToken) + && t.Parent is not UsingDirectiveSyntax + && t.Parent?.Parent is not UsingDirectiveSyntax) + .Select(t => t.Text) + .ToHashSet(); + + // Collect all text in the file to check for namespace segment usage + var fullText = tree.GetText().ToString(); + + foreach (var usingDirective in usings) + { + var namespaceName = usingDirective.Name?.ToString(); + if (string.IsNullOrEmpty(namespaceName)) continue; + + // Get the last segment of the namespace (most likely to appear as a qualifier) + var lastSegment = namespaceName.Split('.').Last(); + + // Check if any identifier in the file could reference something from this namespace + // This is a heuristic — syntax-only analysis cannot be 100% accurate + var isLikelyUsed = allIdentifiers.Contains(lastSegment) + || fullText.Contains(lastSegment + "."); + + if (!isLikelyUsed) + { + var span = usingDirective.GetLocation().GetLineSpan(); + diagnostics.Add(new LintDiagnostic( + RuleId: "SL1006", + Message: $"Using directive '{namespaceName}' appears to be unused", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } + + return diagnostics; + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1007_CyclomaticComplexityAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1007_CyclomaticComplexityAnalyzer.cs new file mode 100644 index 0000000..938c8d1 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1007_CyclomaticComplexityAnalyzer.cs @@ -0,0 +1,117 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1007: Cyclomatic complexity should not exceed a configurable threshold. +/// Counts decision points (if, else if, case, while, for, foreach, &&, ||, ??, ?:, catch). +/// Default threshold: 10. +/// +public sealed class SL1007_CyclomaticComplexityAnalyzer : IRuleAnalyzer +{ + private const int DefaultMaxComplexity = 10; + + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1007", + Title: "Method has high cyclomatic complexity", + Description: "High cyclomatic complexity makes code hard to test and maintain. Consider decomposing into smaller methods. Default threshold: 10.", + Category: RuleCategory.Maintainability, + DefaultSeverity: LintSeverity.Suggestion, + Source: "builtin", + HasAnalyzer: true + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var maxComplexity = config.GetRuleOption(Metadata.RuleId, "maxComplexity", DefaultMaxComplexity); + var root = tree.GetRoot(); + var walker = new ComplexityWalker(filePath, severity, maxComplexity); + walker.Visit(root); + return walker.Diagnostics; + } + + private sealed class ComplexityWalker(string filePath, LintSeverity severity, int maxComplexity) : CSharpSyntaxWalker + { + public List Diagnostics { get; } = []; + + public override void VisitMethodDeclaration(MethodDeclarationSyntax node) + { + if (node.Body != null || node.ExpressionBody != null) + { + var complexity = CalculateComplexity(node); + CheckComplexity(complexity, node.Identifier.Text, node.Identifier); + } + // Don't call base — we don't want to count nested methods double + } + + public override void VisitConstructorDeclaration(ConstructorDeclarationSyntax node) + { + if (node.Body != null) + { + var complexity = CalculateComplexity(node); + CheckComplexity(complexity, node.Identifier.Text, node.Identifier); + } + } + + private static int CalculateComplexity(SyntaxNode methodNode) + { + int complexity = 1; // Base complexity + + foreach (var node in methodNode.DescendantNodes()) + { + complexity += node switch + { + IfStatementSyntax => 1, + WhileStatementSyntax => 1, + ForStatementSyntax => 1, + ForEachStatementSyntax => 1, + DoStatementSyntax => 1, + CaseSwitchLabelSyntax => 1, + CasePatternSwitchLabelSyntax => 1, + ConditionalExpressionSyntax => 1, + CatchClauseSyntax => 1, + SwitchExpressionArmSyntax => 1, + _ => 0 + }; + } + + // Count logical operators (&&, ||, ??) + foreach (var token in methodNode.DescendantTokens()) + { + if (token.IsKind(SyntaxKind.AmpersandAmpersandToken) + || token.IsKind(SyntaxKind.BarBarToken) + || token.IsKind(SyntaxKind.QuestionQuestionToken)) + { + complexity++; + } + } + + return complexity; + } + + private void CheckComplexity(int complexity, string name, SyntaxToken identifier) + { + if (complexity > maxComplexity) + { + var span = identifier.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1007", + Message: $"Method '{name}' has cyclomatic complexity of {complexity} (max: {maxComplexity})", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1008_ConsistentBracePlacementAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1008_ConsistentBracePlacementAnalyzer.cs new file mode 100644 index 0000000..d5bcedd --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1008_ConsistentBracePlacementAnalyzer.cs @@ -0,0 +1,87 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1008: Brace placement should be consistent — either Allman style (new line) or K&R (same line). +/// Default: Allman style (new line for braces), matching the .NET standard. +/// +public sealed class SL1008_ConsistentBracePlacementAnalyzer : IRuleAnalyzer +{ + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1008", + Title: "Inconsistent brace placement", + Description: "Brace placement should be consistent. Default style is Allman (opening brace on a new line), the .NET community standard.", + Category: RuleCategory.Style, + DefaultSeverity: LintSeverity.Suggestion, + Source: "builtin", + HasAnalyzer: true + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var style = config.GetRuleOption(Metadata.RuleId, "style", "newLine"); + var expectNewLine = style.Equals("newLine", StringComparison.OrdinalIgnoreCase); + + var root = tree.GetRoot(); + var diagnostics = new List(); + + foreach (var openBrace in root.DescendantTokens().Where(t => t.IsKind(SyntaxKind.OpenBraceToken))) + { + // Skip initializers and collection expressions + if (openBrace.Parent is InitializerExpressionSyntax + || openBrace.Parent is AnonymousObjectCreationExpressionSyntax) + { + continue; + } + + var braceLineSpan = openBrace.GetLocation().GetLineSpan(); + var braceLine = braceLineSpan.StartLinePosition.Line; + + // Get the previous significant token + var prevToken = openBrace.GetPreviousToken(); + if (prevToken == default) continue; + + var prevLineSpan = prevToken.GetLocation().GetLineSpan(); + var prevLine = prevLineSpan.EndLinePosition.Line; + + var isOnNewLine = braceLine > prevLine; + + if (expectNewLine && !isOnNewLine) + { + diagnostics.Add(new LintDiagnostic( + RuleId: "SL1008", + Message: "Opening brace should be on a new line (Allman style)", + Severity: severity, + FilePath: filePath, + Line: braceLineSpan.StartLinePosition.Line + 1, + Column: braceLineSpan.StartLinePosition.Character + 1, + EndLine: braceLineSpan.EndLinePosition.Line + 1, + EndColumn: braceLineSpan.EndLinePosition.Character + 1 + )); + } + else if (!expectNewLine && isOnNewLine) + { + diagnostics.Add(new LintDiagnostic( + RuleId: "SL1008", + Message: "Opening brace should be on the same line (K&R style)", + Severity: severity, + FilePath: filePath, + Line: braceLineSpan.StartLinePosition.Line + 1, + Column: braceLineSpan.StartLinePosition.Character + 1, + EndLine: braceLineSpan.EndLinePosition.Line + 1, + EndColumn: braceLineSpan.EndLinePosition.Character + 1 + )); + } + } + + return diagnostics; + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1009_TrailingWhitespaceAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1009_TrailingWhitespaceAnalyzer.cs new file mode 100644 index 0000000..cfa7614 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1009_TrailingWhitespaceAnalyzer.cs @@ -0,0 +1,61 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1009: Lines should not have trailing whitespace. +/// Inspired by SA1028. +/// +public sealed class SL1009_TrailingWhitespaceAnalyzer : IRuleAnalyzer +{ + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1009", + Title: "Trailing whitespace detected", + Description: "Lines should not have trailing whitespace characters. Trailing whitespace creates noisy diffs and serves no purpose.", + Category: RuleCategory.Style, + DefaultSeverity: LintSeverity.Suggestion, + Source: "builtin", + HasAnalyzer: true + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var diagnostics = new List(); + var text = tree.GetText(); + + for (int i = 0; i < text.Lines.Count; i++) + { + var line = text.Lines[i]; + var lineText = line.ToString(); + + if (lineText.Length > 0 && char.IsWhiteSpace(lineText[^1])) + { + // Find where the trailing whitespace starts + int trailingStart = lineText.Length - 1; + while (trailingStart > 0 && char.IsWhiteSpace(lineText[trailingStart - 1])) + { + trailingStart--; + } + + diagnostics.Add(new LintDiagnostic( + RuleId: "SL1009", + Message: "Line has trailing whitespace", + Severity: severity, + FilePath: filePath, + Line: i + 1, + Column: trailingStart + 1, + EndLine: i + 1, + EndColumn: lineText.Length + 1 + )); + } + } + + return diagnostics; + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1010_FileLengthAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1010_FileLengthAnalyzer.cs new file mode 100644 index 0000000..d25f720 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1010_FileLengthAnalyzer.cs @@ -0,0 +1,53 @@ +using Microsoft.CodeAnalysis; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1010: Files should not exceed a configurable line count. +/// Default: 500 lines. +/// +public sealed class SL1010_FileLengthAnalyzer : IRuleAnalyzer +{ + private const int DefaultMaxLines = 500; + + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1010", + Title: "File is too long", + Description: "Large files are harder to navigate and maintain. Consider splitting into multiple files with focused responsibilities. Default threshold: 500 lines.", + Category: RuleCategory.Maintainability, + DefaultSeverity: LintSeverity.Suggestion, + Source: "builtin", + HasAnalyzer: true + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var maxLines = config.GetRuleOption(Metadata.RuleId, "maxLines", DefaultMaxLines); + var text = tree.GetText(); + var lineCount = text.Lines.Count; + + if (lineCount > maxLines) + { + return + [ + new LintDiagnostic( + RuleId: "SL1010", + Message: $"File is {lineCount} lines long (max: {maxLines})", + Severity: severity, + FilePath: filePath, + Line: 1, + Column: 1, + EndLine: 1, + EndColumn: 1 + ) + ]; + } + + return []; + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1011_PatternMatchingAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1011_PatternMatchingAnalyzer.cs new file mode 100644 index 0000000..4ede00b --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1011_PatternMatchingAnalyzer.cs @@ -0,0 +1,101 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1011: Prefer pattern matching over type check + cast. +/// Detects: if (x is Type) { var y = (Type)x; } → suggest: if (x is Type y) +/// Inspired by IDE0019, IDE0020. +/// +public sealed class SL1011_PatternMatchingAnalyzer : IRuleAnalyzer +{ + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1011", + Title: "Use pattern matching instead of type check and cast", + Description: "Use C# pattern matching (is Type variable) instead of separate type check and cast. This is more concise and avoids potential null reference issues.", + Category: RuleCategory.Style, + DefaultSeverity: LintSeverity.Suggestion, + Source: "builtin", + HasAnalyzer: true, + DocumentationUrl: "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0020" + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var root = tree.GetRoot(); + var walker = new PatternMatchingWalker(filePath, severity); + walker.Visit(root); + return walker.Diagnostics; + } + + private sealed class PatternMatchingWalker(string filePath, LintSeverity severity) : CSharpSyntaxWalker + { + public List Diagnostics { get; } = []; + + public override void VisitIfStatement(IfStatementSyntax node) + { + // Pattern 1: if (x is Type) { ... (Type)x ... } + if (node.Condition is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.IsExpression } isExpr) + { + var checkedExpr = isExpr.Left.ToString(); + + // Look for a cast to the same type in the if body + if (node.Statement is BlockSyntax block) + { + foreach (var castExpr in block.DescendantNodes().OfType()) + { + if (castExpr.Type.ToString() == isExpr.Right.ToString() + && castExpr.Expression.ToString() == checkedExpr) + { + var span = isExpr.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1011", + Message: $"Use pattern matching: 'if ({checkedExpr} is {isExpr.Right} variable)' instead of type check + cast", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + break; + } + } + } + } + + // Pattern 2: x as Type followed by null check + // if (x as Type != null) → use: if (x is Type variable) + if (node.Condition is BinaryExpressionSyntax nullCheckExpr + && (nullCheckExpr.IsKind(SyntaxKind.NotEqualsExpression) || nullCheckExpr.IsKind(SyntaxKind.EqualsExpression))) + { + var asExpr = nullCheckExpr.Left as BinaryExpressionSyntax + ?? nullCheckExpr.Right as BinaryExpressionSyntax; + + if (asExpr?.IsKind(SyntaxKind.AsExpression) == true) + { + var span = node.Condition.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1011", + Message: "Use pattern matching instead of 'as' operator with null check", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } + + base.VisitIfStatement(node); + } + } +} diff --git a/src/SharpLinter.Core/Rules/BuiltIn/SL1012_StringConcatInLoopAnalyzer.cs b/src/SharpLinter.Core/Rules/BuiltIn/SL1012_StringConcatInLoopAnalyzer.cs new file mode 100644 index 0000000..59de7e3 --- /dev/null +++ b/src/SharpLinter.Core/Rules/BuiltIn/SL1012_StringConcatInLoopAnalyzer.cs @@ -0,0 +1,117 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.BuiltIn; + +/// +/// SL1012: String concatenation inside loops should use StringBuilder. +/// Detects += with string operands inside for, foreach, while, and do loops. +/// Inspired by CA1845. +/// +public sealed class SL1012_StringConcatInLoopAnalyzer : IRuleAnalyzer +{ + public RuleMetadata Metadata { get; } = new( + RuleId: "SL1012", + Title: "Avoid string concatenation in loops — use StringBuilder", + Description: "String concatenation (+= or + in loops) creates a new string object on each iteration, leading to O(n²) performance. Use StringBuilder instead.", + Category: RuleCategory.Performance, + DefaultSeverity: LintSeverity.Warning, + Source: "builtin", + HasAnalyzer: true, + DocumentationUrl: "https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1845" + ); + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None) return []; + + var root = tree.GetRoot(); + var walker = new StringConcatWalker(filePath, severity); + walker.Visit(root); + return walker.Diagnostics; + } + + private sealed class StringConcatWalker(string filePath, LintSeverity severity) : CSharpSyntaxWalker + { + public List Diagnostics { get; } = []; + + public override void VisitForStatement(ForStatementSyntax node) + { + CheckBlockForStringConcat(node.Statement); + base.VisitForStatement(node); + } + + public override void VisitForEachStatement(ForEachStatementSyntax node) + { + CheckBlockForStringConcat(node.Statement); + base.VisitForEachStatement(node); + } + + public override void VisitWhileStatement(WhileStatementSyntax node) + { + CheckBlockForStringConcat(node.Statement); + base.VisitWhileStatement(node); + } + + public override void VisitDoStatement(DoStatementSyntax node) + { + CheckBlockForStringConcat(node.Statement); + base.VisitDoStatement(node); + } + + private void CheckBlockForStringConcat(StatementSyntax statement) + { + var assignments = statement.DescendantNodes().OfType(); + + foreach (var assignment in assignments) + { + // Check for: str += "something" or str += variable + if (assignment.IsKind(SyntaxKind.AddAssignmentExpression)) + { + // Heuristic: if the right side is a string literal, interpolated string, + // or the left side is commonly named like a string builder pattern + if (IsLikelyStringExpression(assignment.Right) || IsLikelyStringExpression(assignment.Left)) + { + ReportDiagnostic(assignment); + } + } + + // Check for: str = str + "something" + if (assignment.IsKind(SyntaxKind.SimpleAssignmentExpression) + && assignment.Right is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.AddExpression } addExpr) + { + if ((IsLikelyStringExpression(addExpr.Left) || IsLikelyStringExpression(addExpr.Right)) + && addExpr.Left.ToString() == assignment.Left.ToString()) + { + ReportDiagnostic(assignment); + } + } + } + } + + private static bool IsLikelyStringExpression(ExpressionSyntax expression) + { + return expression is LiteralExpressionSyntax { RawKind: (int)SyntaxKind.StringLiteralExpression } + || expression is InterpolatedStringExpressionSyntax; + } + + private void ReportDiagnostic(AssignmentExpressionSyntax assignment) + { + var span = assignment.GetLocation().GetLineSpan(); + Diagnostics.Add(new LintDiagnostic( + RuleId: "SL1012", + Message: "String concatenation in a loop — consider using StringBuilder", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } +} diff --git a/src/SharpLinter.Core/Rules/Custom/CustomRuleLoader.cs b/src/SharpLinter.Core/Rules/Custom/CustomRuleLoader.cs new file mode 100644 index 0000000..e353fe8 --- /dev/null +++ b/src/SharpLinter.Core/Rules/Custom/CustomRuleLoader.cs @@ -0,0 +1,138 @@ +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.Custom; + +/// +/// Loads custom rule definitions from a .sharplinter.rules.yaml file. +/// +public static class CustomRuleLoader +{ + private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + /// + /// Loads custom rules from the specified YAML file. + /// Returns an empty list if the file does not exist. + /// + public static IReadOnlyList LoadFromFile(string filePath) + { + if (!File.Exists(filePath)) + { + return []; + } + + var yaml = File.ReadAllText(filePath); + return LoadFromYaml(yaml); + } + + /// + /// Loads custom rules from a YAML string. + /// + public static IReadOnlyList LoadFromYaml(string yaml) + { + var doc = YamlDeserializer.Deserialize(yaml); + if (doc?.Rules == null || doc.Rules.Count == 0) + { + return []; + } + + var analyzers = new List(); + + foreach (var ruleDef in doc.Rules) + { + var metadata = new RuleMetadata( + RuleId: ruleDef.Id ?? throw new InvalidOperationException("Custom rule must have an 'id'"), + Title: ruleDef.Title ?? ruleDef.Id, + Description: ruleDef.Description ?? "", + Category: ParseCategory(ruleDef.Category), + DefaultSeverity: ParseSeverity(ruleDef.Severity), + Source: "custom", + HasAnalyzer: true + ); + + IRuleAnalyzer? analyzer = ruleDef.Type?.ToLowerInvariant() switch + { + "pattern" => new PatternRuleAnalyzer(metadata, ruleDef.Pattern), + "metric" => new MetricRuleAnalyzer(metadata, ruleDef.Metric), + "naming" => new NamingRuleAnalyzer(metadata, ruleDef.Naming), + _ => null + }; + + if (analyzer != null) + { + analyzers.Add(analyzer); + } + } + + return analyzers; + } + + private static RuleCategory ParseCategory(string? category) => category?.ToLowerInvariant() switch + { + "style" => RuleCategory.Style, + "naming" => RuleCategory.Naming, + "design" => RuleCategory.Design, + "performance" => RuleCategory.Performance, + "security" => RuleCategory.Security, + "maintainability" => RuleCategory.Maintainability, + _ => RuleCategory.Maintainability + }; + + private static LintSeverity ParseSeverity(string? severity) => severity?.ToLowerInvariant() switch + { + "error" => LintSeverity.Error, + "warning" => LintSeverity.Warning, + "suggestion" or "info" => LintSeverity.Suggestion, + "none" or "off" => LintSeverity.None, + _ => LintSeverity.Warning + }; +} + +/// YAML document root. +public sealed class CustomRulesDocument +{ + public List Rules { get; set; } = []; +} + +/// A single custom rule definition from YAML. +public sealed class CustomRuleDefinition +{ + public string? Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? Category { get; set; } + public string? Severity { get; set; } + public string? Type { get; set; } + public PatternConfig? Pattern { get; set; } + public MetricConfig? Metric { get; set; } + public NamingConfig? Naming { get; set; } +} + +/// Configuration for pattern-type custom rules. +public sealed class PatternConfig +{ + public string? Kind { get; set; } + public string? Match { get; set; } + public List? Exclude { get; set; } + public string? Scope { get; set; } +} + +/// Configuration for metric-type custom rules. +public sealed class MetricConfig +{ + public string? Target { get; set; } + public string? Measure { get; set; } + public int Max { get; set; } = int.MaxValue; +} + +/// Configuration for naming-type custom rules. +public sealed class NamingConfig +{ + public string? Target { get; set; } + public string? Pattern { get; set; } +} diff --git a/src/SharpLinter.Core/Rules/Custom/MetricRuleAnalyzer.cs b/src/SharpLinter.Core/Rules/Custom/MetricRuleAnalyzer.cs new file mode 100644 index 0000000..c456dcb --- /dev/null +++ b/src/SharpLinter.Core/Rules/Custom/MetricRuleAnalyzer.cs @@ -0,0 +1,139 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.Custom; + +/// +/// Executes metric-type custom rules: quantitative thresholds on code elements. +/// Supports measuring: lines, parameters, complexity for methods, classes, and files. +/// +public sealed class MetricRuleAnalyzer : IRuleAnalyzer +{ + private readonly MetricConfig? _config; + + public RuleMetadata Metadata { get; } + + public MetricRuleAnalyzer(RuleMetadata metadata, MetricConfig? config) + { + Metadata = metadata; + _config = config; + } + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None || _config == null) return []; + + var root = tree.GetRoot(); + var diagnostics = new List(); + + var target = _config.Target?.ToLowerInvariant(); + var measure = _config.Measure?.ToLowerInvariant(); + + switch (target) + { + case "method": + AnalyzeMethods(root, filePath, severity, measure, diagnostics); + break; + case "class": + AnalyzeClasses(root, filePath, severity, measure, diagnostics); + break; + case "file": + AnalyzeFile(tree, filePath, severity, measure, diagnostics); + break; + } + + return diagnostics; + } + + private void AnalyzeMethods(SyntaxNode root, string filePath, LintSeverity severity, string? measure, List diagnostics) + { + foreach (var method in root.DescendantNodes().OfType()) + { + int value = measure switch + { + "lines" => GetLineCount(method.Body), + "parameters" => method.ParameterList.Parameters.Count, + _ => 0 + }; + + if (value > _config!.Max) + { + var span = method.Identifier.GetLocation().GetLineSpan(); + diagnostics.Add(new LintDiagnostic( + RuleId: Metadata.RuleId, + Message: $"Method '{method.Identifier.Text}' has {measure} count of {value} (max: {_config.Max})", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } + } + + private void AnalyzeClasses(SyntaxNode root, string filePath, LintSeverity severity, string? measure, List diagnostics) + { + foreach (var classDecl in root.DescendantNodes().OfType()) + { + int value = measure switch + { + "lines" => GetLineCount(classDecl), + "methods" => classDecl.Members.OfType().Count(), + "fields" => classDecl.Members.OfType().Count(), + _ => 0 + }; + + if (value > _config!.Max) + { + var span = classDecl.Identifier.GetLocation().GetLineSpan(); + diagnostics.Add(new LintDiagnostic( + RuleId: Metadata.RuleId, + Message: $"Class '{classDecl.Identifier.Text}' has {measure} count of {value} (max: {_config.Max})", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } + } + + private void AnalyzeFile(SyntaxTree tree, string filePath, LintSeverity severity, string? measure, List diagnostics) + { + var text = tree.GetText(); + int value = measure switch + { + "lines" => text.Lines.Count, + _ => 0 + }; + + if (value > _config!.Max) + { + diagnostics.Add(new LintDiagnostic( + RuleId: Metadata.RuleId, + Message: $"File has {measure} count of {value} (max: {_config.Max})", + Severity: severity, + FilePath: filePath, + Line: 1, + Column: 1, + EndLine: 1, + EndColumn: 1 + )); + } + } + + private static int GetLineCount(SyntaxNode? node) + { + if (node == null) return 0; + var span = node.GetLocation().GetLineSpan(); + return span.EndLinePosition.Line - span.StartLinePosition.Line + 1; + } +} diff --git a/src/SharpLinter.Core/Rules/Custom/NamingRuleAnalyzer.cs b/src/SharpLinter.Core/Rules/Custom/NamingRuleAnalyzer.cs new file mode 100644 index 0000000..a614e18 --- /dev/null +++ b/src/SharpLinter.Core/Rules/Custom/NamingRuleAnalyzer.cs @@ -0,0 +1,116 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Text.RegularExpressions; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.Custom; + +/// +/// Executes naming-type custom rules: regex-based naming convention enforcement +/// on types, methods, fields, properties, and parameters. +/// +public sealed class NamingRuleAnalyzer : IRuleAnalyzer +{ + private readonly NamingConfig? _config; + private readonly Regex? _regex; + + public RuleMetadata Metadata { get; } + + public NamingRuleAnalyzer(RuleMetadata metadata, NamingConfig? config) + { + Metadata = metadata; + _config = config; + _regex = config?.Pattern != null + ? new Regex(config.Pattern, RegexOptions.Compiled) + : null; + } + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None || _config == null || _regex == null) return []; + + var root = tree.GetRoot(); + var diagnostics = new List(); + + var target = _config.Target?.ToLowerInvariant(); + + switch (target) + { + case "class": + CheckNodes(root, n => n.Identifier, filePath, severity, diagnostics); + break; + case "interface": + CheckNodes(root, n => n.Identifier, filePath, severity, diagnostics); + break; + case "method": + CheckNodes(root, n => n.Identifier, filePath, severity, diagnostics); + break; + case "property": + CheckNodes(root, n => n.Identifier, filePath, severity, diagnostics); + break; + case "field": + foreach (var field in root.DescendantNodes().OfType()) + { + foreach (var variable in field.Declaration.Variables) + { + CheckIdentifier(variable.Identifier, filePath, severity, diagnostics); + } + } + break; + case "parameter": + CheckNodes(root, n => n.Identifier, filePath, severity, diagnostics); + break; + case "variable": + foreach (var local in root.DescendantNodes().OfType()) + { + foreach (var variable in local.Declaration.Variables) + { + CheckIdentifier(variable.Identifier, filePath, severity, diagnostics); + } + } + break; + default: + // Check all identifiers + foreach (var token in root.DescendantTokens().Where(t => t.IsKind(SyntaxKind.IdentifierToken))) + { + CheckIdentifier(token, filePath, severity, diagnostics); + } + break; + } + + return diagnostics; + } + + private void CheckNodes(SyntaxNode root, Func getIdentifier, + string filePath, LintSeverity severity, List diagnostics) where T : SyntaxNode + { + foreach (var node in root.DescendantNodes().OfType()) + { + CheckIdentifier(getIdentifier(node), filePath, severity, diagnostics); + } + } + + private void CheckIdentifier(SyntaxToken identifier, string filePath, LintSeverity severity, List diagnostics) + { + var name = identifier.Text; + if (string.IsNullOrEmpty(name)) return; + + if (!_regex!.IsMatch(name)) + { + var span = identifier.GetLocation().GetLineSpan(); + diagnostics.Add(new LintDiagnostic( + RuleId: Metadata.RuleId, + Message: $"'{name}' does not match the required naming pattern: {_config!.Pattern}", + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } + } +} diff --git a/src/SharpLinter.Core/Rules/Custom/PatternRuleAnalyzer.cs b/src/SharpLinter.Core/Rules/Custom/PatternRuleAnalyzer.cs new file mode 100644 index 0000000..a0c8ddc --- /dev/null +++ b/src/SharpLinter.Core/Rules/Custom/PatternRuleAnalyzer.cs @@ -0,0 +1,180 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Text.RegularExpressions; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules.Custom; + +/// +/// Executes pattern-type custom rules: regex matching against syntax elements. +/// Supports matching against comments, identifiers, invocations, numeric literals, and string literals. +/// +public sealed class PatternRuleAnalyzer : IRuleAnalyzer +{ + private readonly PatternConfig? _config; + private readonly Regex? _regex; + + public RuleMetadata Metadata { get; } + + public PatternRuleAnalyzer(RuleMetadata metadata, PatternConfig? config) + { + Metadata = metadata; + _config = config; + _regex = config?.Match != null + ? new Regex(config.Match, RegexOptions.Compiled | RegexOptions.IgnoreCase) + : null; + } + + public IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config) + { + var severity = config.GetEffectiveSeverity(Metadata.RuleId, Metadata.DefaultSeverity); + if (severity == LintSeverity.None || _config == null) return []; + + var root = tree.GetRoot(); + var diagnostics = new List(); + + var kind = _config.Kind?.ToLowerInvariant(); + + switch (kind) + { + case "comment": + AnalyzeComments(root, filePath, severity, diagnostics); + break; + case "invocation": + AnalyzeInvocations(root, filePath, severity, diagnostics); + break; + case "numeric-literal": + AnalyzeNumericLiterals(root, filePath, severity, diagnostics); + break; + case "string-literal": + AnalyzeStringLiterals(root, filePath, severity, diagnostics); + break; + case "identifier": + AnalyzeIdentifiers(root, filePath, severity, diagnostics); + break; + default: + // Generic: search all tokens for the pattern + AnalyzeAllTokens(root, filePath, severity, diagnostics); + break; + } + + return diagnostics; + } + + private void AnalyzeComments(SyntaxNode root, string filePath, LintSeverity severity, List diagnostics) + { + foreach (var trivia in root.DescendantTrivia()) + { + if (trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) + || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia)) + { + var text = trivia.ToString(); + if (_regex?.IsMatch(text) == true) + { + AddDiagnostic(trivia.GetLocation(), filePath, severity, diagnostics); + } + } + } + } + + private void AnalyzeInvocations(SyntaxNode root, string filePath, LintSeverity severity, List diagnostics) + { + foreach (var invocation in root.DescendantNodes().OfType()) + { + var text = invocation.Expression.ToString(); + if (_regex?.IsMatch(text) == true) + { + AddDiagnostic(invocation.GetLocation(), filePath, severity, diagnostics); + } + } + } + + private void AnalyzeNumericLiterals(SyntaxNode root, string filePath, LintSeverity severity, List diagnostics) + { + var excludeSet = _config?.Exclude?.Select(e => e.ToString()).ToHashSet() ?? []; + + foreach (var literal in root.DescendantNodes().OfType()) + { + if (literal.IsKind(SyntaxKind.NumericLiteralExpression)) + { + var text = literal.Token.ValueText; + if (!excludeSet.Contains(text)) + { + // Only check within method bodies if scope is method-body + if (_config?.Scope == "method-body") + { + var inMethod = literal.Ancestors().Any(a => + a is MethodDeclarationSyntax || a is ConstructorDeclarationSyntax); + if (!inMethod) continue; + } + + AddDiagnostic(literal.GetLocation(), filePath, severity, diagnostics); + } + } + } + } + + private void AnalyzeStringLiterals(SyntaxNode root, string filePath, LintSeverity severity, List diagnostics) + { + foreach (var literal in root.DescendantNodes().OfType()) + { + if (literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + var text = literal.Token.ValueText; + if (_regex?.IsMatch(text) == true) + { + AddDiagnostic(literal.GetLocation(), filePath, severity, diagnostics); + } + } + } + } + + private void AnalyzeIdentifiers(SyntaxNode root, string filePath, LintSeverity severity, List diagnostics) + { + foreach (var token in root.DescendantTokens().Where(t => t.IsKind(SyntaxKind.IdentifierToken))) + { + if (_regex?.IsMatch(token.Text) == true) + { + AddDiagnostic(token.GetLocation(), filePath, severity, diagnostics); + } + } + } + + private void AnalyzeAllTokens(SyntaxNode root, string filePath, LintSeverity severity, List diagnostics) + { + var fullText = root.ToFullString(); + if (_regex == null) return; + + foreach (Match match in _regex.Matches(fullText)) + { + var position = root.SyntaxTree.GetText().Lines.GetLinePosition(match.Index); + diagnostics.Add(new LintDiagnostic( + RuleId: Metadata.RuleId, + Message: Metadata.Title, + Severity: severity, + FilePath: filePath, + Line: position.Line + 1, + Column: position.Character + 1, + EndLine: position.Line + 1, + EndColumn: position.Character + match.Length + 1 + )); + } + } + + private void AddDiagnostic(Location location, string filePath, LintSeverity severity, List diagnostics) + { + var span = location.GetLineSpan(); + diagnostics.Add(new LintDiagnostic( + RuleId: Metadata.RuleId, + Message: Metadata.Title, + Severity: severity, + FilePath: filePath, + Line: span.StartLinePosition.Line + 1, + Column: span.StartLinePosition.Character + 1, + EndLine: span.EndLinePosition.Line + 1, + EndColumn: span.EndLinePosition.Character + 1 + )); + } +} diff --git a/src/SharpLinter.Core/Rules/IRuleAnalyzer.cs b/src/SharpLinter.Core/Rules/IRuleAnalyzer.cs new file mode 100644 index 0000000..8e03dee --- /dev/null +++ b/src/SharpLinter.Core/Rules/IRuleAnalyzer.cs @@ -0,0 +1,26 @@ +using Microsoft.CodeAnalysis; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; + +namespace SharpLinter.Core.Rules; + +/// +/// Interface for all lint rule analyzers — built-in, custom, or provider-backed. +/// Each implementation inspects a Roslyn SyntaxTree and returns diagnostics. +/// +public interface IRuleAnalyzer +{ + /// + /// Metadata describing this rule (ID, title, category, severity, etc.). + /// + RuleMetadata Metadata { get; } + + /// + /// Analyzes the given syntax tree and returns any diagnostics found. + /// + /// The Roslyn syntax tree to analyze. + /// The file path (for diagnostic reporting). + /// The current lint configuration (for rule-specific options). + /// A list of diagnostics found by this rule. + IReadOnlyList Analyze(SyntaxTree tree, string filePath, LintConfiguration config); +} diff --git a/src/SharpLinter.Core/Rules/RuleCategory.cs b/src/SharpLinter.Core/Rules/RuleCategory.cs new file mode 100644 index 0000000..2843929 --- /dev/null +++ b/src/SharpLinter.Core/Rules/RuleCategory.cs @@ -0,0 +1,25 @@ +namespace SharpLinter.Core.Rules; + +/// +/// Categories for lint rules, used for grouping and filtering. +/// +public enum RuleCategory +{ + /// Code style rules (braces, whitespace, formatting). + Style, + + /// Naming convention rules (PascalCase, camelCase, prefixes). + Naming, + + /// Design rules (encapsulation, error handling patterns). + Design, + + /// Performance rules (string concatenation, allocation patterns). + Performance, + + /// Security rules (input validation, sensitive data handling). + Security, + + /// Maintainability rules (complexity, method length, TODOs). + Maintainability +} diff --git a/src/SharpLinter.Core/Rules/RuleMetadata.cs b/src/SharpLinter.Core/Rules/RuleMetadata.cs new file mode 100644 index 0000000..ba31438 --- /dev/null +++ b/src/SharpLinter.Core/Rules/RuleMetadata.cs @@ -0,0 +1,25 @@ +using SharpLinter.Core.Analysis; + +namespace SharpLinter.Core.Rules; + +/// +/// Describes a lint rule's metadata — its identity, classification, and origin. +/// +/// Unique rule identifier (e.g., "SL1001", "CA1051", "CUSTOM001"). +/// Human-readable short title (e.g., "Add braces to control flow"). +/// Detailed explanation of the rule and why it matters. +/// The rule's classification category. +/// Default severity when no configuration override is applied. +/// Origin of the rule: "builtin", "microsoft-learn", or "custom". +/// Whether SharpLinter has a built-in analyzer implementation for this rule. +/// Optional link to external documentation (e.g., MS Learn page). +public record RuleMetadata( + string RuleId, + string Title, + string Description, + RuleCategory Category, + LintSeverity DefaultSeverity, + string Source, + bool HasAnalyzer, + string? DocumentationUrl = null +); diff --git a/src/SharpLinter.Core/SharpLinter.Core.csproj b/src/SharpLinter.Core/SharpLinter.Core.csproj new file mode 100644 index 0000000..9ba08d1 --- /dev/null +++ b/src/SharpLinter.Core/SharpLinter.Core.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + true + SharpLinter.Core + + + SharpLinter + 1.0.0 + rahu619 + A free, customizable C# linter with dynamically-fetched rules from Microsoft's public rule catalog, easy YAML-based custom rule definitions, and multiple output formats including SARIF for CI/CD. + csharp;linter;roslyn;code-analysis;formatting;dotnet;static-analysis + MIT + https://github.com/rahu619/SharpLinter + README.md + https://github.com/rahu619/SharpLinter + + + + + + + + + + + + + + + + + + diff --git a/tests/SharpLinter.ClientTest/Program.cs b/tests/SharpLinter.ClientTest/Program.cs new file mode 100644 index 0000000..380e1af --- /dev/null +++ b/tests/SharpLinter.ClientTest/Program.cs @@ -0,0 +1,30 @@ +using System; + +namespace SharpLinter.ClientTest +{ + // Violation: Class name should use PascalCase (SL1005) + class my_bad_class + { + // Violation: Public field (SL1003) + public string badField = "No encapsulation"; + + public static void Main(string[] args) + { + Console.WriteLine("Starting Verification Client..."); + + // Violation: Missing braces on control flow (SL1001) + if (args.Length == 0) + Console.WriteLine("No arguments provided."); + else + Console.WriteLine("Arguments provided."); + + // Violation: String concatenation in loop (SL1012) + string result = ""; + for (int i = 0; i < 5; i++) + { + result += i.ToString(); + } + Console.WriteLine("Loop result: " + result); + } + } +} diff --git a/tests/SharpLinter.ClientTest/SharpLinter.ClientTest.csproj b/tests/SharpLinter.ClientTest/SharpLinter.ClientTest.csproj new file mode 100644 index 0000000..3f3a88c --- /dev/null +++ b/tests/SharpLinter.ClientTest/SharpLinter.ClientTest.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/tests/SharpLinter.ClientTest/nuget.config b/tests/SharpLinter.ClientTest/nuget.config new file mode 100644 index 0000000..b349b1e --- /dev/null +++ b/tests/SharpLinter.ClientTest/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/SharpLinter.Core.Tests/CliTests.cs b/tests/SharpLinter.Core.Tests/CliTests.cs new file mode 100644 index 0000000..498c073 --- /dev/null +++ b/tests/SharpLinter.Core.Tests/CliTests.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using Xunit; +using SharpLinter.Cli; +using System.IO; + +namespace SharpLinter.Core.Tests; + +public class CliTests +{ + [Fact] + public async Task Cli_RulesCommand_ShouldReturnZeroExitCode() + { + // Arrange + var args = new[] { "rules", "--source", "builtin" }; + + // Redirect console output + using var sw = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(sw); + + try + { + // Act + var exitCode = await Program.Main(args); + + // Assert + exitCode.Should().Be(0); + var output = sw.ToString(); + output.Should().Contain("SL1001"); + output.Should().Contain("SL1012"); + } + finally + { + Console.SetOut(originalOut); + } + } + + [Fact] + public async Task Cli_AnalyzeCommand_ShouldScanFileAndReportIssues() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".cs"; + var code = """ + class my_class { + void Run() { + if (true) Console.WriteLine(); + } + } + """; + await File.WriteAllTextAsync(tempFile, code); + + var args = new[] { "analyze", tempFile, "--no-cache" }; + + using var sw = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(sw); + + try + { + // Act + var exitCode = await Program.Main(args); + + // Assert + // Since we have a warning/error (SL1005 naming convention and SL1001 braces), exit code will depend on rules + var output = sw.ToString(); + output.Should().Contain("SL1001"); // braces + output.Should().Contain("SL1005"); // naming convention (my_class) + } + finally + { + Console.SetOut(originalOut); + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public async Task Cli_SyncCommand_ShouldFetchAndCacheRules() + { + // Arrange + var args = new[] { "sync", "--force" }; + + using var sw = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(sw); + + try + { + // Act + var exitCode = await Program.Main(args); + + // Assert + exitCode.Should().Be(0); + var output = sw.ToString(); + output.Should().MatchRegex("(?i)(Synced|cache is up to date)"); + } + finally + { + Console.SetOut(originalOut); + } + } +} diff --git a/tests/SharpLinter.Core.Tests/Rules/BuiltIn/AnalyzerTests.cs b/tests/SharpLinter.Core.Tests/Rules/BuiltIn/AnalyzerTests.cs new file mode 100644 index 0000000..a3c396b --- /dev/null +++ b/tests/SharpLinter.Core.Tests/Rules/BuiltIn/AnalyzerTests.cs @@ -0,0 +1,175 @@ +using FluentAssertions; +using Xunit; +using SharpLinter.Core.Analysis; +using SharpLinter.Core.Configuration; +using SharpLinter.Core.Rules.BuiltIn; +using SharpLinter.Core.Rules.Custom; + +namespace SharpLinter.Core.Tests.Rules.BuiltIn; + +public class AnalyzerTests +{ + private static LintConfiguration CreateSingleRuleConfig(string ruleId, LintSeverity severity = LintSeverity.Warning) + { + var config = new LintConfiguration + { + Preset = "none", + Rules = new Dictionary() + }; + + // Disable all standard rules by default + string[] allRuleIds = ["SL1001", "SL1002", "SL1003", "SL1004", "SL1005", "SL1006", "SL1007", "SL1008", "SL1009", "SL1010", "SL1011", "SL1012"]; + foreach (var id in allRuleIds) + { + config.Rules[id] = new RuleOverride { Severity = LintSeverity.None }; + } + + // Enable only the target rule + config.Rules[ruleId] = new RuleOverride { Severity = severity }; + config.Formatting.Enabled = false; // Disable formatter to keep test output pure + + return config; + } + + [Fact] + public async Task AddBracesAnalyzer_ShouldFlagMissingBraces() + { + // Arrange + var code = """ + class TestClass + { + void Run(bool condition) + { + if (condition) + System.Console.WriteLine("No braces"); + } + } + """; + + var config = CreateSingleRuleConfig("SL1001"); + var engine = new LintEngine(config); + + // Act + var result = await engine.AnalyzeCodeAsync(code); + + // Assert + result.Diagnostics.Should().ContainSingle(d => d.RuleId == "SL1001"); + result.Diagnostics[0].Message.Should().Contain("'if' statement should use braces"); + } + + [Fact] + public async Task EmptyCatchBlockAnalyzer_ShouldFlagEmptyCatchWithoutComment() + { + // Arrange + var code = """ + class TestClass + { + void Run() + { + try { + int.Parse("not a number"); + } catch (System.Exception) { + } + } + } + """; + + var config = CreateSingleRuleConfig("SL1002"); + var engine = new LintEngine(config); + + // Act + var result = await engine.AnalyzeCodeAsync(code); + + // Assert + result.Diagnostics.Should().ContainSingle(d => d.RuleId == "SL1002"); + } + + [Fact] + public async Task EmptyCatchBlockAnalyzer_ShouldNotFlagEmptyCatchWithComment() + { + // Arrange + var code = """ + class TestClass + { + void Run() + { + try { + int.Parse("not a number"); + } catch (System.Exception) { + // Intentionally ignored because of test scenario + } + } + } + """; + + var config = CreateSingleRuleConfig("SL1002"); + var engine = new LintEngine(config); + + // Act + var result = await engine.AnalyzeCodeAsync(code); + + // Assert + result.Diagnostics.Should().BeEmpty(); + } + + [Fact] + public async Task PublicFieldAnalyzer_ShouldFlagPublicInstanceFields() + { + // Arrange + var code = """ + class TestClass + { + public int MyField; + public const int MyConst = 42; + public static readonly int MyStaticReadonly = 100; + } + """; + + var config = CreateSingleRuleConfig("SL1003"); + var engine = new LintEngine(config); + + // Act + var result = await engine.AnalyzeCodeAsync(code); + + // Assert + result.Diagnostics.Should().ContainSingle(d => d.RuleId == "SL1003" && d.Message.Contains("MyField")); + } + + [Fact] + public async Task CustomRules_ShouldParseAndExecuteCorrectly() + { + // Arrange + var yaml = """ + rules: + - id: "CUSTOM999" + title: "Avoid HACK comments" + description: "Clean up HACK comments before committing" + category: "Maintainability" + severity: "warning" + type: "pattern" + pattern: + kind: "comment" + match: "HACK" + """; + + var code = """ + // HACK: this is a temporary fix + class TestClass {} + """; + + var customRules = CustomRuleLoader.LoadFromYaml(yaml); + customRules.Should().ContainSingle(); + customRules[0].Metadata.RuleId.Should().Be("CUSTOM999"); + + var config = Presets.GetPreset("recommended"); + config.CustomRulesFile = null; + + // Manual evaluation + var tree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(code); + var diagnostics = customRules[0].Analyze(tree, "test.cs", config); + + // Assert + diagnostics.Should().ContainSingle(); + diagnostics[0].RuleId.Should().Be("CUSTOM999"); + } +} diff --git a/tests/SharpLinter.Core.Tests/SharpLinter.Core.Tests.csproj b/tests/SharpLinter.Core.Tests/SharpLinter.Core.Tests.csproj new file mode 100644 index 0000000..5c0fe9f --- /dev/null +++ b/tests/SharpLinter.Core.Tests/SharpLinter.Core.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +