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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ dotnet fsi build.fsx
# Just lint and test
dotnet fsi build.fsx -- -p Verify

# Run analyzers (G-Research.FSharp.Analyzers, Ionide.Analyzers)
# Run analyzers (G-Research.FSharp.Analyzers, Ionide.Analyzers) over the whole solution.
# This is what CI does, and it takes minutes.
dotnet msbuild /t:AnalyzeSolution

# The same analyzers over the files the working tree touched, which takes seconds.
# Use this while working; it cannot see a finding your change causes in a file you did
# not edit, so run the full one above before opening a pull request.
dotnet fsi build.fsx -- -p AnalyzeChanged
```

Individual steps:
Expand Down
29 changes: 29 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## [23.0.0-alpha.8] - 2026-09-18

### Added
* Tool tips on F# snippets are syntax highlighted. The compiler hands a signature over as runs it has already classified, and `ToolTipReader` was flattening them into one string, so `val printfn: format: Printf.TextWriterFormat<'T> -> 'T` rendered as undifferentiated text. The runs keep their classification now and a tip is colored by the same `--code-*` rules as the snippet it describes, so a keyword, a type, a function and a type parameter look the same whether you read them in the code or in the tip. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* A signature too wide for its tool tip is laid out over several lines instead of wrapping wherever the line runs out. It breaks after the name, after each parameter and before the result, the parameters indented under the name and the result under them, the way a formatter would write it. The compiler's own parser decides where those points are, so the breaks land on syntax rather than a guess, and a line that fits, or that is not a signature, is left as it was. Nearly every line this touched was a member of a type: one with six optional parameters ran to 600 characters. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

### Changed
* A tool tip opens with the doc comment and the signature follows it, where the signature used to come first. The doc text is shown in the color a comment has in a snippet. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* Code snippets are no longer laid out with a table. A snippet is a `<div class="fsdocs-snippet">` holding one `<pre class="fsdocs-snippet-lines">` for the line numbers and one `<pre class="fssnip">` for the code, placed side by side with CSS grid. Markdown code blocks, notebook output and non-F# snippets use the same wrapper, so one selector covers every code block on the page. A stylesheet that targets `table.pre`, `td.lines`, `td.snippet`, or the `.code-block-wrapper` the copy button used to insert, needs updating, and a script that looks for a snippet, such as the Mermaid recipe in the documentation, should look for `.fsdocs-snippet` instead of `table.pre`. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* Line numbers lost the `: ` suffix and sit in a column marked `aria-hidden`, so a screen reader reads the code and not the numbering. The code column scrolls on its own, so the line numbers stay in view while a wide snippet scrolls. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* The default stylesheet ships a new syntax palette built from GitHub's light and dark hues. The previous one had grown a separate hue per token kind, eight of them teal or purple, on top of a dark green default text color in light mode. Tokens are grouped into roles instead: keywords, types, the modules and namespaces that hold them, callables, values, strings, and one mute for comments, fsi output, inactive code and line numbers. Modules take the green GitHub reserves for tags, so `List.map String.trim` reads as container then function rather than one orange run. Identifiers, operators and punctuation read as plain text. Every color is a `--code-*` custom property, so a site can restyle the whole scheme without touching a selector. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* `ToolTipSpan` gained a `Token of kind: TokenKind * body: string` case for a classified run of tool tip text, and `ToolTipReader.stripParameterAttributes` now takes and returns those runs instead of a string. Code that matches on `ToolTipSpan` has to handle the new case. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

### Fixed
* A type abbreviation in a tool tip was colored as an identifier rather than as a type, so `int` and `bool` were orange while `string`, `option`, `list`, `array` and `unit` were plain, in the same position of the same signature. The compiler tags an abbreviation apart from the type it stands for, and the tag was being grouped with parameters and locals. The `System.` qualifier of a fully written name takes the module color instead of plain for the same reason. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* A tool tip lost every line break in the signature it was showing. The compiler does not hand the break over in a tag of its own, it arrives inside a `Text` or `Space` run with the indentation of the next line attached, and only the `Space` case was split on. A tip is flowing text, so the browser collapsed the rest and `type List<'T>` ran its cases, interfaces and members together on one line. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* A doc comment reached the tool tip as the raw XML it was written as, so `<summary>` and `<param>` tags showed up in the tip. The comment is read as XML now and a `<see cref="..."/>` keeps the name it points at, rather than leaving a hole in the sentence it sits in. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* Several syntax token classes had no matching rule in the default stylesheet and were rendered in the plain code color: union cases (`uc`), F# preprocessor directives (`pp`), escaped characters (`esc`), enumeration cases (`en`), mutable bindings (`mv`), and identifiers in every non-F# language (`i`), which is the bulk of a C#, JavaScript, TypeScript or HTML snippet. The stylesheet was matching `u`, `prep`, `e` and `v`, names the formatters stopped emitting. Compiler errors (`cerr`) and omitted code (`omitted`) are styled for the first time. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

* A code block rendered from an `OtherBlock` opened its wrapper element without ever closing it, leaving unbalanced HTML on the page whenever code snippet wrapping was on. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)

## [23.0.0-alpha.7] - 2026-09-15

### Changed
Expand Down
252 changes: 251 additions & 1 deletion build.fsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,233 @@ let buildStage =
run $"dotnet build %s{solutionFile} --configuration %s{configuration} -tl"
}

// --------------------------------------------------------------------------------------
// Analyzers
//
// `dotnet msbuild /t:AnalyzeSolution` is the full run, the one CI does, and it takes minutes.
//
// The `AnalyzeChanged` pipeline below runs the same analyzers over the files the working tree
// touched, which takes seconds. It cannot see a finding your change causes in a file you did not
// edit, so the full run is still the one to do before opening a pull request.
// --------------------------------------------------------------------------------------

/// Where the analyzer packages were restored to. `dotnet restore` writes a `Pkg*` property per
/// package that asks for one, so no version is repeated here.
let private analyzerPaths (ctx: Internal.StageContext) =
async {
let properties = [ "PkgG-Research_FSharp_Analyzers"; "PkgIonide_Analyzers" ]

let flags =
properties
|> List.map (fun property -> $"--getProperty:%s{property}")
|> String.concat " "

let! result =
ctx.RunCommandCaptureAll(
$"dotnet msbuild src/FSharp.Formatting/FSharp.Formatting.fsproj %s{flags}",
workingDir = root,
disablePrintCommand = true,
disablePrintOutput = true
)

if result.ExitCode <> 0 then
failwith $"Could not read the analyzer package paths.\n%s{result.StandardError}"

use json = Text.Json.JsonDocument.Parse result.StandardOutput
let values = json.RootElement.GetProperty "Properties"

return
properties
|> List.map (fun property ->
let path = values.GetProperty(property).GetString()

if String.IsNullOrEmpty path then
failwith $"%s{property} is empty, restore the solution first."

path @@ "analyzers/dotnet/fs")
}

/// The same projects Directory.Solution.targets analyzes.
let private analyzerProjects =
!!(root @@ "src/**/*.fsproj")
|> Seq.map (fun project -> project.Replace('\\', '/'))
|> Seq.sort
|> List.ofSeq

type private AnalysisTarget =
/// An empty file list asks for every file of the project.
| Project of project: string * files: string list
| Script of file: string

/// Every project that owns a changed file, scoped to the changed files of its own, plus the build
/// script when that changed. A changed project file asks for the whole project.
let private targetsFor (files: string list) =
let endsWith extensions (path: string) =
extensions
|> List.exists (fun (extension: string) -> path.EndsWith(extension, StringComparison.Ordinal))

// `--include-files` only matches an absolute path, and silently reports a clean run otherwise.
let absolute (file: string) = (root @@ file).Replace('\\', '/')

let keep extensions =
List.choose
(fun file ->
if endsWith extensions file then
Some(absolute file)
else
None)
files

let sources = keep [ ".fs"; ".fsi" ]
let projectFiles = keep [ ".fsproj" ]

[
for project in analyzerProjects do
let folder = project.Substring(0, project.LastIndexOf '/' + 1)

let owns (file: string) =
file.StartsWith(folder, StringComparison.Ordinal)

if List.exists owns projectFiles then
yield Project(project, [])
else
match List.filter owns sources with
| [] -> ()
| owned -> yield Project(project, owned)

if List.contains "build.fsx" files then
yield Script(absolute "build.fsx")
]

/// The changed files git reports, relative to the repository root, without the deleted ones.
let private changedFiles (ctx: Internal.StageContext) =
async {
let! result =
ctx.RunCommandCaptureAll(
"git status --porcelain --untracked-files=all",
workingDir = root,
disablePrintCommand = true,
disablePrintOutput = true
)

if result.ExitCode <> 0 then
failwith $"Could not read the git status.\n%s{result.StandardError}"

return
result.StandardOutput.Split('\n')
|> Array.choose (fun (line: string) ->
let line = line.TrimEnd('\r')

if line.Length < 4 || line[0] = 'D' || line[1] = 'D' then
None
else
let path = line.Substring 3

let path =
match path.IndexOf(" -> ", StringComparison.Ordinal) with
| -1 -> path
| arrow -> path.Substring(arrow + 4)

Some(path.Trim('"').Replace('\\', '/')))
|> List.ofArray
}

/// One process per target, several at a time, each printing its output in one piece as it
/// finishes. No SARIF is written: a report of the files you touched is not one of the solution.
let private runAnalyzers (ctx: Internal.StageContext) (targets: AnalysisTarget list) =
async {
let! analyzers = analyzerPaths ctx

let nameOf target =
match target with
| Script file -> Path.GetFileName file
| Project(project, _) -> Path.GetFileNameWithoutExtension project

printfn $"""Analyzing: %s{targets |> List.map nameOf |> String.concat ", "}"""

let analyze target =
async {
let started = DateTime.UtcNow

let arguments =
[
for analyzer in analyzers do
"--analyzers-path"
analyzer

"--exclude-analyzers"
"PartialAppAnalyzer"
"ReturnStructPartialActivePatternAnalyzer"

// These report at Info by default, which never fails a run.
"--treat-as-error"
"IONIDE-002"
"IONIDE-010"
"IONIDE-012"

"-c"
configuration
"--code-root"
root

match target with
| Script file ->
// The scripts NuGet generates for a `#r "nuget: ..."` are not ours.
"--exclude-files"
"**/.packagemanagement/**"
"--script"
file
| Project(project, files) ->
match files with
| [] -> ()
| files ->
"--include-files"
yield! files

"--project"
project
]

let command =
arguments |> List.map (fun argument -> $"\"%s{argument}\"") |> String.concat " "

let! result =
ctx.RunCommandCaptureAll(
$"dotnet fsharp-analyzers %s{command}",
workingDir = root,
disablePrintCommand = true,
disablePrintOutput = true
)

let elapsed = DateTime.UtcNow - started

let scope =
match target with
| Script _
| Project(_, []) -> ""
| Project(_, files) -> $" (%i{files.Length} files)"

let outcome =
if result.ExitCode = 0 then
"clean"
else
$"exit code %i{result.ExitCode}"

printfn $"\n=== %s{nameOf target}%s{scope}: %s{outcome} in %.1f{elapsed.TotalSeconds}s"
printf "%s" result.StandardOutput
eprintf "%s" result.StandardError

return result.ExitCode
}

// Every process type checks a whole project, so a handful at a time is what keeps the
// machine busy without the runs starving each other of memory.
let! exitCodes =
Async.Parallel(List.map analyze targets, max 2 (Environment.ProcessorCount / 2))

return Array.fold max 0 exitCodes
}

let packStage =
stage "NuGet" {
run $"dotnet pack %s{solutionFile} --output \"%s{artifactsDir}\" --configuration %s{configuration} -tl"
Expand Down Expand Up @@ -117,6 +344,29 @@ pipeline "Verify" {
runIfOnlySpecified true
}

// The analyzers, over the files the working tree touched, which is seconds rather than minutes.
// `./build.fsx -p AnalyzeChanged`.
pipeline "AnalyzeChanged" {
workingDir root
stage "RestoreTools" { run "dotnet tool restore" }
stage "Restore" { run $"dotnet restore %s{solutionFile} -tl" }

stage "Analyze" {
run (fun ctx ->
async {
let! files = changedFiles ctx

match targetsFor files with
| [] ->
Console.WriteLine "No changed file is analyzed."
return 0
| targets -> return! runAnalyzers ctx targets
})
}

runIfOnlySpecified true
}

// Start the documentation site in watch mode with the locally built fsdocs tool.
// Runs until interrupted (Ctrl+C); the site is served on http://localhost:8901.
// Every argument after the pipeline name is passed on to `fsdocs watch`, for example
Expand All @@ -130,7 +380,7 @@ pipeline "Docs" {
|> Array.skip 1
|> String.concat " "

$"dotnet run --project src/fsdocs-tool -- watch %s{extraArgs}")
$"dotnet watch --project src/fsdocs-tool -- watch %s{extraArgs}")
}

runIfOnlySpecified true
Expand Down
6 changes: 3 additions & 3 deletions docs/_body.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
// as a syntax-highlighted code block. Promote those blocks to
// <div class="mermaid"> elements so one plain fence works in both places.
for (const code of document.querySelectorAll('code[lang="mermaid"]')) {
// fsdocs wraps the snippet in <table class="pre"><tr><td><pre><code>...;
// replace the outermost wrapper so no table scaffolding is left around
// fsdocs wraps the snippet in <div class="fsdocs-snippet"><pre><code>...;
// replace the outermost wrapper so no snippet scaffolding is left around
// the diagram.
const snippet = code.closest('table.pre') ?? code.closest('pre');
const snippet = code.closest('.fsdocs-snippet') ?? code.closest('pre');
if (!snippet) continue;
const diagram = document.createElement('div');
diagram.className = 'mermaid';
Expand Down
28 changes: 12 additions & 16 deletions docs/codeformat.fsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,27 +138,23 @@ If the input contains multiple snippets separated using the `//[snippet:...]` co
*)

(**
<table class="pre"><tr><td class="lines"><pre class="fssnip">
<span class="l">1: </span>
<span class="l">2: </span>
<span class="l">3: </span>
<span class="l">4: </span>
<span class="l">5: </span>
<span class="l">6: </span>
<span class="l">7: </span>
</pre>
</td>
<td class="snippet"><pre class="fssnip"><span class="c">// [snippet: First sample]</span>
<span class="i">printf</span> <span class="s">"The answer is: %A"</span> <span class="n">42</span>
<div class="fsdocs-snippet">
<pre class="fsdocs-snippet-lines" aria-hidden="true"><span class="l">1</span>
<span class="l">2</span>
<span class="l">3</span>
<span class="l">4</span>
<span class="l">5</span>
<span class="l">6</span>
<span class="l">7</span>
</pre><pre class="fssnip"><span class="c">// [snippet: First sample]</span>
<span class="id">printf</span> <span class="s">"The answer is: %A"</span> <span class="n">42</span>
<span class="c">// [/snippet]</span>

<span class="c">// [snippet: Second sample]</span>
<span class="i">printf</span> <span class="s">"Hello world!"</span>
<span class="id">printf</span> <span class="s">"Hello world!"</span>
<span class="c">// [/snippet]</span>
</pre>
</td>
</tr>
</table>
</div>
*)

(**
Expand Down
Loading
Loading