The 5 MB document becomes unusable primarily because Sapling's current line tracker does not split CRLF line endings. The benchmark file has 51,481 CRLF pairs. Swift treats `"\r\n"` as one newline grapheme cluster, and `EditorActiveLineTracker.lines(from:)` searches the `String` for `Character("\n")`. The result is one giant `EditorLine` containing the entire 5.5M UTF-16-unit document.
- The initial styling pass reports 1 styled line, not 51,482 styled lines.
This explains the visible source-only behavior: the editor believes the whole document is the active line. Dirty-line invalidation is technically working, but the dirty line is the entire file.
Milestone 2.7 corrected this by introducing a UTF-16 based `DocumentLineIndex` with explicit LF, CRLF, and CR handling. The corrected benchmark now reports 51,482 editor lines, 51,481 rendered lines, and one active source line.
The document mostly consists of repeated reference-style link text. Milestone 2 rendering does not promote reference-style links, so even after correct line splitting most lines would remain visually close to source text unless future rendering adds support for that construct.
## Benchmark Harness
Reusable benchmark command:
```sh
swift run -c release SaplingEditorBenchmark
```
Single-file command:
```sh
swift run -c release SaplingEditorBenchmark Docs/Benchmarks/5mb.md
```
The harness measures:
- file read time
- document model creation
- editor state parse and line-model creation
- render-plan generation
- initial attributed-string generation
- active-line lookup
- selection update
- dirty-line invalidation for click and typing paths
- dirty render update for typing
-`NSTextStorage` updates
-`NSLayoutManager` glyph and layout generation
- viewport glyph-range lookup as a scroll proxy
Percentages below are percentages of measured benchmark stage time, not Instruments Time Profiler samples.
| Measured total | 4,376.271 ms | 5,674.222 ms | +29.704% |
| Initial attributed styling | 903.494 ms | 773.474 ms | -14.390% |
| Dirty typing render | 887.213 ms | 55.805 ms | -93.710% |
| Full TextKit layout | 766.331 ms | 1,320.499 ms | +72.052% |
| Dirty invalidation typing | 566.852 ms | 980.651 ms | +72.997% |
| Layout after typing | 490.160 ms | 0.903 ms | -99.816% |
| Selection update | 136.422 ms | 394.957 ms | +189.516% |
| Typing state update | 136.721 ms | 382.159 ms | +179.516% |
| Render plan generation | 51.323 ms | 637.268 ms | +1,141.680% |
What improved:
- The source-only rendering failure disappeared.
- Dirty-line rendering now behaves as intended: the typing render touched 3 styled lines instead of one giant document-sized line.
- Post-typing TextKit layout dropped from 490.160 ms to 0.903 ms.
- Dirty typing render dropped from 887.213 ms to 55.805 ms.
What became more expensive:
- The benchmark now performs real per-line work for 51,482 editor lines. Render-plan generation, state updates, selection updates, and dirty invalidation are now measuring the actual line count instead of one accidental giant line.
- Full TextKit layout increased from 766.331 ms to 1,320.499 ms after the document became many real layout lines.
What bottlenecks remain:
-`EditorState.updateSelection` and `EditorState.updateSource` still rebuild the full line model.
-`EditorDirtyLineInvalidator.plan` still scans full previous/current strings and rebuilds current line boundaries.
-`MarkdownTextStyler.apply` still recomputes the full line list before applying dirty-line styling.
- Full-document render-plan generation is expensive when run across 51,482 lines.
Conclusion:
The CRLF bug caused the source-only display and pathological dirty render/layout behavior. Fixing it restored correctness and removed the giant-line typing/rendering failure. It also exposed the real remaining scalability issue: editor updates still perform document-wide line indexing and render planning work.
Before the CRLF fix, the largest single cost was initial attributed styling of one giant line. After the fix, the largest single cost is full TextKit layout over the correctly segmented document.
After the CRLF fix, measured stage groups are:
| Area | Representative operations | Time |
| --- | --- | ---: |
| TextKit layout | full layout, glyph generation, layout after typing | 1,601.141 ms |
| Whole-document editor scans | dirty invalidation, selection/source updates, active-line lookup, line generation | 2,379.015 ms |
| Rendering/render planning | initial attributed styling, full render-plan generation, dirty typing styling | 1,466.547 ms |
| File/model load | file read, model init, state init | 212.336 ms |
Typing is no longer dominated by dirty rendering of one giant line. After the fix, typing is dominated by dirty invalidation and state updates that still scan/rebuild full-document line data.
Evidence supports this conclusion: cached glyph-range lookup is cheap after layout, but full layout generation remains expensive. A visible AppKit scroll-wheel trace is still needed to prove whether slow scrolling comes from repeated layout invalidation, drawing, or accessibility/selection work during live scrolling.
There is no evidence of an explicit safety fallback, disabled rendering threshold, performance guard, or render-plan failure. The rendered mode was absent because the editor's line model contained no inactive lines.
After Milestone 2.7:
-`DocumentLineIndex` scans UTF-16 code units and treats LF, CRLF, and CR as explicit line endings.
-`EditorActiveLineTracker.lines(from:)` returns 51,482 editor lines for the benchmark document.
- The corrected rendered-mode trace is 51,481 rendered lines and 1 source line.
| Full render-plan generation | benchmark / future full render | on demand | O(line count + inline regex work) | 637.268 ms after fix |
| Initial attributed styling | initial render | once per view text load | O(n + line count) | 773.474 ms after fix |
| Dirty attributed styling | typing/active line change | per dirty render | O(n + dirty content) today | 55.805 ms after fix because styler still rebuilds line list before styling 3 lines |
No O(n^2) operation was proven by this benchmark. After the CRLF fix, the measured failure is multiple O(n) scans over 51,482 real editor lines plus full TextKit layout.
| Document opening | O(n) read + O(n) editor line model + O(n) initial styling + O(n) layout | 3.651 ms read, 208.638 ms state init, 773.474 ms styling, 1,320.499 ms layout |
| Typing | O(n) state update + O(n) dirty diff + O(n) dirty styling setup + local TextKit layout | 382.159 ms + 980.651 ms + 55.805 ms + 0.903 ms |
| Clicking / cursor movement | O(n) selection update + O(n) dirty plan | 394.957 ms + 204.958 ms |
| Active-line changes | O(n) line lookup + O(n) line model rebuild + dirty render | measured through selection and dirty planning |
| Scrolling after cached layout | not proven O(n) by harness | viewport glyph-range lookup was near zero after full layout |
| Scrolling during invalidated layout | likely TextKit layout-bound, not isolated by this harness | full layout and post-typing layout are expensive; live scroll trace still required |
Any operation that calls `EditorActiveLineTracker.lines`, `EditorActiveLineTracker.lineIndex`, or `EditorDirtyLineInvalidator.plan` currently grows with total document size.
## Viewport Analysis
Sapling currently operates on the entire document for editor state and rendering inputs. It does not operate on visible lines plus a buffer.
```mermaid
flowchart TD
Source["Full Markdown source"] --> State["EditorState.lines for full source"]
Source --> Dirty["Dirty invalidator scans full previous/current source"]
Source --> Styler["MarkdownTextStyler recomputes full line list"]
| Render planning | Entire document in full benchmark; dirty line only for actual render plans after valid dirty plan | full render-plan generation measured separately |
1. Remove full-document line-model rebuilding from selection changes. Cursor movement should not rebuild every `EditorLine`.
2. Change dirty invalidation to use native edited ranges instead of prefix/suffix diffing full strings on every edit.
3. Change `MarkdownTextStyler.apply` so dirty rendering does not recompute the full line list before styling dirty ranges.
4. Add a visible AppKit scroll benchmark or Time Profiler trace to isolate live scroll-wheel latency after correct CRLF handling.
5. Keep `DocumentLineIndex` as the single source for editor line segmentation; future editor systems should not search raw Swift `String` values for newline characters.
swift -e 'import Foundation; let s = "a\r\nb"; print(s.firstIndex(of: "\n") as Any); print(s.split(separator: "\n", omittingEmptySubsequences: false).count); print((s as NSString).components(separatedBy: "\n").count)'
swift run -c release SaplingEditorBenchmark Docs/Benchmarks/5mb.md