krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 25a6d84c1f

25a6d84c1f41024c27ce1de5be1d3bf6425f76d0

parent: a49927e318

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 06:17 UTC

Add spell checking, truncate-lines, click to fold or toggle, and gj/gk

Spell checking skips what spell-fu skips in Org (code, links, dates, tags,
keywords, properties, src and example blocks); SPC t s and the spell-check
setting. toggle-truncate-lines on C-x x t and SPC t w, with
org-startup-truncated. Clicking a heading's stars cycles it and clicking a
checkbox toggles it. gj/gk move by screen lines where no keymap binding
takes them.

Ref docs/review-2026-10-05.md tier 2 item 14

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +1 −1
@@ -449,7 +449,7 @@ public struct EmacsImport: Sendable {
449449 add(name, .integer(n))
450450 case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities",
451451 "org-cycle-hide-drawer-startup", "org-cycle-hide-block-startup", "org-use-speed-commands", "org-startup-with-inline-images",
452 "org-startup-indented", "org-hide-leading-stars", "org-startup-align-all-tables":
452 "org-startup-indented", "org-hide-leading-stars", "org-startup-align-all-tables", "org-startup-truncated":
453453 add(name, .bool(truthy))
454454 case "org-hide-drawer-startup", "org-hide-block-startup":
455455 add(name == "org-hide-drawer-startup" ? "org-cycle-hide-drawer-startup" : "org-cycle-hide-block-startup", .bool(truthy))
Sources/OrgApp/UserConfig.swift +4
@@ -88,6 +88,10 @@ public enum UserConfig {
8888 comment: "show only a heading's last star without indentation; #+STARTUP: hidestars/showstars overrides", legacy: ""),
8989 Setting(section: "", key: "org-startup-align-all-tables", defaultsKey: "startupAlignAllTables", kind: .bool, fallback: .bool(false),
9090 comment: "align every table when a file opens; #+STARTUP: (no)align overrides", legacy: ""),
91 Setting(section: "", key: "org-startup-truncated", defaultsKey: "startupTruncated", kind: .bool, fallback: .bool(false),
92 comment: "truncate long lines instead of wrapping them (Emacs's default is t; Doom wraps)", legacy: ""),
93 Setting(section: "", key: "spell-check", defaultsKey: "spellCheck", kind: .bool, fallback: .bool(false),
94 comment: "check spelling while typing, outside code, links, dates, tags and keywords", legacy: ""),
9195 Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false),
9296 comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""),
9397 Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false),
Sources/OrgCore/Commands/Command.swift +8 −1
@@ -32,6 +32,10 @@ public struct EditingOptions: Sendable, Equatable, Codable {
3232 public var hideLeadingStars = false
3333 /// `org-startup-align-all-tables`.
3434 public var startupAlignAllTables = false
35 /// `org-startup-truncated`: long lines run off the right edge instead of wrapping.
36 public var startupTruncated = false
37 /// Spell checking while typing, outside code and markup.
38 public var spellCheck = false
3539 /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens.
3640 public var hideDrawerStartup: Bool
3741 /// `org-cycle-hide-block-startup`: blocks are folded when a file opens.
@@ -52,8 +56,11 @@ public struct EditingOptions: Sendable, Equatable, Codable {
5256 logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true,
5357 hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil,
5458 useSpeedCommands: Bool = false, startupWithInlineImages: Bool = false,
55 startupIndented: Bool = true, hideLeadingStars: Bool = false, startupAlignAllTables: Bool = false
59 startupIndented: Bool = true, hideLeadingStars: Bool = false, startupAlignAllTables: Bool = false,
60 startupTruncated: Bool = false, spellCheck: Bool = false
5661 ) {
62 self.startupTruncated = startupTruncated
63 self.spellCheck = spellCheck
5764 self.startupIndented = startupIndented
5865 self.hideLeadingStars = hideLeadingStars
5966 self.startupAlignAllTables = startupAlignAllTables
Sources/OrgCore/Keymap/Presets.swift +11
@@ -207,6 +207,14 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
207207 keys = "SPC h r r"
208208 command = "app.reload-keymap"
209209 mode = "normal"
210 [[bind]]
211 keys = "SPC t s"
212 command = "editor.toggle-spell-check"
213 mode = "normal"
214 [[bind]]
215 keys = "SPC t w"
216 command = "editor.toggle-truncate-lines"
217 mode = "normal"
210218
211219 # Localleader (SPC m)
212220 [[bind]]
@@ -708,6 +716,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
708716 keys = "C-x n w"
709717 command = "org.widen"
710718 [[bind]]
719 keys = "C-x x t"
720 command = "editor.toggle-truncate-lines"
721 [[bind]]
711722 keys = "C-c C-j"
712723 command = "org.goto"
713724 [[bind]]
Sources/OrgCore/Keymap/Vim.swift +6
@@ -46,6 +46,8 @@ public struct Vim: Sendable {
4646 case playMacro(Character, Int)
4747 /// A block selection: one range per line.
4848 case selectBlock([Range<Int>])
49 /// `gj`, `gk`: down (or, negative, up) by screen lines, which only the editor knows.
50 case visualLines(Int)
4951 }
5052
5153 public struct Context: Sendable {
@@ -462,6 +464,10 @@ public struct Vim: Sendable {
462464 }
463465 }
464466
467 if mode == .normal, rest.count == 2, first == KeyChord("g"), rest.last == KeyChord("j") || rest.last == KeyChord("k") {
468 return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)])
469 }
470
465471 // Motions move the caret, or extend the selection in visual states.
466472 var target = caret
467473 switch motion(Array(rest), &buffer, &target, count: n, operatorPending: false) {
Sources/OrgEditorAppKit/EditorCommands.swift +2
@@ -42,6 +42,8 @@ public enum EditorCommands {
4242 EditorCommand(id: "org.widen", title: "Widen", action: .view),
4343 EditorCommand(id: "org.narrow.toggle", title: "Narrow to Subtree or Widen", action: .view),
4444 EditorCommand(id: "org.toggle-inline-images", title: "Show or Hide Inline Images", action: .view),
45 EditorCommand(id: "editor.toggle-spell-check", title: "Check Spelling While Typing", action: .view),
46 EditorCommand(id: "editor.toggle-truncate-lines", title: "Truncate or Wrap Long Lines", action: .view),
4547 EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view),
4648 EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view),
4749 EditorCommand(id: "org.table.expand", title: "Expand Table Columns", action: .view),
Sources/OrgEditorAppKit/Keys.swift +7
@@ -83,12 +83,19 @@ final class OrgTextView: NSTextView {
8383 /// ⌘-click: given the clicked offset, true when it followed a link.
8484 var onCommandClick: ((Int) -> Bool)?
8585
86 /// A plain single click: given the clicked offset, true when it was handled.
87 var onClick: ((Int) -> Bool)?
88
8689 override func mouseDown(with event: NSEvent) {
8790 if event.modifierFlags.contains(.command), let onCommandClick {
8891 let offset = characterIndexForInsertion(at: convert(event.locationInWindow, from: nil))
8992 if onCommandClick(offset) { return }
9093 }
9194 onMouseDown?()
95 if event.modifierFlags.intersection([.command, .shift, .option, .control]).isEmpty, event.clickCount == 1,
96 onClick?(characterIndexForInsertion(at: convert(event.locationInWindow, from: nil))) == true {
97 return
98 }
9299 super.mouseDown(with: event)
93100 }
94101}
Sources/OrgEditorAppKit/Modal.swift +5
@@ -132,6 +132,11 @@ extension OrgEditor {
132132 textView.setSelectedRange(NSRange(range))
133133 case .command(let id):
134134 run(id)
135 case .visualLines(let count):
136 for _ in 0..<abs(count) {
137 if count > 0 { textView.moveDown(nil) } else { textView.moveUp(nil) }
138 }
139 clampCaret()
135140 case .undo(let count):
136141 for _ in 0..<count { textView.undoManager?.undo() }
137142 clampCaret()
Sources/OrgEditorAppKit/OrgEditor.swift +8 −7
@@ -97,6 +97,8 @@ public final class OrgEditor: NSObject {
9797
9898 public let mode: Mode
9999 public var isOrg: Bool { mode == .org }
100 /// The spell checker's skipped ranges and the text they were found in.
101 var spellSkipCache: (text: String, ranges: [Range<Int>])?
100102 /// Highlights of the whole file in plain mode, and the revision they were made for.
101103 private var codeRuns: (revision: Int, runs: [(range: Range<Int>, category: SyntaxCategory)])?
102104
@@ -119,6 +121,7 @@ public final class OrgEditor: NSObject {
119121 self.run("org.link.open")
120122 return true
121123 }
124 orgTextView.onClick = { [weak self] offset in self?.click(at: offset) ?? false }
122125 orgTextView.onMouseDown = { [weak self] in
123126 self?.markActive = false
124127 self?.modalMouseDown()
@@ -164,6 +167,8 @@ public final class OrgEditor: NSObject {
164167 }
165168 if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) }
166169 }
170 spellChecking = options.spellCheck
171 truncatesLines = options.startupTruncated
167172 }
168173
169174 /// Replaces the storage's text. Setting the attributed string directly is about 100x
@@ -431,6 +436,8 @@ public final class OrgEditor: NSObject {
431436 case "org.cycle-global": cycleGlobal()
432437 case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id)
433438 case "org.toggle-inline-images": toggleImages()
439 case "editor.toggle-spell-check": toggleSpellCheck()
440 case "editor.toggle-truncate-lines": toggleTruncateLines()
434441 default: tableColumnWidth(id)
435442 }
436443 case .edit(let selector, let extending):
@@ -1274,7 +1281,7 @@ public final class OrgEditor: NSObject {
12741281
12751282 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
12761283 /// back jumps to the end of the heading line.
1277 fileprivate func selectionChanged() {
1284 func selectionChanged() {
12781285 let selection = textView.selectedRange()
12791286 defer { previousSelection = selection.location }
12801287 onSelectionChange?(selection)
@@ -1335,12 +1342,6 @@ extension OrgEditor: DocumentBuffer {
13351342 }
13361343}
13371344
1338extension OrgEditor: NSTextViewDelegate {
1339 public func textViewDidChangeSelection(_ notification: Notification) {
1340 selectionChanged()
1341 }
1342}
1343
13441345extension OrgEditor: NSTextStorageDelegate {
13451346 public nonisolated func textStorage(
13461347 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
Sources/OrgEditorAppKit/ViewToggles.swift added +101
@@ -0,0 +1,101 @@
1#if os(macOS)
2import AppKit
3import OrgCore
4import OrgPresentation
5
6/// Spell checking, line truncation and mouse clicks on heading stars and checkboxes.
7extension OrgEditor {
8 // MARK: - Spell checking
9
10 public var spellChecking: Bool {
11 get { textView.isContinuousSpellCheckingEnabled }
12 set {
13 textView.isContinuousSpellCheckingEnabled = newValue
14 if !newValue, let storage = textView.textStorage {
15 storage.removeAttribute(.spellingState, range: NSRange(location: 0, length: storage.length))
16 }
17 }
18 }
19
20 func toggleSpellCheck() {
21 spellChecking.toggle()
22 onMessage?(spellChecking ? "Spell checking enabled" : "Spell checking disabled")
23 }
24
25 /// Ranges spell-fu skips in Org, for this revision of the text.
26 func spellSkipped() -> [Range<Int>] {
27 if let cached = spellSkipCache, cached.text == document.text { return cached.ranges }
28 let ranges = isOrg ? SpellCheck.skipped(document.tree) : []
29 spellSkipCache = (document.text, ranges)
30 return ranges
31 }
32
33 // MARK: - Truncation
34
35 /// `toggle-truncate-lines`: long lines run off the right edge instead of wrapping.
36 public var truncatesLines: Bool {
37 get { !(textView.textContainer?.widthTracksTextView ?? true) }
38 set {
39 guard let container = textView.textContainer, newValue != truncatesLines else { return }
40 let scroll = textView.enclosingScrollView
41 scroll?.hasHorizontalScroller = newValue
42 textView.isHorizontallyResizable = newValue
43 if newValue {
44 container.widthTracksTextView = false
45 container.size = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
46 textView.autoresizingMask = [.width, .height]
47 } else {
48 let width = scroll?.contentSize.width ?? textView.frame.width
49 textView.setFrameSize(NSSize(width: width, height: textView.frame.height))
50 container.size = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)
51 container.widthTracksTextView = true
52 textView.autoresizingMask = [.width]
53 }
54 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
55 textView.needsDisplay = true
56 }
57 }
58
59 func toggleTruncateLines() {
60 truncatesLines.toggle()
61 onMessage?(truncatesLines ? "Truncate long lines enabled" : "Truncate long lines disabled")
62 }
63
64 // MARK: - Clicks
65
66 /// A click on a heading's stars cycles it; a click on a checkbox toggles it.
67 func click(at offset: Int) -> Bool {
68 guard isOrg else { return false }
69 var node = document.tree.root
70 while let child = node.child(containing: offset) ?? node.child(containing: offset - 1) {
71 node = child
72 if child.kind == .heading || child.kind == .item { break }
73 }
74 let tokens = node.tokens.filter { $0.range.lowerBound <= offset && offset <= $0.range.upperBound }
75 if node.kind == .heading, tokens.contains(where: { $0.kind == .stars }) {
76 setCaret(node.range.lowerBound)
77 _ = cycleAtCaret()
78 return true
79 }
80 if node.kind == .item, let box = tokens.first(where: { $0.kind == .checkbox }) {
81 setCaret(box.range.lowerBound)
82 run("org.checkbox.toggle")
83 return true
84 }
85 return false
86 }
87}
88
89extension OrgEditor: NSTextViewDelegate {
90 public func textViewDidChangeSelection(_ notification: Notification) {
91 selectionChanged()
92 }
93
94 /// No spelling marks in code, links, dates, tags or keywords.
95 public func textView(_ textView: NSTextView, shouldSetSpellingState value: Int, range affectedCharRange: NSRange) -> Int {
96 guard value != 0 else { return value }
97 let range = affectedCharRange.location..<NSMaxRange(affectedCharRange)
98 return spellSkipped().contains { $0.overlaps(range) } ? 0 : value
99 }
100}
101#endif
Sources/OrgPresentation/SpellCheck.swift added +43
@@ -0,0 +1,43 @@
1import Foundation
2import OrgCore
3
4/// Where spell checking stays off: the faces spell-fu skips in Org (code, links, dates, tags,
5/// keywords, meta lines, properties, LaTeX, src and example blocks).
6public enum SpellCheck {
7 public static func skipped(_ tree: OrgTree) -> [Range<Int>] {
8 let ns = tree.text as NSString
9 func line(_ p: Int) -> Range<Int> {
10 let r = ns.lineRange(for: NSRange(location: p, length: 0))
11 return r.location..<(r.location + r.length)
12 }
13 let whole: Set<SyntaxKind> = [.code, .verbatim, .link, .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget,
14 .macro, .inlineSourceBlock, .latexFragment, .entity, .latexEnvironment, .keyword, .affiliatedKeyword,
15 .planning, .clock, .propertyDrawer, .fixedWidth, .tableFormula, .horizontalRule]
16 let tokens: Set<SyntaxKind> = [.stars, .todoKeyword, .priority, .tags, .bullet, .checkbox]
17 var ranges: [Range<Int>] = []
18 for node in tree.root.descendants() {
19 if whole.contains(node.kind) {
20 ranges.append(node.range)
21 continue
22 }
23 switch node.kind {
24 case .block, .dynamicBlock, .drawer:
25 let first = line(node.range.lowerBound)
26 let name = ns.substring(with: NSRange(location: first.lowerBound, length: first.count)).trimmingCharacters(in: .whitespaces).lowercased()
27 if node.kind == .block, ["src", "example", "export", "comment"].contains(where: { name.hasPrefix("#+begin_\($0)") }) {
28 ranges.append(node.range)
29 } else {
30 ranges.append(first)
31 var end = node.range.upperBound
32 while end > node.range.lowerBound, ns.character(at: end - 1) == 10 { end -= 1 }
33 ranges.append(line(max(node.range.lowerBound, end - 1)))
34 }
35 case .heading, .item:
36 ranges += node.tokens.filter { tokens.contains($0.kind) }.map(\.range)
37 default:
38 break
39 }
40 }
41 return ranges
42 }
43}
Sources/Orgstar/ContentView.swift +4 −1
@@ -43,6 +43,8 @@ struct ContentView: View {
4343 @AppStorage("startupIndented") private var startupIndented = true
4444 @AppStorage("hideLeadingStars") private var hideLeadingStars = false
4545 @AppStorage("startupAlignAllTables") private var startupAlignAllTables = false
46 @AppStorage("startupTruncated") private var startupTruncated = false
47 @AppStorage("spellCheck") private var spellCheck = false
4648 @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true
4749 @AppStorage("hideBlockStartup") private var hideBlockStartup = false
4850
@@ -52,7 +54,8 @@ struct ContentView: View {
5254 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer,
5355 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline,
5456 useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages,
55 startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables
57 startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables,
58 startupTruncated: startupTruncated, spellCheck: spellCheck
5659 )
5760 }
5861 /// A block being edited apart (`org-edit-special`).
Sources/Orgstar/Preferences.swift +3 −2
@@ -21,7 +21,7 @@ enum Preferences {
2121 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int,
2222 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String,
2323 hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String, useSpeedCommands: Bool, startupWithInlineImages: Bool,
24 startupIndented: Bool, hideLeadingStars: Bool, startupAlignAllTables: Bool
24 startupIndented: Bool, hideLeadingStars: Bool, startupAlignAllTables: Bool, startupTruncated: Bool, spellCheck: Bool
2525 ) -> EditingOptions {
2626 EditingOptions(
2727 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree,
@@ -31,7 +31,8 @@ enum Preferences {
3131 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup,
3232 logReschedule: LogHow(rawValue: logReschedule), logRedeadline: LogHow(rawValue: logRedeadline),
3333 useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages,
34 startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables
34 startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables,
35 startupTruncated: startupTruncated, spellCheck: spellCheck
3536 )
3637 }
3738}
Tests/OrgEditorAppKitTests/ViewToggleTests.swift added +60
@@ -0,0 +1,60 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6@MainActor
7struct ViewToggleTests {
8 /// A plain click at the middle of the character at `offset`.
9 func click(_ h: Harness, _ offset: Int) {
10 let screen = h.textView.firstRect(forCharacterRange: NSRange(location: offset, length: 1), actualRange: nil)
11 let inWindow = h.window.convertPoint(fromScreen: NSPoint(x: screen.midX, y: screen.midY))
12 let event = NSEvent.mouseEvent(with: .leftMouseDown, location: inWindow, modifierFlags: [], timestamp: 0,
13 windowNumber: h.window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)!
14 h.textView.mouseDown(with: event)
15 }
16
17 @Test func clickingStarsCyclesAndCheckboxesToggle() {
18 let h = Harness("* Top\nbody\n- [ ] task\n* Next\n")
19 click(h, 0)
20 #expect(h.visibleLines == ["* Top\n", "* Next\n"])
21 click(h, 0)
22 #expect(h.visibleLines.count == 4)
23 click(h, h.offset(of: "[ ]") + 1)
24 #expect(h.editor.document.text == "* Top\nbody\n- [X] task\n* Next\n")
25 // Elsewhere a click is the text view's.
26 #expect(!h.editor.click(at: h.offset(of: "Top") + 1))
27 #expect(!h.editor.click(at: h.offset(of: "task") + 1))
28 }
29
30 @Test func truncatingAndSpellChecking() {
31 let h = Harness("* Hedding\n~codez~ wurds\n")
32 #expect(!h.editor.truncatesLines)
33 h.editor.run("editor.toggle-truncate-lines")
34 #expect(h.editor.truncatesLines && h.textView.enclosingScrollView?.hasHorizontalScroller == true)
35 h.editor.run("editor.toggle-truncate-lines")
36 #expect(!h.editor.truncatesLines && h.textView.textContainer?.widthTracksTextView == true)
37 h.editor.run("editor.toggle-spell-check")
38 #expect(h.textView.isContinuousSpellCheckingEnabled)
39 let code = NSRange(location: h.offset(of: "codez"), length: 5)
40 let prose = NSRange(location: h.offset(of: "wurds"), length: 5)
41 #expect(h.editor.textView(h.textView, shouldSetSpellingState: 1, range: code) == 0)
42 #expect(h.editor.textView(h.textView, shouldSetSpellingState: 1, range: prose) == 1)
43 }
44
45 @Test func gjMovesByScreenLines() {
46 let long = Array(repeating: "word", count: 60).joined(separator: " ")
47 let h = Harness(long + "\nnext\n")
48 // Doom binds `g j` to the next heading; without that binding it's the engine's.
49 h.editor.keymap = KeymapPreset.emacs.keymap
50 h.editor.modal = true
51 h.caret(at: 0)
52 h.caret(at: 0)
53 ModalTests().type(h, "g j")
54 #expect(h.caret > 0 && h.caret < long.utf16.count, "\(h.caret)")
55 ModalTests().type(h, "g k")
56 #expect(h.caret == 0)
57 ModalTests().type(h, "j")
58 #expect(h.caret == long.utf16.count + 1)
59 }
60}
Tests/OrgPresentationTests/SpellCheckTests.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2import Testing
3import OrgCore
4@testable import OrgPresentation
5
6struct SpellCheckTests {
7 /// The words left for the spell checker.
8 func checked(_ text: String) -> [String] {
9 let skipped = SpellCheck.skipped(OrgParser.parse(text))
10 let ns = text as NSString
11 var words: [String] = []
12 ns.enumerateSubstrings(in: NSRange(location: 0, length: ns.length), options: .byWords) { word, range, _, _ in
13 if !skipped.contains(where: { $0.contains(range.location) }) { words.append(word!) }
14 }
15 return words
16 }
17
18 @Test func skipsMarkupAndCode() {
19 let text = """
20 #+TITLE: Speling
21 * TODO [#A] Headng wurd :tagg:
22 SCHEDULED: <2026-10-06 Tue>
23 :PROPERTIES:
24 :ID: abcxyz
25 :END:
26 Prose ~codez~ =verbz= [[https://x.org][desc]] <2026-01-01> wurds.
27 - [ ] itemm
28 #+begin_src sh
29 ecko
30 #+end_src
31 #+begin_quote
32 quotd
33 #+end_quote
34 : fixd
35 $x^2$ \\alpha
36 """ + "\n"
37 #expect(checked(text) == ["Headng", "wurd", "Prose", "wurds", "itemm", "quotd"])
38 }
39}