krz/orgstar

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

Sources/OrgCore/Compute/Tangle.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgCore/Compute/Tangle.swift history · blame · raw

479 lines · 26483 bytes

28 symbols in this file
  1import Foundation
  2
  3// `org-babel-tangle` (Org 9.8.7 ob-tangle.el): source blocks written to the files their
  4// `:tangle` names, with `:noweb`, `:padline`, `:shebang`, `:mkdirp`, `:tangle-mode`,
  5// `:prologue` and `:epilogue`, `:var` for shells, Python and Emacs Lisp, and `:comments` for
  6// languages whose comment syntax is known. Header values written as Lisp are evaluated with
  7// the file's name and folder; other Lisp is refused.
  8
  9public enum Tangle {
 10    /// All blocks (C-c C-v t), the block at an offset (C-u C-c C-v t), or the blocks going to
 11    /// the same file as the block at an offset (C-u C-u C-c C-v t).
 12    public enum Scope: Sendable, Equatable {
 13        case all
 14        case block(Int)
 15        case target(Int)
 16    }
 17
 18    public struct File: Sendable, Equatable {
 19        /// Absolute.
 20        public var path: String
 21        public var contents: String
 22        /// From `:tangle-mode`, or 0o755 for a file with a `:shebang`; nil leaves the mode alone.
 23        public var mode: Int?
 24        /// `:mkdirp`: the file's directory is made if missing.
 25        public var makeDirectories: Bool
 26    }
 27
 28    public struct Output: Sendable, Equatable {
 29        public var files: [File]
 30        /// Blocks written, for "Tangled N code blocks".
 31        public var blocks: Int
 32    }
 33
 34    /// `org-babel-tangle-lang-exts`, with what Org 9.8.7's ob-LANG files add to it.
 35    static let extensions = [
 36        "emacs-lisp": "el", "elisp": "el", "bibtex": "bib",
 37        "C++": "cpp", "D": "d", "awk": "awk", "clojure": "clj", "clojurescript": "cljs", "csharp": "cs", "fortran": "F90",
 38        "groovy": "groovy", "haskell": "hs", "java": "java", "julia": "jl", "latex": "tex", "LilyPond": "ly", "lisp": "lisp",
 39        "lua": "lua", "maxima": "max", "ocaml": "ml", "perl": "pl", "processing": "pde", "python": "py", "ruby": "rb", "sed": "sed",
 40    ]
 41    /// `org-babel-shell-names`, and `shell` itself.
 42    static let shells: Set<String> = ["sh", "bash", "zsh", "fish", "csh", "ash", "dash", "ksh", "mksh", "posh", "shell"]
 43
 44    /// The files tangling `text`, the contents of the Org file at `path`, writes.
 45    public static func run(text: String, path: String, scope: Scope = .all, tree: OrgTree? = nil) -> Result<Output, Babel.Failure> {
 46        let ns = text as NSString
 47        let model = DocumentModel(tree: tree ?? OrgParser.parse(text))
 48        do {
 49            var order: [String] = []
 50            var specs: [String: [(language: String?, body: String, params: Babel.Params)]] = [:]
 51            // `org-babel-tangle-collect-blocks`: blocks numbered from 1 under each heading.
 52            var counters: [Int: Int] = [:]
 53            var lastHeading: Int?? = .none
 54            var counter = 0
 55            for block in model.srcBlocks {
 56                if lastHeading == .some(block.heading) { counter += 1 } else { (counter, lastHeading) = (1, .some(block.heading)) }
 57                counters[block.range.lowerBound] = counter
 58            }
 59            func add(_ block: SrcBlockInfo, _ params: Babel.Params, counter: Int) throws {
 60                guard let file = try fileName(path: path, language: block.language, tangle: params.single[":tangle"] ?? "no") else { return }
 61                let parsed = tree ?? OrgParser.parse(text)
 62                let links = { (other: SrcBlockInfo, otherParams: Babel.Params) in
 63                    try Self.links(other, params: otherParams, counter: counters[other.range.lowerBound] ?? 1, path: path, model: model, tree: parsed, text: ns)
 64                }
 65                let body = try spec(block, body: try body(block, params: params, model: model, text: ns, links: links), params: params, counter: counter,
 66                                    path: path, model: model, tree: parsed, text: ns)
 67                if specs[file] == nil { order.append(file) }
 68                specs[file, default: []].append((block.language, body, params))
 69            }
 70            switch scope {
 71            case .block(let offset):
 72                guard let block = block(at: offset, in: model) else { throw Babel.Failure.message("Point is not in a source code block") }
 73                try add(block, try params(block, model: model, text: ns, path: path), counter: 1)
 74            case .all, .target:
 75                var target: String?
 76                if case .target(let offset) = scope {
 77                    guard let block = block(at: offset, in: model) else { throw Babel.Failure.message("Point is not in a source code block") }
 78                    target = try params(block, model: model, text: ns, path: path).single[":tangle"] ?? "no"
 79                }
 80                for block in model.srcBlocks where !excluded(block, model: model) {
 81                    let params = try params(block, model: model, text: ns, path: path)
 82                    let tangle = params.single[":tangle"] ?? "no"
 83                    if tangle == "no" || (block.language == nil && tangle == "yes") { continue }
 84                    if let target, target != tangle { continue }
 85                    try add(block, params, counter: counters[block.range.lowerBound] ?? 1)
 86                }
 87            }
 88            var files: [File] = []
 89            var blocks = 0
 90            for file in order {
 91                var contents = ""
 92                var mode: Int?
 93                var makeDirectories = false
 94                var sheBanged = false
 95                for spec in specs[file]! {
 96                    let shebang = spec.params.single[":shebang"].flatMap { $0.isEmpty ? nil : $0 }
 97                    var tangleMode = try spec.params.single[":tangle-mode"].map(fileMode)
 98                    if shebang != nil, tangleMode == nil { tangleMode = 0o755 }
 99                    // `add-to-list` then `mapc`: the first mode given is the one left.
100                    if mode == nil { mode = tangleMode }
101                    if let mkdirp = spec.params.single[":mkdirp"], mkdirp != "no" { makeDirectories = true }
102                    if spec.params.single[":padline"] != "no", !contents.isEmpty { contents += "\n" }
103                    if let shebang, !sheBanged {
104                        contents += shebang + "\n"
105                        sheBanged = true
106                    }
107                    contents += spec.body
108                    blocks += 1
109                }
110                files.append(File(path: file, contents: contents, mode: mode, makeDirectories: makeDirectories))
111            }
112            return .success(Output(files: files, blocks: blocks))
113        } catch let error as Babel.Failure {
114            return .failure(error)
115        } catch {
116            return .failure(.message("\(error)"))
117        }
118    }
119
120    /// `org-babel-where-is-src-block-head`.
121    static func block(at offset: Int, in model: DocumentModel) -> SrcBlockInfo? {
122        model.srcBlocks.first { $0.range.contains(offset) || $0.range.upperBound == offset }
123    }
124
125    /// `org-in-commented-heading-p` and `org-in-archived-heading-p`.
126    static func excluded(_ block: SrcBlockInfo, model: DocumentModel) -> Bool {
127        guard let heading = block.heading else { return false }
128        return ([heading] + model.ancestors(of: heading)).contains { index in
129            let h = model.headings[index]
130            return AgendaSource.isCommented(h.title) || h.localTags.contains("ARCHIVE")
131        }
132    }
133
134    static func params(_ block: SrcBlockInfo, model: DocumentModel, text: NSString, path: String) throws -> Babel.Params {
135        var params = try Babel.Params(block: block, model: model, text: text)
136        // Lists for ob-C, ob-java and ob-fortran are read by the language's expansion.
137        for key in params.lisp where ![":tangle-mode", ":includes", ":defines", ":namespaces", ":imports"].contains(key) {
138            guard let value = params.single[key] else { continue }
139            params.single[key] = try evaluate(value, key: key, path: path)
140        }
141        return params
142    }
143
144    /// An interpreter with what a file's buffer knows: `buffer-file-name`, `default-directory`,
145    /// `system-type` and the file name functions.
146    static func fileLisp(path: String) -> Elisp {
147        let lisp = Elisp()
148        let directory = (path as NSString).deletingLastPathComponent + "/"
149        lisp.set("buffer-file-name", .string(path))
150        lisp.set("default-directory", .string(directory))
151        lisp.set("system-type", .symbol("darwin"))
152        func string(_ args: [Sexp], _ i: Int) throws -> String { try Elisp.string(args[i]) }
153        lisp.define("expand-file-name") { lisp, args in
154            try Elisp.arity(args, 1...2, "expand-file-name")
155            let base = args.count > 1 && !Elisp.isNil(args[1]) ? try string(args, 1) : (try Elisp.string(lisp.value("default-directory")))
156            return .string(expand(try string(args, 0), in: (base as NSString).expandingTildeInPath))
157        }
158        lisp.define("file-name-directory") { _, args in
159            try Elisp.arity(args, 1...1, "file-name-directory")
160            let name = try string(args, 0)
161            guard let slash = name.lastIndex(of: "/") else { return .nil }
162            return .string(String(name[...slash]))
163        }
164        lisp.define("file-name-nondirectory") { _, args in
165            try Elisp.arity(args, 1...1, "file-name-nondirectory")
166            let name = try string(args, 0)
167            return .string(name.lastIndex(of: "/").map { String(name[name.index(after: $0)...]) } ?? name)
168        }
169        lisp.define("file-name-sans-extension") { _, args in
170            try Elisp.arity(args, 1...1, "file-name-sans-extension")
171            return .string((try string(args, 0) as NSString).deletingPathExtension)
172        }
173        lisp.define("file-name-as-directory") { _, args in
174            try Elisp.arity(args, 1...1, "file-name-as-directory")
175            let name = try string(args, 0)
176            return .string(name.hasSuffix("/") ? name : name + "/")
177        }
178        return lisp
179    }
180
181    /// `org-babel-read` of a header value written as Lisp, in `fileLisp`.
182    static func evaluate(_ value: String, key: String, path: String) throws -> String {
183        let refusal = Babel.Failure.message("\(key) \(value) is Lisp tangling can't evaluate yet; nothing was tangled.")
184        let lisp = fileLisp(path: path)
185        guard let form = try? LispReader.readFirst(value).sexp, let result = try? lisp.eval(form), !Elisp.isNil(result) else { throw refusal }
186        switch result {
187        case .string(let s): return s
188        case .symbol(let s): return s
189        case .integer, .float: return Elisp.printed(result)
190        default: throw refusal
191        }
192    }
193
194    /// `org-babel-effective-tangled-filename`.
195    static func fileName(path: String, language: String?, tangle: String) throws -> String? {
196        let directory = (path as NSString).deletingLastPathComponent
197        let base: String
198        switch tangle {
199        case "no", "": return nil
200        case "yes": base = (path as NSString).deletingPathExtension
201        default: base = expand(tangle, in: directory)
202        }
203        guard tangle == "yes", let language else { return base }
204        return base + "." + (extensions[language] ?? language)
205    }
206
207    /// `expand-file-name`.
208    static func expand(_ name: String, in directory: String) -> String {
209        let expanded = (name as NSString).expandingTildeInPath
210        let absolute = expanded.hasPrefix("/") ? expanded : (directory as NSString).appendingPathComponent(expanded)
211        return (absolute as NSString).standardizingPath
212    }
213
214    /// `org-babel-tangle-single-block`'s body: noweb references expanded or stripped, the
215    /// language's expansion with `:var`, `:prologue` and `:epilogue`, `-r` coderefs taken
216    /// out, and indentation and surrounding blank space trimmed.
217    static func body(_ block: SrcBlockInfo, params: Babel.Params, model: DocumentModel, text: NSString,
218                     links: ((SrcBlockInfo, Babel.Params) throws -> (begin: String, end: String))? = nil) throws -> String {
219        var body = Babel.normalizedBody(block)
220        let noweb = params.single[":noweb"]
221        if Babel.nowebTangles(noweb) {
222            if noweb == "strip-tangle" {
223                body = body.replacingOccurrences(of: "<<([^ \\t\\n](?:.*?[^ \\t\\n])?)>>", with: "", options: .regularExpression)
224            } else {
225                body = try Babel.expandNoweb(block, params: params, model: model, text: text, tangling: true, links: links)
226            }
227        }
228        if params.single[":no-expand"] == nil {
229            body = try expandBody(body, language: block.language ?? "", params: params, model: model, text: text)
230        }
231        // `(string-match "-r" extra)`.
232        let switches = block.switches.joined(separator: " ")
233        if switches.contains("-r") {
234            // `org-src-coderef-regexp` of `-l "FORMAT"`, or of `org-coderef-label-format`.
235            let format = switches.firstMatch(of: /-l +"([^"\n]+)"/).map { String($0.1) } ?? "(ref:%s)"
236            let pattern = "[ \\t]*" + NSRegularExpression.escapedPattern(for: format)
237                .replacingOccurrences(of: "%s", with: "[-a-zA-Z0-9_][-a-zA-Z0-9_ ]*") + "[ \\t]*$"
238            body = body.components(separatedBy: "\n")
239                .map { $0.replacingOccurrences(of: pattern, with: "", options: .regularExpression) }
240                .joined(separator: "\n")
241        }
242        let preserve = block.switches.contains("-i")
243        if !preserve { body = removeIndentation(body) }
244        body = body.replacingOccurrences(of: "[ \\t\\n\\r]+$", with: "", options: .regularExpression)
245        return preserve
246            ? body.replacingOccurrences(of: "^([ \\t]*\\n)+", with: "", options: .regularExpression)
247            : body.replacingOccurrences(of: "^[ \\t\\n\\r]+", with: "", options: .regularExpression)
248    }
249
250    /// `org-babel-spec-to-string`: the block's text in the file, with its `:comments`.
251    static func spec(_ block: SrcBlockInfo, body: String, params: Babel.Params, counter: Int, path: String, model: DocumentModel, tree: OrgTree, text: NSString) throws -> String {
252        let comments = params.single[":comments"] ?? "no"
253        guard comments != "no" else { return body + "\n" }
254        let language = block.language ?? ""
255        let linked = ["both", "link", "yes", "noweb"].contains(comments)
256        var out = ""
257        if comments == "org" || comments == "both" {
258            let prose = orgComment(block, model: model, text: text)
259            if prose.contains(where: { !$0.isWhitespace }) { out += try commentRegion(prose, language: language) + "\n" }
260        }
261        let link = linked ? try links(block, params: params, counter: counter, path: path, model: model, tree: tree, text: text) : nil
262        if let link { out += try commentRegion(link.begin, language: language) + "\n" }
263        out += body + "\n"
264        if let link { out += try commentRegion(link.end, language: language) + "\n" }
265        return out
266    }
267
268    /// `org-babel-tangle-comment-format-beg` and `-end` for a block: `[[LINK][NAME]]` and
269    /// `NAME ends here`, the link relative to the tangled file's folder.
270    static func links(_ block: SrcBlockInfo, params: Babel.Params, counter: Int, path: String, model: DocumentModel, tree: OrgTree, text: NSString) throws -> (begin: String, end: String) {
271        let directory = (path as NSString).deletingLastPathComponent
272        let tangle = params.single[":tangle"] ?? "no"
273        let target = tangle.contains("/") ? (expand(tangle, in: directory) as NSString).deletingLastPathComponent : directory
274        let start = beginLine(block, text: text)
275        let context = EditContext(revision: 0, text: text as String, tree: tree, selection: [start..<start])
276        let stored = Links.store(in: context, path: relativePath(path, from: target)).link
277        let name = block.name ?? ((block.heading.map { model.headings[$0].title.trimmingCharacters(in: .whitespaces) } ?? "No heading") + ":\(counter)")
278        return ("[[\(stored)][\(name)]]", "\(name) ends here")
279    }
280
281    /// `file-relative-name`.
282    static func relativePath(_ path: String, from directory: String) -> String {
283        let to = (path as NSString).standardizingPath.split(separator: "/")
284        let from = (directory as NSString).standardizingPath.split(separator: "/")
285        let common = zip(to, from).prefix { $0 == $1 }.count
286        return (Array(repeating: "..", count: from.count - common) + to.dropFirst(common).map(String.init)).joined(separator: "/")
287    }
288
289    /// The start of the block's `#+begin_src` line.
290    static func beginLine(_ block: SrcBlockInfo, text: NSString) -> Int {
291        let regex = try! NSRegularExpression(pattern: "^[ \\t]*#\\+begin_src", options: [.anchorsMatchLines, .caseInsensitive])
292        return regex.firstMatch(in: text as String, range: NSRange(block.range))?.range.location ?? block.range.lowerBound
293    }
294
295    /// The prose `:comments org` takes: from the heading's title, or the end of the block
296    /// before, to the block, without its common indentation.
297    static func orgComment(_ block: SrcBlockInfo, model: DocumentModel, text: NSString) -> String {
298        let begin = beginLine(block, text: text)
299        var start = 0
300        if let heading = block.heading {
301            let line = text.substring(with: NSRange(model.headings[heading].headingRange))
302            start = model.headings[heading].headingRange.lowerBound + (line.firstMatch(of: #/^\*+ /#).map { $0.output.utf16.count } ?? 0)
303        }
304        let end = try! NSRegularExpression(pattern: "^[ \\t]*#\\+end_src", options: [.anchorsMatchLines, .caseInsensitive])
305        for other in model.srcBlocks where other.range.lowerBound < begin {
306            if let m = end.firstMatch(in: text as String, range: NSRange(other.range)), NSMaxRange(m.range) <= begin { start = max(start, NSMaxRange(m.range)) }
307        }
308        return removeIndentation(text.substring(with: NSRange(location: start, length: max(0, begin - start))))
309    }
310
311    /// Comment syntax of the language's major mode (`org-src-get-lang-mode`), as
312    /// `comment-region` writes it.
313    static let commentSyntax: [String: (start: String, end: String)] = {
314        var table: [String: (String, String)] = [:]
315        for language in ["emacs-lisp", "elisp", "lisp", "scheme"] { table[language] = (";; ", "") }
316        for language in ["python", "ruby", "perl", "conf", "toml", "makefile", "awk", "tcl"] { table[language] = ("# ", "") }
317        for language in shells.subtracting(["fish"]) { table[language] = ("# ", "") }
318        for language in ["js", "java", "cpp", "C++"] { table[language] = ("// ", "") }
319        for language in ["c", "C", "css"] { table[language] = ("/* ", " */") }
320        for language in ["lua", "sql", "sqlite"] { table[language] = ("-- ", "") }
321        for language in ["latex", "tex", "prolog"] { table[language] = ("%% ", "") }
322        for language in ["javascript", "objc", "csharp", "idl", "pike", "antlr", "verilog"] { table[language] = ("// ", "") }
323        for language in ["html", "xml", "nxml", "mhtml", "sgml"] { table[language] = ("<!-- ", " -->") }
324        for language in ["m4", "icon", "desktop"] { table[language] = ("# ", "") }
325        for language in ["ps", "metapost"] { table[language] = ("% ", "") }
326        for language in ["f90", "dcl"] { table[language] = ("! ", "") }
327        table["asm"] = (";; ", "")
328        table["octave"] = ("## ", "")
329        table["vhdl"] = ("-- ", "")
330        table["pascal"] = ("{ ", " }")
331        table["texinfo"] = ("@c ", "")
332        table["bibtex"] = ("@Comment ", "")
333        table["nroff"] = ("\\\" ", "")
334        table["bat"] = ("rem ", "")
335        return table
336    }()
337
338    /// `comment-region` over `text`: each line that isn't blank, commented.
339    static func commentRegion(_ text: String, language: String) throws -> String {
340        guard let syntax = commentSyntax[language] else {
341            throw Babel.Failure.message(":comments needs the comment syntax of \(language.isEmpty ? "the block's language" : language), which isn't known yet; nothing was tangled.")
342        }
343        // The comment starts at the lines' least indentation, as `comment-region` puts it.
344        let lines = text.components(separatedBy: "\n")
345        let indent = lines.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " || $0 == "\t" }.count }.min() ?? 0
346        return lines.map { line in
347            line.contains(where: { !$0.isWhitespace }) ? String(line.prefix(indent)) + syntax.start + line.dropFirst(indent) + syntax.end : line
348        }.joined(separator: "\n")
349    }
350
351    /// `org-remove-indentation`.
352    static func removeIndentation(_ s: String) -> String {
353        let lines = s.components(separatedBy: "\n")
354        let indents = lines.filter { $0.contains { !$0.isWhitespace } }.map { $0.prefix { $0 == " " || $0 == "\t" }.count }
355        let common = indents.min() ?? 0
356        guard common > 0 else { return s }
357        return lines.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " || $0 == "\t" }.count))) }.joined(separator: "\n")
358    }
359
360    /// `org-babel-expand-body:LANG`, or the generic expansion with the language's variable
361    /// assignments; tables in `:var` taken apart by `org-babel-disassemble-tables`.
362    static func expandBody(_ body: String, language: String, params: Babel.Params, model: DocumentModel, text: NSString) throws -> String {
363        let refs = Babel.References(resolved: [:], options: Babel.Options())
364        let tables = try Babel.disassemble(try params.vars.map { variable in
365            do {
366                return (variable.name, try Babel.resolve(variable.value, model: model, text: text, refs: refs))
367            } catch is Babel.Needed {
368                throw Babel.Failure.message(":var \(variable.value) would run a block while tangling, which isn't supported yet; nothing was tangled.")
369            }
370        }, params: params)
371        let vars = tables.vars
372        if let expanded = try Babel.languageExpansion(body, language: language, tables: tables, params: params) { return expanded }
373        let prologue = params.single[":prologue"]
374        let epilogue = params.single[":epilogue"]
375        if language == "emacs-lisp" || language == "elisp" {
376            guard !vars.isEmpty else { return body + "\n" }
377            let bindings = vars.map { "(\($0.0) '\(Babel.lispLiteral($0.1)))" }.joined(separator: "\n      ")
378            return "(let (\(bindings))\n" + (prologue.map { $0 + "\n      " } ?? "") + body + (epilogue.map { "\n      " + $0 + "\n" } ?? "") + "\n)"
379        }
380        var lines: [String] = []
381        if shells.contains(language) {
382            lines = try vars.map { name, value in
383                switch value {
384                case .scalar(let s), .number(let s): return "\(name)='" + s.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
385                default: throw Babel.Failure.message("Tables and lists in :var aren't supported for tangling \(language) yet; nothing was tangled.")
386                }
387            }
388        } else if language == "python" {
389            lines = vars.map { "\($0.0)=\(Babel.pythonLiteral($0.1))" }
390        } else if !vars.isEmpty {
391            throw Babel.Failure.message(":var isn't supported for tangling \(language) yet; nothing was tangled.")
392        }
393        return ((prologue.map { [$0] } ?? []) + lines + [body] + (epilogue.map { [$0] } ?? [])).joined(separator: "\n")
394    }
395
396    /// `org-babel-interpret-file-mode`, for `:tangle-mode`.
397    static func fileMode(_ value: String) throws -> Int {
398        let v = value.trimmingCharacters(in: .whitespaces)
399        if let m = v.wholeMatch(of: /\(identity\s+#o([0-7]{3})\)/) ?? v.wholeMatch(of: /#o([0-7]{3})/) ?? v.wholeMatch(of: /o0?([0-7]{3})/) {
400            return Int(m.1, radix: 8)!
401        }
402        if v.wholeMatch(of: /[r-][w-][xs-][r-][w-][xs-][r-][w-][x-]/) != nil {
403            let chars = Array(v)
404            var mode = 0
405            for (i, bit) in [0o400, 0o200, 0o100, 0o40, 0o20, 0o10, 0o4, 0o2, 0o1].enumerated() where chars[i] != "-" { mode |= bit }
406            return mode
407        }
408        if v.wholeMatch(of: /[ugoa]*[+=-][rwx]*(,[ugoa]*[+=-][rwx]*)*/) != nil {
409            var mode = 0o644
410            for clause in v.split(separator: ",") {
411                let who = clause.prefix { "ugoa".contains($0) }
412                let rest = clause.dropFirst(who.count)
413                let op = rest.first!
414                let perms = rest.dropFirst()
415                var mask = 0
416                for w in (who.isEmpty ? "a" : String(who)) {
417                    switch w {
418                    case "u": mask |= 0o700
419                    case "g": mask |= 0o070
420                    case "o": mask |= 0o007
421                    default: mask |= 0o777
422                    }
423                }
424                var bits = 0
425                for p in perms {
426                    switch p {
427                    case "r": bits |= 0o444
428                    case "w": bits |= 0o222
429                    default: bits |= 0o111
430                    }
431                }
432                switch op {
433                case "+": mode |= bits & mask
434                case "-": mode &= ~(bits & mask)
435                default: mode = (mode & ~mask) | (bits & mask)
436                }
437            }
438            return mode
439        }
440        throw Babel.Failure.message("File mode \(value) isn't one tangling understands; nothing was tangled.")
441    }
442}
443
444/// C-c C-v t (`org-babel-tangle`): the app writes the files.
445public struct TangleCommand: OrgCommand {
446    public let scope: Scope
447
448    public enum Scope: Sendable { case all, block, target }
449
450    public init(_ scope: Scope = .all) { self.scope = scope }
451
452    public var id: String {
453        switch scope {
454        case .all: "org.babel.tangle"
455        case .block: "org.babel.tangle-block"
456        case .target: "org.babel.tangle-target"
457        }
458    }
459
460    public var title: String {
461        switch scope {
462        case .all: "Tangle File"
463        case .block: "Tangle Block"
464        case .target: "Tangle Block's Target"
465        }
466    }
467
468    public func applies(in context: EditContext) -> Bool {
469        scope == .all ? !DocumentModel(tree: context.tree).srcBlocks.isEmpty : KeyContext.holds("src", in: context)
470    }
471
472    public func run(in context: EditContext) -> CommandStep {
473        switch scope {
474        case .all: .external(.tangle(.all))
475        case .block: .external(.tangle(.block(context.caret)))
476        case .target: .external(.tangle(.target(context.caret)))
477        }
478    }
479}