diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b15ab71e..a49abae05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Improved error message when conditional compilation directives produce invalid syntax for some define combinations. [#563](https://github.com/fsprojects/fantomas/issues/563) + ## [8.0.0-alpha-003] - 2026-03-03 ### Fixed diff --git a/docs/docs/end-users/ConditionalCompilationDirectives.md b/docs/docs/end-users/ConditionalCompilationDirectives.md new file mode 100644 index 000000000..140b034cd --- /dev/null +++ b/docs/docs/end-users/ConditionalCompilationDirectives.md @@ -0,0 +1,75 @@ +--- +category: End-users +categoryindex: 1 +index: 13 +--- +# Conditional Compilation Directives + +Fantomas supports formatting F# code that contains conditional compilation directives (`#if`, `#else`, `#endif`). +However, there is an important limitation to be aware of. + +## How Fantomas handles directives + +Fantomas needs to parse your code into an abstract syntax tree (AST) before it can format it. +The F# parser processes `#if` / `#else` / `#endif` directives at parse time, meaning it picks one branch based on which defines are active and ignores the other. + +To handle this, Fantomas: + +1. Parses your code without any defines to discover all conditional directives. +2. Determines every possible combination of defines. +3. Parses and formats the code once for each combination. +4. Merges the results back together. + +## The limitation: all define combinations must produce valid syntax + +Because Fantomas parses your code under **every** define combination, **each combination must result in a valid syntax tree**. + +For example, the following code **cannot** be formatted: + +```fsharp +module F = + let a: string = + #if FOO + "" + #endif + #if BAR + "a" + #endif + + let baz: unit = () +``` + +When neither `FOO` nor `BAR` is defined, the code becomes: + +```fsharp +module F = + let a: string = + + let baz: unit = () +``` + +This is not valid F# — `let a` has no body — so the parser raises an error and Fantomas cannot proceed. + +## How to fix it + +Make sure that every combination of defines still produces valid F# code. The most common fix is to add an `#else` branch: + +```fsharp +module F = + let a: string = + #if FOO + "" + #else + "a" + #endif + + let baz: unit = () +``` + +Now, regardless of whether `FOO` is defined, the parser always sees a complete `let` binding. + +## Using `.fantomasignore` + +If you cannot restructure the directives (e.g. because the code is generated or must match a particular pattern), you can exclude the file from formatting using a [`.fantomasignore`](https://fsprojects.github.io/fantomas/docs/end-users/IgnoreFiles.html) file. + + diff --git a/src/Fantomas.Core/CodeFormatterImpl.fs b/src/Fantomas.Core/CodeFormatterImpl.fs index a517ea42f..940acf571 100644 --- a/src/Fantomas.Core/CodeFormatterImpl.fs +++ b/src/Fantomas.Core/CodeFormatterImpl.fs @@ -33,22 +33,47 @@ let parse (isSignature: bool) (source: ISourceText) : Async<(ParsedInput * Defin | hashDirectives -> let defineCombinations = Defines.getDefineCombination hashDirectives - defineCombinations - |> List.map (fun defineCombination -> - async { - let untypedTree, diagnostics = - Fantomas.FCS.Parse.parseFile isSignature source defineCombination.Value - - let errors = - diagnostics - |> List.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error) - - if not errors.IsEmpty then - raise (ParseException diagnostics) - - return (untypedTree, defineCombination) - }) - |> Async.Parallel + async { + let! results = + defineCombinations + |> List.map (fun defineCombination -> + async { + let untypedTree, diagnostics = + Fantomas.FCS.Parse.parseFile isSignature source defineCombination.Value + + let errors = + diagnostics + |> List.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error) + + if errors.IsEmpty then + return Ok(untypedTree, defineCombination) + else + let defineNames = + if defineCombination.Value.IsEmpty then + "no defines" + else + defineCombination.Value |> String.concat ", " + + return Error defineNames + }) + |> Async.Parallel + + let failures = + results + |> Array.choose (function + | Error name -> Some name + | _ -> None) + |> Array.toList + + if not failures.IsEmpty then + raise (DefineParseException(failures)) + + return + results + |> Array.choose (function + | Ok result -> Some result + | _ -> None) + } /// Format an abstract syntax tree using given config let formatAST diff --git a/src/Fantomas.Core/FormatConfig.fs b/src/Fantomas.Core/FormatConfig.fs index 577c1a31a..b96dda285 100644 --- a/src/Fantomas.Core/FormatConfig.fs +++ b/src/Fantomas.Core/FormatConfig.fs @@ -4,11 +4,24 @@ open System open System.ComponentModel open Fantomas.FCS.Parse +/// Raised when the F# parser produces errors for source code without conditional directives. exception ParseException of diagnostics: FSharpParserDiagnostic list +/// Raised when Fantomas encounters a problem during formatting. type FormatException(msg: string) = inherit Exception(msg) +/// Raised when one or more conditional compilation define combinations produce invalid syntax trees. +type DefineParseException(combinations: string list) = + inherit + FormatException( + let joined = combinations |> String.concat ", " + $"Parsing failed for define combination(s): %s{joined}." + ) + + /// The define combinations that failed to parse. + member _.Combinations = combinations + type Num = int type MultilineFormatterType = diff --git a/src/Fantomas/Program.fs b/src/Fantomas/Program.fs index 9b3e4215e..9c844c8e8 100644 --- a/src/Fantomas/Program.fs +++ b/src/Fantomas/Program.fs @@ -382,7 +382,14 @@ Join our Discord community: https://discord.gg/Cpq9vf8BJH match verbosity with | VerbosityLevel.Normal -> match exn with - | :? ParseException -> "Could not parse file." + | :? ParseException -> "Could not parse the file." + | :? DefineParseException as dpe -> + let combinations = + dpe.Combinations + |> List.map (fun c -> if c = "no defines" then "no defines" else $"[%s{c}]") + |> String.concat ", " + + $"When Fantomas encounters #if directives in a file, it tries to format all possible combinations of defines and will merge all different versions back into one.\nFor %s{combinations}, however, we were not able to parse the file.\nWhile you may not use this combination in your project, Fantomas requires it to produce valid code.\nConsider fixing the code or ignoring this file.\nFor more information see: https://fsprojects.github.io/fantomas/docs/end-users/ConditionalCompilationDirectives.html" | :? FormatException as fe -> fe.Message | _ -> "" | VerbosityLevel.Detailed -> $"%A{exn}"