krz/orgstar

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

Sources/OrgCore/Commands/Narrowing.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgCore/Commands/Narrowing.swift history · blame · raw

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}