Repository navigation
[markdown] Index footnote definitions and labels on Document for O(1) lookup - #2760
Conversation
PR HealthBreaking changes ✔️
This check can be disabled by tagging the PR with
Coverage
|
| File | Coverage |
|---|---|
| pkgs/markdown/lib/markdown.dart | 💔 Not covered |
| pkgs/markdown/lib/src/document.dart | 💚 96 % ⬆️ 1 % |
| pkgs/markdown/lib/src/inline_syntaxes/footnote_ref_syntax.dart | 💚 100 % |
This check for test coverage is informational (issues shown here will not fail the PR).
This check can be disabled by tagging the PR with skip-coverage-check.
License Headers ✔️
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
| Files |
|---|
| no missing headers |
All source files should start with a license header.
Unrelated files missing license headers
| Files |
|---|
| pkgs/html/test/differential_test.dart |
| pkgs/html/test/dom_parsing_test.dart |
| pkgs/html/test/mxss_test.dart |
| pkgs/html/test/noah_ark_clause_limit_test.dart |
This check can be disabled by tagging the PR with skip-license-check.
API leaks ✔️
The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.
| Package | Leaked API symbol | Leaking sources |
|---|
This check can be disabled by tagging the PR with skip-leaking-check.
Unused Dependencies ✔️
| Package | Status |
|---|---|
| markdown | ✔️ All dependencies utilized correctly. |
For details on how to fix these, see dependency_validator.
This check can be disabled by tagging the PR with skip-unused-dependencies-check.
There was a problem hiding this comment.
Code Review
This pull request optimizes footnote reference lookups from O(N) to O(1) by caching footnote keys and label ordinals in the Document class, and adds tests for reusing documents and handling case-insensitive duplicate footnote definitions. The review feedback suggests optimizing the findFootnoteLabel implementation to avoid list allocations by iterating forward, adding proper DartDoc documentation to the new extension, and simplifying the null-checking logic in footnote_ref_syntax.dart.
| extension DocumentFootnotes on Document { | ||
| String? findFootnoteLabel(String key) => (_footnoteKeyByLower ??= { | ||
| for (final k in footnoteReferences.keys.toList().reversed) | ||
| k.toLowerCase(): k, | ||
| })[key]; | ||
|
|
||
| int footnoteLabelOrdinal(String key) => | ||
| _footnoteLabelIndex.putIfAbsent(key, () { | ||
| final index = footnoteLabels.length; | ||
| footnoteLabels.add(key); | ||
| return index; | ||
| }); | ||
| } |
There was a problem hiding this comment.
The current implementation of findFootnoteLabel allocates a temporary list of keys and reverses it (footnoteReferences.keys.toList().reversed) to ensure that the first duplicate definition wins. We can achieve the same result more efficiently without any list allocations by iterating forward and using map[lower] ??= k. Additionally, adding documentation to this extension and its methods improves maintainability. Note that DartDoc comments should describe what the function does on the first line, rather than starting with 'Returns'.
/// Internal extension to provide O(1) footnote lookups on Document.
extension DocumentFootnotes on Document {
/// Finds the case-sensitive footnote label for the given lowercase key.
///
/// This is null if no such footnote reference exists.
String? findFootnoteLabel(String key) {
if (_footnoteKeyByLower == null) {
final map = <String, String>{};
for (final k in footnoteReferences.keys) {
map[k.toLowerCase()] ??= k;
}
_footnoteKeyByLower = map;
}
return _footnoteKeyByLower![key];
}
/// Obtains the 0-based ordinal index for the given footnote key.
int footnoteLabelOrdinal(String key) =>
_footnoteLabelIndex.putIfAbsent(key, () {
final index = footnoteLabels.length;
footnoteLabels.add(key);
return index;
});
}References
- At least all public members should have documentation, answering the why. (link)
- DartDoc comments should not start with "Returns" if it's not the first line of the DartDoc. The first line should describe what the function does, and subsequent lines can describe the return value.
| // `label` is what footnoteReferences stored, it is case sensitive. | ||
| final label = refs.keys.firstWhere( | ||
| (k) => k.toLowerCase() == key, | ||
| orElse: () => '', | ||
| ); | ||
| final label = parser.document.findFootnoteLabel(key); | ||
| // `count != null` means footnote was valid. | ||
| var count = refs[label]; | ||
| var count = label == null ? null : refs[label]; | ||
| // And then check if footnote was matched. | ||
| if (count == null) { | ||
| if (label == null || count == null) { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
The null checks and ternary operators here can be simplified. Since label can be null, checking label == null first allows us to safely look up refs[label] and perform a single check on count without redundant conditions.
// label is what footnoteReferences stored, it is case sensitive.
final label = parser.document.findFootnoteLabel(key);
if (label == null) {
return null;
}
var count = refs[label];
if (count == null) {
return null;
}12ff205 to
9773032
Compare
… lookup
Cache a case-folded footnote definition key map (`_footnoteKeyByLower`) and a
0-based footnote label index map (`_footnoteLabelIndex`) on `Document`, exposed
to `FootnoteRefSyntax` via a package-private `DocumentFootnotes` extension (with
`lib/markdown.dart` exporting `show Document, LinkReference` from
`src/document.dart`).
----
### Why this change is necessary
Even after fast-rejecting empty `footnoteReferences` and non-`[^...]` brackets,
`FootnoteRefSyntax.tryCreateFootnoteLink` still performed two linear O(D) scans
whenever a document contained D footnote definitions (`[^1]: a`, ..., `[^D]: a`)
and R footnote-shaped references (`[^...]`):
1. **Linear scan of `footnoteReferences.keys` (`O(D * R) = O(n^2)`)**:
Because `Document.footnoteReferences` stores case-sensitive keys (used when
rendering `href="#fn-$id"`), `FootnoteRefSyntax` looked up each lowercase
`key` via:
```dart
final label = refs.keys.firstWhere(
(k) => k.toLowerCase() == key,
orElse: () => '',
);
```
On a 64 KB input with D = 4,000 footnote definitions followed by R = 8,000
footnote references (such as `[^x]` * 8,000), `firstWhere` linearly scanned
all 4,000 keys and called `k.toLowerCase()` 3.2 * 10^7 times.
2. **Linear scan of `footnoteLabels` (`O(D * R) = O(n^2)`)**:
When a footnote reference matched, `var pos = labels.indexOf(key);` linearly
scanned `document.footnoteLabels` on every reference to find or assign its
1-based footnote number.
### How this change works
1. `Document` holds two private fields:
- `Map<String, String>? _footnoteKeyByLower;`: lazily built once on the first
footnote lookup after block parsing has populated `footnoteReferences`.
Iterating `footnoteReferences.keys.toList().reversed` ensures that if two
definitions share the same lowercase key, the earliest definition wins—
matching `refs.keys.firstWhere((k) => k.toLowerCase() == key)` semantics.
- `final _footnoteLabelIndex = <String, int>{};`: maps each referenced
lowercase `key` to its 0-based index in `footnoteLabels`, appending to
`footnoteLabels` via `putIfAbsent` when `key` is referenced for the first
time.
2. `DocumentFootnotes` exposes `findFootnoteLabel(String key)` and
`footnoteLabelOrdinal(String key)` inside `lib/src/`, while
`export 'src/document.dart' show Document, LinkReference;` in
`lib/markdown.dart` keeps `DocumentFootnotes` package-private so the public
API of `package:markdown` is unchanged.
Iterate `footnoteReferences.keys` forward and keep the first label per lower-cased key with `??=` instead of reversing a copy of the keys, document the `DocumentFootnotes` extension, and return early when no definition matches.
9773032 to
0e850f2
Compare
Revisions updated by `dart tools/rev_sdk_deps.dart`. core (https://github.com/dart-lang/core/compare/d7bd45c..24e09c4): 24e09c4c Sun Nov 1 21:12:19 2026 +0000 dependabot[bot] Bump the github-actions group with 4 updates (dart-lang/core#1037) 87ac1b4c Fri Oct 30 16:02:43 2026 +0100 Michael Thomsen [os_detect] Deprecate package:os_detect in favor of package:platform (dart-lang/core#1003) 8debf335 Fri Oct 30 01:57:36 2026 +0530 Sahaja Pallapothula Clarify Characters equality and Unicode normalization (dart-lang/core#1031) ecosystem (https://github.com/dart-lang/ecosystem/compare/24b9ba8..f8faabf): f8faabf Sun Nov 1 18:16:20 2026 +0000 dependabot[bot] Bump the github-actions group with 2 updates (dart-lang/ecosystem#481) 27c2fc8 Sat Oct 31 12:36:53 2026 +0100 Moritz Fix publish workflow pins, add tag_prefix to health, and update docs (dart-lang/ecosystem#480) 05641a9 Sat Oct 31 09:56:42 2026 +0100 Moritz Restore deprecated publish.yaml workflow and firehose CLI entrypoint (dart-lang/ecosystem#479) 78abf72 Fri Oct 30 08:24:07 2026 -0700 Kevin Moore fix(repo_manage): use pub_semver for changelog -wip transitions (dart-lang/ecosystem#475) 0bfa654 Fri Oct 30 09:33:57 2026 +0100 Moritz Move publish check to health workflow and use individual package publishing workflows from `setup-dart` (dart-lang/ecosystem#454) adf7979 Thu Oct 29 23:46:49 2026 -0700 Kevin Moore test(firehose): use hasLength, containsPair, and isFalse (dart-lang/ecosystem#477) http (https://github.com/dart-lang/http/compare/c76dfb3..8c0d66a): 8c0d66a Fri Oct 30 09:54:41 2026 -0700 Brian Quinlan chore(cupertino_http): check ffigen generation (dart-lang/http#2044) c8737d1 Fri Oct 30 09:11:46 2026 +0100 Moritz chore(http): run Firefox tests with -headless instead of xvfb-run (dart-lang/http#2033) 05d0b18 Fri Oct 30 07:08:19 2026 +0100 Moritz fix(http2): make StreamException a StreamTransportException and forward ALPN errors to onError (dart-lang/http#2009) i18n (https://github.com/dart-lang/i18n/compare/4c7b4ad..7475ce6): 7475ce64 Sat Oct 31 02:49:28 2026 +0100 Moritz [intl4x] Fix NumberFormat.percent ignoring digits (dart-lang/i18n#1115) shelf (https://github.com/dart-lang/shelf/compare/62d3e4e..9946de5): 9946de5 Sun Nov 1 12:27:59 2026 +0000 dependabot[bot] build(deps): bump the github-actions group with 3 updates (dart-lang/shelf#554) test (https://github.com/dart-lang/test/compare/6d74720..2a9e017): 2a9e0175 Sun Nov 1 03:47:24 2026 +0000 dependabot[bot] Bump the github-actions group with 6 updates (dart-lang/test#2813) 55cd5cd5 Fri Oct 30 13:43:47 2026 -0700 Nate Bosch Migrate publishing workflows from firehose to setup-dart (dart-lang/test#2812) 10dc78c0 Fri Oct 30 11:21:53 2026 -0700 Nate Bosch [checks] Describe what the stream did in failures (dart-lang/test#2811) c11ad06d Fri Oct 30 08:59:57 2026 -0700 Nate Bosch [checks] Add CheckFailure.describeNested (dart-lang/test#2793) 0137b43e Fri Oct 30 05:40:43 2026 +0530 Shriraj Jadhav [checks] Use the Iterable.last member for the last expectation (dart-lang/test#2807) 50c9f454 Thu Oct 29 15:11:39 2026 -0700 Nate Bosch [checks] Collapse clauses for described conditions (dart-lang/test#2794) tools (https://github.com/dart-lang/tools/compare/3980dca..1579f8d): 1579f8d69 Mon Nov 2 02:44:58 2026 +0530 Joshua fix(coverage): reject package: and dart: URIs outside known roots in Resolver (dart-lang/tools#2764) f869fdca4 Sun Nov 1 10:26:34 2026 +0100 dart-ecosystem-bot[bot] [boring] Update BoringSSL to ed9209cf (dart-lang/tools#2774) d10e2b87f Sun Nov 1 10:26:11 2026 +0100 dart-ecosystem-bot[bot] [boring] Pin the prebuilt binaries of boring-binaries-20261101-33e9e69722af (dart-lang/tools#2777) 33e9e6972 Sun Nov 1 05:56:30 2026 +0000 dependabot[bot] build(deps): bump the github-actions group with 4 updates (dart-lang/tools#2775) f84d0d0d2 Sun Nov 1 05:36:12 2026 +0000 dependabot[bot] build(deps): bump ffigen from 22.0.0 to 23.0.0 in /pkgs/boring (dart-lang/tools#2776) 14ad5e515 Fri Oct 30 10:48:58 2026 -0700 Kevin Moore [mime] Harden BoundMultipartStream error routing, headers, and epilogue (dart-lang/tools#2629) 7a780d48c Fri Oct 30 12:57:03 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid rescanning failed plain-text runs in InlineParser (dart-lang/tools#2757) 3671d33a9 Fri Oct 30 12:56:48 2026 +0100 Jonas Finnemann Jensen [markdown] Index footnote definitions and labels on Document for O(1) lookup (dart-lang/tools#2760) 6f38e9fda Fri Oct 30 12:56:09 2026 +0100 Jonas Finnemann Jensen [markdown] Bound the domain of extended URL autolinks to DNS limits (dart-lang/tools#2759) aa0c38544 Fri Oct 30 12:20:40 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid quadratic removeRange in _combineAdjacentText (dart-lang/tools#2758) f425baf46 Fri Oct 30 11:16:28 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid rescanning failed space runs in LineBreakSyntax (dart-lang/tools#2756) c7eafbec3 Fri Oct 30 11:09:45 2026 +0100 Jonas Finnemann Jensen [markdown] Reject '<' in AutolinkSyntax (dart-lang/tools#2755) 86ed0f902 Fri Oct 30 11:08:25 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid quadratic backtracking in headerPattern (dart-lang/tools#2754) 8ea07517f Fri Oct 30 07:45:42 2026 +0100 Moritz [boring] Regenerate the x509-limbo expected failures in the refresh job (dart-lang/tools#2712) 40235c365 Thu Oct 29 14:46:17 2026 +0100 Jonas Finnemann Jensen [markdown] Make HtmlBlockSyntax.parseChildLines iterative (dart-lang/tools#2749) 0ff03f643 Thu Oct 29 14:41:33 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid re-escaping nested image alt text in ImageSyntax.createNode (dart-lang/tools#2752) 784654d01 Thu Oct 29 14:37:11 2026 +0100 Jonas Finnemann Jensen [markdown] Add test cases covering parser and renderer edge cases (dart-lang/tools#2753) 4f2718ad8 Thu Oct 29 14:36:52 2026 +0100 Jonas Finnemann Jensen [markdown] Make Document._parseInlineContent iterative (dart-lang/tools#2750) cac1421d5 Thu Oct 29 14:17:06 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid quadratic duplicate heading ID probing in HtmlRenderer (dart-lang/tools#2748) 66880bcd3 Thu Oct 29 14:11:27 2026 +0100 Jonas Finnemann Jensen [markdown] Check footnote label and empty map before scanning footnote keys (dart-lang/tools#2751) 7f8696523 Thu Oct 29 14:07:06 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid quadratic blank-line lookahead in CodeBlockSyntax (dart-lang/tools#2747) 9da6bb291 Thu Oct 29 14:03:04 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid exponential and quadratic backtracking in tablePattern (dart-lang/tools#2746) dartdoc is held back at db94437, before dart-lang/dartdoc#4312, because its mock SDK version changes conflict in the google3 3-way merge with the hotfix in cl/1005963642. native is held back at e0ca7c6, before dart-lang/native#3742, because deprecating `MacOSCodeConfig(targetVersion: ...)` causes a fatal `deprecated_member_use` diagnostic in `pkg/dartdev/lib/src/native_assets.dart`. web is held back at 4e60e61, before dart-lang/web#596, because it changes `package:web` signatures (e.g. `innerHTML=`, `parseFromString`, `Worker()`) from `JSAny` to `String` in ways that break callers in google3. Change-Id: I53d2cbde741c45b8fd33bca42742549819dd332b Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/572780 Reviewed-by: Brian Quinlan <[email protected]> Commit-Queue: Nate Bosch <[email protected]>
Cache a case-folded footnote definition key map (
_footnoteKeyByLower) and a0-based footnote label index map (
_footnoteLabelIndex) onDocument, exposedto
FootnoteRefSyntaxvia a package-privateDocumentFootnotesextension (withlib/markdown.dartexportingshow Document, LinkReferencefromsrc/document.dart).Why this change is necessary
Even after fast-rejecting empty
footnoteReferencesand non-[^...]brackets,FootnoteRefSyntax.tryCreateFootnoteLinkstill performed two linear O(D) scanswhenever a document contained D footnote definitions (
[^1]: a, ...,[^D]: a)and R footnote-shaped references (
[^...]):footnoteReferences.keys(O(D * R) = O(n^2)):Because
Document.footnoteReferencesstores case-sensitive keys (used whenrendering
href="#fn-$id"),FootnoteRefSyntaxlooked up each lowercasekeyvia:footnote references (such as
[^x]* 8,000),firstWherelinearly scannedall 4,000 keys and called
k.toLowerCase()3.2 * 10^7 times.footnoteLabels(O(D * R) = O(n^2)):When a footnote reference matched,
var pos = labels.indexOf(key);linearlyscanned
document.footnoteLabelson every reference to find or assign its1-based footnote number.
How this change works
Documentholds two private fields:Map<String, String>? _footnoteKeyByLower;: lazily built once on the firstfootnote lookup after block parsing has populated
footnoteReferences.Iterating
footnoteReferences.keys.toList().reversedensures that if twodefinitions share the same lowercase key, the earliest definition wins—
matching
refs.keys.firstWhere((k) => k.toLowerCase() == key)semantics.final _footnoteLabelIndex = <String, int>{};: maps each referencedlowercase
keyto its 0-based index infootnoteLabels, appending tofootnoteLabelsviaputIfAbsentwhenkeyis referenced for the firsttime.
DocumentFootnotesexposesfindFootnoteLabel(String key)andfootnoteLabelOrdinal(String key)insidelib/src/, whileexport 'src/document.dart' show Document, LinkReference;inlib/markdown.dartkeepsDocumentFootnotespackage-private so the publicAPI of
package:markdownis unchanged.Differential testing
Rendered every input with
origin/mainand with this branch (GFM extensionset, 30 s per-file timeout) and compared the HTML. Sources:
mdn/content,github/docs,kubernetes/website,freeCodeCamp,tldr,flutter/website,dart-lang/site-www,microsoft/vscode-docs,withastro/docs,rust-lang/book, GitHub READMEs, and Wikipediacmark,cmark-gfm,commonmark.js,pulldown-cmark,md4c,goldmark,markdown-it,comrak,marked,pandoc, and pathological generatorscmark,md4c, andmarkdown-it-pyfuzzersOSS-Fuzz inputs that sit at the 30 s timeout or the stack limit flip between
runs; every one reported as a new failure was re-run standalone on both trees.
Results: