Sources/OrgCore/Commands/Narrowing.swift
90 lines · 4833 bytes
1import Foundation
2
3/// The regions `org-narrow-to-subtree` and `org-narrow-to-block` (Org 9.8.7) narrow to. The
4/// editor shows only that text until `widen`.
5public enum Narrowing {
6 public struct Failure: Error, Equatable {
7 public let message: String
8 }
9
10 /// `org-narrow-to-subtree`: the heading at or above `offset` and its subtree, without the
11 /// newline before the next heading.
12 public static func subtree(at offset: Int, in text: String) throws -> Range<Int> {
13 let buffer = EmacsBuffer(text, point: offset)
14 guard (try? buffer.backToHeading()) != nil else { throw Failure(message: "Before first headline") }
15 let start = buffer.point
16 buffer.endOfSubtree(toHeading: true)
17 if buffer.atHeading, !buffer.eobp { buffer.point -= 1 }
18 return start..<buffer.point
19 }
20
21 /// `org-narrow-to-block` through `org-narrow-to-element`: a src, example, export, comment
22 /// or verse block with the blank lines after it; for other blocks, from their opening or
23 /// closing line, the lines between.
24 public static func block(at offset: Int, in text: String) throws -> Range<Int> {
25 let tree = OrgParser.parse(text)
26 let buffer = EmacsBuffer(text, point: offset)
27 var probe = offset
28 // Blank lines after a block belong to it.
29 if buffer.lookingAtLine("^[ \\t]*$") {
30 buffer.point = buffer.lineStart(offset)
31 buffer.skipBackward([32, 9, 10, 13])
32 probe = buffer.lineStart(buffer.point)
33 }
34 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp,
35 .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget,
36 .citation, .citationReference, .exportSnippet, .inlineBabelCall, .itemTag]
37 var chain: [SyntaxNode] = []
38 var node = tree.root
39 while let child = node.child(containing: probe) {
40 chain.append(child)
41 node = child
42 }
43 guard let block = chain.last(where: { !objects.contains($0.kind) }), block.kind == .block,
44 let wrapper = Wrappers.starting(at: block.range.lowerBound, in: tree) else { throw Failure(message: "Not in a block") }
45 let opening = buffer.substring(block.range.lowerBound, buffer.lineEnd(block.range.lowerBound))
46 let name = opening.firstMatch(of: /(?i)#\+begin_(\S*)/).map { $0.1.lowercased() } ?? ""
47 let contentsBegin = min(buffer.length, buffer.lineEnd(block.range.lowerBound) + 1)
48 if ["src", "example", "export", "comment", "verse"].contains(name) {
49 buffer.point = wrapper.closeLine
50 buffer.forwardLine(1)
51 while !buffer.eobp, buffer.lookingAt("[ \\t]*$") != nil, buffer.forwardLine(1) == 0 {}
52 return block.range.lowerBound..<buffer.point
53 }
54 guard probe < contentsBegin || probe >= wrapper.closeLine, contentsBegin < wrapper.closeLine else { throw Failure(message: "Not in a block") }
55 return contentsBegin..<wrapper.closeLine
56 }
57}
58
59/// C-c C-j: `org-goto` with `outline-path-completion`: a heading of this file by its path.
60public struct GotoHeading: OrgCommand {
61 public init() {}
62 public var id: String { "org.goto" }
63 public var title: String { "Go to Heading…" }
64 public func applies(in context: EditContext) -> Bool { true }
65
66 static func targets(_ context: EditContext) -> [(path: String, start: Int)] {
67 let model = DocumentModel(tree: context.tree)
68 var seen: Set<String> = []
69 return model.headings.indices.compactMap { index in
70 let path = model.outlinePath(of: index).map(Self.plainTitle).joined(separator: "/")
71 guard seen.insert(path).inserted else { return nil }
72 return (path, model.headings[index].headingRange.lowerBound)
73 }
74 }
75
76 /// The title as completion shows it: links as their descriptions, no trailing blanks.
77 static func plainTitle(_ title: String) -> String {
78 Links.displayFormat(title).trimmingCharacters(in: .whitespaces)
79 }
80
81 public func run(in context: EditContext) -> CommandStep {
82 let targets = Self.targets(context)
83 guard !targets.isEmpty else { return .failed("No headings") }
84 guard let answer = context.answers["goto"] else {
85 return .prompt(Prompt(key: "goto", message: "Goto:", choices: targets.map(\.path)))
86 }
87 guard let target = targets.first(where: { $0.path == answer }) else { return .failed("No such heading") }
88 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [target.start..<target.start]))
89 }
90}