diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ececb87 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +indent_style = space +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.nix] +indent_size = 2 + +[*.{fs,fsi,fsx}] +indent_size = 4 diff --git a/Example.Tests/CalculatorTests.fs b/Example.Tests/CalculatorTests.fs index 40f9b66..0a96e10 100644 --- a/Example.Tests/CalculatorTests.fs +++ b/Example.Tests/CalculatorTests.fs @@ -6,17 +6,22 @@ open Xunit type CalculatorTests() = [] - [] - member _.AddOne_increments() = - Assert.Equal(42, Calculator.addOne 41) + [] - [] - member _.AbsoluteDifference_preserves_order() = - Assert.Equal(7, Calculator.absoluteDifference 10 3) + module Calculator = + - let addOne value = value + 1 + + let addOne value = value - 1 - [] - [ 0", "year % 100 = 0")>] - member _.LeapYear_handles_centuries() = - Assert.True(Calculator.isLeapYear 2000) - Assert.False(Calculator.isLeapYear 1900) + let absoluteDifference left right = + if left >= right then left - right else right - left + + member _.AddOne_increments() = + Assert.Equal(42, Calculator.addOne 41) + """)>] + member _.AddOne_increments() = Assert.Equal(42, Calculator.addOne 41) diff --git a/Example.Tests/MutationCaseAttribute.fs b/Example.Tests/MutationCaseAttribute.fs index 12e6475..f1175e4 100644 --- a/Example.Tests/MutationCaseAttribute.fs +++ b/Example.Tests/MutationCaseAttribute.fs @@ -3,11 +3,7 @@ namespace Mutannot open System [] -type MutationCaseAttribute(id: string, file: string, line: int, find: string, replace: string) = +type MutationCaseAttribute(patch: string) = inherit Attribute() - member _.Id = id - member _.File = file - member _.Line = line - member _.Find = find - member _.Replace = replace + member _.Patch = patch diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index e7195fc..13666be 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -1,7 +1,12 @@ +open System +open System.IO +open System.Reflection +open System.Runtime.InteropServices open Fli -[] -let main argv = +type MutationCase = { TestName: string; Patch: string } + +let ensureCleanWorkingDirectory () = let gitState = cli { Exec "git" @@ -12,6 +17,123 @@ let main argv = if gitState.Text <> None then eprintfn "Uncommitted changes. Refusing to run." + exit 2 + +let applyPatch patch = + cli { + Exec "git" + Arguments [ "apply"; "-" ] + Input patch + } + |> Command.execute + |> Output.throwIfErrored + |> ignore + +let restore () = + cli { + Exec "git" + Arguments [ "restore"; "--staged"; "--worktree"; "." ] + } + |> Command.execute + |> Output.throwIfErrored + |> ignore + +let ensureBuilt projectPath = + cli { + Exec "dotnet" + Arguments [ "build"; projectPath ] + Output(new StreamWriter(Console.OpenStandardOutput())) + } + |> Command.execute + |> Output.throwIfErrored + |> ignore + +let runTest projectPath testName = + cli { + Exec "dotnet" + Arguments [ "test"; projectPath; "--filter"; $"FullyQualifiedName={testName}" ] + Output(new StreamWriter(Console.OpenStandardOutput())) + } + |> Command.execute + |> ignore + +let getAssemblyPath projectPath = + cli { + Exec "dotnet" + Arguments [ "msbuild"; projectPath; "--getProperty:TargetPath" ] + } + |> Command.execute + |> Output.toText + +let getMetadataLoadContext (assemblyPath: string) = + // This allows us to inspect assemblies regardless of the platform that they were built for + // https://learn.microsoft.com/en-us/dotnet/standard/assembly/inspect-contents-using-metadataloadcontext + let assemblyDir = Path.GetDirectoryName assemblyPath + + let pathAssemblyResolver = + [ yield assemblyPath + yield! Directory.EnumerateFiles(assemblyDir, "*.dll") + yield! Directory.EnumerateFiles(assemblyDir, "*.exe") + yield! Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") ] + |> PathAssemblyResolver + + new MetadataLoadContext(pathAssemblyResolver, typeof.Assembly.GetName().Name) + +let unindented (s: string) = + let lines = s.Split([| "\r\n"; "\n" |], StringSplitOptions.None) + + let indexOfFirstNonEmptyLine = + lines |> Array.findIndex (not << String.IsNullOrWhiteSpace) + + let identantionOfFirstNonEmptyLine = + lines[indexOfFirstNonEmptyLine] |> Seq.takeWhile Char.IsWhiteSpace |> Seq.length + + lines[indexOfFirstNonEmptyLine..] + |> Seq.map (fun line -> line.Substring(min identantionOfFirstNonEmptyLine line.Length)) + |> String.concat Environment.NewLine + +let getMutationCases projectPath = + ensureBuilt projectPath + + let assemblyPath = getAssemblyPath projectPath + + use metadataLoadContext = getMetadataLoadContext assemblyPath + + let assemblyTypes = + assemblyPath |> metadataLoadContext.LoadFromAssemblyPath |> _.GetTypes() + + let assemblyMethods = + assemblyTypes + |> Seq.collect _.GetMethods(BindingFlags.Public ||| BindingFlags.Instance) + + assemblyMethods + |> Seq.collect (fun m -> + m.GetCustomAttributesData() + |> Seq.choose (fun attr -> + match attr.AttributeType.FullName with + | "Mutannot.MutationCaseAttribute" -> + Some + { TestName = $"{m.DeclaringType.FullName}.{m.Name}" + Patch = attr.ConstructorArguments[0].Value :?> string |> unindented } + | _ -> None)) + |> Seq.toList + +[] +let main argv = + if argv.Length <> 1 then + eprintfn "Usage: mutannot " exit 1 + ensureCleanWorkingDirectory () + + AppDomain.CurrentDomain.ProcessExit.Add(fun _ -> restore ()) + + let projectPath = argv[0] + + for mutationCase in getMutationCases projectPath do + printfn "MUTATION\n\n%s" <| mutationCase.Patch + applyPatch mutationCase.Patch + runTest projectPath mutationCase.TestName + restore () + 0 diff --git a/flake.lock b/flake.lock index 6908e25..8244c7c 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,47 @@ { "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "git-temp-commit": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778481212, + "narHash": "sha256-Vy0ufQ51CHkamX+XB8hhgohBHJesKli0jF503NuSY20=", + "ref": "main", + "rev": "622b553f46920a2f3cc92f26c1f49cabb612de5f", + "revCount": 2, + "type": "git", + "url": "https://codeberg.org/svenvanheugten/git-temp-commit.git" + }, + "original": { + "ref": "main", + "type": "git", + "url": "https://codeberg.org/svenvanheugten/git-temp-commit.git" + } + }, "nixpkgs": { "locked": { "lastModified": 1776877367, @@ -18,8 +60,25 @@ }, "root": { "inputs": { + "flake-utils": "flake-utils", + "git-temp-commit": "git-temp-commit", "nixpkgs": "nixpkgs" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 16f31af..f3fe06f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,57 +1,50 @@ { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + git-temp-commit = { + url = "git+https://codeberg.org/svenvanheugten/git-temp-commit.git?ref=main"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + }; }; outputs = - { self, nixpkgs }: - let - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - forEachSystem = - f: nixpkgs.lib.genAttrs systems (system: f system (import nixpkgs { inherit system; })); - in { - formatter = forEachSystem (_system: pkgs: pkgs.nixfmt-rfc-style); + self, + nixpkgs, + flake-utils, + git-temp-commit, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.default = pkgs.buildDotnetModule { + pname = "mutannot"; + version = "0.1.0"; + src = ./Mutannot; + projectFile = "Mutannot.fsproj"; + nugetDeps = ./Mutannot/deps.nix; + executables = [ "mutannot" ]; + dotnet-sdk = pkgs.dotnet-sdk_10; + dotnet-runtime = pkgs.dotnet-sdk_10; + useDotnetFromEnv = true; - packages = forEachSystem ( - _system: pkgs: - let - mutannot = pkgs.buildDotnetModule { - pname = "mutannot"; - version = "0.1.0"; - src = ./Mutannot; - projectFile = "Mutannot.fsproj"; - nugetDeps = ./Mutannot/deps.nix; - executables = [ "mutannot" ]; - dotnet-sdk = pkgs.dotnet-sdk_10; - dotnet-runtime = pkgs.dotnet-sdk_10; - useDotnetFromEnv = true; - - meta = { - mainProgram = "mutannot"; - }; + meta = { + mainProgram = "mutannot"; }; - in - { - default = mutannot; - mutannot = mutannot; - } - ); + }; - devShells = forEachSystem ( - _system: pkgs: { - default = pkgs.mkShell { - packages = [ - pkgs.git - pkgs.dotnet-sdk_10 - ]; - }; - } - ); - }; + devShells.default = pkgs.mkShell { + packages = [ + pkgs.git + pkgs.dotnet-sdk_10 + git-temp-commit.packages.${system}.default + ]; + }; + } + ); }