From fe463b825d7b21c9751ee28193396acbbf18b190 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:39:25 +0000 Subject: [PATCH] perf: avoid O(n^2) re-scan when splitting Markdown pipe-table rows pipeTableFindSplits recomputed List.length over the full remaining line and the post-delimiter remainder on every recursive call, to compute the chunk size for the current cell. For a row with many delimiters (cells), this makes parsing quadratic in the row length. Track the number of consumed characters incrementally while scanning instead, so each character is counted exactly once. No behavior change: same delimiter/escape/code/math handling, same output for all existing table tests. Added a new test exercising an escaped pipe inside a cell to cover the escape-handling branch of the scan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 5 +++ .../MarkdownTableParser.fs | 34 ++++++++++++------- tests/FSharp.Markdown.Tests/Markdown.fs | 29 ++++++++++++++++ 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index abac577c5..e6a2f3980 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,10 @@ # Changelog +## [Unreleased] + +### Changed +* Avoid recomputing `List.length` on the shrinking remainder for every scanned character when splitting a Markdown pipe-table row into cells (`pipeTableFindSplits` in `MarkdownTableParser.fs`). The scan now tracks the number of consumed characters incrementally, turning an O(n²) split into an O(n) one for rows with many delimiters, with no behavior change. + ## [23.0.0-alpha.2] - 2026-09-10 ### Fixed diff --git a/src/FSharp.Formatting.Markdown/MarkdownTableParser.fs b/src/FSharp.Formatting.Markdown/MarkdownTableParser.fs index 0ea689154..c0a9c5d50 100644 --- a/src/FSharp.Formatting.Markdown/MarkdownTableParser.fs +++ b/src/FSharp.Formatting.Markdown/MarkdownTableParser.fs @@ -20,26 +20,34 @@ let rec pipeTableFindSplits (delim: char array) (line: char list) = let cLstToStr (x: char list) = x |> Array.ofList |> System.String.Concat + // Scans the line and, on success, returns the number of characters consumed up to and + // including the found delimiter, together with the remaining list. Tracking the consumed + // count incrementally (rather than recomputing `List.length` over the shrinking remainder + // on every character) keeps this scan linear instead of quadratic in the row length. let rec ptfs delim line = match line with - | DelimitedLatexDisplayMath [ '$'; '$' ] (_body, rest) -> ptfs delim rest - | DelimitedLatexInlineMath [ '$' ] (_body, rest) -> ptfs delim rest - | List.DelimitedWith [ '`'; ' ' ] [ ' '; '`' ] (_body, rest, _s, _e) -> ptfs delim rest - | List.DelimitedNTimes '`' (_body, rest, _s, _e) -> ptfs delim rest - | x :: rest when Array.exists ((=) x) delim -> Some rest - | '\\' :: _ :: rest - | _ :: rest -> ptfs delim rest + | DelimitedLatexDisplayMath [ '$'; '$' ] (_body, rest) + | DelimitedLatexInlineMath [ '$' ] (_body, rest) + | List.DelimitedWith [ '`'; ' ' ] [ ' '; '`' ] (_body, rest, _, _) + | List.DelimitedNTimes '`' (_body, rest, _, _) -> + let consumedHere = List.length line - List.length rest + + ptfs delim rest + |> Option.map (fun (count, remainder) -> (count + consumedHere, remainder)) + | x :: rest when Array.exists ((=) x) delim -> Some(1, rest) + | '\\' :: _ :: rest -> ptfs delim rest |> Option.map (fun (count, remainder) -> (count + 2, remainder)) + | _ :: rest -> ptfs delim rest |> Option.map (fun (count, remainder) -> (count + 1, remainder)) | [] -> None - let rest = ptfs delim line + let result = ptfs delim line - match rest with + match result with | None -> [ cLstToStr line ] - | Some _x when List.isEmpty line -> [ "" ] - | Some x -> - let chunkSize = List.length line - List.length x - 1 + | Some _ when List.isEmpty line -> [ "" ] + | Some(count, x) -> + let chunkSize = count - 1 - cLstToStr (Seq.take chunkSize line |> Seq.toList) :: pipeTableFindSplits delim x + cLstToStr (List.truncate chunkSize line) :: pipeTableFindSplits delim x diff --git a/tests/FSharp.Markdown.Tests/Markdown.fs b/tests/FSharp.Markdown.Tests/Markdown.fs index bc25ea0ef..2d7ead07f 100644 --- a/tests/FSharp.Markdown.Tests/Markdown.fs +++ b/tests/FSharp.Markdown.Tests/Markdown.fs @@ -818,6 +818,35 @@ let ``Transform tables with delimiters in code or math correctly`` () = Markdown.ToHtml doc |> shouldEqualNoWhiteSpace expected +[] +let ``Transform tables with escaped pipe characters correctly`` () = + let doc = + """| a | b | +|---|---| +| 1\|2 | 3 | +""" + + let expected = + """ + + + + + + + + + + + + +

a

b

1\|2

3

+ +""" + |> properNewLines + + Markdown.ToHtml doc |> shouldEqualNoWhiteSpace expected + [] let ``Parse empty blockquote followed by content`` () = let doc =