Sources/OrgCore/Compute/Tangle.swift
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}