From 8db465590759cfa32d1863cbd155dc9b3103e922 Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 19 Sep 2025 17:03:13 +0200 Subject: [PATCH 01/14] Separate MCP project --- .editorconfig | 5 +- Directory.Packages.props | 77 ++++++------ fantomas.sln | 18 ++- global.json | 2 +- mcp/README.md | 49 ++++++++ mcp/script.fsx | 49 ++++++++ mcp/server.fsx | 30 +++++ mcp/tools/format.fsx | 27 +++++ src/Fantomas.Core/AssemblyInfo.fs | 1 + src/Fantomas.MCP/Fantomas.MCP.fsproj | 21 ++++ src/Fantomas.MCP/Library.fs | 137 +++++++++++++++++++++ src/Fantomas.MCP/packages.lock.json | 175 +++++++++++++++++++++++++++ 12 files changed, 547 insertions(+), 44 deletions(-) create mode 100644 mcp/README.md create mode 100644 mcp/script.fsx create mode 100644 mcp/server.fsx create mode 100644 mcp/tools/format.fsx create mode 100644 src/Fantomas.MCP/Fantomas.MCP.fsproj create mode 100644 src/Fantomas.MCP/Library.fs create mode 100644 src/Fantomas.MCP/packages.lock.json diff --git a/.editorconfig b/.editorconfig index 6e9e7984a3..694215b714 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,4 +9,7 @@ fsharp_blank_lines_around_nested_multiline_expressions=false fsharp_keep_max_number_of_blank_lines=1 [tests/*.fsx] -fsharp_multiline_bracket_style = aligned \ No newline at end of file +fsharp_multiline_bracket_style = aligned + +[src/Fantomas.MCP/*.fs] +fsharp_multiline_bracket_style = stroustrup \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index d3eb0e1bfb..9074850265 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,42 +1,39 @@ - - true - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fantomas.sln b/fantomas.sln index 455820b02b..89224ad715 100644 --- a/fantomas.sln +++ b/fantomas.sln @@ -43,6 +43,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fantomas.Client.Tests", "src\Fantomas.Client.Tests\Fantomas.Client.Tests.fsproj", "{68814E36-3957-4D1C-BCDB-84C3C8478BEC}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fantomas.MCP", "src\Fantomas.MCP\Fantomas.MCP.fsproj", "{F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -149,13 +151,25 @@ Global {68814E36-3957-4D1C-BCDB-84C3C8478BEC}.Release|x64.Build.0 = Release|Any CPU {68814E36-3957-4D1C-BCDB-84C3C8478BEC}.Release|x86.ActiveCfg = Release|Any CPU {68814E36-3957-4D1C-BCDB-84C3C8478BEC}.Release|x86.Build.0 = Release|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|x64.Build.0 = Debug|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|x86.Build.0 = Debug|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|Any CPU.Build.0 = Release|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|x64.ActiveCfg = Release|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|x64.Build.0 = Release|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|x86.ActiveCfg = Release|Any CPU + {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DEBFC920-A647-4859-890D-B59793D27594} EndGlobalSection - GlobalSection(NestedProjects) = preSolution - EndGlobalSection EndGlobal diff --git a/global.json b/global.json index 6f6aff9de6..d3dbe09378 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.400", + "version": "9.0.300", "rollForward": "latestPatch" } } \ No newline at end of file diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000000..c41995388d --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,49 @@ + +# MCP + +## Goal + +Give contributors more tools to use AI to come up with fixes to the codebase. +Having a set of specific tools calls could improve the hallucinating and help an LLM avoid a bunch of BS. + +## Tool calls + +I think we will need to have a second fsi script which the mcp server can call. +Because we don't want a hard dependency on the source code. +Maybe the LLM made a change which leads to a broken code base. + +### Parse + +expose the parser as tool call. +Return both untyped F# AST and Oak? + +### Format + +expose the format call. +input should be source code, isFsi, settings (.editorconfig) +Include validation here as well? + +### Rebuild + +Produce a new binary of Fantomas.Core. +Should probably be called in most other tool calls. + +### Trivia + +Analyze which trivia was detected in which nodes. + +### Define solver + +Expose the define solver + +### Style guide + +Fetch both style guides and return in LLM friendly fashion. + +### Test generation + +Guide the LLM to generate tests. + +## Prompts + +We should probably also create a bunch of example prompt on how to ask the LLM to fix certain issues. \ No newline at end of file diff --git a/mcp/script.fsx b/mcp/script.fsx new file mode 100644 index 0000000000..3d0b5ebe93 --- /dev/null +++ b/mcp/script.fsx @@ -0,0 +1,49 @@ +#r "nuget: CliWrap" + +open System.IO +open System.Text +open CliWrap +open CliWrap.Buffered + +let input = "let a = 9" + +let inputStream = new MemoryStream(Encoding.UTF8.GetBytes(input)) + +let repositoryRoot = Path.Combine(__SOURCE_DIRECTORY__, "..") |> Path.GetFullPath + +let fantomasCoreFsproj = + Path.Combine(repositoryRoot, "src/Fantomas.Core/Fantomas.Core.fsproj") + +let buildResult = + Cli + .Wrap("dotnet") + .WithArguments([| "build"; fantomasCoreFsproj; "-v"; "n" |]) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync() + .Task.Result + +if buildResult.ExitCode <> 0 then + let gitStatus = + Cli.Wrap("git").WithArguments("status").ExecuteBufferedAsync().Task.Result + + printfn + $"""Currently the Fantomas code base does not build. + +Build Error: + +{buildResult.StandardOutput}{buildResult.StandardError} + +Git status: + +{gitStatus.StandardOutput} +""" +else + let result = + Cli + .Wrap("dotnet") + .WithArguments([| "fsi"; "/Users/nojaf/Projects/fantomas/mcp/tools/format.fsx" |]) + .WithStandardInputPipe(PipeSource.FromStream(inputStream)) + .ExecuteBufferedAsync() + .Task.Result + + printfn $"Result: {result.StandardOutput}" diff --git a/mcp/server.fsx b/mcp/server.fsx new file mode 100644 index 0000000000..c2cb54cc71 --- /dev/null +++ b/mcp/server.fsx @@ -0,0 +1,30 @@ +#r "nuget: ModelContextProtocol, 0.3.0-preview.4" +#r "nuget: Microsoft.Extensions.Hosting" +#r "nuget: CliWrap" + +// Reference: https://www.slaveoftime.fun/blog/single-fsharp-script-to-write-a-mcp-server---simplified-integration-for-dynamic-script-execution + +open System +open System.IO +open System.Text +open System.Threading +open System.ComponentModel +open Microsoft.Extensions.Hosting +open Microsoft.Extensions.Logging +open Microsoft.Extensions.DependencyInjection +open ModelContextProtocol.Server + +[] +type FantomasMcpServer() = //as this = + [] + member this.GetDate() = + let now = DateTime.Now + let result = now.ToString "yyyy-MM-dd HH:mm:ss" + $"The current date in Nojafian Elven time is {result}" + +let builder = Host.CreateApplicationBuilder(Environment.GetCommandLineArgs()) + +builder.Logging.AddConsole(fun consoleLogOptions -> consoleLogOptions.LogToStandardErrorThreshold <- LogLevel.Trace) +builder.Services.AddMcpServer().WithStdioServerTransport().WithTools() + +builder.Build().RunAsync() |> Async.AwaitTask |> Async.RunSynchronously diff --git a/mcp/tools/format.fsx b/mcp/tools/format.fsx new file mode 100644 index 0000000000..bb4e166c4d --- /dev/null +++ b/mcp/tools/format.fsx @@ -0,0 +1,27 @@ +#r "nuget: CliWrap" +#r "nuget: Thoth.Json.System.Text.Json" +#r "../../artifacts/bin/Fantomas.FCS/debug/Fantomas.FCS.dll" +#r "../../artifacts/bin/Fantomas.Core/debug/Fantomas.Core.dll" +#r "../../artifacts/bin/Fantomas.MCP/debug/Fantomas.MCP.dll" + +open Fantomas + +async { + let input = stdin.ReadToEnd() + + let isSignature = + System.Environment.GetCommandLineArgs() |> Array.contains "--signature" + + let! output = MCP.format input isSignature + + printfn $"formatted:\n{output}" +} +|> Async.RunSynchronously + + + + +// TODO: don't just dump a response, we should create some event sourced response. +// Numerous things happen here; code builds, input was parsed, trivia detected code formatted, result validated, result trivia detected, etc. +// We need to return this as JSON or some other structured format. +// See https://t3.chat/chat/76da167e-f594-4b3a-9f11-3facb9c11b5e diff --git a/src/Fantomas.Core/AssemblyInfo.fs b/src/Fantomas.Core/AssemblyInfo.fs index 1c3295927b..11c607f85a 100644 --- a/src/Fantomas.Core/AssemblyInfo.fs +++ b/src/Fantomas.Core/AssemblyInfo.fs @@ -3,6 +3,7 @@ namespace Fantomas.Core open System.Runtime.CompilerServices [] +[] do () diff --git a/src/Fantomas.MCP/Fantomas.MCP.fsproj b/src/Fantomas.MCP/Fantomas.MCP.fsproj new file mode 100644 index 0000000000..98f21935c7 --- /dev/null +++ b/src/Fantomas.MCP/Fantomas.MCP.fsproj @@ -0,0 +1,21 @@ + + + + net9.0 + true + + + + + + + + + + + + + + + + diff --git a/src/Fantomas.MCP/Library.fs b/src/Fantomas.MCP/Library.fs new file mode 100644 index 0000000000..ef6ff6fd3f --- /dev/null +++ b/src/Fantomas.MCP/Library.fs @@ -0,0 +1,137 @@ +module Fantomas.MCP + +open Fantomas.Core +open Thoth.Json.Core +open Thoth.Json.System.Text.Json + +type FormatEvents<'state> = Result + +let encodeRange (range: Fantomas.FCS.Text.Range) = + Encode.object [ + "startLine", Encode.int range.StartLine + "startColumn", Encode.int range.StartColumn + "endLine", Encode.int range.EndLine + "endColumn", Encode.int range.EndColumn + ] + +type InitialState = { Input: string; IsSignature: bool } + +let getSourceText input isSignatureFile : FormatEvents = + try + let _ = CodeFormatterImpl.getSourceText input + + Ok( + [ + Encode.object [ + "name", Encode.string "Created SourceText" + "description", Encode.string "Successfully created internal ISourceText value for input" + ] + ], + { + Input = input + IsSignature = isSignatureFile + } + ) + + with ex -> + Error [ + Encode.object [ + "name", Encode.string "Failed to create ISourceText" + "description", Encode.string $"Could not construct FCS.Text.ISourceText for \"{input}\"" + "exception", Encode.string (ex.ToString()) + ] + ] + +[] +type StateWithUntypedTree = { + Input: string + IsSignature: bool + UntypedTrees: (FCS.Syntax.ParsedInput * string list) array +} + +let parseUntypedTree (events, state: InitialState) = + async { + let type_ = if state.IsSignature then "signature" else "implementation" + + try + let! untypedTrees = CodeFormatter.ParseAsync(state.IsSignature, state.Input) + + let nextEvents = + Encode.object [ + "name", Encode.string "Parsed input to Untyped AST" + "description", Encode.string "Source input was able to be parsed as untyped AST" + "untypedTrees", + Encode.array ( + untypedTrees + |> Array.map (fun (ast, defines) -> + Encode.object [ + "defines", Encode.list (List.map Encode.string defines) + "ast", Encode.string (string ast) + ]) + ) + ] + :: events + + return + Ok( + nextEvents, + { + Input = state.Input + IsSignature = state.IsSignature + UntypedTrees = untypedTrees + } + ) + with + | ParseException(diagnostics) -> + let nextEvents = + Encode.object [ + "name", Encode.string "Parse exception raised during parsing of input to Untyped AST failed" + "description", Encode.string $"Fantomas.FCS was unable to parse {type_} input" + "input", Encode.string state.Input + "diagnostics", + diagnostics + |> List.map (fun diag -> + Encode.object [ + "severity", Encode.string (string diag.Severity) + "subCategory", Encode.string diag.SubCategory + "range", Encode.losslessOption encodeRange diag.Range + ]) + |> Encode.list + ] + :: events + + return Error(nextEvents) + | ex -> + let nextEvents = + Encode.object [ + "name", Encode.string "Unexpected exception during parsing of input to Untyped AST failed" + "description", Encode.string $"Fantomas.FCS was unable to parse {type_} input" + "input", Encode.string state.Input + "error", Encode.string (ex.ToString()) + ] + :: events + + return Error(nextEvents) + } + +let (>>=) f g = + async { + match f with + | Error e -> return Error e + | Ok(e, s) -> return! g (e, s) + } + +let format input isSignature : Async = + async { + let! events = getSourceText input isSignature >>= parseUntypedTree + + let orderedEvents = + match events with + | Error e -> e + | Ok(e, _) -> e + |> List.rev + + let json = Encode.object [ "events", Encode.list orderedEvents ] + + return Encode.toString 2 json + } diff --git a/src/Fantomas.MCP/packages.lock.json b/src/Fantomas.MCP/packages.lock.json new file mode 100644 index 0000000000..c531038c0a --- /dev/null +++ b/src/Fantomas.MCP/packages.lock.json @@ -0,0 +1,175 @@ +{ + "version": 2, + "dependencies": { + "net9.0": { + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.1.1, )", + "resolved": "1.1.1", + "contentHash": "+H2t/t34h6mhEoUvHi8yGXyuZ2GjSovcGYehJrS2MDm2XgmPfZL2Sdxg+uL2lKgZ4M6tTwKHIlxOob2bgh0NRQ==", + "dependencies": { + "Microsoft.SourceLink.AzureRepos.Git": "1.1.1", + "Microsoft.SourceLink.Bitbucket.Git": "1.1.1", + "Microsoft.SourceLink.GitHub": "1.1.1", + "Microsoft.SourceLink.GitLab": "1.1.1" + } + }, + "G-Research.FSharp.Analyzers": { + "type": "Direct", + "requested": "[0.18.0, )", + "resolved": "0.18.0", + "contentHash": "axpIIOMOHUK/GS1s1q4thDTfcZxBVaQryPFDAURPX78TTLqHPF83USUgI4yM2cvIo+ZA7+c5SUWtkVXKkedlVQ==" + }, + "Ionide.Analyzers": { + "type": "Direct", + "requested": "[0.14.7, )", + "resolved": "0.14.7", + "contentHash": "xjYTHbb/aP6QPD9rJOZNJW3YN7KAegWAy42N61A9m7BVMspaSNEX8Y57Cey9ySWCrjvEaXKAnzVuyL9N3zZVLA==" + }, + "Ionide.KeepAChangelog.Tasks": { + "type": "Direct", + "requested": "[0.3.0, )", + "resolved": "0.3.0", + "contentHash": "hHyUItpnkq16G5Crwx/I1lnBDoZ///hHo/s0mSRB1MiX7fFHCN0xWk6XnvMPXry4fgBii7UFNkMcc6a6rUrYQQ==" + }, + "Thoth.Json.System.Text.Json": { + "type": "Direct", + "requested": "[0.2.1, )", + "resolved": "0.2.1", + "contentHash": "fEh3IyCtTJhfS6olPsTuU0YSnl3DT755wzyz332TXY4DGIIXkHfcSZkVZ3soDKlsRCd0HvAGqs8jXrYK4+0/aw==", + "dependencies": { + "FSharp.Core": "5.0.2", + "Fable.Core": "4.1.0", + "System.Text.Json": "9.0.0", + "Thoth.Json.Core": "0.7.0" + } + }, + "Fable.Core": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "NISAbAVGEcvH2s+vHLSOCzh98xMYx4aIadWacQdWPcQLploxpSQXLEe9SeszUBhbHa73KMiKREsH4/W3q4A4iA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "TMBuzAHpTenGbGgk0SMTwyEkyijY/Eae4ZGsFNYJvAr/LDn1ku3Etp3FPxChmDp5HHF3kzJuoaa08N0xjqAJfQ==" + }, + "Microsoft.NETCore.Targets": { + "type": "Transitive", + "resolved": "1.1.3", + "contentHash": "3Wrmi0kJDzClwAC+iBdUBpEKmEle8FQNsCs77fkiOIw/9oYA07bL1EZNX0kQ2OMN3xpwvl0vAtOCYY3ndDNlhQ==" + }, + "Microsoft.SourceLink.AzureRepos.Git": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "qB5urvw9LO2bG3eVAkuL+2ughxz2rR7aYgm2iyrB8Rlk9cp2ndvGRCvehk3rNIhRuNtQaeKwctOl1KvWiklv5w==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "1.1.1", + "Microsoft.SourceLink.Common": "1.1.1" + } + }, + "Microsoft.SourceLink.Bitbucket.Git": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "cDzxXwlyWpLWaH0em4Idj0H3AmVo3L/6xRXKssYemx+7W52iNskj/SQ4FOmfCb8YQt39otTDNMveCZzYtMoucQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "1.1.1", + "Microsoft.SourceLink.Common": "1.1.1" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "1.1.1", + "Microsoft.SourceLink.Common": "1.1.1" + } + }, + "Microsoft.SourceLink.GitLab": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "tvsg47DDLqqedlPeYVE2lmiTpND8F0hkrealQ5hYltSmvruy/Gr5nHAKSsjyw5L3NeM/HLMI5ORv7on/M4qyZw==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "1.1.1", + "Microsoft.SourceLink.Common": "1.1.1" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A==" + }, + "Thoth.Json.Core": { + "type": "Transitive", + "resolved": "0.7.0", + "contentHash": "BENcsjQMfG++NvzIUQG3xv8esB2PfUfe8ggpcNwhmqEPuJp8KORM5MCNqqwuBHZ4LOUK61L/bldFPGqr0K9sLQ==", + "dependencies": { + "FSharp.Core": "5.0.2", + "Fable.Core": "4.1.0" + } + }, + "fantomas.core": { + "type": "Project", + "dependencies": { + "FSharp.Core": "[8.0.100, )", + "Fantomas.FCS": "[1.0.0, )" + } + }, + "fantomas.fcs": { + "type": "Project", + "dependencies": { + "FSharp.Core": "[8.0.100, )", + "System.Collections.Immutable": "[8.0.0, )", + "System.Diagnostics.DiagnosticSource": "[8.0.1, )", + "System.Memory": "[4.6.0, )", + "System.Runtime": "[4.3.1, )" + } + }, + "FSharp.Core": { + "type": "CentralTransitive", + "requested": "[8.0.100, )", + "resolved": "8.0.100", + "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + }, + "System.Collections.Immutable": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "vaoWjvkG1aenR2XdjaVivlCV9fADfgyhW5bZtXT23qaEea0lWiUljdQuze4E31vKM7ZWJaSUsbYIKE3rnzfZUg==" + }, + "System.Memory": { + "type": "CentralTransitive", + "requested": "[4.6.0, )", + "resolved": "4.6.0", + "contentHash": "OEkbBQoklHngJ8UD8ez2AERSk2g+/qpAaSWWCBFbpH727HxDq5ydVkuncBaKcKfwRqXGWx64dS6G1SUScMsitg==" + }, + "System.Runtime": { + "type": "CentralTransitive", + "requested": "[4.3.1, )", + "resolved": "4.3.1", + "contentHash": "abhfv1dTK6NXOmu4bgHIONxHyEqFjW8HwXPmpY9gmll+ix9UNo4XDcmzJn6oLooftxNssVHdJC1pGT9jkSynQg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.1", + "Microsoft.NETCore.Targets": "1.1.3" + } + } + } + } +} \ No newline at end of file From f2fe438c85e7fdec6d921df6ec4e80c280de1dad Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 11:19:32 +0200 Subject: [PATCH 02/14] Initial version of format mcp tool --- .editorconfig | 2 +- fantomas.sln | 14 -- mcp/README.md | 6 +- mcp/server.fsx | 78 +++++++- mcp/tools/format.fsx | 238 ++++++++++++++++++++++++- src/Fantomas.Core/AssemblyInfo.fs | 2 +- src/Fantomas.Core/CodeFormatterImpl.fs | 41 +++-- src/Fantomas.Core/Fantomas.Core.fsproj | 5 +- src/Fantomas.Core/MCPEvents.fs | 39 ++++ src/Fantomas.Core/Trivia.fs | 3 + src/Fantomas.MCP/Fantomas.MCP.fsproj | 21 --- src/Fantomas.MCP/Library.fs | 137 -------------- src/Fantomas.MCP/packages.lock.json | 175 ------------------ 13 files changed, 387 insertions(+), 374 deletions(-) create mode 100644 src/Fantomas.Core/MCPEvents.fs delete mode 100644 src/Fantomas.MCP/Fantomas.MCP.fsproj delete mode 100644 src/Fantomas.MCP/Library.fs delete mode 100644 src/Fantomas.MCP/packages.lock.json diff --git a/.editorconfig b/.editorconfig index 694215b714..bc07e9f86a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ fsharp_keep_max_number_of_blank_lines=1 [tests/*.fsx] fsharp_multiline_bracket_style = aligned -[src/Fantomas.MCP/*.fs] +[mcp/**/*.fsx] fsharp_multiline_bracket_style = stroustrup \ No newline at end of file diff --git a/fantomas.sln b/fantomas.sln index 89224ad715..d3f08fe48d 100644 --- a/fantomas.sln +++ b/fantomas.sln @@ -43,8 +43,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fantomas.Client.Tests", "src\Fantomas.Client.Tests\Fantomas.Client.Tests.fsproj", "{68814E36-3957-4D1C-BCDB-84C3C8478BEC}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fantomas.MCP", "src\Fantomas.MCP\Fantomas.MCP.fsproj", "{F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -151,18 +149,6 @@ Global {68814E36-3957-4D1C-BCDB-84C3C8478BEC}.Release|x64.Build.0 = Release|Any CPU {68814E36-3957-4D1C-BCDB-84C3C8478BEC}.Release|x86.ActiveCfg = Release|Any CPU {68814E36-3957-4D1C-BCDB-84C3C8478BEC}.Release|x86.Build.0 = Release|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|x64.ActiveCfg = Debug|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|x64.Build.0 = Debug|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|x86.ActiveCfg = Debug|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Debug|x86.Build.0 = Debug|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|Any CPU.Build.0 = Release|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|x64.ActiveCfg = Release|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|x64.Build.0 = Release|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|x86.ActiveCfg = Release|Any CPU - {F59FFDA4-4D2C-4498-860D-0C31DD0F31EE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/mcp/README.md b/mcp/README.md index c41995388d..f8a2871941 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,4 +46,8 @@ Guide the LLM to generate tests. ## Prompts -We should probably also create a bunch of example prompt on how to ask the LLM to fix certain issues. \ No newline at end of file +We should probably also create a bunch of example prompt on how to ask the LLM to fix certain issues. + +## Test issues + +Try and solve https://github.com/fsprojects/fantomas/issues/3188 diff --git a/mcp/server.fsx b/mcp/server.fsx index c2cb54cc71..7e0a821326 100644 --- a/mcp/server.fsx +++ b/mcp/server.fsx @@ -7,20 +7,86 @@ open System open System.IO open System.Text -open System.Threading open System.ComponentModel open Microsoft.Extensions.Hosting open Microsoft.Extensions.Logging open Microsoft.Extensions.DependencyInjection open ModelContextProtocol.Server +open CliWrap +open CliWrap.Buffered + +let repositoryRoot = Path.Combine(__SOURCE_DIRECTORY__, "..") |> Path.GetFullPath + +let fantomasCoreFsproj = + Path.Combine(repositoryRoot, "src/Fantomas.Core/Fantomas.Core.fsproj") + +let formatScript = + Path.Combine(repositoryRoot, "mcp", "tools", "format.fsx") |> Path.GetFullPath + +let buildCodebase () = + task { + let! buildResult = + Cli + .Wrap("dotnet") + .WithArguments([| "build"; fantomasCoreFsproj; "-v"; "n" |]) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync() + .Task + + if buildResult.ExitCode = 0 then + return Ok() + else + let! gitStatus = Cli.Wrap("git").WithArguments("status").ExecuteBufferedAsync().Task + + return + Error + $"""Currently the Fantomas code base does not build. + +Build Error: + +{buildResult.StandardOutput}{buildResult.StandardError} + +Git status: + +{gitStatus.StandardOutput} + """ + } [] type FantomasMcpServer() = //as this = - [] - member this.GetDate() = - let now = DateTime.Now - let result = now.ToString "yyyy-MM-dd HH:mm:ss" - $"The current date in Nojafian Elven time is {result}" + [] + member this.FormatCode(sourceCode: string, isSignature: bool) = + task { + match! buildCodebase () with + | Error(error) -> return error + | Ok() -> + let inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sourceCode)) + + let! result = + Cli + .Wrap("dotnet") + .WithArguments( + [| + yield "fsi" + yield formatScript + if isSignature then + yield "--" + yield "--signature" + |] + ) + .WithStandardInputPipe(PipeSource.FromStream inputStream) + .ExecuteBufferedAsync() + .Task + + return result.StandardOutput + } let builder = Host.CreateApplicationBuilder(Environment.GetCommandLineArgs()) diff --git a/mcp/tools/format.fsx b/mcp/tools/format.fsx index bb4e166c4d..4107d03b98 100644 --- a/mcp/tools/format.fsx +++ b/mcp/tools/format.fsx @@ -2,9 +2,214 @@ #r "nuget: Thoth.Json.System.Text.Json" #r "../../artifacts/bin/Fantomas.FCS/debug/Fantomas.FCS.dll" #r "../../artifacts/bin/Fantomas.Core/debug/Fantomas.Core.dll" -#r "../../artifacts/bin/Fantomas.MCP/debug/Fantomas.MCP.dll" -open Fantomas +open Fantomas.Core + +// let st = Fantomas.Core.CodeFormatterImpl.getSourceText "let a = 0" +// printfn "%A" (st.GetType()) +// +// open Fantomas.Core +// open Thoth.Json.Core +// open Thoth.Json.System.Text.Json +// +// type FormatEvents<'state> = Result +// +// let encodeRange (range: Fantomas.FCS.Text.Range) = +// Encode.object [ +// "startLine", Encode.int range.StartLine +// "startColumn", Encode.int range.StartColumn +// "endLine", Encode.int range.EndLine +// "endColumn", Encode.int range.EndColumn +// ] +// +// type InitialState = { Input: string; IsSignature: bool } +// +// let getSourceText input isSignatureFile : FormatEvents = +// try +// let _ = CodeFormatterImpl.getSourceText input +// +// Ok( +// [ +// Encode.object [ +// "name", Encode.string "Created SourceText" +// "description", Encode.string "Successfully created internal ISourceText value for input" +// ] +// ], +// { +// Input = input +// IsSignature = isSignatureFile +// } +// ) +// +// with ex -> +// Error [ +// Encode.object [ +// "name", Encode.string "Failed to create ISourceText" +// "description", Encode.string $"Could not construct FCS.Text.ISourceText for \"{input}\"" +// "exception", Encode.string (ex.ToString()) +// ] +// ] +// +// [] +// type StateWithUntypedTree = { +// Input: string +// IsSignature: bool +// UntypedTrees: (Fantomas.FCS.Syntax.ParsedInput * string list) array +// } +// +// let parseUntypedTree (events, state: InitialState) = +// async { +// let type_ = if state.IsSignature then "signature" else "implementation" +// +// try +// let! untypedTrees = CodeFormatter.ParseAsync(state.IsSignature, state.Input) +// +// let nextEvents = +// Encode.object [ +// "name", Encode.string "Parsed input to Untyped AST" +// "description", Encode.string "Source input was able to be parsed as untyped AST" +// "untypedTrees", +// Encode.array ( +// untypedTrees +// |> Array.map (fun (ast, defines) -> +// Encode.object [ +// "defines", Encode.list (List.map Encode.string defines) +// "ast", Encode.string (string ast) +// ]) +// ) +// ] +// :: events +// +// return +// Ok( +// nextEvents, +// { +// Input = state.Input +// IsSignature = state.IsSignature +// UntypedTrees = untypedTrees +// } +// ) +// with +// | ParseException(diagnostics) -> +// let nextEvents = +// Encode.object [ +// "name", Encode.string "Parse exception raised during parsing of input to Untyped AST failed" +// "description", Encode.string $"Fantomas.FCS was unable to parse {type_} input" +// "input", Encode.string state.Input +// "diagnostics", +// diagnostics +// |> List.map (fun diag -> +// Encode.object [ +// "severity", Encode.string (string diag.Severity) +// "subCategory", Encode.string diag.SubCategory +// "range", Encode.losslessOption encodeRange diag.Range +// ]) +// |> Encode.list +// ] +// :: events +// +// return Error(nextEvents) +// | ex -> +// let nextEvents = +// Encode.object [ +// "name", Encode.string "Unexpected exception during parsing of input to Untyped AST failed" +// "description", Encode.string $"Fantomas.FCS was unable to parse {type_} input" +// "input", Encode.string state.Input +// "error", Encode.string (ex.ToString()) +// ] +// :: events +// +// return Error(nextEvents) +// } +// +// let (>>=) f g = +// async { +// match f with +// | Error e -> return Error e +// | Ok(e, s) -> return! g (e, s) +// } +// +// let format input isSignature : Async = +// async { +// let! events = getSourceText input isSignature >>= parseUntypedTree +// +// let orderedEvents = +// match events with +// | Error e -> e +// | Ok(e, _) -> e +// |> List.rev +// +// let json = Encode.object [ "events", Encode.list orderedEvents ] +// +// return Encode.toString 2 json +// } +// + +module OakPrinter = + open Thoth.Json.Core + open Thoth.Json.System.Text.Json + open Fantomas.FCS.Diagnostics + open Fantomas.FCS.Text + open Fantomas.FCS.Parse + open Fantomas.Core + open Fantomas.Core.SyntaxOak + + let encodeRange (m: range) = + Encode.object [ + "startLine", Encode.int m.StartLine + "startColumn", Encode.int m.StartColumn + "endLine", Encode.int m.EndLine + "endColumn", Encode.int m.EndColumn + ] + + let encodeTriviaNode (triviaNode: TriviaNode) : IEncodable = + let contentType, content = + match triviaNode.Content with + | CommentOnSingleLine comment -> "commentOnSingleLine", Some comment + | LineCommentAfterSourceCode comment -> "lineCommentAfterSourceCode", Some comment + | BlockComment(comment, _, _) -> "blockComment", Some comment + | Newline -> "newline", None + | Directive directive -> "directive", Some directive + | Cursor -> "cursor", None + + Encode.object [ + "range", encodeRange triviaNode.Range + "type", Encode.string contentType + "content", Encode.lossyOption Encode.string content + ] + + let rec encodeNode (node: Node) (continuation: IEncodable -> IEncodable) : IEncodable = + let continuations = List.map encodeNode (Array.toList node.Children) + + let text = + match node with + | :? SingleTextNode as stn -> + if stn.Text.Length < 13 then + stn.Text + else + sprintf "%s.." (stn.Text.Substring(0, 10)) + |> Some + | _ -> None + + let finalContinuation (children: IEncodable list) = + Encode.object [ + yield "type", Encode.string (node.GetType().Name) + match text with + | None -> () + | Some text -> yield ("text", Encode.string text) + yield "range", encodeRange node.Range + if node.HasContentBefore then + yield "contentBefore", Encode.seq (Seq.map encodeTriviaNode node.ContentBefore) + if node.Children.Length > 0 then + yield "children", Encode.list children + if node.HasContentAfter then + yield "contentAfter", Encode.seq (Seq.map encodeTriviaNode node.ContentAfter) + ] + |> continuation + + Continuation.sequence continuations finalContinuation + + let encodeOak (oak: Fantomas.Core.SyntaxOak.Oak) = encodeNode oak id |> Encode.toString 2 async { let input = stdin.ReadToEnd() @@ -12,9 +217,34 @@ async { let isSignature = System.Environment.GetCommandLineArgs() |> Array.contains "--signature" - let! output = MCP.format input isSignature + let mutable formatted = None + + MCPEvents.resetEvents () + + try + let! output = CodeFormatter.FormatDocumentAsync(isSignature, input) + formatted <- Some(output.Code) + with + | ParseException(diags) -> MCPEvents.addEvent (MCPEvents.EventKind.ParseFailed(diags)) + | ex -> MCPEvents.addEvent (MCPEvents.EventKind.UnExpectedExceptionHappened(ex)) + + let events = + MCPEvents.capturedEvents + |> Seq.map (fun event -> + match event.Kind with + | MCPEvents.EventKind.CreatedOakRaw oak -> + string ( + { + event with + Kind = MCPEvents.EventKind.CreatedOak(OakPrinter.encodeOak oak) + } + ) + | _ -> string event) + |> String.concat "\n\n" + + let formatted = formatted |> Option.defaultValue "" - printfn $"formatted:\n{output}" + printfn $"Events:\n---\n{events}\n---\n\nFormatted code:\n---\n{formatted}---" } |> Async.RunSynchronously diff --git a/src/Fantomas.Core/AssemblyInfo.fs b/src/Fantomas.Core/AssemblyInfo.fs index 11c607f85a..eaf31e6634 100644 --- a/src/Fantomas.Core/AssemblyInfo.fs +++ b/src/Fantomas.Core/AssemblyInfo.fs @@ -3,7 +3,7 @@ namespace Fantomas.Core open System.Runtime.CompilerServices [] -[] +[] do () diff --git a/src/Fantomas.Core/CodeFormatterImpl.fs b/src/Fantomas.Core/CodeFormatterImpl.fs index a517ea42f1..da01f60eb5 100644 --- a/src/Fantomas.Core/CodeFormatterImpl.fs +++ b/src/Fantomas.Core/CodeFormatterImpl.fs @@ -6,7 +6,9 @@ open Fantomas.FCS.Syntax open Fantomas.FCS.Text open MultipleDefineCombinations -let getSourceText (source: string) : ISourceText = source.TrimEnd() |> SourceText.ofString +let getSourceText (source: string) : ISourceText = + MCPEvents.addEvent MCPEvents.EventKind.SourceTextCreated + source.TrimEnd() |> SourceText.ofString let parse (isSignature: bool) (source: ISourceText) : Async<(ParsedInput * DefineCombination) array> = // First get the syntax tree without any defines @@ -18,20 +20,20 @@ let parse (isSignature: bool) (source: ISourceText) : Async<(ParsedInput * Defin | ParsedInput.ImplFile(ParsedImplFileInput(trivia = { ConditionalDirectives = directives })) | ParsedInput.SigFile(ParsedSigFileInput(trivia = { ConditionalDirectives = directives })) -> directives - match hashDirectives with - | [] -> - async { - let errors = - baseDiagnostics - |> List.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error) + let errors = + baseDiagnostics + |> List.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error) + + if not errors.IsEmpty then + raise (ParseException baseDiagnostics) - if not errors.IsEmpty then - raise (ParseException baseDiagnostics) + MCPEvents.addEvent (MCPEvents.EventKind.ParsedBaseUntypedTree(baseUntypedTree, baseDiagnostics)) - return [| (baseUntypedTree, DefineCombination.Empty) |] - } + match hashDirectives with + | [] -> async { return [| (baseUntypedTree, DefineCombination.Empty) |] } | hashDirectives -> let defineCombinations = Defines.getDefineCombination hashDirectives + MCPEvents.addEvent (MCPEvents.EventKind.SourceCodeHadDefines defineCombinations) defineCombinations |> List.map (fun defineCombination -> @@ -46,6 +48,10 @@ let parse (isSignature: bool) (source: ISourceText) : Async<(ParsedInput * Defin if not errors.IsEmpty then raise (ParseException diagnostics) + MCPEvents.addEvent ( + MCPEvents.EventKind.ParsedUntypedTreeWithDefines(untypedTree, diagnostics, defineCombination) + ) + return (untypedTree, defineCombination) }) |> Async.Parallel @@ -66,12 +72,23 @@ let formatAST ASTTransformer.mkOak (Some sourceText) ast |> Trivia.enrichTree config sourceText ast + MCPEvents.addEvent (MCPEvents.EventKind.CreatedOakRaw oak) + let oak = match cursor with | None -> oak | Some cursor -> Trivia.insertCursor oak cursor - context |> CodePrinter.genFile oak |> Context.dump false + let contextAfter = context |> CodePrinter.genFile oak + + MCPEvents.addEvent ( + contextAfter.WriterEvents + |> Seq.map string + |> String.concat " , " + |> MCPEvents.CollectedEventsAfterCodePrinter + ) + + contextAfter |> Context.dump false let formatDocument (config: FormatConfig) diff --git a/src/Fantomas.Core/Fantomas.Core.fsproj b/src/Fantomas.Core/Fantomas.Core.fsproj index 5ab93cf12a..045ea2443f 100644 --- a/src/Fantomas.Core/Fantomas.Core.fsproj +++ b/src/Fantomas.Core/Fantomas.Core.fsproj @@ -18,10 +18,11 @@ - - + + + diff --git a/src/Fantomas.Core/MCPEvents.fs b/src/Fantomas.Core/MCPEvents.fs new file mode 100644 index 0000000000..73db507974 --- /dev/null +++ b/src/Fantomas.Core/MCPEvents.fs @@ -0,0 +1,39 @@ +module internal Fantomas.Core.MCPEvents + +open System +open System.Collections.Concurrent +open Fantomas.FCS.Parse +open SyntaxOak + +[] +type EventKind = + | SourceTextCreated + | ParsedBaseUntypedTree of + baseTree: Fantomas.FCS.Syntax.ParsedInput * + baseDiagnostics: Fantomas.FCS.Parse.FSharpParserDiagnostic list + | SourceCodeHadDefines of combinations: Fantomas.Core.DefineCombination list + | ParsedUntypedTreeWithDefines of + tree: Fantomas.FCS.Syntax.ParsedInput * + diagnostics: Fantomas.FCS.Parse.FSharpParserDiagnostic list * + defineCombination: Fantomas.Core.DefineCombination + | ParseFailed of diagnostics: FSharpParserDiagnostic list + | FoundTrivia of TriviaNode array + | CreatedOakRaw of oak: Oak + | CreatedOak of string + | CollectedEventsAfterCodePrinter of string + | UnExpectedExceptionHappened of exn + +[] +type MCPEvent = { Created: DateTime; Kind: EventKind } + +let capturedEvents = ConcurrentQueue() + +let addEvent eventKind = + capturedEvents.Enqueue( + { Kind = eventKind + Created = DateTime.Now } + ) + +let resetEvents () = + while capturedEvents.TryDequeue() |> fst do + () diff --git a/src/Fantomas.Core/Trivia.fs b/src/Fantomas.Core/Trivia.fs index 8ea4141fed..7224e19dcc 100644 --- a/src/Fantomas.Core/Trivia.fs +++ b/src/Fantomas.Core/Trivia.fs @@ -337,6 +337,9 @@ let enrichTree (config: FormatConfig) (sourceText: ISourceText) (ast: ParsedInpu [| yield! comments; yield! newlines; yield! directives |] |> Array.sortBy (fun n -> n.Range.Start.Line, n.Range.Start.Column) + if trivia.Length > 0 then + MCPEvents.addEvent (MCPEvents.EventKind.FoundTrivia trivia) + addToTree tree trivia tree diff --git a/src/Fantomas.MCP/Fantomas.MCP.fsproj b/src/Fantomas.MCP/Fantomas.MCP.fsproj deleted file mode 100644 index 98f21935c7..0000000000 --- a/src/Fantomas.MCP/Fantomas.MCP.fsproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net9.0 - true - - - - - - - - - - - - - - - - diff --git a/src/Fantomas.MCP/Library.fs b/src/Fantomas.MCP/Library.fs deleted file mode 100644 index ef6ff6fd3f..0000000000 --- a/src/Fantomas.MCP/Library.fs +++ /dev/null @@ -1,137 +0,0 @@ -module Fantomas.MCP - -open Fantomas.Core -open Thoth.Json.Core -open Thoth.Json.System.Text.Json - -type FormatEvents<'state> = Result - -let encodeRange (range: Fantomas.FCS.Text.Range) = - Encode.object [ - "startLine", Encode.int range.StartLine - "startColumn", Encode.int range.StartColumn - "endLine", Encode.int range.EndLine - "endColumn", Encode.int range.EndColumn - ] - -type InitialState = { Input: string; IsSignature: bool } - -let getSourceText input isSignatureFile : FormatEvents = - try - let _ = CodeFormatterImpl.getSourceText input - - Ok( - [ - Encode.object [ - "name", Encode.string "Created SourceText" - "description", Encode.string "Successfully created internal ISourceText value for input" - ] - ], - { - Input = input - IsSignature = isSignatureFile - } - ) - - with ex -> - Error [ - Encode.object [ - "name", Encode.string "Failed to create ISourceText" - "description", Encode.string $"Could not construct FCS.Text.ISourceText for \"{input}\"" - "exception", Encode.string (ex.ToString()) - ] - ] - -[] -type StateWithUntypedTree = { - Input: string - IsSignature: bool - UntypedTrees: (FCS.Syntax.ParsedInput * string list) array -} - -let parseUntypedTree (events, state: InitialState) = - async { - let type_ = if state.IsSignature then "signature" else "implementation" - - try - let! untypedTrees = CodeFormatter.ParseAsync(state.IsSignature, state.Input) - - let nextEvents = - Encode.object [ - "name", Encode.string "Parsed input to Untyped AST" - "description", Encode.string "Source input was able to be parsed as untyped AST" - "untypedTrees", - Encode.array ( - untypedTrees - |> Array.map (fun (ast, defines) -> - Encode.object [ - "defines", Encode.list (List.map Encode.string defines) - "ast", Encode.string (string ast) - ]) - ) - ] - :: events - - return - Ok( - nextEvents, - { - Input = state.Input - IsSignature = state.IsSignature - UntypedTrees = untypedTrees - } - ) - with - | ParseException(diagnostics) -> - let nextEvents = - Encode.object [ - "name", Encode.string "Parse exception raised during parsing of input to Untyped AST failed" - "description", Encode.string $"Fantomas.FCS was unable to parse {type_} input" - "input", Encode.string state.Input - "diagnostics", - diagnostics - |> List.map (fun diag -> - Encode.object [ - "severity", Encode.string (string diag.Severity) - "subCategory", Encode.string diag.SubCategory - "range", Encode.losslessOption encodeRange diag.Range - ]) - |> Encode.list - ] - :: events - - return Error(nextEvents) - | ex -> - let nextEvents = - Encode.object [ - "name", Encode.string "Unexpected exception during parsing of input to Untyped AST failed" - "description", Encode.string $"Fantomas.FCS was unable to parse {type_} input" - "input", Encode.string state.Input - "error", Encode.string (ex.ToString()) - ] - :: events - - return Error(nextEvents) - } - -let (>>=) f g = - async { - match f with - | Error e -> return Error e - | Ok(e, s) -> return! g (e, s) - } - -let format input isSignature : Async = - async { - let! events = getSourceText input isSignature >>= parseUntypedTree - - let orderedEvents = - match events with - | Error e -> e - | Ok(e, _) -> e - |> List.rev - - let json = Encode.object [ "events", Encode.list orderedEvents ] - - return Encode.toString 2 json - } diff --git a/src/Fantomas.MCP/packages.lock.json b/src/Fantomas.MCP/packages.lock.json deleted file mode 100644 index c531038c0a..0000000000 --- a/src/Fantomas.MCP/packages.lock.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net9.0": { - "DotNet.ReproducibleBuilds": { - "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "+H2t/t34h6mhEoUvHi8yGXyuZ2GjSovcGYehJrS2MDm2XgmPfZL2Sdxg+uL2lKgZ4M6tTwKHIlxOob2bgh0NRQ==", - "dependencies": { - "Microsoft.SourceLink.AzureRepos.Git": "1.1.1", - "Microsoft.SourceLink.Bitbucket.Git": "1.1.1", - "Microsoft.SourceLink.GitHub": "1.1.1", - "Microsoft.SourceLink.GitLab": "1.1.1" - } - }, - "G-Research.FSharp.Analyzers": { - "type": "Direct", - "requested": "[0.18.0, )", - "resolved": "0.18.0", - "contentHash": "axpIIOMOHUK/GS1s1q4thDTfcZxBVaQryPFDAURPX78TTLqHPF83USUgI4yM2cvIo+ZA7+c5SUWtkVXKkedlVQ==" - }, - "Ionide.Analyzers": { - "type": "Direct", - "requested": "[0.14.7, )", - "resolved": "0.14.7", - "contentHash": "xjYTHbb/aP6QPD9rJOZNJW3YN7KAegWAy42N61A9m7BVMspaSNEX8Y57Cey9ySWCrjvEaXKAnzVuyL9N3zZVLA==" - }, - "Ionide.KeepAChangelog.Tasks": { - "type": "Direct", - "requested": "[0.3.0, )", - "resolved": "0.3.0", - "contentHash": "hHyUItpnkq16G5Crwx/I1lnBDoZ///hHo/s0mSRB1MiX7fFHCN0xWk6XnvMPXry4fgBii7UFNkMcc6a6rUrYQQ==" - }, - "Thoth.Json.System.Text.Json": { - "type": "Direct", - "requested": "[0.2.1, )", - "resolved": "0.2.1", - "contentHash": "fEh3IyCtTJhfS6olPsTuU0YSnl3DT755wzyz332TXY4DGIIXkHfcSZkVZ3soDKlsRCd0HvAGqs8jXrYK4+0/aw==", - "dependencies": { - "FSharp.Core": "5.0.2", - "Fable.Core": "4.1.0", - "System.Text.Json": "9.0.0", - "Thoth.Json.Core": "0.7.0" - } - }, - "Fable.Core": { - "type": "Transitive", - "resolved": "4.1.0", - "contentHash": "NISAbAVGEcvH2s+vHLSOCzh98xMYx4aIadWacQdWPcQLploxpSQXLEe9SeszUBhbHa73KMiKREsH4/W3q4A4iA==" - }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" - }, - "Microsoft.NETCore.Platforms": { - "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "TMBuzAHpTenGbGgk0SMTwyEkyijY/Eae4ZGsFNYJvAr/LDn1ku3Etp3FPxChmDp5HHF3kzJuoaa08N0xjqAJfQ==" - }, - "Microsoft.NETCore.Targets": { - "type": "Transitive", - "resolved": "1.1.3", - "contentHash": "3Wrmi0kJDzClwAC+iBdUBpEKmEle8FQNsCs77fkiOIw/9oYA07bL1EZNX0kQ2OMN3xpwvl0vAtOCYY3ndDNlhQ==" - }, - "Microsoft.SourceLink.AzureRepos.Git": { - "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "qB5urvw9LO2bG3eVAkuL+2ughxz2rR7aYgm2iyrB8Rlk9cp2ndvGRCvehk3rNIhRuNtQaeKwctOl1KvWiklv5w==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" - } - }, - "Microsoft.SourceLink.Bitbucket.Git": { - "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "cDzxXwlyWpLWaH0em4Idj0H3AmVo3L/6xRXKssYemx+7W52iNskj/SQ4FOmfCb8YQt39otTDNMveCZzYtMoucQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" - } - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" - }, - "Microsoft.SourceLink.GitHub": { - "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" - } - }, - "Microsoft.SourceLink.GitLab": { - "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "tvsg47DDLqqedlPeYVE2lmiTpND8F0hkrealQ5hYltSmvruy/Gr5nHAKSsjyw5L3NeM/HLMI5ORv7on/M4qyZw==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" - } - }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A==" - }, - "Thoth.Json.Core": { - "type": "Transitive", - "resolved": "0.7.0", - "contentHash": "BENcsjQMfG++NvzIUQG3xv8esB2PfUfe8ggpcNwhmqEPuJp8KORM5MCNqqwuBHZ4LOUK61L/bldFPGqr0K9sLQ==", - "dependencies": { - "FSharp.Core": "5.0.2", - "Fable.Core": "4.1.0" - } - }, - "fantomas.core": { - "type": "Project", - "dependencies": { - "FSharp.Core": "[8.0.100, )", - "Fantomas.FCS": "[1.0.0, )" - } - }, - "fantomas.fcs": { - "type": "Project", - "dependencies": { - "FSharp.Core": "[8.0.100, )", - "System.Collections.Immutable": "[8.0.0, )", - "System.Diagnostics.DiagnosticSource": "[8.0.1, )", - "System.Memory": "[4.6.0, )", - "System.Runtime": "[4.3.1, )" - } - }, - "FSharp.Core": { - "type": "CentralTransitive", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" - }, - "System.Collections.Immutable": { - "type": "CentralTransitive", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" - }, - "System.Diagnostics.DiagnosticSource": { - "type": "CentralTransitive", - "requested": "[8.0.1, )", - "resolved": "8.0.1", - "contentHash": "vaoWjvkG1aenR2XdjaVivlCV9fADfgyhW5bZtXT23qaEea0lWiUljdQuze4E31vKM7ZWJaSUsbYIKE3rnzfZUg==" - }, - "System.Memory": { - "type": "CentralTransitive", - "requested": "[4.6.0, )", - "resolved": "4.6.0", - "contentHash": "OEkbBQoklHngJ8UD8ez2AERSk2g+/qpAaSWWCBFbpH727HxDq5ydVkuncBaKcKfwRqXGWx64dS6G1SUScMsitg==" - }, - "System.Runtime": { - "type": "CentralTransitive", - "requested": "[4.3.1, )", - "resolved": "4.3.1", - "contentHash": "abhfv1dTK6NXOmu4bgHIONxHyEqFjW8HwXPmpY9gmll+ix9UNo4XDcmzJn6oLooftxNssVHdJC1pGT9jkSynQg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.1", - "Microsoft.NETCore.Targets": "1.1.3" - } - } - } - } -} \ No newline at end of file From a0ebcad19ccf54698a9a29bbbe2512a1ab7d2a65 Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 11:36:44 +0200 Subject: [PATCH 03/14] Add agents md --- AGENTS.md | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..d151295fac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,288 @@ +# AGENTS.md - AI Agent Guide for Fantomas + +This guide is specifically designed for AI agents working on the Fantomas F# code formatter project. It provides essential information about project structure, development workflow, and best practices. + +## Table of Contents + +1. [Project Overview](#project-overview) +2. [Project Structure](#project-structure) +3. [Development Workflow](#development-workflow) +4. [Core Architecture](#core-architecture) +5. [Testing Guidelines](#testing-guidelines) +6. [Common Tasks](#common-tasks) +7. [Troubleshooting](#troubleshooting) +8. [Resources](#resources) + +## Project Overview + +Fantomas is an F# code formatter that transforms F# source code into a standardized format. The project follows a two-phase approach: + +1. **Transform**: Parse F# source code into a custom tree model (Oak) +2. **Traverse**: Walk the tree to generate formatted code + +### Key Principles + +- **Valid Code Only**: Fantomas requires valid F# source code to format +- **Test-Driven Development**: Always write tests before implementing fixes +- **Minimal Changes**: Make the smallest possible changes to achieve the goal +- **Consistency**: Follow existing code patterns and conventions + +## Project Structure + +``` +fantomas/ +├── src/ +│ ├── Fantomas.Core/ # Core formatting engine +│ │ ├── CodePrinter.fs # Main code generation logic +│ │ ├── SyntaxOak.fs # Custom tree model +│ │ └── Context.fs # Formatting context and events +│ ├── Fantomas.Core.Tests/ # Unit tests +│ │ ├── ModuleTests.fs # Module-related tests +│ │ └── TestHelpers.fs # Test utilities +│ ├── Fantomas.FCS/ # F# Compiler Service integration +│ └── Fantomas/ # CLI interface +├── docs/ # Documentation +├── build.fsx # Build script +└── global.json # .NET SDK version +``` + +### Key Files for AI Agents + +- **`src/Fantomas.Core/CodePrinter.fs`**: Main formatting logic +- **`src/Fantomas.Core/SyntaxOak.fs`**: Tree model definitions +- **`src/Fantomas.Core.Tests/ModuleTests.fs`**: Module formatting tests +- **`src/Fantomas.Core.Tests/TestHelpers.fs`**: Test utilities + +## Development Workflow + +### 1. Setup + +```bash +# Clone and setup +git clone +cd fantomas +dotnet tool restore +dotnet restore + +# Build and test +dotnet fsi build.fsx +``` + +### 2. Making Changes + +1. **Write Test First**: Create a failing test that demonstrates the issue +2. **Implement Fix**: Make minimal changes to fix the issue +3. **Verify**: Run tests to ensure fix works +4. **Format**: Format your changes using the build script + +### 3. Testing + +```bash +# Run all tests +dotnet fsi build.fsx + +# Run specific test +dotnet test src/Fantomas.Core.Tests/ --filter "test-name" + +# Format changed files +dotnet fsi build.fsx -p FormatChanged +``` + +### 4. Build Commands + +```bash +# Full build with tests +dotnet fsi build.fsx + +# Format all files +dotnet fsi build.fsx -p FormatAll + +# Format only changed files +dotnet fsi build.fsx -p FormatChanged + +# Setup git hooks +dotnet fsi build.fsx -p EnsureRepoConfig +``` + +## Core Architecture + +### Two-Phase Processing + +#### Phase 1: Transform to Oak +1. **Parse**: Use F# compiler to parse source into untyped AST +2. **Transform**: Map AST to custom Oak tree model +3. **Collect Trivia**: Add comments, directives, and blank lines + +#### Phase 2: Traverse to Code +1. **Generate Events**: Walk Oak tree to create WriterEvents +2. **Apply Context**: Use Context to manage indentation and formatting +3. **Output**: Convert events to formatted source code + +### Key Components + +#### Oak Tree Model +- **NodeBase**: Base class for all nodes with trivia support +- **SingleTextNode**: Represents text tokens +- **IdentListNode**: Represents identifiers with dots +- **ModuleOrNamespaceNode**: Represents module/namespace declarations + +#### Context System +- **WriterEvents**: Capture formatting actions (indent, newline, text) +- **WriterModel**: Track current state (column, indentation level) +- **Config**: Formatting configuration options + +#### Trivia System +- **ContentBefore/ContentAfter**: Attach comments and directives to nodes +- **Directive**: Conditional compilation directives (#if, #endif) +- **Comment**: Line and block comments +- **Newline**: Blank lines and spacing + +## Testing Guidelines + +### Test Structure + +```fsharp +[] +let ``descriptive test name, issue-number`` () = + formatSourceString """ + // Input code here + """ config + |> prepend newline + |> should equal """ + // Expected output here + """ +``` + +### Test Naming Convention + +- Start with lowercase letter +- Use descriptive names +- Include issue number for bug fixes: `"fix description, 1234"` +- Use backticks for multi-word names + +### Test Categories + +- **ModuleTests.fs**: Module and namespace formatting +- **TypeTests.fs**: Type definition formatting +- **ExpressionTests.fs**: Expression formatting +- **PatternTests.fs**: Pattern matching formatting + +### Writing Tests + +1. **Reproduce Issue**: Create test that shows the problem +2. **Set Expectations**: Define expected output +3. **Verify Fix**: Ensure test passes after implementation +4. **Add Variations**: Test edge cases and similar scenarios + +## Common Tasks + +### Fixing Formatting Issues + +1. **Identify Problem**: Understand what's wrong with current formatting +2. **Locate Code**: Find relevant code in `CodePrinter.fs` +3. **Write Test**: Create failing test for the issue +4. **Implement Fix**: Modify formatting logic +5. **Verify**: Run tests and check for regressions + +### Adding New Features + +1. **Understand Requirement**: Clarify what needs to be formatted +2. **Find Similar Code**: Look for existing patterns in CodePrinter +3. **Extend Oak Model**: Add new node types if needed +4. **Implement Logic**: Add formatting logic +5. **Add Tests**: Cover all code paths and edge cases + +### Working with Conditional Directives + +- Directives are stored as trivia on nodes +- Use `hasDirectivesInContentBefore` to detect directives +- Apply special formatting when directives are present +- Test with different define combinations + +### Debugging CodePrinter + +- Use `dumpAndContinue` to inspect Context during traversal +- Breakpoints in CodePrinter show function composition, not execution +- Check WriterEvents to understand formatting decisions +- Use MCP tools to test formatting in real-time + +## Troubleshooting + +### Common Issues + +#### "The formatted result is not valid F# code" +- Check if the fix produces syntactically correct F# code +- Verify indentation and spacing +- Test with F# compiler to ensure validity + +#### Tests Failing +- Ensure test expectations match actual output +- Check for whitespace differences +- Verify test is using correct configuration + +#### Build Errors +- Run `dotnet clean` and rebuild +- Check for syntax errors in your changes +- Ensure all dependencies are restored + +### Debugging Tips + +1. **Use MCP Tools**: Test formatting with `mcp_Fantomas_format_code` +2. **Check Events**: Examine WriterEvents to understand formatting +3. **Compare Outputs**: Use diff tools to compare expected vs actual +4. **Isolate Changes**: Make minimal changes and test incrementally + +## Resources + +### Documentation +- [Getting Started](https://fsprojects.github.io/fantomas/docs/contributors/Getting%20Started.html) +- [Core Architecture](https://fsprojects.github.io/fantomas/docs/contributors/Transforming.html) +- [CodePrinter Guide](https://fsprojects.github.io/fantomas/docs/contributors/Traverse.html) +- [Pull Request Guidelines](https://fsprojects.github.io/fantomas/docs/contributors/Pull%20request%20ground%20rules.html) + +### Key Concepts +- [Conditional Compilation Directives](https://fsprojects.github.io/fantomas/docs/contributors/Conditional%20Compilation%20Directives.html) +- [Trivia Handling](https://fsprojects.github.io/fantomas/docs/contributors/The%20Missing%20Comment.html) +- [Multiple Defines](https://fsprojects.github.io/fantomas/docs/contributors/Multiple%20Times.html) + +### Tools +- **MCP Fantomas**: Real-time formatting testing +- **Build Script**: `dotnet fsi build.fsx` for all operations +- **Test Framework**: NUnit with FsUnit assertions +- **Git Hooks**: Automatic formatting on commit + +### Best Practices + +1. **Always Test First**: Write failing test before implementing fix +2. **Minimal Changes**: Make smallest possible change to fix issue +3. **Follow Patterns**: Use existing code patterns and conventions +4. **Document Changes**: Add meaningful commit messages and PR descriptions +5. **Verify Validity**: Ensure formatted code is valid F# code +6. **Check Regressions**: Run full test suite to catch unintended changes + +### Example Workflow + +```bash +# 1. Setup +git checkout -b fix-3188 +dotnet fsi build.fsx + +# 2. Write test +# Add test to ModuleTests.fs + +# 3. Implement fix +# Modify CodePrinter.fs + +# 4. Test and verify +dotnet test src/Fantomas.Core.Tests/ --filter "3188" +dotnet fsi build.fsx + +# 5. Format changes +dotnet fsi build.fsx -p FormatChanged + +# 6. Final verification +dotnet fsi build.fsx +``` + +This guide should help AI agents understand the Fantomas project structure and contribute effectively to the codebase. + From b1e1b8cce86365ba0d2e38d4774091e61d40ebde Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 11:44:57 +0200 Subject: [PATCH 04/14] Add validation check --- mcp/server.fsx | 1 + mcp/tools/format.fsx | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mcp/server.fsx b/mcp/server.fsx index 7e0a821326..be4eb904dc 100644 --- a/mcp/server.fsx +++ b/mcp/server.fsx @@ -59,6 +59,7 @@ type FantomasMcpServer() = //as this = The ultimate Fantomas debug tool. This format the input code with a locally built Fantomas.Core and will report detailed events of what happened during this formatting process. This will give key insight of how the F# source code was transformed to Untyped AST, Syntax Oak, what trivia was collected and what writer events were produced. +It will also will also let you know if the formatted code was still valid F# code! Upon invocation this tool will try and compile the local codebase so that the latest code changes are reflected. If the build did not succeed, an the build error will be reported. """)>] diff --git a/mcp/tools/format.fsx b/mcp/tools/format.fsx index 4107d03b98..8f77715c7b 100644 --- a/mcp/tools/format.fsx +++ b/mcp/tools/format.fsx @@ -148,10 +148,7 @@ open Fantomas.Core module OakPrinter = open Thoth.Json.Core open Thoth.Json.System.Text.Json - open Fantomas.FCS.Diagnostics open Fantomas.FCS.Text - open Fantomas.FCS.Parse - open Fantomas.Core open Fantomas.Core.SyntaxOak let encodeRange (m: range) = @@ -228,6 +225,11 @@ async { | ParseException(diags) -> MCPEvents.addEvent (MCPEvents.EventKind.ParseFailed(diags)) | ex -> MCPEvents.addEvent (MCPEvents.EventKind.UnExpectedExceptionHappened(ex)) + let! isValid = + match formatted with + | None -> async { return false } + | Some(formattedCode) -> CodeFormatter.IsValidFSharpCodeAsync(isSignature, formattedCode) + let events = MCPEvents.capturedEvents |> Seq.map (fun event -> @@ -244,7 +246,8 @@ async { let formatted = formatted |> Option.defaultValue "" - printfn $"Events:\n---\n{events}\n---\n\nFormatted code:\n---\n{formatted}---" + printfn + $"Events:\n---\n{events}\n---\n\nResult is valid F# code: {isValid}\n---\n\nFormatted code:\n---\n{formatted}---" } |> Async.RunSynchronously From 325d366becca449d5fe8b25a8f35634a04dfdf2c Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 14:26:02 +0200 Subject: [PATCH 05/14] Add more context in Agents.md --- AGENTS.md | 225 +++++++++++++++++++++++++++++++-- src/Fantomas.Core/SyntaxOak.fs | 4 + 2 files changed, 220 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d151295fac..43b8de21cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,12 @@ This guide is specifically designed for AI agents working on the Fantomas F# cod 4. [Core Architecture](#core-architecture) 5. [Testing Guidelines](#testing-guidelines) 6. [Common Tasks](#common-tasks) -7. [Troubleshooting](#troubleshooting) -8. [Resources](#resources) +7. [MCP Tools](#mcp-tools) +8. [Configuration](#configuration) +9. [Error Handling](#error-handling) +10. [Code Style](#code-style) +11. [Troubleshooting](#troubleshooting) +12. [Resources](#resources) ## Project Overview @@ -27,6 +31,16 @@ Fantomas is an F# code formatter that transforms F# source code into a standardi - **Minimal Changes**: Make the smallest possible changes to achieve the goal - **Consistency**: Follow existing code patterns and conventions +### Quick Start Commands + +```bash +# Most common commands for LLMs +dotnet fsi build.fsx # Build and test +dotnet fsi build.fsx -p FormatChanged # Format changes +dotnet test --filter "test-name" # Run specific test +dotnet fsi build.fsx -p FormatAll # Format all files +``` + ## Project Structure ``` @@ -34,8 +48,8 @@ fantomas/ ├── src/ │ ├── Fantomas.Core/ # Core formatting engine │ │ ├── CodePrinter.fs # Main code generation logic -│ │ ├── SyntaxOak.fs # Custom tree model -│ │ └── Context.fs # Formatting context and events +│ │ ├── SyntaxOak.fs # Custom tree model (also includes types of Trivia) +│ │ └── Context.fs # Formatting context and events (includes lots of helpers) │ ├── Fantomas.Core.Tests/ # Unit tests │ │ ├── ModuleTests.fs # Module-related tests │ │ └── TestHelpers.fs # Test utilities @@ -48,10 +62,36 @@ fantomas/ ### Key Files for AI Agents -- **`src/Fantomas.Core/CodePrinter.fs`**: Main formatting logic -- **`src/Fantomas.Core/SyntaxOak.fs`**: Tree model definitions -- **`src/Fantomas.Core.Tests/ModuleTests.fs`**: Module formatting tests -- **`src/Fantomas.Core.Tests/TestHelpers.fs`**: Test utilities +### `src/Fantomas.Core/CodePrinter.fs` + +- Main formatting logic +- Constructs pipeline for `Context` instance to collect `WriterEvents` based on `Oak` model +- `genNode` is common helper for `Trivia` of `Node` to be processed +- **Critical**: Changes to `genNode` impact the entire file - scope logic closer to where problems reside instead +- Careful with edits to auxiliary functions, changing these can be very impactful, consider local edits instead + +### `src/Fantomas.Core/SyntaxOak.fs` + +- Custom tree model definitions +- `Node` can hold `Trivia` where nodes from `Fantomas.FCS.Syntax.ParsedInput` cannot +- `TriviaContent` is union types for the trivia types (comments, newlines, directives, cursor) + +### `src/Fantomas.Core/Trivia.fs` + +- Trivia assigment +- Different algoritm based `TriviaContent` +- Assigment is best effort approach, hard to perfect + +### `src/Fantomas.Core/Context.fs` + +- `WriterEvents` models the formatted result code without committing yet. +- Often a simple code formatting path is tried, to only be reverted when the code does not fit a given threshold + +### `src/Fantomas.Core.Tests/*Tests.fs` + +- Test files have a fixed format +- Input and output in test should be valid F# code +- Try and change as little as possible config values ## Development Workflow @@ -137,6 +177,25 @@ dotnet fsi build.fsx -p EnsureRepoConfig - **Comment**: Line and block comments - **Newline**: Blank lines and spacing +### Common Patterns + +#### Adding New Oak Node Types +1. Define the node type in `SyntaxOak.fs` +2. Add transformation logic in `ASTTransformer.fs` +3. Add formatting logic in `CodePrinter.fs` +4. Update tests in appropriate `*Tests.fs` file + +#### CodePrinter Patterns +- Use `genNode` for common trivia handling +- Use `genTriviaFor` for specific trivia types +- Use `dumpAndContinue` for debugging context state +- Prefer local helper functions over modifying global ones + +#### Trivia Assignment Patterns +- Use `Trivia.enrichTree` to attach trivia to nodes +- Check `hasDirectivesInContentBefore` for conditional compilation +- Use `insertCursor` for cursor position handling + ## Testing Guidelines ### Test Structure @@ -206,6 +265,154 @@ let ``descriptive test name, issue-number`` () = - Check WriterEvents to understand formatting decisions - Use MCP tools to test formatting in real-time +## MCP Tools + +### Fantomas Format Code Tool + +The MCP Fantomas tool provides real-time formatting testing and debugging capabilities. It's essential for understanding how code transformations work. + +#### Usage + +```bash +# Tool configuration (in ~/.cursor/mcp.json) +"Fantomas": { + "type": "stdio", + "command": "dotnet", + "args": ["fsi", "/path/to/fantomas/mcp/server.fsx"] +} +``` + +#### Expected Usage + +1. **Input Requirements**: The tool expects valid F# code as input + - Invalid F# code will result in parse errors and limited debugging information + - Use the tool with problematic but syntactically correct F# code + +2. **Tool Behavior**: + - Automatically builds the local Fantomas codebase before formatting + - Reports detailed events during the formatting process + - Shows transformation from F# source → Untyped AST → Syntax Oak → WriterEvents + - Validates that formatted output is still valid F# code + +3. **Debugging Workflow**: + ```fsharp + // Start with problematic code + let problematicCode = """ + module Test + let x=1+2 + """ + + // Use MCP tool to see transformation events + // Analyze WriterEvents to understand formatting decisions + // Identify where formatting logic needs adjustment + ``` + +4. **Integration with Development**: + - Use before writing tests to understand expected behavior + - Use to debug existing formatting issues + - Use to verify fixes work as expected + +## Configuration + +### Finding Configuration Options + +Configuration is primarily handled through `FormatConfig` in the codebase: + +```fsharp +// Common configuration options +type FormatConfig = { + IndentSize: int + MaxLineLength: int + // ... other options +} +``` + +### Configuration Best Practices + +1. **Minimal Changes**: Try to change as few config values as possible +2. **Test Impact**: Always test configuration changes with existing tests +3. **Documentation**: Document why specific config values are needed +4. **Default Behavior**: Prefer using default configuration when possible + +### Common Configuration Patterns + +```fsharp +// Use default config for most tests +let config = FormatConfig.Default + +// Only modify specific settings when necessary +let customConfig = + { FormatConfig.Default with + IndentSize = 2 + MaxLineLength = 120 } +``` + +## Error Handling + +### Parse Errors + +Fantomas requires valid F# code to format. Parse errors are handled in `CodeFormatterImpl.fs`: + +```fsharp +// Parse errors raise ParseException +if not errors.IsEmpty then + raise (ParseException baseDiagnostics) +``` + +#### Common Parse Error Scenarios + +1. **Syntax Errors**: Invalid F# syntax will cause parse failures +2. **Missing Dependencies**: Unresolved type references +3. **Conditional Compilation**: Issues with `#if` directives + +#### Handling Parse Errors + +- **For Testing**: Ensure test input is valid F# code +- **For Development**: Fix syntax errors before testing formatting +- **For MCP Tool**: Use valid F# code to get meaningful debugging output + +### Format Errors + +#### "The formatted result is not valid F# code" +- Check if formatting logic produces syntactically correct output +- Verify indentation and spacing are correct +- Test with F# compiler to ensure validity + +#### WriterEvent Errors +- Check Context state during formatting +- Use `dumpAndContinue` to inspect intermediate states +- Verify WriterEvents produce valid code sequences + +## Code Style + +### F# Conventions Used in Fantomas + +1. **Naming**: + - Use camelCase for functions and variables + - Use PascalCase for types and modules + - Use descriptive names that explain intent + +2. **Formatting**: + - Follow Fantomas' own formatting rules + - Use consistent indentation (4 spaces) + - Prefer composition over complex expressions + +3. **Patterns**: + - Use discriminated unions for tree nodes + - Use active patterns for AST matching + - Prefer immutable data structures + +4. **Comments**: + - Use `//` for single-line comments + - Use `(* *)` for multi-line comments + - Document complex algorithms and decisions + +### File Organization + +- **One type per file** when possible +- **Logical grouping** of related functions +- **Clear separation** between public and internal APIs + ## Troubleshooting ### Common Issues @@ -246,7 +453,7 @@ let ``descriptive test name, issue-number`` () = - [Multiple Defines](https://fsprojects.github.io/fantomas/docs/contributors/Multiple%20Times.html) ### Tools -- **MCP Fantomas**: Real-time formatting testing +- **MCP Fantomas**: Real-time formatting testing and debugging - **Build Script**: `dotnet fsi build.fsx` for all operations - **Test Framework**: NUnit with FsUnit assertions - **Git Hooks**: Automatic formatting on commit diff --git a/src/Fantomas.Core/SyntaxOak.fs b/src/Fantomas.Core/SyntaxOak.fs index c15dc6f0da..b9bbfde087 100644 --- a/src/Fantomas.Core/SyntaxOak.fs +++ b/src/Fantomas.Core/SyntaxOak.fs @@ -15,6 +15,10 @@ type TriviaNode(content: TriviaContent, range: range) = member val Content = content member val Range = range + override this.ToString() = + sprintf + $"%A{this.Content} ({this.Range.StartLine},{this.Range.StartColumn} - {this.Range.EndLine},{this.Range.EndColumn})" + [] type Node = abstract ContentBefore: TriviaNode seq From d8868698ba10ee43d0dd56e0b93f03967a08d607 Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 14:35:40 +0200 Subject: [PATCH 06/14] Comment out print of formatted code --- AGENTS.md | 10 ++++++---- src/Fantomas.Core.Tests/TestHelpers.fs | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43b8de21cc..c70a5d31b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,10 +221,12 @@ let ``descriptive test name, issue-number`` () = ### Test Categories -- **ModuleTests.fs**: Module and namespace formatting -- **TypeTests.fs**: Type definition formatting -- **ExpressionTests.fs**: Expression formatting -- **PatternTests.fs**: Pattern matching formatting +- We organize the tests in `Fantomas.Core.Tests` according to syntax constructs: + - **ModuleTests.fs**: Module and namespace formatting + - **TypeTests.fs**: Type definition formatting + - **ExpressionTests.fs**: Expression formatting + - **PatternTests.fs**: Pattern matching formatting +- `src/Fantomas.Core.Tests/CodePrinterHelperFunctionsTests.fs` can be insightful to understand the `Context` pipeline setup. ### Writing Tests diff --git a/src/Fantomas.Core.Tests/TestHelpers.fs b/src/Fantomas.Core.Tests/TestHelpers.fs index 616b5e2d8e..76c88a3d35 100644 --- a/src/Fantomas.Core.Tests/TestHelpers.fs +++ b/src/Fantomas.Core.Tests/TestHelpers.fs @@ -31,7 +31,7 @@ let formatFSharpString isFsiFile (s: string) config = if formattedCode <> secondFormattedCode then failwith $"The formatted result was not idempotent.\n%s{formattedCode}\n%s{secondFormattedCode}" - printfn "formatted code:\n%s\n" formattedCode + // printfn "formatted code:\n%s\n" formattedCode return formattedCode } From 71bf670d6aeffdebd105c818e990a534bba178ae Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 14:48:01 +0200 Subject: [PATCH 07/14] Bump F# Core --- Directory.Packages.props | 2 +- src/Fantomas.Benchmarks/packages.lock.json | 10 +++++----- src/Fantomas.Client.Tests/packages.lock.json | 8 ++++---- src/Fantomas.Client/packages.lock.json | 6 +++--- src/Fantomas.Core.Tests/packages.lock.json | 10 +++++----- src/Fantomas.Core/packages.lock.json | 8 ++++---- src/Fantomas.FCS/packages.lock.json | 6 +++--- src/Fantomas.Tests/packages.lock.json | 14 +++++++------- src/Fantomas/packages.lock.json | 12 ++++++------ 9 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9074850265..73dce6e452 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ true - + diff --git a/src/Fantomas.Benchmarks/packages.lock.json b/src/Fantomas.Benchmarks/packages.lock.json index bad220a5b4..ce30040db9 100644 --- a/src/Fantomas.Benchmarks/packages.lock.json +++ b/src/Fantomas.Benchmarks/packages.lock.json @@ -22,9 +22,9 @@ }, "FSharp.Core": { "type": "Direct", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + "requested": "[9.0.100, )", + "resolved": "9.0.100", + "contentHash": "ye8yagHGsH08H2Twno5GRWkSbrMtxK/SWiHuPcF+3nODpW65/VJ8RO0aWxp8n9+KQbmahg90wAEL3TEXjF0r6A==" }, "G-Research.FSharp.Analyzers": { "type": "Direct", @@ -266,14 +266,14 @@ "fantomas.core": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "Fantomas.FCS": "[1.0.0, )" } }, "fantomas.fcs": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "System.Collections.Immutable": "[8.0.0, )", "System.Diagnostics.DiagnosticSource": "[8.0.1, )", "System.Memory": "[4.6.0, )", diff --git a/src/Fantomas.Client.Tests/packages.lock.json b/src/Fantomas.Client.Tests/packages.lock.json index 962b7621bb..ad23493d55 100644 --- a/src/Fantomas.Client.Tests/packages.lock.json +++ b/src/Fantomas.Client.Tests/packages.lock.json @@ -10,9 +10,9 @@ }, "FSharp.Core": { "type": "Direct", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + "requested": "[9.0.100, )", + "resolved": "9.0.100", + "contentHash": "ye8yagHGsH08H2Twno5GRWkSbrMtxK/SWiHuPcF+3nODpW65/VJ8RO0aWxp8n9+KQbmahg90wAEL3TEXjF0r6A==" }, "G-Research.FSharp.Analyzers": { "type": "Direct", @@ -131,7 +131,7 @@ "fantomas.client": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "SemanticVersioning": "[2.0.2, )", "StreamJsonRpc": "[2.20.20, )" } diff --git a/src/Fantomas.Client/packages.lock.json b/src/Fantomas.Client/packages.lock.json index 8f534e435f..005444eb2b 100644 --- a/src/Fantomas.Client/packages.lock.json +++ b/src/Fantomas.Client/packages.lock.json @@ -16,9 +16,9 @@ }, "FSharp.Core": { "type": "Direct", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + "requested": "[9.0.100, )", + "resolved": "9.0.100", + "contentHash": "ye8yagHGsH08H2Twno5GRWkSbrMtxK/SWiHuPcF+3nODpW65/VJ8RO0aWxp8n9+KQbmahg90wAEL3TEXjF0r6A==" }, "G-Research.FSharp.Analyzers": { "type": "Direct", diff --git a/src/Fantomas.Core.Tests/packages.lock.json b/src/Fantomas.Core.Tests/packages.lock.json index 9d84872dfc..0c28e1b605 100644 --- a/src/Fantomas.Core.Tests/packages.lock.json +++ b/src/Fantomas.Core.Tests/packages.lock.json @@ -13,9 +13,9 @@ }, "FSharp.Core": { "type": "Direct", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + "requested": "[9.0.100, )", + "resolved": "9.0.100", + "contentHash": "ye8yagHGsH08H2Twno5GRWkSbrMtxK/SWiHuPcF+3nODpW65/VJ8RO0aWxp8n9+KQbmahg90wAEL3TEXjF0r6A==" }, "FsUnit": { "type": "Direct", @@ -132,14 +132,14 @@ "fantomas.core": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "Fantomas.FCS": "[1.0.0, )" } }, "fantomas.fcs": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "System.Collections.Immutable": "[8.0.0, )", "System.Diagnostics.DiagnosticSource": "[8.0.1, )", "System.Memory": "[4.6.0, )", diff --git a/src/Fantomas.Core/packages.lock.json b/src/Fantomas.Core/packages.lock.json index c69d664b1e..a6940a682c 100644 --- a/src/Fantomas.Core/packages.lock.json +++ b/src/Fantomas.Core/packages.lock.json @@ -16,9 +16,9 @@ }, "FSharp.Core": { "type": "Direct", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + "requested": "[9.0.100, )", + "resolved": "9.0.100", + "contentHash": "ye8yagHGsH08H2Twno5GRWkSbrMtxK/SWiHuPcF+3nODpW65/VJ8RO0aWxp8n9+KQbmahg90wAEL3TEXjF0r6A==" }, "G-Research.FSharp.Analyzers": { "type": "Direct", @@ -121,7 +121,7 @@ "fantomas.fcs": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "System.Collections.Immutable": "[8.0.0, )", "System.Diagnostics.DiagnosticSource": "[8.0.1, )", "System.Memory": "[4.6.0, )", diff --git a/src/Fantomas.FCS/packages.lock.json b/src/Fantomas.FCS/packages.lock.json index 74229b4673..464d42de8b 100644 --- a/src/Fantomas.FCS/packages.lock.json +++ b/src/Fantomas.FCS/packages.lock.json @@ -16,9 +16,9 @@ }, "FSharp.Core": { "type": "Direct", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + "requested": "[9.0.100, )", + "resolved": "9.0.100", + "contentHash": "ye8yagHGsH08H2Twno5GRWkSbrMtxK/SWiHuPcF+3nODpW65/VJ8RO0aWxp8n9+KQbmahg90wAEL3TEXjF0r6A==" }, "FsLexYacc": { "type": "Direct", diff --git a/src/Fantomas.Tests/packages.lock.json b/src/Fantomas.Tests/packages.lock.json index 1786c3260a..c3addd24ee 100644 --- a/src/Fantomas.Tests/packages.lock.json +++ b/src/Fantomas.Tests/packages.lock.json @@ -13,9 +13,9 @@ }, "FSharp.Core": { "type": "Direct", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + "requested": "[9.0.100, )", + "resolved": "9.0.100", + "contentHash": "ye8yagHGsH08H2Twno5GRWkSbrMtxK/SWiHuPcF+3nODpW65/VJ8RO0aWxp8n9+KQbmahg90wAEL3TEXjF0r6A==" }, "FsUnit": { "type": "Direct", @@ -207,7 +207,7 @@ "type": "Project", "dependencies": { "Argu": "[6.2.4, )", - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "Fantomas.Client": "[1.0.0, )", "Fantomas.Core": "[1.0.0, )", "Ignore": "[0.2.1, )", @@ -224,7 +224,7 @@ "fantomas.client": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "SemanticVersioning": "[2.0.2, )", "StreamJsonRpc": "[2.20.20, )" } @@ -232,14 +232,14 @@ "fantomas.core": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "Fantomas.FCS": "[1.0.0, )" } }, "fantomas.fcs": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "System.Collections.Immutable": "[8.0.0, )", "System.Diagnostics.DiagnosticSource": "[8.0.1, )", "System.Memory": "[4.6.0, )", diff --git a/src/Fantomas/packages.lock.json b/src/Fantomas/packages.lock.json index 7b8b3bebca..4b6ea5cff6 100644 --- a/src/Fantomas/packages.lock.json +++ b/src/Fantomas/packages.lock.json @@ -32,9 +32,9 @@ }, "FSharp.Core": { "type": "Direct", - "requested": "[8.0.100, )", - "resolved": "8.0.100", - "contentHash": "ZOVZ/o+jI3ormTZOa28Wh0tSRoyle1f7lKFcUN61sPiXI7eDZu8eSveFybgTeyIEyW0ujjp31cp7GOglDgsNEg==" + "requested": "[9.0.100, )", + "resolved": "9.0.100", + "contentHash": "ye8yagHGsH08H2Twno5GRWkSbrMtxK/SWiHuPcF+3nODpW65/VJ8RO0aWxp8n9+KQbmahg90wAEL3TEXjF0r6A==" }, "G-Research.FSharp.Analyzers": { "type": "Direct", @@ -272,7 +272,7 @@ "fantomas.client": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "SemanticVersioning": "[2.0.2, )", "StreamJsonRpc": "[2.20.20, )" } @@ -280,14 +280,14 @@ "fantomas.core": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "Fantomas.FCS": "[1.0.0, )" } }, "fantomas.fcs": { "type": "Project", "dependencies": { - "FSharp.Core": "[8.0.100, )", + "FSharp.Core": "[9.0.100, )", "System.Collections.Immutable": "[8.0.0, )", "System.Diagnostics.DiagnosticSource": "[8.0.1, )", "System.Memory": "[4.6.0, )", From 5122df1071febf1aaca3e58ff270a0bdc573736c Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 15:21:50 +0200 Subject: [PATCH 08/14] More agent context --- AGENTS.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c70a5d31b5..a4ff79a870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,10 @@ Fantomas is an F# code formatter that transforms F# source code into a standardi ```bash # Most common commands for LLMs -dotnet fsi build.fsx # Build and test -dotnet fsi build.fsx -p FormatChanged # Format changes +dotnet build # Quick build check (use during development) dotnet test --filter "test-name" # Run specific test +dotnet fsi build.fsx -p FormatChanged # Format changes +dotnet fsi build.fsx # Full build and test (use for final verification) dotnet fsi build.fsx -p FormatAll # Format all files ``` @@ -118,14 +119,17 @@ dotnet fsi build.fsx ### 3. Testing ```bash -# Run all tests -dotnet fsi build.fsx +# Quick build check during development +dotnet build # Run specific test dotnet test src/Fantomas.Core.Tests/ --filter "test-name" # Format changed files dotnet fsi build.fsx -p FormatChanged + +# Full build and test (use for final verification) +dotnet fsi build.fsx ``` ### 4. Build Commands @@ -171,6 +175,13 @@ dotnet fsi build.fsx -p EnsureRepoConfig - **WriterModel**: Track current state (column, indentation level) - **Config**: Formatting configuration options +#### Indentation System +- **Indentation Events**: `IndentBy`, `UnIndentBy`, `SetIndent`, `RestoreIndent` +- **Column Tracking**: `AtColumn` helps maintain alignment at specific positions +- **Critical Rule**: Indentation only takes effect after `WriteLine`/`WriteLineBecauseOfTrivia` events +- **Common Pattern**: `indent` → `sepNln` → content → `unindent` +- **Helper Functions**: `indentSepNlnUnindent`, `atCurrentColumn`, `atCurrentColumnIndent` + #### Trivia System - **ContentBefore/ContentAfter**: Attach comments and directives to nodes - **Directive**: Conditional compilation directives (#if, #endif) @@ -267,6 +278,62 @@ let ``descriptive test name, issue-number`` () = - Check WriterEvents to understand formatting decisions - Use MCP tools to test formatting in real-time +### Understanding Indentation + +Indentation in Fantomas is **deferred** - it only takes effect after newline events: + +```fsharp +// This pattern is very common in CodePrinter: +indent +> sepNln +> content +> unindent + +// Or using the helper: +indentSepNlnUnindent content +``` + +**Key Points:** +- `indent` adds an `IndentBy` event to the context +- The actual indentation only applies when `WriteLine`/`WriteLineBecauseOfTrivia` events are processed +- `WriterModel.update` in `Context.fs` handles this by setting `Indent = max m.Indent m.AtColumn` on newlines +- Always pair `indent` with `unindent` to avoid indentation drift +- Use `atCurrentColumn` and `atCurrentColumnIndent` for fixed column positioning + +**Example from CodePrinterHelperFunctionsTests.fs:** +```fsharp +let g = !-"first line" +> indent +> sepNln +> !-"second line" +> unindent +// Result: "first line\n second line" +``` + +### Understanding genNode and Trivia Processing + +The `genNode` function is crucial for understanding how indentation interacts with trivia: + +```fsharp +let genNode<'n when 'n :> Node> (n: 'n) (f: Context -> Context) = + enterNode n +> recordCursorNode f n +> leaveNode n + +let enterNode<'n when 'n :> Node> (n: 'n) = + col sepNone n.ContentBefore (genTrivia n) +``` + +**Critical Understanding:** +- `genNode` processes `ContentBefore` trivia first, then runs the function, then `ContentAfter` trivia +- `genTrivia` handles directives and comments, often emitting `sepNlnForTrivia` events +- **Trivia processing can override indentation**: When `genTrivia` processes directives, it may emit newline events that apply indentation before your intended content +- This is why `indentSepNlnUnindent` patterns can fail when nodes have trivia - the trivia emits newlines that consume the indentation + +**Common Issue Pattern:** +```fsharp +// This fails when node has ContentBefore trivia: +indentSepNlnUnindent (genIdentListNode node) +// Because genIdentListNode calls genNode, which processes trivia first +// The trivia emits newlines that apply indentation before the module name +``` + +**Solution Approaches:** +1. **Bypass genNode**: Write content directly without trivia processing +2. **Handle trivia separately**: Process trivia before applying indentation +3. **Use different indentation strategy**: Apply indentation at a different level in the tree + ## MCP Tools ### Fantomas Format Code Tool @@ -474,7 +541,7 @@ if not errors.IsEmpty then ```bash # 1. Setup git checkout -b fix-3188 -dotnet fsi build.fsx +dotnet build # 2. Write test # Add test to ModuleTests.fs @@ -484,7 +551,7 @@ dotnet fsi build.fsx # 4. Test and verify dotnet test src/Fantomas.Core.Tests/ --filter "3188" -dotnet fsi build.fsx +dotnet build # 5. Format changes dotnet fsi build.fsx -p FormatChanged From 701ebe48300c4605e0640d3d93e9ac804df7c754 Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 15:36:28 +0200 Subject: [PATCH 09/14] Fix 3188 --- src/Fantomas.Core.Tests/ModuleTests.fs | 24 ++++++++++++++++++++++++ src/Fantomas.Core/CodePrinter.fs | 19 +++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Fantomas.Core.Tests/ModuleTests.fs b/src/Fantomas.Core.Tests/ModuleTests.fs index 3e486d9c7e..9a72825487 100644 --- a/src/Fantomas.Core.Tests/ModuleTests.fs +++ b/src/Fantomas.Core.Tests/ModuleTests.fs @@ -1086,3 +1086,27 @@ namespace ``G-Research``.``FSharp X``.``Analyzers Y`` module StringAnalyzers = () """ + +[] +let ``hash directives around access modifier in module, 3188`` () = + formatSourceString + """ +[] +module + #if !MCP + internal + #endif + Fantomas.Core.CodeFormatterImpl +""" + config + |> prepend newline + |> should + equal + """ +[] +module +#if !MCP + internal +#endif + Fantomas.Core.CodeFormatterImpl +""" diff --git a/src/Fantomas.Core/CodePrinter.fs b/src/Fantomas.Core/CodePrinter.fs index 71b9ee65a5..edc7eb489e 100644 --- a/src/Fantomas.Core/CodePrinter.fs +++ b/src/Fantomas.Core/CodePrinter.fs @@ -4052,9 +4052,24 @@ let genModule (m: ModuleOrNamespaceNode) = +> genAttributes header.Attributes +> genMultipleTextsNode header.LeadingKeyword +> sepSpace - +> genAccessOpt header.Accessibility + +> optSingle + (fun (accessibility: SingleTextNode) -> + let hasTriviaBeforeAccess = accessibility.HasContentBefore + + onlyIf hasTriviaBeforeAccess indent + +> genSingleTextNode accessibility + +> sepSpace + +> onlyIf hasTriviaBeforeAccess unindent) + header.Accessibility +> onlyIf header.IsRecursive (sepSpace +> !-"rec" +> sepSpace) - +> optSingle genIdentListNode header.Name + +> optSingle + (fun (name: IdentListNode) -> + let hasTriviaBeforeName = name.HasContentBefore + + onlyIf hasTriviaBeforeName (indent +> indent) + +> genIdentListNode name + +> onlyIf hasTriviaBeforeName (unindent +> unindent)) + header.Name |> genNode header) +> newline) m.Header From 322ea4749ff09ddaa21f9cd1c0c0e4915e949272 Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 16:03:02 +0200 Subject: [PATCH 10/14] Add note about stackoverflow on Mac --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a4ff79a870..fdfee6afca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,9 @@ dotnet build # Run specific test dotnet test src/Fantomas.Core.Tests/ --filter "test-name" +# Run tests in release mode (recommended to avoid stack overflows on Mac) +dotnet test src/Fantomas.Core.Tests/ --filter "test-name" -c Release + # Format changed files dotnet fsi build.fsx -p FormatChanged @@ -501,6 +504,11 @@ if not errors.IsEmpty then - Check for syntax errors in your changes - Ensure all dependencies are restored +#### Stack Overflow on Mac +- Stack overflows can occur in debug mode on macOS +- Use `-c Release` flag when running tests: `dotnet test -c Release` +- This is a known issue with F# compilation in debug mode on macOS + ### Debugging Tips 1. **Use MCP Tools**: Test formatting with `mcp_Fantomas_format_code` From d4d7bad0cd1be0174d235bdccf20484ae77fbfc6 Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 16:08:14 +0200 Subject: [PATCH 11/14] Fix 3179 --- src/Fantomas.Core.Tests/AppTests.fs | 112 ++++++++++++++++++++++++++++ src/Fantomas.Core/CodePrinter.fs | 6 +- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/Fantomas.Core.Tests/AppTests.fs b/src/Fantomas.Core.Tests/AppTests.fs index 48889da97b..edd81bfacf 100644 --- a/src/Fantomas.Core.Tests/AppTests.fs +++ b/src/Fantomas.Core.Tests/AppTests.fs @@ -1178,3 +1178,115 @@ let ``don't indent function application arguments when function name is further b c)))))))))))))))))))))) """ + +[] +let ``type annotation spacing should not be adjusted for previous expression length, 3179`` () = + formatSourceString + """ +let x = + (someFunctionCall()) |> anotherOne |> anotherOne |> alsoAnotherOne + + Unchecked.defaultof<_> +""" + config + |> prepend newline + |> should + equal + """ +let x = + (someFunctionCall ()) |> anotherOne |> anotherOne |> alsoAnotherOne + + Unchecked.defaultof<_> +""" + +[] +let ``type annotation spacing should not be adjusted for previous expression length without blank line, 3179`` () = + formatSourceString + """ +let x = + (someFunctionCall()) |> anotherOne |> anotherOne |> alsoAnotherOne + Unchecked.defaultof<_> +""" + config + |> prepend newline + |> should + equal + """ +let x = + (someFunctionCall ()) |> anotherOne |> anotherOne |> alsoAnotherOne + Unchecked.defaultof<_> +""" + +[] +let ``type annotation spacing should not be adjusted for short previous expression, 3179`` () = + formatSourceString + """ +let x = + someFunction() + + Unchecked.defaultof<_> +""" + config + |> prepend newline + |> should + equal + """ +let x = + someFunction () + + Unchecked.defaultof<_> +""" + +[] +let ``type annotation spacing should not be adjusted for different type applications, 3179`` () = + formatSourceString + """ +let x = + (someFunctionCall()) |> anotherOne |> anotherOne |> alsoAnotherOne + + List.map +""" + config + |> prepend newline + |> should + equal + """ +let x = + (someFunctionCall ()) |> anotherOne |> anotherOne |> alsoAnotherOne + + List.map +""" + +[] +let ``type annotation spacing should not be adjusted for generic type applications, 3179`` () = + formatSourceString + """ +let x = + (someFunctionCall()) |> anotherOne |> anotherOne |> alsoAnotherOne + + Some<'T>.Create +""" + config + |> prepend newline + |> should + equal + """ +let x = + (someFunctionCall ()) |> anotherOne |> anotherOne |> alsoAnotherOne + + Some<'T>.Create +""" + +[] +let ``function application type parameters should still be aligned correctly, 3179`` () = + formatSourceString + """ +let x = someFunction (arg1, arg2) +""" + config + |> prepend newline + |> should + equal + """ +let x = someFunction (arg1, arg2) +""" diff --git a/src/Fantomas.Core/CodePrinter.fs b/src/Fantomas.Core/CodePrinter.fs index edc7eb489e..f95acb54ba 100644 --- a/src/Fantomas.Core/CodePrinter.fs +++ b/src/Fantomas.Core/CodePrinter.fs @@ -2084,7 +2084,11 @@ let genTypeApp (addAdditionalColumnOffset: bool) (node: ExprTypeAppNode) (ctx: C +> colGenericTypeParameters node.TypeParameters // we need to make sure each expression in the function application has offset at least greater than // See: https://github.com/fsprojects/fantomas/issues/1611 - +> addFixedSpaces startColumn + // However, for standalone type applications, we don't need this alignment + // Only add fixed spaces when this is called from a function application context (addAdditionalColumnOffset = true) + // AND when we're not in a standalone type application context + // For standalone type applications, we check if there's contentBefore trivia (newlines) which indicates standalone context + +> ifElse (addAdditionalColumnOffset && not node.HasContentBefore) (addFixedSpaces startColumn) sepNone +> genSingleTextNode node.GreaterThan) ctx From 860f3f1e44d515edd13390b40e0c30d4f1de0257 Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 3 Oct 2025 16:18:37 +0200 Subject: [PATCH 12/14] Fix some analyzer problems --- AGENTS.md | 4 ++++ src/Fantomas.Core/CodeFormatterImpl.fs | 2 +- src/Fantomas.Core/SyntaxOak.fs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fdfee6afca..66a8657c06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ dotnet test --filter "test-name" # Run specific test dotnet fsi build.fsx -p FormatChanged # Format changes dotnet fsi build.fsx # Full build and test (use for final verification) dotnet fsi build.fsx -p FormatAll # Format all files +dotnet fsi build.fsx -- -p Analyze # Run code analyzers ``` ## Project Structure @@ -149,6 +150,9 @@ dotnet fsi build.fsx -p FormatChanged # Setup git hooks dotnet fsi build.fsx -p EnsureRepoConfig + +# Run code analyzers +dotnet fsi build.fsx -- -p Analyze ``` ## Core Architecture diff --git a/src/Fantomas.Core/CodeFormatterImpl.fs b/src/Fantomas.Core/CodeFormatterImpl.fs index da01f60eb5..a8aee59a77 100644 --- a/src/Fantomas.Core/CodeFormatterImpl.fs +++ b/src/Fantomas.Core/CodeFormatterImpl.fs @@ -83,7 +83,7 @@ let formatAST MCPEvents.addEvent ( contextAfter.WriterEvents - |> Seq.map string + |> Seq.map string |> String.concat " , " |> MCPEvents.CollectedEventsAfterCodePrinter ) diff --git a/src/Fantomas.Core/SyntaxOak.fs b/src/Fantomas.Core/SyntaxOak.fs index b9bbfde087..b2a4ae2d6d 100644 --- a/src/Fantomas.Core/SyntaxOak.fs +++ b/src/Fantomas.Core/SyntaxOak.fs @@ -17,7 +17,7 @@ type TriviaNode(content: TriviaContent, range: range) = override this.ToString() = sprintf - $"%A{this.Content} ({this.Range.StartLine},{this.Range.StartColumn} - {this.Range.EndLine},{this.Range.EndColumn})" + $"%A{this.Content} (%d{this.Range.StartLine},%d{this.Range.StartColumn} - %d{this.Range.EndLine},%d{this.Range.EndColumn})" [] type Node = From 027a77d4867c0c38616d9d5ca7c87b8ccaa783ba Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 7 Nov 2025 14:34:53 +0100 Subject: [PATCH 13/14] Add untyped ast tool --- mcp/server.fsx | 37 ++++++++++++++++++++++ mcp/tools/ast.fsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 mcp/tools/ast.fsx diff --git a/mcp/server.fsx b/mcp/server.fsx index be4eb904dc..c13349ca08 100644 --- a/mcp/server.fsx +++ b/mcp/server.fsx @@ -23,6 +23,9 @@ let fantomasCoreFsproj = let formatScript = Path.Combine(repositoryRoot, "mcp", "tools", "format.fsx") |> Path.GetFullPath +let astScript = + Path.Combine(repositoryRoot, "mcp", "tools", "ast.fsx") |> Path.GetFullPath + let buildCodebase () = task { let! buildResult = @@ -89,6 +92,40 @@ If the build did not succeed, an the build error will be reported. return result.StandardOutput } + [] + member this.ParseAST(sourceCode: string, isSignature: bool) = + task { + match! buildCodebase () with + | Error(error) -> return error + | Ok() -> + let inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sourceCode)) + + let! result = + Cli + .Wrap("dotnet") + .WithArguments( + [| + yield "fsi" + yield astScript + if isSignature then + yield "--" + yield "--signature" + |] + ) + .WithStandardInputPipe(PipeSource.FromStream inputStream) + .ExecuteBufferedAsync() + .Task + + return result.StandardOutput + } + let builder = Host.CreateApplicationBuilder(Environment.GetCommandLineArgs()) builder.Logging.AddConsole(fun consoleLogOptions -> consoleLogOptions.LogToStandardErrorThreshold <- LogLevel.Trace) diff --git a/mcp/tools/ast.fsx b/mcp/tools/ast.fsx new file mode 100644 index 0000000000..9d53a49eb9 --- /dev/null +++ b/mcp/tools/ast.fsx @@ -0,0 +1,81 @@ +#r "nuget: Thoth.Json.System.Text.Json" +#r "../../artifacts/bin/Fantomas.FCS/debug/Fantomas.FCS.dll" +#r "../../artifacts/bin/Fantomas.Core/debug/Fantomas.Core.dll" + +open Fantomas.Core +open Fantomas.FCS.Text +open Thoth.Json.Core +open Thoth.Json.System.Text.Json + +let encodeRange (range: range) = + Encode.object [ + "startLine", Encode.int range.StartLine + "startColumn", Encode.int range.StartColumn + "endLine", Encode.int range.EndLine + "endColumn", Encode.int range.EndColumn + ] + +let encodeDiagnostic (diag: Fantomas.FCS.Parse.FSharpParserDiagnostic) = + Encode.object [ + "severity", Encode.string (string diag.Severity) + "subCategory", Encode.string diag.SubCategory + "range", Encode.losslessOption encodeRange diag.Range + "errorNumber", Encode.lossyOption Encode.int diag.ErrorNumber + "message", Encode.string diag.Message + ] + +let encodeParsedInput (ast: Fantomas.FCS.Syntax.ParsedInput, defines: string list) = + Encode.object [ + "defines", Encode.list (List.map Encode.string defines) + "ast", Encode.string (string ast) + ] + +async { + let input = stdin.ReadToEnd() + + let isSignature = + System.Environment.GetCommandLineArgs() |> Array.contains "--signature" + + let fileType = if isSignature then "signature" else "implementation" + + try + let! untypedTrees = CodeFormatter.ParseAsync(isSignature, input) + + let result = + Encode.object [ + "success", Encode.bool true + "fileType", Encode.string fileType + "untypedTrees", + Encode.array (Array.map encodeParsedInput untypedTrees) + "diagnostics", Encode.list [] // No errors if we got here + ] + |> Encode.toString 2 + + printfn $"Parsed {fileType} file successfully:\n---\n{result}\n---" + with + | ParseException(diagnostics) -> + let result = + Encode.object [ + "success", Encode.bool false + "fileType", Encode.string fileType + "error", Encode.string $"Fantomas.FCS was unable to parse {fileType} input" + "input", Encode.string input + "diagnostics", Encode.list (List.map encodeDiagnostic diagnostics) + ] + |> Encode.toString 2 + + printfn $"Parse exception raised:\n---\n{result}\n---" + | ex -> + let result = + Encode.object [ + "success", Encode.bool false + "fileType", Encode.string fileType + "error", Encode.string $"Unexpected exception during parsing of {fileType} input" + "exception", Encode.string (ex.ToString()) + ] + |> Encode.toString 2 + + printfn $"Unexpected exception:\n---\n{result}\n---" +} +|> Async.RunSynchronously + From 18d79ce4e18119a7f170220ae3e214ad054519a4 Mon Sep 17 00:00:00 2001 From: nojaf Date: Wed, 12 Nov 2025 13:55:16 +0100 Subject: [PATCH 14/14] Correct test filter --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 66a8657c06..5c49eabd6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -562,7 +562,7 @@ dotnet build # Modify CodePrinter.fs # 4. Test and verify -dotnet test src/Fantomas.Core.Tests/ --filter "3188" +dotnet test src/Fantomas.Core.Tests/ --filter "Name~3188" dotnet build # 5. Format changes