Footnote commands !83

merged merged by cmc on 2026-10-06 03:50 UTC · krz/orgstar:footnotes into main

5 files changed, +627 −1

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +1 −1
@@ -232,7 +232,7 @@ public enum Commands {
232232 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
233233 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
234234 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
235 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(),
235 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true),
236236 ]
237237
238238 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/CtrlCCtrlC.swift +4
@@ -22,6 +22,10 @@ public struct CtrlCCtrlC: OrgCommand {
2222 let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret
2323 return TableRecalculate.tblfmLine(in: context) != nil || atStart ? TableRecalculate(all: true).run(in: context) : TableCommand(.align).run(in: context)
2424 }
25 if buffer.footnoteReferenceAtPoint() != nil
26 || buffer.footnoteDefinitions().contains(where: { $0.begin <= context.caret && context.caret < ($0.contentsBegin ?? $0.end) }) {
27 return FootnoteAction().run(in: context)
28 }
2529 if buffer.lookingAtLine("^[ \\t]*$") { return Self.nothing }
2630 // On a clock line, changing a timestamp updates the clock too.
2731 if buffer.lookingAtLine("^[ \\t]*CLOCK:") { return commitBuffer(context) { $0.clockUpdateTime() } }
Sources/OrgCore/Commands/Footnotes.swift added +520
@@ -0,0 +1,520 @@
1import Foundation
2
3// Footnotes (`org-footnote.el`, Org 9.8.7) with the defaults: `org-footnote-section`
4// "Footnotes", `org-footnote-auto-label` t, `org-footnote-define-inline` and
5// `org-footnote-auto-adjust` nil.
6
7extension EmacsBuffer {
8 struct FootnoteReference: Equatable {
9 /// Nil for anonymous references.
10 var label: String?
11 var begin: Int
12 /// After the closing bracket.
13 var end: Int
14 /// An inline definition's text.
15 var contents: Range<Int>?
16 /// The label of the definition the reference sits in.
17 var definition: String?
18 }
19
20 struct FootnoteDefinition {
21 var label: String
22 var begin: Int
23 var contentsBegin: Int?
24 /// The line after its last non-blank line.
25 var end: Int
26 }
27
28 static let footnoteSection = "Footnotes"
29
30 private func footnoteTree() -> OrgTree { OrgParser.parse(string) }
31
32 /// Definitions in buffer order, ending as `org-element--footnote-separator` ends them.
33 func footnoteDefinitions(_ tree: OrgTree? = nil) -> [FootnoteDefinition] {
34 let tree = tree ?? footnoteTree()
35 return tree.root.descendants().filter { $0.kind == .footnoteDefinition }.compactMap { node in
36 saveExcursion {
37 point = node.range.lowerBound
38 guard let match = lookingAt("\\[fn:([-_\\w]+)\\]") else { return nil }
39 let label = substring(match[1]!.lowerBound, match[1]!.upperBound)
40 point = lineEnd(point)
41 let separator = searchForward("^(?:\\*+ |\\[fn:[-_\\w]+\\]|([ \\t]*\\n){2,})", caseInsensitive: false)
42 var raw = length
43 if let separator {
44 raw = separator[0]!.lowerBound
45 if separator[1] != nil {
46 point = raw
47 skipForward([32, 13, 9, 10])
48 raw = eobp ? length : lineStart(point)
49 }
50 }
51 point = match[0]!.upperBound
52 skipForward([32, 13, 9, 10])
53 let contentsBegin: Int? = point >= raw ? nil : lineStart(point) == node.range.lowerBound ? point : lineStart(point)
54 point = raw
55 skipBackward([32, 13, 9, 10])
56 forwardLine(1)
57 return FootnoteDefinition(label: label, begin: node.range.lowerBound, contentsBegin: contentsBegin, end: point)
58 }
59 }
60 }
61
62 /// References in buffer order.
63 func footnoteReferences(_ tree: OrgTree? = nil) -> [FootnoteReference] {
64 let tree = tree ?? footnoteTree()
65 let definitions = footnoteDefinitions(tree)
66 return tree.root.descendants().filter { $0.kind == .footnoteReference }.compactMap { node in
67 saveExcursion {
68 point = node.range.lowerBound
69 guard let match = lookingAt("\\[fn:([-_\\w]*)(:)?") else { return nil }
70 let label = match[1]!.isEmpty ? nil : substring(match[1]!.lowerBound, match[1]!.upperBound)
71 let contents = match[2] != nil ? match[0]!.upperBound..<(node.range.upperBound - 1) : nil
72 let holder = definitions.last { $0.begin <= node.range.lowerBound && node.range.lowerBound < $0.end }
73 return FootnoteReference(label: label, begin: node.range.lowerBound, end: node.range.upperBound, contents: contents, definition: holder?.label)
74 }
75 }
76 }
77
78 /// `org-footnote-at-reference-p`.
79 func footnoteReferenceAtPoint() -> FootnoteReference? {
80 footnoteReferences().first { $0.begin <= point && point < $0.end }
81 }
82
83 /// `org-footnote-at-definition-p`: anywhere in a definition.
84 func footnoteDefinitionAtPoint() -> FootnoteDefinition? {
85 footnoteDefinitions().first { $0.begin <= point && point < max($0.end, $0.begin + 1) }
86 }
87
88 /// `org-footnote-all-labels`.
89 func footnoteLabels() -> [String] {
90 let tree = footnoteTree()
91 var all: [String] = []
92 for label in footnoteReferences(tree).compactMap(\.label) + footnoteDefinitions(tree).map(\.label) where !all.contains(label) {
93 all.append(label)
94 }
95 return all
96 }
97
98 /// `org-footnote-unique-label`.
99 func footnoteUniqueLabel(_ current: [String]) -> String {
100 var count = 1
101 while current.contains(String(count)) { count += 1 }
102 return String(count)
103 }
104
105 /// Where `org-footnote-get-definition` finds LABEL: its definition, or an inline reference.
106 func footnoteDefinitionStart(_ label: String) -> Int? {
107 let tree = footnoteTree()
108 let definition = footnoteDefinitions(tree).first { $0.label == label }?.begin
109 let inline = footnoteReferences(tree).first { $0.label == label && $0.contents != nil && $0.begin > 0 }?.begin
110 return [definition, inline].compactMap { $0 }.min()
111 }
112
113 /// `org-footnote--allow-reference-p` for a labeled reference.
114 func footnoteReferenceAllowed() -> Bool {
115 if bolp { return false }
116 let tree = footnoteTree()
117 var chain: [SyntaxNode] = []
118 var node = tree.root
119 while let child = node.child(containing: point) {
120 chain.append(child)
121 node = child
122 }
123 if chain.contains(where: { $0.kind == .heading }), let heading = chain.first(where: { $0.kind == .heading }),
124 lineStart(point) == heading.range.lowerBound {
125 guard let title = heading.firstChild(.title) else { return false }
126 let tags = heading.tokens.first { $0.kind == .tags }
127 return point >= title.range.lowerBound && tags.map { point < $0.range.lowerBound } ?? true
128 }
129 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp,
130 .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript]
131 let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer]
132 let element = chain.last { !objects.contains($0.kind) && !containers.contains($0.kind) }
133 switch element?.kind {
134 case nil, .paragraph, .tableRow, .tableCell:
135 break
136 case .footnoteDefinition:
137 let definition = footnoteDefinitions(tree).first { $0.begin == element!.range.lowerBound }
138 if let begin = definition?.contentsBegin, point >= begin { break }
139 return character(at: point).map { [32, 9, 10, 13].contains($0) } ?? false
140 case .block:
141 let block = element!
142 let opening = saveExcursion { () -> Bool in
143 point = block.range.lowerBound
144 return lookingAt("[ \\t]*#\\+begin_verse") != nil
145 }
146 guard opening, let wrapper = Wrappers.starting(at: block.range.lowerBound, in: tree) else { return false }
147 let contentsBegin = lineEnd(block.range.lowerBound) + 1
148 return point >= contentsBegin && point < wrapper.closeLine
149 default:
150 // Blank lines after an element.
151 let after = saveExcursion { () -> Int in
152 point = element!.range.upperBound
153 skipBackward([32, 13, 9, 10])
154 forwardLine(1)
155 return point
156 }
157 return point >= after
158 }
159 guard let object = chain.last(where: { objects.contains($0.kind) }) else { return true }
160 if point == object.range.lowerBound { return true }
161 if chain.contains(where: { $0.kind == .link }) { return false }
162 switch object.kind {
163 case .bold, .italic, .underline, .strikeThrough, .superscript:
164 return point > object.range.lowerBound && point <= object.range.upperBound - 1
165 default:
166 return false
167 }
168 }
169
170 /// `org-back-over-empty-lines`, returning the number of empty lines passed.
171 @discardableResult
172 func backOverEmptyLinesCount() -> Int {
173 let position = point
174 backOverEmptyLines()
175 return countLines(point, position)
176 }
177
178 /// `org-end-of-meta-data` with FULL t.
179 func endOfMetaDataFull() {
180 endOfMetaData()
181 guard !atHeading else { return }
182 let end = saveExcursion { () -> Int in outlineNextHeading(); return point }
183 while !eobp {
184 if lookingAt("[ \\t]*$|^[ \\t]*CLOCK:") != nil {
185 forwardLine(1)
186 } else if lookingAt("[ \\t]*:[-_\\w]+:[ \\t]*$") != nil {
187 guard searchForward("^[ \\t]*:END:[ \\t]*$", bound: end, caseInsensitive: true) != nil else { return }
188 forwardLine(1)
189 } else {
190 return
191 }
192 }
193 }
194
195 /// `org-footnote--clear-footnote-section`.
196 func clearFootnoteSection() {
197 point = 0
198 let pattern = "^\\*+ +" + NSRegularExpression.escapedPattern(for: Self.footnoteSection) + "[ \\t]*$"
199 while let match = searchForward(pattern, caseInsensitive: false) {
200 point = match[0]!.lowerBound
201 let end = endOfSubtree(toHeading: true)
202 deleteRegion(match[0]!.lowerBound, end)
203 point = match[0]!.lowerBound
204 }
205 point = length
206 skipBackward([32, 13, 9, 10])
207 if !bobp {
208 forwardLine(1)
209 if eolp { insert("\n") }
210 }
211 deleteRegion(point, length)
212 if saveExcursion({ backOverEmptyLinesCount() }) == 0 { insert("\n") }
213 insert("* " + Self.footnoteSection + "\n")
214 }
215
216 /// `org-footnote-create-definition`: the definition's start.
217 func createFootnoteDefinition(_ label: String) -> Int {
218 saveExcursion {
219 let pattern = "^\\*+[ \\t]+" + NSRegularExpression.escapedPattern(for: Self.footnoteSection) + "[ \\t]*$"
220 point = 0
221 if let match = searchForward(pattern, caseInsensitive: false) {
222 point = match[0]!.upperBound
223 endOfMetaDataFull()
224 if !bolp { insert("\n") }
225 } else {
226 clearFootnoteSection()
227 }
228 if backOverEmptyLinesCount() == 0 { insert("\n") }
229 insert("[fn:\(label)] \n")
230 forwardLine(-1)
231 return point
232 }
233 }
234
235 /// `org-footnote-goto-definition`.
236 func gotoFootnoteDefinition(_ label: String, at start: Int) {
237 point = start
238 if let match = lookingAt("\\[fn:" + NSRegularExpression.escapedPattern(for: label) + "[]:]") { point = match[0]!.upperBound }
239 }
240
241 /// `org-footnote-new`.
242 func newFootnote() throws {
243 let all = footnoteLabels()
244 let label = footnoteUniqueLabel(all)
245 guard footnoteReferenceAllowed() else { throw UserError(message: "Cannot insert a footnote here") }
246 insert("[fn:\(label)]")
247 gotoFootnoteDefinition(label, at: createFootnoteDefinition(label))
248 }
249
250 /// `org-footnote-goto-previous-reference`.
251 func gotoPreviousFootnoteReference(_ label: String) throws {
252 let references = footnoteReferences().filter { $0.label == label }
253 let opening = "[fn:\(label)".utf16.count + 1
254 guard let reference = references.last(where: { $0.begin + opening <= point }) ?? references.first(where: { $0.begin >= point }) else {
255 throw UserError(message: "Cannot find reference of footnote \"\(label)\"")
256 }
257 point = reference.begin
258 }
259
260 /// `org-footnote--set-label`, with point at the reference or definition.
261 func setFootnoteLabel(_ label: String) {
262 point += 4
263 if character(at: point) == 58 {
264 insert(label)
265 } else if let match = lookingAt("([-_\\w]+)") {
266 replace(match[1]!, with: label)
267 point = match[1]!.lowerBound + label.utf16.count
268 }
269 }
270
271 /// `org-footnote--collect-references`, in org's reading order: nested references follow
272 /// the definition they sit in.
273 func collectFootnoteReferences(anonymous: Bool = false) -> [FootnoteReference] {
274 let references = footnoteReferences().filter { anonymous || $0.label != nil }
275 var nested: [String: [String?]] = [:]
276 for reference in references { if let holder = reference.definition { nested[holder, default: []].append(reference.label) } }
277 var ordered: [FootnoteReference] = []
278 func add(_ reference: FootnoteReference, allowNested: Bool) {
279 guard allowNested || reference.definition == nil else { return }
280 ordered.append(reference)
281 for label in nested[reference.label ?? ""] ?? [] where reference.label != nil {
282 guard let r = references.last(where: { $0.label == label }), !ordered.contains(r) else { continue }
283 add(r, allowNested: true)
284 }
285 }
286 for reference in references { add(reference, allowNested: false) }
287 return ordered
288 }
289
290 /// `org-footnote--collect-definitions`: label and text, last first, as org's alist.
291 func collectFootnoteDefinitions(delete: Bool) -> [(label: String, text: String)] {
292 var seen: Set<String> = []
293 var found: [(label: String, text: String, range: Range<Int>)] = []
294 for definition in footnoteDefinitions() where !seen.contains(definition.label) {
295 seen.insert(definition.label)
296 let begin = saveExcursion { () -> Int in
297 point = definition.begin
298 skipBackward([32, 13, 9, 10])
299 if bobp { return point }
300 forwardLine(1)
301 return point
302 }
303 let text = substring(begin, definition.end).trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r"))
304 found.append((definition.label, text, begin..<definition.end))
305 }
306 if delete { for f in found.reversed() { deleteRegion(f.range.lowerBound, f.range.upperBound) } }
307 return found.reversed().map { ($0.label, $0.text) }
308 }
309
310 /// `org-footnote-renumber-fn:N`.
311 func renumberFootnotes() {
312 var c = 0
313 let references = collectFootnoteReferences().filter { $0.label!.range(of: "^[0-9]+$", options: .regularExpression) != nil }
314 var alist: [String: String] = [:]
315 for label in references.map({ $0.label! }) where alist[label] == nil {
316 c += 1
317 alist[label] = String(c)
318 }
319 let markers = references.map { marker($0.begin) }
320 for (reference, m) in zip(references, markers) {
321 point = m.position
322 setFootnoteLabel(alist[reference.label!]!)
323 }
324 markers.forEach(release)
325 point = 0
326 while let match = searchForward("^\\[fn:([0-9]+)\\]", caseInsensitive: false) {
327 let old = substring(match[1]!.lowerBound, match[1]!.upperBound)
328 let new = alist[old] ?? { c += 1; return String(c) }()
329 replace(match[1]!, with: new)
330 point = match[1]!.lowerBound + new.utf16.count + 1
331 }
332 }
333
334 /// `org-footnote-sort`.
335 func sortFootnotes() {
336 let references = collectFootnoteReferences()
337 let definitions = collectFootnoteDefinitions(delete: true)
338 clearFootnoteSection()
339 var inserted: [String] = []
340 for reference in references where !inserted.contains(reference.label!) && reference.contents == nil {
341 inserted.append(reference.label!)
342 insert("\n" + (definitions.first { $0.label == reference.label }?.text ?? "[fn:\(reference.label!)] DEFINITION NOT FOUND.") + "\n")
343 }
344 for definition in definitions where !inserted.contains(definition.label) {
345 insert("\n" + definition.text + "\n")
346 }
347 }
348
349 /// `org-footnote-normalize`.
350 func normalizeFootnotes() {
351 enum Key: Equatable {
352 case label(String)
353 case anonymous(Int)
354 }
355 var n = 0
356 var translations: [String: String] = [:]
357 var definitions: [(key: Key, text: String)] = []
358 let collected = collectFootnoteReferences(anonymous: true)
359 let markers = collected.map { marker($0.begin) }
360 var keys: [Key] = []
361 for (reference, m) in zip(collected, markers) {
362 let new: String
363 let key: Key
364 if let label = reference.label {
365 if let known = translations[label] {
366 new = known
367 } else {
368 n += 1
369 new = String(n)
370 translations[label] = new
371 }
372 key = .label(label)
373 } else {
374 n += 1
375 new = String(n)
376 key = .anonymous(n)
377 }
378 keys.append(key)
379 point = m.position
380 setFootnoteLabel(new)
381 if let contents = reference.contents {
382 let size = contents.count
383 let removed = substring(point, point + size + 1)
384 deleteRegion(point, point + size + 1)
385 let text = "[fn:\(new)] " + String(removed.dropFirst()).trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r"))
386 definitions.insert((key, text), at: 0)
387 }
388 }
389 markers.forEach(release)
390 definitions += collectFootnoteDefinitions(delete: true).map { (Key.label($0.label), $0.text) }
391 clearFootnoteSection()
392 var inserted: [Key] = []
393 for key in keys where !inserted.contains(key) {
394 inserted.append(key)
395 let stored = definitions.first { $0.key == key }?.text
396 let text: String
397 switch key {
398 case .anonymous(let number):
399 text = stored ?? "[fn:\(number)] DEFINITION NOT FOUND."
400 case .label(let label):
401 let new = translations[label]!
402 if let stored, let match = stored.firstMatch(of: /^\[fn:(.*?)\]/) {
403 text = stored.replacingCharacters(in: match.1.startIndex..<match.1.endIndex, with: new)
404 } else {
405 text = stored ?? "[fn:\(new)] DEFINITION NOT FOUND."
406 }
407 }
408 insert("\n" + text + "\n")
409 }
410 for definition in definitions where !inserted.contains(definition.key) {
411 n += 1
412 let number = n
413 let text = definition.text.replacingOccurrences(of: "(?m)^\\[fn:[-_\\w]+\\]", with: "[fn:\(number)]", options: .regularExpression)
414 insert("\n" + text + "\n")
415 }
416 }
417
418 /// `org-footnote-delete`: the message to show.
419 func deleteFootnote() throws -> String {
420 let label: String
421 if let reference = footnoteReferenceAtPoint() {
422 guard let l = reference.label else {
423 deleteRegion(reference.begin, reference.end)
424 return "Anonymous footnote removed"
425 }
426 label = l
427 } else if let definition = footnoteDefinitionAtPoint() {
428 label = definition.label
429 } else {
430 throw UserError(message: "Don't know which footnote to remove")
431 }
432 var references = 0
433 while let reference = footnoteReferences().first(where: { $0.label == label }) {
434 deleteRegion(reference.begin, reference.end)
435 references += 1
436 }
437 var definitions = 0
438 while let definition = footnoteDefinitions().first(where: { $0.label == label }) {
439 let begin = saveExcursion { () -> Int in
440 point = definition.begin
441 skipBackward([32, 13, 9, 10])
442 if bobp { return point }
443 forwardLine(1)
444 return point
445 }
446 deleteRegion(begin, definition.end)
447 definitions += 1
448 }
449 return "\(definitions) definition(s) of and \(references) reference(s) of footnote \(label) removed"
450 }
451}
452
453/// C-c C-x f: `org-footnote-action`. On a reference, goes to its definition; on a definition's
454/// label, back to a reference; elsewhere inserts a new footnote, or where one can't go, offers
455/// the footnote menu.
456public struct FootnoteAction: OrgCommand {
457 let special: Bool
458 public init(special: Bool = false) { self.special = special }
459 public var id: String { special ? "org.footnote.menu" : "org.footnote.action" }
460 public var title: String { special ? "Footnote Menu" : "Footnote Action" }
461 public func applies(in context: EditContext) -> Bool { true }
462
463 static let menu = FastSelection(items: [
464 .option(key: "s", name: "sort"), .option(key: "r", name: "renumber fn:N"), .option(key: "S", name: "renumber and sort"),
465 .option(key: "n", name: "normalize"), .option(key: "d", name: "delete"),
466 ], multiple: false)
467
468 public func run(in context: EditContext) -> CommandStep {
469 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
470 if !special {
471 if let reference = buffer.footnoteReferenceAtPoint() {
472 guard let label = reference.label else { return move(context, to: reference.contents!.lowerBound) }
473 if let start = buffer.footnoteDefinitionStart(label) {
474 buffer.gotoFootnoteDefinition(label, at: start)
475 return move(context, to: buffer.point, message: Self.definitionMessage)
476 }
477 guard let answer = context.answers["create"] else {
478 return .prompt(Prompt(key: "create", message: "No definition for \(label). Create one?", choices: ["yes", "no"]))
479 }
480 guard answer == "yes" else { return .failed("Quit") }
481 return commitBuffer(context) { buffer in
482 buffer.gotoFootnoteDefinition(label, at: buffer.createFootnoteDefinition(label))
483 }
484 }
485 if let definition = buffer.footnoteDefinitions().first(where: { $0.begin <= context.caret && context.caret < ($0.contentsBegin ?? $0.end) }) {
486 return commitBuffer(context) { try $0.gotoPreviousFootnoteReference(definition.label) }
487 }
488 if buffer.footnoteReferenceAllowed() { return commitBuffer(context) { try $0.newFootnote() } }
489 }
490 guard let key = context.answers["footnote"] else {
491 return .prompt(Prompt(key: "footnote", message: "Footnotes: [s]ort | [r]enumber fn:N | [S]=r+s | [n]ormalize | [d]elete", fast: Self.menu))
492 }
493 var message: String?
494 let step = commitBuffer(context) { buffer in
495 let caret = buffer.marker(buffer.point)
496 defer {
497 buffer.point = caret.position
498 buffer.release(caret)
499 }
500 switch key {
501 case "s": buffer.sortFootnotes()
502 case "r": buffer.renumberFootnotes()
503 case "S":
504 buffer.renumberFootnotes()
505 buffer.sortFootnotes()
506 case "n": buffer.normalizeFootnotes()
507 case "d": message = try buffer.deleteFootnote()
508 default: throw EmacsBuffer.UserError(message: "No such footnote command \(key)")
509 }
510 }
511 guard case .commit(let result) = step, let message else { return step }
512 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, effects: result.effects + [.message(message)]))
513 }
514
515 static let definitionMessage = "Edit definition and go back with `C-c &' or, if unique, with `C-c C-c'."
516
517 private func move(_ context: EditContext, to offset: Int, message: String? = nil) -> CommandStep {
518 .commit(EditResult(baseRevision: context.revision, edits: [], selection: [offset..<offset], effects: message.map { [.message($0)] } ?? []))
519 }
520}
Sources/OrgCore/Keymap/Presets.swift +3
@@ -641,6 +641,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
641641 keys = "C-c C-y"
642642 command = "org.timestamp.evaluate-range"
643643 [[bind]]
644 keys = "C-c C-x f"
645 command = "org.footnote.action"
646 [[bind]]
644647 keys = "C-c C-x C-r"
645648 command = "org.clock.report"
646649 [[bind]]
Tests/OrgCoreTests/FootnoteTests.swift added +99
@@ -0,0 +1,99 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct FootnoteTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let messy = """
9 #+TITLE: Notes
10 * H
11 A[fn:b] B[fn:2] C[fn::anon text] D[fn:x:inline def].
12
13 [fn:2] Two.
14 Still two.
15
16 [fn:b] Bee[fn:n].
17
18 [fn:z] Unused.
19
20 [fn:n] Nested.
21 * Other
22 E[fn:2] and *bold* text
23 """ + "\n"
24
25 /// (text, where the caret goes: the offset of this marker's end, or its start with a "^"
26 /// prefix, in the text).
27 static func caret(_ text: String, _ at: String) -> Int {
28 let start = at.hasPrefix("^")
29 let needle = start ? String(at.dropFirst()) : at
30 let r = (text as NSString).range(of: needle)
31 precondition(r.location != NSNotFound, needle)
32 return start ? r.location : NSMaxRange(r)
33 }
34
35 func compare(_ cases: [(text: String, at: String, command: any OrgCommand, answers: [String: String], form: String)]) throws {
36 let emacs = try EmacsOracle.run(cases.map { c in
37 EmacsOracle.Case(text: c.text, point: EmacsOracle.point(Self.caret(c.text, c.at), in: c.text), form: c.form)
38 })
39 for (c, theirs) in zip(cases, emacs) {
40 let ours = runCommand(c.command, c.text, caret: Self.caret(c.text, c.at), answers: c.answers)
41 let theirCaret = EmacsOracle.offset(theirs.point, in: theirs.text)
42 #expect(ours.text == theirs.text && ours.failure == nil && ours.caret == theirCaret && theirs.error.isEmpty,
43 "\(c.form) at \(c.at.debugDescription)\nours: \(ours.text.debugDescription) @\(ours.caret) \(ours.failure ?? "")\nemacs: \(theirs.text.debugDescription) @\(theirCaret) \(theirs.error)")
44 }
45 }
46
47 static func menu(_ key: Character) -> String {
48 "(cl-letf (((symbol-function 'read-char-exclusive) (lambda (&rest _) ?\(key)))) (org-footnote-action '(4)))"
49 }
50
51 @Test(.enabled(if: oracle))
52 func actsLikeOrgFootnoteAction() throws {
53 let action = "(cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))) (org-footnote-action))"
54 let plain = "* H\nSome text here.\n"
55 let sectioned = "* H\nText[fn:1] more.\n\n* Footnotes\n\n[fn:1] One.\n"
56 let tagged = "* TODO Heading title :tag:\nBody\n"
57 let missing = "* H\nSee[fn:q] here.\n* Next\n"
58 try compare([
59 (plain, "Some text", FootnoteAction(), [:], action),
60 (plain, "here.", FootnoteAction(), [:], action),
61 ("Top text\n", "Top", FootnoteAction(), [:], action),
62 (sectioned, "more", FootnoteAction(), [:], action),
63 (sectioned, "Text[fn", FootnoteAction(), [:], action),
64 (sectioned, "^[fn:1] One", FootnoteAction(), [:], action),
65 (sectioned, "[fn:1] O", FootnoteAction(), [:], action),
66 (tagged, "Heading", FootnoteAction(), [:], action),
67 (Self.messy, "B[fn:", FootnoteAction(), [:], action),
68 (Self.messy, "C[fn::an", FootnoteAction(), [:], action),
69 (Self.messy, "D[fn:x:in", FootnoteAction(), [:], action),
70 (Self.messy, "^[fn:b] Bee", FootnoteAction(), [:], action),
71 (Self.messy, "Still", FootnoteAction(), [:], action),
72 (Self.messy, "E[fn:2] and *bo", FootnoteAction(), [:], action),
73 (missing, "See[fn", FootnoteAction(), ["create": "yes"], action),
74 ])
75 }
76
77 @Test(.enabled(if: oracle))
78 func runsTheFootnoteMenuLikeOrg() throws {
79 let text = Self.messy
80 var cases: [(text: String, at: String, command: any OrgCommand, answers: [String: String], form: String)] = []
81 for key in ["s", "r", "S", "n"] {
82 cases.append((text, "Notes", FootnoteAction(special: true), ["footnote": key], Self.menu(Character(key))))
83 }
84 cases.append((text, "B[fn:", FootnoteAction(special: true), ["footnote": "d"], Self.menu("d")))
85 cases.append((text, "C[fn::an", FootnoteAction(special: true), ["footnote": "d"], Self.menu("d")))
86 cases.append((text, "Unused", FootnoteAction(special: true), ["footnote": "d"], Self.menu("d")))
87 let numbered = "* H\nX[fn:3] Y[fn:1] Z[fn:3]\n\n[fn:1] one\n\n[fn:3] three\n\n[fn:7] seven\n"
88 cases.append((numbered, "^X", FootnoteAction(special: true), ["footnote": "r"], Self.menu("r")))
89 cases.append((numbered, "^X", FootnoteAction(special: true), ["footnote": "S"], Self.menu("S")))
90 cases.append((numbered, "^X", FootnoteAction(special: true), ["footnote": "n"], Self.menu("n")))
91 try compare(cases)
92 }
93
94 @Test func offersTheMenuWhereNoReferenceCanGo() {
95 let text = "#+TITLE: Notes\n| a | b |\n"
96 #expect(runCommand(FootnoteAction(), text, caret: 5).failure == "prompt: footnote")
97 #expect(runCommand(CtrlCCtrlC(), "A[fn:1]\n\n[fn:1] x\n", caret: 2).caret == 15)
98 }
99}