krz/orgstar

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

Commit 7708760851

7708760851bb006641ba731d1966cf18dc873e22

parent: 1d22097f14

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 05:16 UTC

Port fill paragraph

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +5 −2
@@ -13,16 +13,19 @@ public struct EditingOptions: Sendable, Equatable, Codable {
13 public var listAllowAlphabetical: Bool 13 public var listAllowAlphabetical: Bool
14 /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note. 14 /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note.
15 public var logRepeat: Bool 15 public var logRepeat: Bool
16 /// `fill-column` for M-q.
17 public var fillColumn: Int
16 18
17 public init( 19 public init(
18 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, 20 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
19 listAllowAlphabetical: Bool = false, logRepeat: Bool = true 21 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70
20 ) { 22 ) {
21 self.tagsColumn = tagsColumn 23 self.tagsColumn = tagsColumn
22 self.insertHeadingRespectContent = insertHeadingRespectContent 24 self.insertHeadingRespectContent = insertHeadingRespectContent
23 self.metaReturnMaySplitLine = metaReturnMaySplitLine 25 self.metaReturnMaySplitLine = metaReturnMaySplitLine
24 self.listAllowAlphabetical = listAllowAlphabetical 26 self.listAllowAlphabetical = listAllowAlphabetical
25 self.logRepeat = logRepeat 27 self.logRepeat = logRepeat
28 self.fillColumn = fillColumn
26 } 29 }
27 30
28 /// Org's own defaults. 31 /// Org's own defaults.
@@ -148,7 +151,7 @@ public enum Commands {
148 InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(), 151 InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(),
149 MoveItemUp(), MoveItemDown(), ToggleCheckbox(), 152 MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
150 InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true), 153 InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true),
151 RemovePlanning(deadline: false), RemovePlanning(deadline: true), 154 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(),
152 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 155 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
153 ] 156 ]
154 157
Sources/OrgCore/Commands/Fill.swift added +480
@@ -0,0 +1,480 @@
1import Foundation
2
3// M-q: `org-fill-paragraph` (Org 9.8.7) over Emacs's `fill-region-as-paragraph` (fill.el), for
4// paragraphs, comments and comment blocks, with `sentence-end-double-space` nil,
5// `colon-double-space` nil, `left-margin` 0 and left justification.
6
7extension EmacsBuffer {
8 /// `org-element-paragraph-separate`, which org uses as `paragraph-start`.
9 var paragraphStartPattern: String {
10 let letters = options.listAllowAlphabetical ? "|[A-Za-z]" : ""
11 return "(?:\\*+ |\\[fn:[-_\\w]+\\]|%%\\(|[ \\t]*(?:$|\\||\\+(?:-+\\+)+[ \\t]*$|#(?: |$|\\+(?:BEGIN_\\S+|\\S+(?:\\[.*\\])?:[ \\t]*))|:(?: |$|[-_\\w]+:[ \\t]*$)|-{5,}[ \\t]*$|\\\\begin\\{([A-Za-z0-9*]+)\\}|(?:[-+*]|(?:[0-9]+\(letters))[.)])(?:[ \\t]|$)))"
12 }
13
14 static let adaptiveFillPattern = "[-–!|#%;>*·•‣⁃◦ \\t]*"
15 static let sentenceEndPattern = "([.?!…‽][\\]\"'”’)}»›]*($|[\\t \\u00A0])|[。.?!]+)[ \\u00A0\\t\\n]*"
16
17 /// An element as `org-element-at-point` sees it, with offsets in the buffer.
18 struct Element {
19 let kind: SyntaxKind
20 let range: Range<Int>
21 /// The enclosing element, for paragraphs in items.
22 let parentKind: SyntaxKind?
23 let parentStart: Int?
24 /// For blocks: the word after `#+begin_`, lowercased.
25 let blockType: String?
26 /// For plain lists: the first item's start.
27 let firstItem: Int?
28 }
29
30 static let elementKinds: Set<SyntaxKind> = [.paragraph, .comment, .block, .table, .tableRow, .item, .plainList, .heading,
31 .keyword, .fixedWidth, .drawer, .propertyDrawer, .planning, .dynamicBlock,
32 .footnoteDefinition, .horizontalRule, .clock, .nodeProperty]
33
34 /// The innermost element (not object) holding `offset`, as `org-element-at-point`. Greater
35 /// blocks (quote, center, special) are parsed into their elements.
36 func element(at offset: Int) -> Element? {
37 element(at: offset, in: string, base: 0)
38 }
39
40 private func element(at offset: Int, in text: String, base: Int) -> Element? {
41 let tree = OrgParser.parse(text, defaults: settings)
42 let local = offset - base
43 var node = tree.root
44 var found: SyntaxNode?
45 while true {
46 let child = node.child(containing: local) ?? (local == node.range.upperBound ? node.children.last : nil)
47 guard let child, Self.elementKinds.contains(child.kind) || child.kind == .section || child.kind == .zerothSection else { break }
48 if Self.elementKinds.contains(child.kind) { found = child }
49 if [.heading, .paragraph, .comment, .tableRow, .block].contains(child.kind) { break }
50 node = child
51 }
52 guard let found else { return nil }
53 let range = (found.range.lowerBound + base)..<(found.range.upperBound + base)
54 var blockType: String?
55 if found.kind == .block {
56 let first = found.text.prefix { $0 != "\n" }.lowercased()
57 blockType = first.firstMatch(of: /#\+begin_(\S+)/).map { String($0.1) }
58 if let type = blockType, !["src", "example", "export", "comment", "verse"].contains(type) {
59 let contentsStart = lineEnd(range.lowerBound) + 1
60 let closing = saveExcursion { () -> Int in
61 point = range.upperBound
62 skipBackward(Self.whitespace)
63 return lineStart(point)
64 }
65 if offset >= contentsStart, offset < closing, contentsStart < closing {
66 return element(at: offset, in: substring(contentsStart, closing), base: contentsStart)
67 }
68 }
69 }
70 let parent = found.parent.flatMap { Self.elementKinds.contains($0.kind) ? $0 : nil }
71 return Element(
72 kind: found.kind, range: range, parentKind: parent?.kind, parentStart: parent.map { $0.range.lowerBound + base },
73 blockType: blockType, firstItem: found.children.first { $0.kind == .item }.map { $0.range.lowerBound + base }
74 )
75 }
76
77 /// Where an element's contents end: after its last non-blank line.
78 func contentsEnd(_ node: Element) -> Int {
79 saveExcursion {
80 point = node.range.upperBound
81 skipBackward(Self.whitespace)
82 return min(lineEnd(point) + 1, length)
83 }
84 }
85
86 /// `org-list-item-body-column`.
87 func itemBodyColumn(_ item: Int) -> Int {
88 saveExcursion {
89 point = item
90 let match = looking("[ \\t]*(\\S+)", setsMatch: false)!
91 point = match[1]!.upperBound
92 return currentColumn + 1
93 }
94 }
95
96 /// `org-adaptive-fill-function` at point's line.
97 func orgAdaptiveFillPrefix() -> String? {
98 saveExcursion {
99 guard !atHeading else { return nil }
100 let lineBegin = lineStart(point)
101 guard let element = element(at: lineBegin) else { return nil }
102 switch element.kind {
103 case .comment:
104 let indent = substring(lineBegin, lineBegin + (looking("[ \\t]*", setsMatch: false).map { $0[0]!.count } ?? 0))
105 return indent + "# "
106 case .item, .plainList:
107 let item = element.kind == .item ? element.range.lowerBound : (element.firstItem ?? element.range.lowerBound)
108 return String(repeating: " ", count: itemBodyColumn(item))
109 case .block where element.blockType == "comment":
110 let begin = lineEnd(element.range.lowerBound) + 1
111 let end = saveExcursion { () -> Int in
112 point = element.range.upperBound
113 skipBackward(Self.whitespace)
114 return lineStart(point)
115 }
116 guard lineBegin >= begin, lineBegin < end else { return nil }
117 point = lineBegin
118 return looking("[ \\t]+", setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) } ?? ""
119 case .paragraph:
120 point = lineBegin
121 if element.parentKind == .item, let parent = element.parentStart {
122 return String(repeating: " ", count: itemBodyColumn(parent))
123 }
124 if let prefix = contextPrefix(from: postAffiliated(element), to: element.range.upperBound, paragraphOnly: true) {
125 return prefix
126 }
127 return looking("[ \\t]+", setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) } ?? ""
128 default:
129 return nil
130 }
131 }
132 }
133
134 /// Start of an element after its affiliated keywords.
135 func postAffiliated(_ node: Element) -> Int {
136 saveExcursion {
137 point = node.range.lowerBound
138 while looking("[ \\t]*#\\+(?:CAPTION|DATA|HEADERS?|LABEL|NAME|PLOT|RESNAME|RESULTS?|SOURCE|SRCNAME|TBLNAME|ATTR_[-_\\w]+)(?:\\[.*\\])?:", setsMatch: false) != nil {
139 if forwardLine(1) != 0 { break }
140 }
141 return point
142 }
143 }
144
145 /// `fill-match-adaptive-prefix`, with org's function or, in paragraphs, the regexp after
146 /// any markup at the line start.
147 func matchAdaptivePrefix(paragraphOnly: Bool) -> String? {
148 let prefix: String?
149 if paragraphOnly {
150 let start = point
151 prefix = saveExcursion {
152 skipLineStartMarkup()
153 return looking(Self.adaptiveFillPattern, setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) }
154 }
155 point = start
156 } else {
157 prefix = orgAdaptiveFillPrefix() ?? looking(Self.adaptiveFillPattern, setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) }
158 }
159 guard let prefix else { return nil }
160 return (prefix as NSString).length >= options.fillColumn ? nil : prefix
161 }
162
163 /// Moves past the opening markup of an object that starts at point, as
164 /// `org-adaptive-fill-paragraph-function` does.
165 private func skipLineStartMarkup() {
166 let line = substring(point, lineEnd(point)) + "\n"
167 let tree = OrgParser.parse(line, defaults: settings)
168 var node: SyntaxNode? = tree.root
169 while let current = node, let child = current.child(containing: 0), child.range.lowerBound == 0 {
170 if [.bold, .italic, .underline, .strikeThrough].contains(child.kind) {
171 point += 1
172 return
173 }
174 if child.kind == .link, let description = child.firstChild(.linkDescription) {
175 point += description.range.lowerBound
176 return
177 }
178 node = child
179 }
180 }
181
182 /// `fill-context-prefix`.
183 func contextPrefix(from: Int, to: Int, paragraphOnly: Bool = false) -> String? {
184 saveExcursion {
185 point = from
186 if eolp { forwardLine(1) }
187 point = lineStart(point)
188 var first = matchAdaptivePrefix(paragraphOnly: paragraphOnly)
189 forwardLine(1)
190 if point < to {
191 point = lineStart(point)
192 let second: String? = looking(paragraphStartPattern, setsMatch: false) != nil ? nil : matchAdaptivePrefix(paragraphOnly: paragraphOnly)
193 if let second {
194 if first == nil { first = "" }
195 var pattern = "^"
196 var rest = Substring(second)
197 while let match = rest.firstMatch(of: /^[ \t]*([^ \t]+)/) {
198 pattern += ".*" + NSRegularExpression.escapedPattern(for: String(match.1))
199 rest = rest[match.range.upperBound...]
200 }
201 if first!.range(of: pattern, options: .regularExpression) != nil { return second }
202 let common = zip(first!, second).prefix { $0 == $1 }.count
203 return common == 0 ? nil : String(first!.prefix(common))
204 }
205 }
206 guard let firstPrefix = first else { return nil }
207 let keep = firstPrefix.allSatisfy({ $0 == " " || $0 == "\t" }) || firstPrefix.range(of: "^\\s*#( |$)", options: .regularExpression) != nil
208 let result = keep ? firstPrefix : String(repeating: " ", count: displayWidth(firstPrefix))
209 let probe = result + "a"
210 let regex = Self.regex(paragraphStartPattern, caseInsensitive: false)
211 if let match = regex.firstMatch(in: probe, options: [.anchored], range: NSRange(location: 0, length: (probe as NSString).length)), match.range.location == 0 {
212 return nil
213 }
214 return result
215 }
216 }
217
218 /// `fill-nobreak-p` with org's predicates.
219 func fillNoBreak() -> Bool {
220 if bolp { return false }
221 let startsParagraph = saveExcursion { () -> Bool in
222 skipForward(Self.blanks)
223 return !eolp && looking(paragraphStartPattern, setsMatch: false) != nil
224 }
225 if startsParagraph { return true }
226 let lineBreak = saveExcursion { () -> Bool in
227 skipBackward(Self.blanks)
228 skipBackward([92])
229 return looking("\\\\\\\\($|[^\\\\])", setsMatch: false) != nil
230 }
231 if lineBreak { return true }
232 if looking("[ \\t]*\\{\\{\\{n(?:\\([^\\n)]*\\))?\\}\\}\\}[.)](?:$| )", setsMatch: false) != nil { return true }
233 let saved = lastMatch
234 defer { lastMatch = saved }
235 if atTimestamp() != nil, looking("[\\[<](" + Self.timestampCore + ")[^\\]>\\n]*[\\]>]", setsMatch: false) == nil { return true }
236 return false
237 }
238
239 /// `fill-move-to-break-point` for text without CJK characters.
240 func moveToBreakPoint(_ lineBegin: Int) {
241 if lineBegin > point { point = lineBegin }
242 while true {
243 guard let match = searchBackward("[ \\t]", bound: lineBegin, caseInsensitive: false) else {
244 point = lineBegin
245 break
246 }
247 point = match[0]!.lowerBound + 1
248 if fillNoBreak() {
249 skipBackward(Self.blanks, bound: lineBegin)
250 } else {
251 break
252 }
253 }
254 skipBackward(Self.blanks)
255 if lineBegin >= point {
256 let end = lineEnd(point)
257 var first = true
258 point = lineBegin
259 while point < end, first || fillNoBreak() {
260 skipForward(Self.blanks)
261 while point < length, let c = character(at: point), c != 32, c != 10, c != 9 { point += 1 }
262 first = false
263 }
264 }
265 }
266
267 /// `skip-chars-backward` stopping at `bound`.
268 func skipBackward(_ set: Set<unichar>, bound: Int) {
269 while point > bound, let c = character(at: point - 1), set.contains(c) { point -= 1 }
270 }
271
272 /// `canonically-space-region` with single spaces after sentences.
273 func canonicallySpace(_ begin: Int, _ endOffset: Int) {
274 saveExcursion {
275 let end = marker(endOffset, advances: true)
276 defer { release(end) }
277 for offset in begin..<end.position where character(at: offset) == 9 { replace(offset..<(offset + 1), with: " ") }
278 point = begin
279 let pattern = "(" + Self.sentenceEndPattern + ") *| +"
280 while point < end.position, let match = searchForward(pattern, bound: end.position, caseInsensitive: false) {
281 let matchEnd = match[0]!.upperBound
282 let start: Int
283 if let sentence = match[1], matchEnd > sentence.upperBound {
284 start = sentence.upperBound
285 } else if match[1] != nil {
286 let lastNonSpace = saveExcursion { () -> Int in point = matchEnd; skipBackward([32]); return point }
287 start = min(matchEnd, lastNonSpace + 1)
288 } else {
289 let before = saveExcursion { () -> unichar? in
290 point = match[0]!.lowerBound
291 skipBackward(Set(" ]})\"'".utf16))
292 return character(at: point - 1)
293 }
294 start = match[0]!.lowerBound + (before == 10 ? 0 : 1)
295 }
296 if start < matchEnd { deleteRegion(start, matchEnd) }
297 }
298 }
299 }
300
301 /// `fill-region-as-paragraph` with left justification and adaptive fill.
302 func fillRegionAsParagraph(_ fromOffset: Int, _ toOffset: Int, prefix fixedPrefix: String? = nil) {
303 point = min(fromOffset, toOffset)
304 var to = max(fromOffset, toOffset)
305 skipForward(Self.whitespace)
306 let fromPlusIndent = point
307 point = lineStart(point)
308 var from = max(point, fromOffset)
309 point = to
310 var oneLeft = false
311 while point > from, character(at: point - 1) == 10 {
312 if oneLeft {
313 deleteRegion(point - 1, point)
314 } else {
315 point -= 1
316 oneLeft = true
317 }
318 }
319 let toMarker = marker(point, advances: true)
320 defer { release(toMarker) }
321 point = fromPlusIndent
322 guard toMarker.position > point else { return }
323 var prefix = fixedPrefix
324 if prefix == nil || prefix == "" { prefix = contextPrefix(from: from, to: toMarker.position) }
325 point = lineStart(from)
326 // `fill-delete-prefix`.
327 let prefixPattern: String
328 if let prefix, !prefix.allSatisfy({ $0 == " " || $0 == "\t" }) {
329 prefixPattern = "[ \\t]*(" + NSRegularExpression.escapedPattern(for: prefix).replacingOccurrences(of: "[ \\t]+", with: "[ \\t]*", options: .regularExpression) + ")?[ \\t]*"
330 } else {
331 prefixPattern = "[ \\t]*"
332 }
333 point = from
334 forwardLine(1)
335 while point < toMarker.position {
336 if let match = looking(prefixPattern, setsMatch: false) { deleteRegion(point, match[0]!.upperBound) }
337 if forwardLine(1) != 0 { break }
338 }
339 point = from
340 if let match = looking(prefixPattern, setsMatch: false) { point = match[0]!.upperBound }
341 from = point
342 // `fill-delete-newlines`.
343 var at = from
344 while at < toMarker.position {
345 if character(at: at) == 10 { replace(at..<(at + 1), with: " ") }
346 at += 1
347 }
348 point = from
349 skipForward(Self.blanks)
350 canonicallySpace(point, toMarker.position)
351 point = toMarker.position
352 let end = point
353 skipBackward(Self.blanks)
354 if point < end { deleteRegion(point, end) }
355 point = from
356 while point < toMarker.position {
357 let lineBegin = point
358 moveToColumn(options.fillColumn)
359 var breaks = false
360 if point < toMarker.position, lineBegin < toMarker.position {
361 if currentColumn <= options.fillColumn { point += 1 }
362 moveToBreakPoint(lineBegin)
363 skipForward(Self.blanks)
364 breaks = point < toMarker.position
365 }
366 guard breaks else {
367 point = toMarker.position
368 break
369 }
370 // `fill-newline`.
371 skipBackward(Self.blanks)
372 insert("\n")
373 indentLineTo(0)
374 if let prefix, !prefix.isEmpty { insertBeforeMarkers(prefix) }
375 }
376 point = toMarker.position
377 if !eobp { point += 1 }
378 }
379
380 /// `org-fill-paragraph` without a region or justification.
381 func fillParagraph() {
382 saveExcursion {
383 if lookingAt("^[ \\t]*$") != nil || saveExcursion({ beginningOfLine(); return lookingAt("[ \\t]*$") != nil }) {
384 skipForward(Self.whitespace)
385 }
386 let eol = lineEnd(point)
387 guard let element = element(at: eol) else { return }
388 switch element.kind {
389 case .paragraph:
390 let begin = paragraphContentsBegin(element)
391 var end = contentsEnd(element)
392 guard lineEnd(point) >= begin else { return }
393 var cuts = [begin]
394 saveExcursion {
395 point = begin
396 while let match = searchForward("\\\\\\\\[ \\t]*\\n", bound: end, caseInsensitive: false) {
397 cuts.insert(match[0]!.upperBound, at: 0)
398 }
399 }
400 for cut in cuts where cut != end {
401 fillRegionAsParagraph(cut, end)
402 end = cut
403 }
404 case .comment:
405 let begin = postAffiliated(element)
406 let elementEnd = element.range.upperBound
407 guard point >= begin, point <= elementEnd else { return }
408 let from = saveExcursion { () -> Int in
409 endOfLine()
410 if searchBackward("^[ \\t]*#[ \\t]*$", bound: begin, caseInsensitive: false) != nil {
411 forwardLine(1)
412 return point
413 }
414 return begin
415 }
416 let to = saveExcursion { () -> Int in
417 endOfLine()
418 if searchForward("^[ \\t]*#[ \\t]*$", bound: elementEnd, caseInsensitive: false) != nil {
419 return lineStart(point) - 1
420 }
421 point = elementEnd
422 skipBackward(Self.whitespace)
423 return lineEnd(point)
424 }
425 guard to > from else { return }
426 let prefix = saveExcursion { () -> String in
427 beginningOfLine()
428 let comment = looking("[ \\t]*#", setsMatch: false)!
429 point = comment[0]!.upperBound
430 let marker = substring(comment[0]!.lowerBound, comment[0]!.upperBound)
431 if let rest = looking(Self.adaptiveFillPattern, setsMatch: false) {
432 return marker + substring(rest[0]!.lowerBound, rest[0]!.upperBound)
433 }
434 return marker + " "
435 }
436 saveExcursion { fillRegionAsParagraph(from, to, prefix: prefix) }
437 case .block where element.blockType == "comment":
438 let begin = saveExcursion { () -> Int in
439 point = element.range.lowerBound
440 forwardLine(1)
441 return point
442 }
443 let end = saveExcursion { () -> Int in
444 point = element.range.upperBound
445 skipBackward(Self.whitespace)
446 return lineStart(point)
447 }
448 guard point >= begin, point <= end else { return }
449 let from = saveExcursion { () -> Int in
450 endOfLine()
451 if searchBackward("^[ \\t]*$", bound: begin, caseInsensitive: false) == nil { point = begin }
452 return lineStart(point)
453 }
454 let to = saveExcursion { () -> Int in
455 beginningOfLine()
456 if searchForward("^[ \\t]*$", bound: end, caseInsensitive: false) == nil { point = end }
457 return lineStart(point)
458 }
459 fillRegionAsParagraph(from, to)
460 default:
461 return
462 }
463 }
464 }
465
466 /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that
467 /// opens an item, past the bullet.
468 func paragraphContentsBegin(_ node: Element) -> Int {
469 max(postAffiliated(node), node.range.lowerBound)
470 }
471}
472
473/// M-q: `org-fill-paragraph`.
474public struct FillParagraph: OrgCommand {
475 public init() {}
476 public var id: String { "org.fill-paragraph" }
477 public var title: String { "Fill Paragraph" }
478 public func applies(in context: EditContext) -> Bool { true }
479 public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } }
480}
Sources/OrgCore/Keymap/Presets.swift +6
@@ -267,6 +267,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
267 keys = "C-c C-d" 267 keys = "C-c C-d"
268 command = "org.deadline" 268 command = "org.deadline"
269 [[bind]] 269 [[bind]]
270 keys = "M-q"
271 command = "org.fill-paragraph"
272 [[bind]]
270 keys = "S-<up>" 273 keys = "S-<up>"
271 command = "org.timestamp.up" 274 command = "org.timestamp.up"
272 when = "timestamp" 275 when = "timestamp"
@@ -387,6 +390,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
387 keys = "C-s-d" 390 keys = "C-s-d"
388 command = "org.deadline" 391 command = "org.deadline"
389 [[bind]] 392 [[bind]]
393 keys = "C-s-p"
394 command = "org.fill-paragraph"
395 [[bind]]
390 keys = "C-s-<up>" 396 keys = "C-s-<up>"
391 command = "org.timestamp.up" 397 command = "org.timestamp.up"
392 when = "timestamp" 398 when = "timestamp"
Tests/OrgCoreTests/EmacsOracle.swift +2 −2
@@ -124,11 +124,11 @@ enum EmacsOracle {
124/// The options as Emacs Lisp bindings around `form`. 124/// The options as Emacs Lisp bindings around `form`.
125func withOptions(_ options: EditingOptions, _ form: String) -> String { 125func withOptions(_ options: EditingOptions, _ form: String) -> String {
126 func flag(_ value: Bool) -> String { value ? "t" : "nil" } 126 func flag(_ value: Bool) -> String { value ? "t" : "nil" }
127 return "(let ((org-tags-column \(options.tagsColumn)) (org-insert-heading-respect-content \(flag(options.insertHeadingRespectContent))) (org-M-RET-may-split-line \(flag(options.metaReturnMaySplitLine))) (org-list-allow-alphabetical \(flag(options.listAllowAlphabetical)))) \(form))" 127 return "(let ((org-tags-column \(options.tagsColumn)) (org-insert-heading-respect-content \(flag(options.insertHeadingRespectContent))) (org-M-RET-may-split-line \(flag(options.metaReturnMaySplitLine))) (org-list-allow-alphabetical \(flag(options.listAllowAlphabetical))) (fill-column \(options.fillColumn)) (sentence-end-double-space nil)) \(form))"
128} 128}
129 129
130/// The options in the user's Doom Emacs. 130/// The options in the user's Doom Emacs.
131let doomOptions = EditingOptions(tagsColumn: 0, insertHeadingRespectContent: true, metaReturnMaySplitLine: false, listAllowAlphabetical: true) 131let doomOptions = EditingOptions(tagsColumn: 0, insertHeadingRespectContent: true, metaReturnMaySplitLine: false, listAllowAlphabetical: true, fillColumn: 80)
132 132
133/// Runs a command the way an editor would: build a context, run, apply the edits. 133/// Runs a command the way an editor would: build a context, run, apply the edits.
134func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:], options: EditingOptions = .org) -> (text: String, caret: Int, failure: String?) { 134func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:], options: EditingOptions = .org) -> (text: String, caret: Int, failure: String?) {
Tests/OrgCoreTests/FillTests.swift added +70
@@ -0,0 +1,70 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct FillTests {
6 @Test func fillsAParagraph() {
7 let text = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen\n"
8 let filled = runCommand(FillParagraph(), text, caret: 0).text
9 #expect(filled == "one two three four five six seven eight nine ten eleven twelve\nthirteen fourteen fifteen\n")
10 }
11
12 static let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."
13 static let variants = [
14 long + "\n",
15 "Short\nlines that\nshould join. Two spaces after.\n",
16 "- item " + long + "\n - nested " + long + "\n",
17 "1. [ ] " + long + "\n",
18 "# comment " + long + "\n# more\n",
19 "* Heading\n" + long + "\n\nSecond " + long + "\n",
20 " indented " + long + "\n continued\n",
21 "> quoted " + long + "\n> more quoted text\n",
22 "Has a line break \\\\\n" + long + "\n",
23 "Date <2026-09-10 Thu 10:00> inside a long sentence that keeps going and going and going past the column.\n",
24 "A [[https://example.com/a/very/long/path][link with a description]] that runs past the fill column for sure.\n",
25 "Ends with - a dash that would start a list if it began a line and continues further on.\n",
26 "Words with extra spaces\tand\ttabs that should collapse into single spaces when filled together.\n",
27 "#+CAPTION: cap\nCaptioned paragraph " + long + "\n",
28 "*bold start* of a line with " + long + "\n",
29 "Unicode ünïcödé wörds ñ " + long + "\n",
30 ]
31
32 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
33 func fillMatchesEmacs() throws {
34 let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [(FillParagraph(), "(org-fill-paragraph)", false)]
35 let inputs = Self.variants.map { ($0, EmacsOracle.positions($0).enumerated().filter { $0.offset % 7 == 0 || $0.element == 0 }.map(\.element)) }
36 try compareWithEmacs(inputs, commands)
37 try compareWithEmacs(inputs, commands, options: doomOptions)
38 }
39
40 /// Real paragraphs: `ORGSTAR_ORACLE_CORPUS=<folder>` fills the first 30 paragraph-like lines
41 /// of up to 60 files.
42 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
43 func fillMatchesEmacsOnACorpus() throws {
44 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
45 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
46 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
47 var samples: [(String, [Int])] = []
48 for file in files where samples.count < 60 {
49 guard let text = try? String(contentsOf: file, encoding: .utf8), text.utf16.count <= 20_000, !text.contains("\\r") else { continue }
50 let ns = text as NSString
51 var carets: [Int] = []
52 var location = 0
53 var inSource = false
54 while location < ns.length, carets.count < 30 {
55 let line = ns.lineRange(for: NSRange(location: location, length: 0))
56 let content = ns.substring(with: line)
57 let lower = content.lowercased().trimmingCharacters(in: .whitespaces)
58 // M-q in a src block fills through the language's mode in Emacs; Orgstar leaves code alone.
59 if lower.hasPrefix("#+begin_src") { inSource = true }
60 if lower.hasPrefix("#+end_src") { inSource = false }
61 if !inSource, content.count > 20, !content.hasPrefix("*"), !content.hasPrefix("#+"), !content.hasPrefix("|") { carets.append(line.location + 3) }
62 location = NSMaxRange(line)
63 }
64 if !carets.isEmpty { samples.append((text, carets)) }
65 }
66 let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [(FillParagraph(), "(org-fill-paragraph)", false)]
67 try compareWithEmacs(samples, commands)
68 try compareWithEmacs(samples, commands, options: doomOptions)
69 }
70}