import Foundation import SaplingCore #if os(macOS) import AppKit #endif public enum EditorBenchmarkCategory: String, Hashable, Sendable { case documentLoad case editorUpdate case rendering case layout case interaction } public struct EditorBenchmarkMeasurement: Hashable, Sendable { public var name: String public var category: EditorBenchmarkCategory public var durationMilliseconds: Double public var notes: String public init( name: String, category: EditorBenchmarkCategory, durationMilliseconds: Double, notes: String = "" ) { self.name = name self.category = category self.durationMilliseconds = durationMilliseconds self.notes = notes } } public struct EditorBenchmarkDocumentProfile: Hashable, Sendable { public var fileName: String public var byteCount: Int public var characterCount: Int public var utf16Count: Int public var lineCount: Int public var headingCount: Int public var unorderedListItemCount: Int public var orderedListItemCount: Int public var blockquoteCount: Int public var fencedCodeFenceCount: Int public var inlineCodeLineCount: Int public var boldLineCount: Int public var italicLineCount: Int public var inlineLinkCount: Int public var referenceLinkLikeCount: Int public var imageCount: Int public var maxLineUTF16Length: Int public var averageLineUTF16Length: Double public var markdownStructureSummary: String { [ "\(headingCount) ATX headings", "\(unorderedListItemCount) unordered list items", "\(orderedListItemCount) ordered list items", "\(blockquoteCount) blockquotes", "\(fencedCodeFenceCount) fenced code fences", "\(inlineCodeLineCount) lines with inline code", "\(boldLineCount) lines with bold markers", "\(italicLineCount) lines with italic markers", "\(inlineLinkCount) inline links", "\(referenceLinkLikeCount) reference-style link markers", "\(imageCount) images" ].joined(separator: ", ") } } public struct EditorBenchmarkResult: Sendable { public var document: EditorBenchmarkDocumentProfile public var measurements: [EditorBenchmarkMeasurement] public var renderedModeTrace: EditorRenderedModeTrace public var measuredTotalMilliseconds: Double { measurements.reduce(0) { $0 + $1.durationMilliseconds } } public var hottestMeasurements: [EditorBenchmarkMeasurement] { measurements.sorted { $0.durationMilliseconds > $1.durationMilliseconds } } } public struct EditorRenderedModeTrace: Hashable, Sendable { public var totalLineCount: Int public var sourceLineCount: Int public var renderedLineCount: Int public var headingPlanCount: Int public var paragraphPlanCount: Int public var inlineSpanCount: Int public var markdownDelimiterSpanCount: Int public var unsupportedReferenceLinkLikeCount: Int public var renderedModeWasScheduled: Bool { renderedLineCount > 0 } } public enum EditorBenchmarkProfiler { public static func profileDocument(at url: URL) throws -> EditorBenchmarkResult { var measurements: [EditorBenchmarkMeasurement] = [] let readResult = try measure { try String(contentsOf: url, encoding: .utf8) } let source = readResult.value measurements.append(EditorBenchmarkMeasurement( name: "file_read", category: .documentLoad, durationMilliseconds: readResult.durationMilliseconds, notes: "String(contentsOf:encoding:)" )) let profile = documentProfile(fileName: url.lastPathComponent, source: source) let midpoint = source.utf16.count / 2 let lineIndex = DocumentLineIndex(source: source) let activeLineIndex = lineIndex.lineIndex(containing: midpoint) let documentResult = measure { MarkdownDocument(url: url, title: url.deletingPathExtension().lastPathComponent, content: source) } let markdownDocument = documentResult.value measurements.append(EditorBenchmarkMeasurement( name: "document_model_init", category: .documentLoad, durationMilliseconds: documentResult.durationMilliseconds, notes: "MarkdownDocument construction" )) let stateResult = measure { EditorState( document: EditorDocument(markdownDocument: markdownDocument), activeLineIndex: activeLineIndex ) } var state = stateResult.value measurements.append(EditorBenchmarkMeasurement( name: "document_parse_editor_state_init", category: .documentLoad, durationMilliseconds: stateResult.durationMilliseconds, notes: "Builds EditorState.lines for the whole document" )) let lineGenerationResult = measure { EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex) } let lines = lineGenerationResult.value measurements.append(EditorBenchmarkMeasurement( name: "line_model_generation", category: .editorUpdate, durationMilliseconds: lineGenerationResult.durationMilliseconds, notes: "EditorActiveLineTracker.lines over full source" )) let renderPlanResult = measure { let renderer = HybridMarkdownLineRenderer() return lines.map(renderer.renderPlan(for:)) } let renderPlans = renderPlanResult.value measurements.append(EditorBenchmarkMeasurement( name: "render_plan_generation_all_lines", category: .rendering, durationMilliseconds: renderPlanResult.durationMilliseconds, notes: "HybridMarkdownLineRenderer.renderPlan for every line" )) let renderedModeTrace = renderedTrace( lines: lines, renderPlans: renderPlans, referenceLinkLikeCount: profile.referenceLinkLikeCount ) let typingEdit = DocumentLineIndexEdit( range: NSRange(location: midpoint, length: 0), replacement: "x" ) let changedSource = sourceByInsertingProbeText(in: source, at: midpoint) var changedLineIndex = lineIndex changedLineIndex.replace(typingEdit) let changedActiveLineIndex = changedLineIndex.lineIndex(containing: midpoint + 1) let activeLineResult = measure { lineIndex.lineIndex(containing: midpoint) } measurements.append(EditorBenchmarkMeasurement( name: "active_line_lookup", category: .editorUpdate, durationMilliseconds: activeLineResult.durationMilliseconds, notes: "Line lookup for midpoint cursor location" )) let selectionUpdateResult = measure { state.updateSelection(EditorSelection(location: midpoint, length: 0)) } measurements.append(EditorBenchmarkMeasurement( name: "selection_update", category: .editorUpdate, durationMilliseconds: selectionUpdateResult.durationMilliseconds, notes: "EditorState.updateSelection rebuilds line model" )) let dirtyClickResult = measure { EditorDirtyLineInvalidator.plan( previousText: source, currentLineIndex: lineIndex, edit: nil, previousActiveLineIndex: activeLineIndex, currentActiveLineIndex: changedActiveLineIndex ) } let dirtyClickPlan = dirtyClickResult.value measurements.append(EditorBenchmarkMeasurement( name: "dirty_line_invalidation_click", category: .editorUpdate, durationMilliseconds: dirtyClickResult.durationMilliseconds, notes: "\(dirtyClickPlan.dirtyLineCount) dirty lines" )) let sourceUpdateResult = measure { var updatedState = state updatedState.updateSource( typingEdit, selection: EditorSelection(location: midpoint + typingEdit.replacementUTF16Length, length: 0) ) } measurements.append(EditorBenchmarkMeasurement( name: "typing_state_update", category: .interaction, durationMilliseconds: sourceUpdateResult.durationMilliseconds, notes: "EditorState.updateSource after one-character insertion" )) let dirtyTypingResult = measure { EditorDirtyLineInvalidator.plan( previousText: source, currentLineIndex: changedLineIndex, edit: typingEdit, previousActiveLineIndex: activeLineIndex, currentActiveLineIndex: changedActiveLineIndex ) } let dirtyTypingPlan = dirtyTypingResult.value measurements.append(EditorBenchmarkMeasurement( name: "dirty_line_invalidation_typing", category: .editorUpdate, durationMilliseconds: dirtyTypingResult.durationMilliseconds, notes: "\(dirtyTypingPlan.dirtyLineCount) dirty lines" )) #if os(macOS) measurements.append(contentsOf: profileTextKit( source: source, changedSource: changedSource, lineIndex: lineIndex, changedLineIndex: changedLineIndex, typingEdit: typingEdit, activeLineIndex: activeLineIndex, changedActiveLineIndex: changedActiveLineIndex, dirtyClickPlan: dirtyClickPlan, dirtyTypingPlan: dirtyTypingPlan )) #endif return EditorBenchmarkResult( document: profile, measurements: measurements, renderedModeTrace: renderedModeTrace ) } public static func documentProfile(fileName: String, source: String) -> EditorBenchmarkDocumentProfile { let lineIndex = DocumentLineIndex(source: source) let nsSource = source as NSString let lineSources = lineIndex.boundaries.map { nsSource.substring(with: $0.contentRange) } let lineLengths = lineIndex.boundaries.map(\.contentRange.length) let lineCount = lineIndex.boundaries.count let maxLineLength = lineLengths.max() ?? 0 let totalLineLength = lineLengths.reduce(0, +) return EditorBenchmarkDocumentProfile( fileName: fileName, byteCount: source.lengthOfBytes(using: .utf8), characterCount: source.count, utf16Count: source.utf16.count, lineCount: lineCount, headingCount: countMatches("(?m)^#{1,6}\\s", in: source), unorderedListItemCount: countMatches("(?m)^[-*+]\\s", in: source), orderedListItemCount: countMatches("(?m)^\\d+\\.\\s", in: source), blockquoteCount: countMatches("(?m)^>\\s", in: source), fencedCodeFenceCount: countMatches("(?m)^```", in: source), inlineCodeLineCount: countMatchingLines("`[^`\\n]+`", in: lineSources), boldLineCount: countMatchingLines("\\*\\*[^*\\n]+\\*\\*", in: lineSources), italicLineCount: countMatchingLines("(? EditorRenderedModeTrace { let headingCount = renderPlans.filter { if case .heading = $0.kind { return true } return false }.count let paragraphCount = renderPlans.count - headingCount let spans = renderPlans.flatMap(\.spans) return EditorRenderedModeTrace( totalLineCount: lines.count, sourceLineCount: lines.filter { $0.mode == .source }.count, renderedLineCount: lines.filter { $0.mode == .rendered }.count, headingPlanCount: headingCount, paragraphPlanCount: paragraphCount, inlineSpanCount: spans.count, markdownDelimiterSpanCount: spans.filter { $0.kind == .markdownDelimiter }.count, unsupportedReferenceLinkLikeCount: referenceLinkLikeCount ) } private static func sourceByInsertingProbeText(in source: String, at utf16Location: Int) -> String { let nsSource = source as NSString let location = max(0, min(utf16Location, nsSource.length)) return nsSource.replacingCharacters(in: NSRange(location: location, length: 0), with: "x") } private static func countMatches(_ pattern: String, in source: String) -> Int { guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 } return regex.numberOfMatches(in: source, range: NSRange(location: 0, length: source.utf16.count)) } private static func countMatchingLines(_ pattern: String, in lines: [String]) -> Int { guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 } return lines.reduce(0) { count, line in let range = NSRange(location: 0, length: line.utf16.count) return count + (regex.firstMatch(in: line, range: range) == nil ? 0 : 1) } } private static func measure(_ operation: () throws -> T) rethrows -> (value: T, durationMilliseconds: Double) { let start = DispatchTime.now().uptimeNanoseconds let value = try operation() let end = DispatchTime.now().uptimeNanoseconds return (value, Double(end - start) / 1_000_000) } #if os(macOS) private static func profileTextKit( source: String, changedSource: String, lineIndex: DocumentLineIndex, changedLineIndex: DocumentLineIndex, typingEdit: DocumentLineIndexEdit, activeLineIndex: Int, changedActiveLineIndex: Int, dirtyClickPlan: EditorDirtyLineInvalidationPlan, dirtyTypingPlan: EditorDirtyLineInvalidationPlan ) -> [EditorBenchmarkMeasurement] { var measurements: [EditorBenchmarkMeasurement] = [] let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() let textContainer = NSTextContainer(size: NSSize(width: 760, height: CGFloat.greatestFiniteMagnitude)) textContainer.widthTracksTextView = false textContainer.heightTracksTextView = false layoutManager.addTextContainer(textContainer) textStorage.addLayoutManager(layoutManager) let storageUpdateResult = measure { textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: source) } measurements.append(EditorBenchmarkMeasurement( name: "text_storage_initial_update", category: .layout, durationMilliseconds: storageUpdateResult.durationMilliseconds, notes: "NSTextStorage.replaceCharacters full source" )) let fullRenderPlan = EditorDirtyLineInvalidator.plan( previousText: nil, currentLineIndex: lineIndex, edit: nil, previousActiveLineIndex: nil, currentActiveLineIndex: activeLineIndex ) let attributedStringResult = measure { MarkdownTextStyler.apply( to: textStorage, lineIndex: lineIndex, invalidationPlan: fullRenderPlan, activeLineIndex: activeLineIndex, backgroundColor: .textBackgroundColor, activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), textColor: .labelColor, secondaryTextColor: .secondaryLabelColor, accentColor: .controlAccentColor ) } measurements.append(EditorBenchmarkMeasurement( name: "attributed_string_generation_initial", category: .rendering, durationMilliseconds: attributedStringResult.durationMilliseconds, notes: "\(attributedStringResult.value.styledLineCount) styled lines" )) let coldViewportStack = makeTextKitStack(attributedString: textStorage) let coldViewportResult = measure { coldViewportStack.layoutManager.glyphRange( forBoundingRect: NSRect(x: 0, y: 50_000, width: 760, height: 900), in: coldViewportStack.textContainer ) } measurements.append(EditorBenchmarkMeasurement( name: "cold_viewport_glyph_range_middle", category: .layout, durationMilliseconds: coldViewportResult.durationMilliseconds, notes: "Visible glyph range before explicit full-document layout" )) let coldLineFragmentStack = makeTextKitStack(attributedString: textStorage) let coldLineFragmentResult = measure { lineFragmentRect(atCharacterLocation: coldLineFragmentStack.textStorage.length / 2, in: coldLineFragmentStack) } measurements.append(EditorBenchmarkMeasurement( name: "cold_line_fragment_calculation_midpoint", category: .layout, durationMilliseconds: coldLineFragmentResult.durationMilliseconds, notes: "lineFragmentRect near midpoint before explicit full-document layout" )) let layoutInvalidationResult = measure { var actualRange = NSRange(location: 0, length: 0) layoutManager.invalidateLayout( forCharacterRange: NSRange(location: 0, length: textStorage.length), actualCharacterRange: &actualRange ) } measurements.append(EditorBenchmarkMeasurement( name: "layout_invalidation_full_document", category: .layout, durationMilliseconds: layoutInvalidationResult.durationMilliseconds, notes: "NSLayoutManager.invalidateLayout full character range" )) let glyphGenerationResult = measure { layoutManager.ensureGlyphs(forCharacterRange: NSRange(location: 0, length: textStorage.length)) } measurements.append(EditorBenchmarkMeasurement( name: "glyph_generation_full_document", category: .layout, durationMilliseconds: glyphGenerationResult.durationMilliseconds, notes: "NSLayoutManager.ensureGlyphs full character range" )) let layoutGenerationResult = measure { layoutManager.ensureLayout(for: textContainer) } measurements.append(EditorBenchmarkMeasurement( name: "layout_generation_full_document", category: .layout, durationMilliseconds: layoutGenerationResult.durationMilliseconds, notes: "NSLayoutManager.ensureLayout for text container" )) let cachedLineFragmentResult = measure { lineFragmentRect(atCharacterLocation: textStorage.length / 2, in: ( textStorage: textStorage, layoutManager: layoutManager, textContainer: textContainer )) } measurements.append(EditorBenchmarkMeasurement( name: "cached_line_fragment_calculation_midpoint", category: .layout, durationMilliseconds: cachedLineFragmentResult.durationMilliseconds, notes: "lineFragmentRect near midpoint after full layout" )) let textContainerUsedRectResult = measure { layoutManager.usedRect(for: textContainer) } measurements.append(EditorBenchmarkMeasurement( name: "text_container_used_rect_after_full_layout", category: .layout, durationMilliseconds: textContainerUsedRectResult.durationMilliseconds, notes: "NSTextContainer usedRect after full layout" )) let firstViewportResult = measure { layoutManager.glyphRange( forBoundingRect: NSRect(x: 0, y: 0, width: 760, height: 900), in: textContainer ) } measurements.append(EditorBenchmarkMeasurement( name: "first_viewport_glyph_range", category: .layout, durationMilliseconds: firstViewportResult.durationMilliseconds, notes: "Visible glyph range at document top" )) let clickRenderResult = measure { if dirtyClickPlan.requiresStyling { return MarkdownTextStyler.apply( to: textStorage, lineIndex: lineIndex, invalidationPlan: dirtyClickPlan, activeLineIndex: changedActiveLineIndex, backgroundColor: .textBackgroundColor, activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), textColor: .labelColor, secondaryTextColor: .secondaryLabelColor, accentColor: .controlAccentColor ) } return MarkdownTextStylingResult.empty } measurements.append(EditorBenchmarkMeasurement( name: "render_update_click_dirty", category: .interaction, durationMilliseconds: clickRenderResult.durationMilliseconds, notes: dirtyClickPlan.requiresStyling ? "\(clickRenderResult.value.styledLineCount) styled lines" : "skipped; dirty plan did not require styling" )) let typingStorageResult = measure { let midpoint = textStorage.length / 2 textStorage.replaceCharacters(in: NSRange(location: midpoint, length: 0), with: "x") } measurements.append(EditorBenchmarkMeasurement( name: "text_storage_typing_insert", category: .interaction, durationMilliseconds: typingStorageResult.durationMilliseconds, notes: "NSTextStorage single-character insertion" )) let typingRenderResult = measure { MarkdownTextStyler.apply( to: textStorage, lineIndex: changedLineIndex, invalidationPlan: dirtyTypingPlan, activeLineIndex: changedActiveLineIndex, backgroundColor: .textBackgroundColor, activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), textColor: .labelColor, secondaryTextColor: .secondaryLabelColor, accentColor: .controlAccentColor ) } measurements.append(EditorBenchmarkMeasurement( name: "render_update_typing_dirty", category: .interaction, durationMilliseconds: typingRenderResult.durationMilliseconds, notes: "\(typingRenderResult.value.styledLineCount) styled lines" )) let layoutAfterTypingResult = measure { layoutManager.ensureLayout(forCharacterRange: NSRange(location: textStorage.length / 2, length: 1)) } measurements.append(EditorBenchmarkMeasurement( name: "layout_after_typing", category: .interaction, durationMilliseconds: layoutAfterTypingResult.durationMilliseconds, notes: "NSLayoutManager.ensureLayout for inserted character" )) let scrollTopResult = measure { layoutManager.glyphRange( forBoundingRect: NSRect(x: 0, y: 0, width: 760, height: 900), in: textContainer ) } measurements.append(EditorBenchmarkMeasurement( name: "scroll_latency_top_viewport", category: .interaction, durationMilliseconds: scrollTopResult.durationMilliseconds, notes: "glyphRange for top viewport" )) let scrollMiddleResult = measure { layoutManager.glyphRange( forBoundingRect: NSRect(x: 0, y: 50_000, width: 760, height: 900), in: textContainer ) } measurements.append(EditorBenchmarkMeasurement( name: "scroll_latency_middle_viewport", category: .interaction, durationMilliseconds: scrollMiddleResult.durationMilliseconds, notes: "glyphRange for estimated middle viewport" )) let scrollDeepResult = measure { layoutManager.glyphRange( forBoundingRect: NSRect(x: 0, y: 500_000, width: 760, height: 900), in: textContainer ) } measurements.append(EditorBenchmarkMeasurement( name: "scroll_latency_deep_viewport", category: .interaction, durationMilliseconds: scrollDeepResult.durationMilliseconds, notes: "glyphRange for estimated deep viewport" )) _ = changedSource _ = typingEdit return measurements } private static func makeTextKitStack(attributedString: NSAttributedString) -> ( textStorage: NSTextStorage, layoutManager: NSLayoutManager, textContainer: NSTextContainer ) { let textStorage = NSTextStorage(attributedString: attributedString) let layoutManager = NSLayoutManager() let textContainer = NSTextContainer(size: NSSize(width: 760, height: CGFloat.greatestFiniteMagnitude)) textContainer.widthTracksTextView = false textContainer.heightTracksTextView = false layoutManager.addTextContainer(textContainer) textStorage.addLayoutManager(layoutManager) return (textStorage, layoutManager, textContainer) } @discardableResult private static func lineFragmentRect( atCharacterLocation characterLocation: Int, in stack: ( textStorage: NSTextStorage, layoutManager: NSLayoutManager, textContainer: NSTextContainer ) ) -> NSRect { guard stack.textStorage.length > 0 else { return .zero } let characterLocation = max(0, min(characterLocation, stack.textStorage.length - 1)) let glyphIndex = stack.layoutManager.glyphIndexForCharacter(at: characterLocation) var effectiveRange = NSRange(location: 0, length: 0) return stack.layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &effectiveRange) } #endif }