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 |
Milestone 2.8 added TextKit probes for cold viewport layout and line-fragment calculation. The corrected 5 MB benchmark still segments into 51,482 editor lines and schedules 51,481 rendered lines plus one active source line.
| 20 | `layout_invalidation_full_document` | layout | 0.013 ms | 0.000% | explicit full layout invalidation call |
Measured total: 6,537.243 ms.
The measured total increased relative to the Milestone 2.7 table because Milestone 2.8 added two cold TextKit probes. It should not be compared as an optimization regression.
| File read | document open | once | O(bytes) | yes | no | 2.525 ms |
| `DocumentLineIndex` construction | open, state update, dirty planning, styling | repeated | O(UTF-16 units + lines) | yes | no | `line_model_generation` 205.885 ms |
| Active-line lookup | cursor/source/selection changes | every relevant interaction | O(UTF-16 units + lines) today | yes | no | 191.751 ms |
| `EditorState.updateSelection` | click, cursor movement, selection changes | every selection change | O(document) | yes | no | 398.262 ms |
| `EditorState.updateSource` | typing | every source edit | O(document) | yes | no | 416.540 ms |
| Dirty invalidation | typing, active-line changes, view updates | every styling attempt | O(document) | yes | no | 1,019.380 ms typing; 224.418 ms click |
| Full render planning | initial/full render | on full plan | O(lines + inline parse work) | yes | no | 611.781 ms |
| Dirty render planning | dirty render | per dirty line after plan | O(dirty lines) after line lookup | partly | dirty set, not viewport | 3 styled lines, but 53.512 ms due full line-list setup |
| Initial attributed styling | initial render | once per load | O(document + lines) | yes | no | 787.685 ms |
| `NSTextStorage` full replace | text load | once | O(document) | yes | no | 14.469 ms |
| `NSTextStorage` edit | typing | every edit | local edit | no in benchmark | no | 0.779 ms |
| Full glyph generation | initial/full layout | on full layout | O(document) | yes | no | 200.528 ms |
| Full layout generation | initial/full layout | on full layout | O(layout fragments) | yes | no | 1,314.100 ms |
| Cold viewport glyph range | scroll/query before layout cache exists | as needed | O(layout to queried rect) | yes, by position | yes | 38.597 ms |
| Cold line fragment lookup | selection/layout query before layout cache exists | as needed | O(layout to queried character) | yes, by position | no | 838.968 ms |
| Cached viewport queries | scroll after layout cache exists | per query | near O(visible query) | no proven dependency | yes | below top-20 threshold for 5 MB |
### Performance Budget
| Interaction | Target | Current measured path | Result |
| Document open | documented, not 16 ms | full styling 787.685 ms + full layout 1,314.100 ms + full render planning 611.781 ms | multi-second |
### Document-Bound Operations
The following operations still scale with total document size:
| Operation | Why it exists | Necessary today? | Could become viewport-bound? |
| --- | --- | --- | --- |
| `EditorState.updateSelection` full line rebuild | Keeps active line and status bar line state coherent | yes with current state shape | yes, by caching line index and deriving active line without rebuilding all `EditorLine` values |
| `EditorState.updateSource` full line rebuild | Recomputes line ranges and active/source modes after edits | yes with current state shape | partly, by incrementally updating line index and only materializing affected lines |
| Dirty invalidator full prefix/suffix diff | Finds changed range without native edited range input | yes with current API | yes, by passing TextKit edited ranges into invalidation |
| Dirty invalidator current-line rebuild | Maps changed range to dirty line indexes | yes with current API | yes, by querying cached line boundaries |
| `MarkdownTextStyler.apply` full line-list setup | Converts text storage string to `EditorLine` values before applying dirty attributes | yes with current API | yes, by resolving only dirty line ranges from cached line index |
| Full render-plan generation | Creates a render plan for every line in full render scenarios | yes for current initial full render | yes for interaction paths; initial full render may remain document-bound unless viewport rendering is introduced |
| Initial attributed styling | Applies base attributes and hybrid styling to all lines | yes today | yes, with viewport-bounded styling plus background/full-export handling |
| Full TextKit layout | Calculates layout fragments across the text container | not always necessary for interaction | partly, by avoiding forced full layout and validating viewport/deep-scroll behavior |
### TextKit Findings
TextKit is a real bottleneck for full-document and cold/deep layout work:
-`layout_generation_full_document`: 1,314.100 ms
-`cold_line_fragment_calculation_midpoint`: 838.968 ms
-`glyph_generation_full_document`: 200.528 ms
-`cold_viewport_glyph_range_middle`: 38.597 ms
TextKit is not the measured bottleneck for local typing once layout is cached:
-`text_storage_typing_insert`: 0.779 ms
-`layout_after_typing`: 0.916 ms
Conclusion: TextKit dominates opening and cold layout-to-position work. Sapling's own full-document scans dominate typing, cursor movement, and selection changes.
### Viewport Assessment
Sapling is not currently viewport-bound for editor state, dirty invalidation, render-plan setup, or styling setup. It becomes viewport-like only after TextKit has a layout cache and the benchmark asks for a visible glyph range.
4. Cache render plans by line identity and invalidate only affected lines.
5. Add a visible AppKit scroll benchmark or Time Profiler trace to isolate live scroll-wheel latency after correct CRLF handling.
6. Defer viewport-only styling and TextKit layout strategy changes until document-bound editor-side scans are removed.
7. 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