krz/orgstar

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

Commit 791ada4f3c

791ada4f3c5c635a1aebb4eb90edc01a1ba96a8a

parent: d32d29c062

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 02:13 UTC

Babel tangling

C-c C-v t (org-babel-tangle) writes source blocks to the files :tangle
names, as Org 9.8.7 does: :tangle yes/no/path, header-args from the file
and headings, commented and archived headings skipped, :noweb
(tangle, strip-tangle), :padline, :shebang, :mkdirp, :tangle-mode (octal,
ls and chmod forms), :prologue and :epilogue, :var for shells, Python and
Emacs Lisp, and -r coderefs. Unchanged files are left alone and a file
can't tangle over its own source. Tangle Block and Tangle Block's Target
are C-u and C-u C-u. Works on the Mac and iOS. :comments and header values
Org would evaluate as Lisp refuse with a message.

Babel header arguments now follow Org's priority: the inherited
header-args, then the inherited header-args:LANG, with + additions. Quoted
values are read as Lisp strings, and one :var can hold several
assignments.

Layout: unified · split

Sources/OrgApp/TangleWriter.swift added +71
@@ -0,0 +1,71 @@
1import Foundation
2import OrgCore
3
4/// Writes what `Tangle.run` produced, as `org-babel-tangle` does: a file whose contents
5/// haven't changed is left alone, `:mkdirp` makes its folder, a read-only file is replaced,
6/// and the mode is set after writing.
7public enum TangleWriter {
8 public struct Failure: Error, Equatable {
9 public let message: String
10 }
11
12 /// The paths written or already up to date, in order.
13 @discardableResult
14 public static func write(_ output: Tangle.Output, source: String) throws(Failure) -> [String] {
15 let manager = FileManager.default
16 var paths: [String] = []
17 for file in output.files {
18 let url = URL(fileURLWithPath: file.path)
19 let data = Data(file.contents.utf8)
20 if let existing = manager.contents(atPath: file.path), existing == data {
21 paths.append(file.path)
22 continue
23 }
24 if (file.path as NSString).standardizingPath == (source as NSString).standardizingPath {
25 throw Failure(message: "Not allowed to tangle into the same file as self")
26 }
27 let folder = url.deletingLastPathComponent()
28 if file.makeDirectories {
29 do {
30 try manager.createDirectory(at: folder, withIntermediateDirectories: true)
31 } catch {
32 throw Failure(message: "Can't make \(folder.path): \(error.localizedDescription)")
33 }
34 }
35 if manager.fileExists(atPath: file.path), !manager.isWritableFile(atPath: file.path) {
36 try? manager.removeItem(at: url)
37 }
38 do {
39 try data.write(to: url)
40 if let mode = file.mode { try manager.setAttributes([.posixPermissions: mode], ofItemAtPath: file.path) }
41 } catch {
42 throw Failure(message: "Can't write \(file.path): \(error.localizedDescription)")
43 }
44 paths.append(file.path)
45 }
46 return paths
47 }
48
49 /// `org-babel-tangle`'s message.
50 public static func message(_ output: Tangle.Output, source: String) -> String {
51 "Tangled \(output.blocks) code block\(output.blocks == 1 ? "" : "s") from \((source as NSString).lastPathComponent)"
52 }
53}
54
55extension DocumentSession {
56 /// Tangles the open file: its text as it is now, written beside it.
57 public func tangle(_ scope: Tangle.Scope) -> String {
58 guard let path, let document else { return "No file is open" }
59 switch Tangle.run(text: document.text, path: path, scope: scope, tree: document.tree) {
60 case .failure(.message(let message)):
61 return message
62 case .success(let output):
63 do {
64 try TangleWriter.write(output, source: path)
65 return TangleWriter.message(output, source: path)
66 } catch {
67 return error.message
68 }
69 }
70 }
71}
Sources/OrgCore/Commands/Command.swift +3 −1
@@ -239,6 +239,8 @@ public enum ExternalRequest: Sendable, Equatable {
239 /// Show the agenda for `days` days from `start` (a `Days` number), as 239 /// Show the agenda for `days` days from `start` (a `Days` number), as
240 /// `org-follow-timestamp-link` does. 240 /// `org-follow-timestamp-link` does.
241 case agenda(start: Int, days: Int) 241 case agenda(start: Int, days: Int)
242 /// Write source blocks to their files (`org-babel-tangle`); the app knows the file's path.
243 case tangle(Tangle.Scope)
242} 244}
243 245
244/// A named operation. Keys, menus, the palette and touch controls all run commands. 246/// A named operation. Keys, menus, the palette and touch controls all run commands.
@@ -267,7 +269,7 @@ public enum Commands {
267 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), 269 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
268 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 270 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
269 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), 271 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
270 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), TableEditField(), 272 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), TableEditField(), TangleCommand(.all), TangleCommand(.block), TangleCommand(.target),
271 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "), ReadAndSetProperty(), DeleteProperty(), DeleteProperty(globally: true), PropertyAction(), NextAllowedValue(previous: false), NextAllowedValue(previous: true), InsertColumnView(), ShowColumnView(), ShowColumnView(global: true), ToggleArchiveTag(), ArchiveToSibling(), 273 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "), ReadAndSetProperty(), DeleteProperty(), DeleteProperty(globally: true), PropertyAction(), NextAllowedValue(previous: false), NextAllowedValue(previous: true), InsertColumnView(), ShowColumnView(), ShowColumnView(global: true), ToggleArchiveTag(), ArchiveToSibling(),
272 ] 274 ]
273 275
Sources/OrgCore/Compute/Babel.swift +85 −19
@@ -344,18 +344,26 @@ public enum Babel {
344 (value ?? "").split(whereSeparator: \.isWhitespace).contains { ["yes", "no-export", "strip-export", "eval", "strip-tangle"].contains(String($0)) } 344 (value ?? "").split(whereSeparator: \.isWhitespace).contains { ["yes", "no-export", "strip-export", "eval", "strip-tangle"].contains(String($0)) }
345 } 345 }
346 346
347 /// `org-babel-noweb-p` for `:tangle`.
348 static func nowebTangles(_ value: String?) -> Bool {
349 (value ?? "").split(whereSeparator: \.isWhitespace).contains { ["yes", "tangle", "no-export", "strip-export", "strip-tangle"].contains(String($0)) }
350 }
351
347 /// `org-babel-expand-noweb-references` for evaluation: each `<<name>>` becomes the body of 352 /// `org-babel-expand-noweb-references` for evaluation: each `<<name>>` becomes the body of
348 /// the block named so, or of every block with that `:noweb-ref`, joined by `:noweb-sep`, or 353 /// the block named so, or of every block with that `:noweb-ref`, joined by `:noweb-sep`, or
349 /// the contents of the heading with that ID; the text before it on its line goes before 354 /// the contents of the heading with that ID; the text before it on its line goes before
350 /// each line it brings. 355 /// each line it brings.
351 static func expandNoweb(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text: NSString, depth: Int = 0) throws -> String { 356 /// `tangling` expands the blocks it brings in as tangling would (`:noweb tangle`), not as
357 /// evaluation does.
358 static func expandNoweb(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text: NSString, tangling: Bool = false, depth: Int = 0) throws -> String {
352 guard depth < 32 else { throw Failure.message("Noweb references nest too deeply") } 359 guard depth < 32 else { throw Failure.message("Noweb references nest too deeply") }
353 let body = normalizedBody(block) 360 let body = normalizedBody(block)
354 let prefixing = params.single[":noweb-prefix"].map { $0 != "no" && $0 != "nil" } ?? true 361 let prefixing = params.single[":noweb-prefix"].map { $0 != "no" && $0 != "nil" } ?? true
355 func expand(_ other: SrcBlockInfo) throws -> String { 362 func expand(_ other: SrcBlockInfo) throws -> String {
356 let otherParams = try Params(block: other, model: model, text: text) 363 let otherParams = try Params(block: other, model: model, text: text)
357 return nowebEvaluates(otherParams.single[":noweb"]) 364 let expands = tangling ? nowebTangles(otherParams.single[":noweb"]) : nowebEvaluates(otherParams.single[":noweb"])
358 ? try expandNoweb(other, params: otherParams, model: model, text: text, depth: depth + 1) 365 return expands
366 ? try expandNoweb(other, params: otherParams, model: model, text: text, tangling: tangling, depth: depth + 1)
359 : normalizedBody(other) 367 : normalizedBody(other)
360 } 368 }
361 func inCommentedHeading(_ b: SrcBlockInfo) -> Bool { 369 func inCommentedHeading(_ b: SrcBlockInfo) -> Bool {
@@ -442,17 +450,20 @@ public enum Babel {
442 init(block: SrcBlockInfo, model: DocumentModel, text: NSString, extra: [[(String, String)]] = [], inline: Bool = false) throws { 450 init(block: SrcBlockInfo, model: DocumentModel, text: NSString, extra: [[(String, String)]] = [], inline: Bool = false) throws {
443 var layers: [[(String, String)]] = [] 451 var layers: [[(String, String)]] = []
444 let language = block.language ?? "" 452 let language = block.language ?? ""
445 func properties(_ entries: [Property]) { 453 // `org-babel-params-from-properties`: the inherited `header-args`, then the inherited
446 for key in ["header-args", "header-args:\(language)"] { 454 // `header-args:LANG`, each the nearest value with `+` additions after it.
455 var levels = [model.fileProperties]
456 if let heading = block.heading {
457 levels += (model.ancestors(of: heading).reversed() + [heading]).map { model.headings[$0].properties }
458 }
459 for key in ["header-args", "header-args:\(language)"] {
460 var value: String?
461 for entries in levels {
447 for entry in entries where entry.key.lowercased() == key { 462 for entry in entries where entry.key.lowercased() == key {
448 let pairs = Params.parse(entry.value) 463 value = entry.additive ? [value, entry.value].compactMap { $0 }.joined(separator: " ") : entry.value
449 layers.append(pairs)
450 } 464 }
451 } 465 }
452 } 466 if let value { layers.append(Params.parse(value)) }
453 properties(model.fileProperties)
454 if let heading = block.heading {
455 for index in model.ancestors(of: heading) + [heading] { properties(model.headings[index].properties) }
456 } 467 }
457 let firstLine = inline ? "" : text.substring(with: text.lineRange(for: NSRange(location: block.range.lowerBound, length: 0))) 468 let firstLine = inline ? "" : text.substring(with: text.lineRange(for: NSRange(location: block.range.lowerBound, length: 0)))
458 if let r = firstLine.range(of: "(?i)#\\+begin_src[ \\t]+\\S+", options: .regularExpression) { 469 if let r = firstLine.range(of: "(?i)#\\+begin_src[ \\t]+\\S+", options: .regularExpression) {
@@ -485,11 +496,13 @@ public enum Babel {
485 results.insert(word, at: 0) 496 results.insert(word, at: 0)
486 } 497 }
487 case ":var": 498 case ":var":
488 guard let eq = value.firstIndex(of: "=") else { throw Failure.message(":var \(value) needs a name and a value") } 499 for value in Params.splitVars(value) {
489 let name = value[..<eq].trimmingCharacters(in: .whitespaces) 500 guard let eq = value.firstIndex(of: "=") else { throw Failure.message(":var \(value) needs a name and a value") }
490 let assigned = value[value.index(after: eq)...].trimmingCharacters(in: .whitespaces) 501 let name = value[..<eq].trimmingCharacters(in: .whitespaces)
491 vars.removeAll { $0.name == name } 502 let assigned = value[value.index(after: eq)...].trimmingCharacters(in: .whitespaces)
492 vars.append((name, assigned)) 503 vars.removeAll { $0.name == name }
504 vars.append((name, assigned))
505 }
493 case ":wrap": 506 case ":wrap":
494 wrap = value 507 wrap = value
495 default: 508 default:
@@ -501,6 +514,59 @@ public enum Babel {
501 514
502 /// `org-babel-parse-header-arguments`: split before each `:key` outside quotes and 515 /// `org-babel-parse-header-arguments`: split before each `:key` outside quotes and
503 /// parentheses. 516 /// parentheses.
517 /// A Lisp string's contents as `read` gives them.
518 static func unescape(_ s: String) -> String {
519 var out = ""
520 var escaped = false
521 for c in s {
522 if escaped {
523 switch c {
524 case "n": out.append("\n")
525 case "t": out.append("\t")
526 default: out.append(c)
527 }
528 escaped = false
529 } else if c == "\\" {
530 escaped = true
531 } else {
532 out.append(c)
533 }
534 }
535 return out
536 }
537
538 /// `org-babel-parse-multiple-vars`: one `:var` value holding several assignments split
539 /// on spaces outside quotes and brackets, pieces split beside `=` joined again.
540 static func splitVars(_ value: String) -> [String] {
541 var pieces: [String] = []
542 var current = ""
543 var depth = 0
544 var quoted = false
545 var previous: Character = " "
546 for c in value {
547 if c == "\"", previous != "\\" { quoted.toggle() }
548 previous = c
549 if !quoted, "([{".contains(c) { depth += 1 }
550 if !quoted, ")]}".contains(c) { depth = max(0, depth - 1) }
551 if c == " ", !quoted, depth == 0 {
552 if !current.isEmpty { pieces.append(current) }
553 current = ""
554 } else {
555 current.append(c)
556 }
557 }
558 if !current.isEmpty { pieces.append(current) }
559 var joined: [String] = []
560 for piece in pieces {
561 if let last = joined.last, last.hasSuffix("=") || piece.hasPrefix("=") {
562 joined[joined.count - 1] = last + piece
563 } else {
564 joined.append(piece)
565 }
566 }
567 return joined.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
568 }
569
504 static func parse(_ text: String) -> [(String, String)] { 570 static func parse(_ text: String) -> [(String, String)] {
505 var parts: [String] = [] 571 var parts: [String] = []
506 var current = "" 572 var current = ""
@@ -508,7 +574,7 @@ public enum Babel {
508 var quoted = false 574 var quoted = false
509 var previous: Character = " " 575 var previous: Character = " "
510 for c in text { 576 for c in text {
511 if c == "\"" { quoted.toggle() } 577 if c == "\"", previous != "\\" { quoted.toggle() }
512 if !quoted { 578 if !quoted {
513 if c == "(" { depth += 1 } 579 if c == "(" { depth += 1 }
514 if c == ")" { depth = max(0, depth - 1) } 580 if c == ")" { depth = max(0, depth - 1) }
@@ -528,7 +594,7 @@ public enum Babel {
528 let key = String(trimmed.prefix { !$0.isWhitespace }).lowercased() 594 let key = String(trimmed.prefix { !$0.isWhitespace }).lowercased()
529 var value = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces) 595 var value = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces)
530 if key != ":var", value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") { 596 if key != ":var", value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") {
531 value = String(value.dropFirst().dropLast()) 597 value = Params.unescape(String(value.dropFirst().dropLast()))
532 } 598 }
533 pairs.append((key, value)) 599 pairs.append((key, value))
534 } 600 }
@@ -581,7 +647,7 @@ public enum Babel {
581 647
582 /// `org-babel-ref-resolve` for literals and named tables and lists in the file. 648 /// `org-babel-ref-resolve` for literals and named tables and lists in the file.
583 static func resolve(_ value: String, model: DocumentModel, text: NSString) throws -> VarValue { 649 static func resolve(_ value: String, model: DocumentModel, text: NSString) throws -> VarValue {
584 if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { return .scalar(String(value.dropFirst().dropLast())) } 650 if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { return .scalar(Params.unescape(String(value.dropFirst().dropLast()))) }
585 if let n = number(value) { return .number(n) } 651 if let n = number(value) { return .number(n) }
586 if value.contains("(") || value.contains("[") { throw Failure.message("References like \(value) aren't supported yet.") } 652 if value.contains("(") || value.contains("[") { throw Failure.message("References like \(value) aren't supported yet.") }
587 // A named element: `#+NAME: value` above a table or list. 653 // A named element: `#+NAME: value` above a table or list.
Sources/OrgCore/Compute/Tangle.swift added +296
@@ -0,0 +1,296 @@
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`, and `:var` for shells, Python and Emacs Lisp. Comments
6// (`:comments`) and header values Org would evaluate as Lisp aren't supported.
7
8public enum Tangle {
9 /// All blocks (C-c C-v t), the block at an offset (C-u C-c C-v t), or the blocks going to
10 /// the same file as the block at an offset (C-u C-u C-c C-v t).
11 public enum Scope: Sendable, Equatable {
12 case all
13 case block(Int)
14 case target(Int)
15 }
16
17 public struct File: Sendable, Equatable {
18 /// Absolute.
19 public var path: String
20 public var contents: String
21 /// From `:tangle-mode`, or 0o755 for a file with a `:shebang`; nil leaves the mode alone.
22 public var mode: Int?
23 /// `:mkdirp`: the file's directory is made if missing.
24 public var makeDirectories: Bool
25 }
26
27 public struct Output: Sendable, Equatable {
28 public var files: [File]
29 /// Blocks written, for "Tangled N code blocks".
30 public var blocks: Int
31 }
32
33 /// `org-babel-tangle-lang-exts`.
34 static let extensions = ["emacs-lisp": "el", "elisp": "el", "bibtex": "bib"]
35 /// `org-babel-shell-names`, and `shell` itself.
36 static let shells: Set<String> = ["sh", "bash", "zsh", "fish", "csh", "ash", "dash", "ksh", "mksh", "posh", "shell"]
37
38 /// The files tangling `text`, the contents of the Org file at `path`, writes.
39 public static func run(text: String, path: String, scope: Scope = .all, tree: OrgTree? = nil) -> Result<Output, Babel.Failure> {
40 let ns = text as NSString
41 let model = DocumentModel(tree: tree ?? OrgParser.parse(text))
42 do {
43 var order: [String] = []
44 var specs: [String: [(language: String?, body: String, params: Babel.Params)]] = [:]
45 func add(_ block: SrcBlockInfo, _ params: Babel.Params) throws {
46 guard let file = try fileName(path: path, language: block.language, tangle: params.single[":tangle"] ?? "no") else { return }
47 let body = try body(block, params: params, model: model, text: ns)
48 if specs[file] == nil { order.append(file) }
49 specs[file, default: []].append((block.language, body, params))
50 }
51 switch scope {
52 case .block(let offset):
53 guard let block = block(at: offset, in: model) else { throw Babel.Failure.message("Point is not in a source code block") }
54 try add(block, try params(block, model: model, text: ns))
55 case .all, .target:
56 var target: String?
57 if case .target(let offset) = scope {
58 guard let block = block(at: offset, in: model) else { throw Babel.Failure.message("Point is not in a source code block") }
59 target = try params(block, model: model, text: ns).single[":tangle"] ?? "no"
60 }
61 for block in model.srcBlocks where !excluded(block, model: model) {
62 let params = try params(block, model: model, text: ns)
63 let tangle = params.single[":tangle"] ?? "no"
64 if tangle == "no" || (block.language == nil && tangle == "yes") { continue }
65 if let target, target != tangle { continue }
66 try add(block, params)
67 }
68 }
69 var files: [File] = []
70 var blocks = 0
71 for file in order {
72 var contents = ""
73 var mode: Int?
74 var makeDirectories = false
75 var sheBanged = false
76 for spec in specs[file]! {
77 let shebang = spec.params.single[":shebang"].flatMap { $0.isEmpty ? nil : $0 }
78 var tangleMode = try spec.params.single[":tangle-mode"].map(fileMode)
79 if shebang != nil, tangleMode == nil { tangleMode = 0o755 }
80 // `add-to-list` then `mapc`: the first mode given is the one left.
81 if mode == nil { mode = tangleMode }
82 if let mkdirp = spec.params.single[":mkdirp"], mkdirp != "no" { makeDirectories = true }
83 if spec.params.single[":padline"] != "no", !contents.isEmpty { contents += "\n" }
84 if let shebang, !sheBanged {
85 contents += shebang + "\n"
86 sheBanged = true
87 }
88 contents += spec.body + "\n"
89 blocks += 1
90 }
91 files.append(File(path: file, contents: contents, mode: mode, makeDirectories: makeDirectories))
92 }
93 return .success(Output(files: files, blocks: blocks))
94 } catch let error as Babel.Failure {
95 return .failure(error)
96 } catch {
97 return .failure(.message("\(error)"))
98 }
99 }
100
101 /// `org-babel-where-is-src-block-head`.
102 static func block(at offset: Int, in model: DocumentModel) -> SrcBlockInfo? {
103 model.srcBlocks.first { $0.range.contains(offset) || $0.range.upperBound == offset }
104 }
105
106 /// `org-in-commented-heading-p` and `org-in-archived-heading-p`.
107 static func excluded(_ block: SrcBlockInfo, model: DocumentModel) -> Bool {
108 guard let heading = block.heading else { return false }
109 return ([heading] + model.ancestors(of: heading)).contains { index in
110 let h = model.headings[index]
111 return AgendaSource.isCommented(h.title) || h.localTags.contains("ARCHIVE")
112 }
113 }
114
115 static func params(_ block: SrcBlockInfo, model: DocumentModel, text: NSString) throws -> Babel.Params {
116 let params = try Babel.Params(block: block, model: model, text: text)
117 for (key, value) in params.single where value.hasPrefix("(") && key != ":tangle-mode" {
118 throw Babel.Failure.message("\(key) \(value) is Lisp to evaluate, which tangling doesn't support yet; nothing was tangled.")
119 }
120 if let comments = params.single[":comments"], comments != "no" {
121 throw Babel.Failure.message(":comments \(comments) isn't supported yet; nothing was tangled.")
122 }
123 return params
124 }
125
126 /// `org-babel-effective-tangled-filename`.
127 static func fileName(path: String, language: String?, tangle: String) throws -> String? {
128 let directory = (path as NSString).deletingLastPathComponent
129 let base: String
130 switch tangle {
131 case "no", "": return nil
132 case "yes": base = (path as NSString).deletingPathExtension
133 default: base = expand(tangle, in: directory)
134 }
135 guard tangle == "yes", let language else { return base }
136 return base + "." + (extensions[language] ?? language)
137 }
138
139 /// `expand-file-name`.
140 static func expand(_ name: String, in directory: String) -> String {
141 let expanded = (name as NSString).expandingTildeInPath
142 let absolute = expanded.hasPrefix("/") ? expanded : (directory as NSString).appendingPathComponent(expanded)
143 return (absolute as NSString).standardizingPath
144 }
145
146 /// `org-babel-tangle-single-block`'s body: noweb references expanded or stripped, the
147 /// language's expansion with `:var`, `:prologue` and `:epilogue`, `-r` coderefs taken
148 /// out, and indentation and surrounding blank space trimmed.
149 static func body(_ block: SrcBlockInfo, params: Babel.Params, model: DocumentModel, text: NSString) throws -> String {
150 var body = Babel.normalizedBody(block)
151 let noweb = params.single[":noweb"]
152 if Babel.nowebTangles(noweb) {
153 if noweb == "strip-tangle" {
154 body = body.replacingOccurrences(of: "<<([^ \\t\\n](?:.*?[^ \\t\\n])?)>>", with: "", options: .regularExpression)
155 } else {
156 body = try Babel.expandNoweb(block, params: params, model: model, text: text, tangling: true)
157 }
158 }
159 if params.single[":no-expand"] == nil {
160 body = try expandBody(body, language: block.language ?? "", params: params, model: model, text: text)
161 }
162 // `(string-match "-r" extra)`.
163 if block.switches.joined(separator: " ").contains("-r") {
164 if block.switches.contains("-l") { throw Babel.Failure.message("-l coderef formats aren't supported yet; nothing was tangled.") }
165 body = body.components(separatedBy: "\n")
166 .map { $0.replacingOccurrences(of: "[ \\t]*\\(ref:[-a-zA-Z0-9_][-a-zA-Z0-9_ ]*\\)[ \\t]*$", with: "", options: .regularExpression) }
167 .joined(separator: "\n")
168 }
169 let preserve = block.switches.contains("-i")
170 if !preserve { body = removeIndentation(body) }
171 body = body.replacingOccurrences(of: "[ \\t\\n\\r]+$", with: "", options: .regularExpression)
172 return preserve
173 ? body.replacingOccurrences(of: "^([ \\t]*\\n)+", with: "", options: .regularExpression)
174 : body.replacingOccurrences(of: "^[ \\t\\n\\r]+", with: "", options: .regularExpression)
175 }
176
177 /// `org-remove-indentation`.
178 static func removeIndentation(_ s: String) -> String {
179 let lines = s.components(separatedBy: "\n")
180 let indents = lines.filter { $0.contains { !$0.isWhitespace } }.map { $0.prefix { $0 == " " || $0 == "\t" }.count }
181 let common = indents.min() ?? 0
182 guard common > 0 else { return s }
183 return lines.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " || $0 == "\t" }.count))) }.joined(separator: "\n")
184 }
185
186 /// `org-babel-expand-body:LANG`, or the generic expansion with the language's variable
187 /// assignments.
188 static func expandBody(_ body: String, language: String, params: Babel.Params, model: DocumentModel, text: NSString) throws -> String {
189 let vars = try params.vars.map { ($0.name, try Babel.resolve($0.value, model: model, text: text)) }
190 let prologue = params.single[":prologue"]
191 let epilogue = params.single[":epilogue"]
192 if language == "emacs-lisp" || language == "elisp" {
193 guard !vars.isEmpty else { return body + "\n" }
194 let bindings = vars.map { "(\($0.0) '\(Babel.lispLiteral($0.1)))" }.joined(separator: "\n ")
195 return "(let (\(bindings))\n" + (prologue.map { $0 + "\n " } ?? "") + body + (epilogue.map { "\n " + $0 + "\n" } ?? "") + "\n)"
196 }
197 var lines: [String] = []
198 if shells.contains(language) {
199 lines = try vars.map { name, value in
200 switch value {
201 case .scalar(let s), .number(let s): return "\(name)='" + s.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
202 default: throw Babel.Failure.message("Tables and lists in :var aren't supported for tangling \(language) yet; nothing was tangled.")
203 }
204 }
205 } else if language == "python" {
206 lines = vars.map { "\($0.0)=\(Babel.pythonLiteral($0.1))" }
207 } else if !vars.isEmpty {
208 throw Babel.Failure.message(":var isn't supported for tangling \(language) yet; nothing was tangled.")
209 }
210 return ((prologue.map { [$0] } ?? []) + lines + [body] + (epilogue.map { [$0] } ?? [])).joined(separator: "\n")
211 }
212
213 /// `org-babel-interpret-file-mode`, for `:tangle-mode`.
214 static func fileMode(_ value: String) throws -> Int {
215 let v = value.trimmingCharacters(in: .whitespaces)
216 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})/) {
217 return Int(m.1, radix: 8)!
218 }
219 if v.wholeMatch(of: /[r-][w-][xs-][r-][w-][xs-][r-][w-][x-]/) != nil {
220 let chars = Array(v)
221 var mode = 0
222 for (i, bit) in [0o400, 0o200, 0o100, 0o40, 0o20, 0o10, 0o4, 0o2, 0o1].enumerated() where chars[i] != "-" { mode |= bit }
223 return mode
224 }
225 if v.wholeMatch(of: /[ugoa]*[+=-][rwx]*(,[ugoa]*[+=-][rwx]*)*/) != nil {
226 var mode = 0o644
227 for clause in v.split(separator: ",") {
228 let who = clause.prefix { "ugoa".contains($0) }
229 let rest = clause.dropFirst(who.count)
230 let op = rest.first!
231 let perms = rest.dropFirst()
232 var mask = 0
233 for w in (who.isEmpty ? "a" : String(who)) {
234 switch w {
235 case "u": mask |= 0o700
236 case "g": mask |= 0o070
237 case "o": mask |= 0o007
238 default: mask |= 0o777
239 }
240 }
241 var bits = 0
242 for p in perms {
243 switch p {
244 case "r": bits |= 0o444
245 case "w": bits |= 0o222
246 default: bits |= 0o111
247 }
248 }
249 switch op {
250 case "+": mode |= bits & mask
251 case "-": mode &= ~(bits & mask)
252 default: mode = (mode & ~mask) | (bits & mask)
253 }
254 }
255 return mode
256 }
257 throw Babel.Failure.message("File mode \(value) isn't one tangling understands; nothing was tangled.")
258 }
259}
260
261/// C-c C-v t (`org-babel-tangle`): the app writes the files.
262public struct TangleCommand: OrgCommand {
263 public let scope: Scope
264
265 public enum Scope: Sendable { case all, block, target }
266
267 public init(_ scope: Scope = .all) { self.scope = scope }
268
269 public var id: String {
270 switch scope {
271 case .all: "org.babel.tangle"
272 case .block: "org.babel.tangle-block"
273 case .target: "org.babel.tangle-target"
274 }
275 }
276
277 public var title: String {
278 switch scope {
279 case .all: "Tangle File"
280 case .block: "Tangle Block"
281 case .target: "Tangle Block's Target"
282 }
283 }
284
285 public func applies(in context: EditContext) -> Bool {
286 scope == .all ? !DocumentModel(tree: context.tree).srcBlocks.isEmpty : KeyContext.holds("src", in: context)
287 }
288
289 public func run(in context: EditContext) -> CommandStep {
290 switch scope {
291 case .all: .external(.tangle(.all))
292 case .block: .external(.tangle(.block(context.caret)))
293 case .target: .external(.tangle(.target(context.caret)))
294 }
295 }
296}
Sources/OrgCore/Keymap/Presets.swift +6
@@ -789,6 +789,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
789 keys = "C-c '" 789 keys = "C-c '"
790 command = "org.edit-special" 790 command = "org.edit-special"
791 [[bind]] 791 [[bind]]
792 keys = "C-c C-v t"
793 command = "org.babel.tangle"
794 [[bind]]
795 keys = "C-c C-v C-t"
796 command = "org.babel.tangle"
797 [[bind]]
792 keys = "C-c `" 798 keys = "C-c `"
793 command = "org.table.edit-field" 799 command = "org.table.edit-field"
794 when = "table" 800 when = "table"
Sources/Orgstar/ContentView.swift +2
@@ -423,6 +423,8 @@ struct ContentView: View {
423 switch request { 423 switch request {
424 case .openLink(let link): 424 case .openLink(let link):
425 if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) } 425 if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) }
426 case .tangle(let scope):
427 commands.show(session.tangle(scope))
426 case .agenda(let start, let days): 428 case .agenda(let start, let days):
427 commands.agendaRequest = (start, days) 429 commands.agendaRequest = (start, days)
428 openWindow(id: "agenda") 430 openWindow(id: "agenda")
Sources/OrgstarMobile/EditorScreen.swift +1
@@ -132,6 +132,7 @@ struct EditorScreen: View {
132 } 132 }
133 } 133 }
134 editor.onEditBlock = { edit in sheet = .block(edit) } 134 editor.onEditBlock = { edit in sheet = .block(edit) }
135 editor.onTangle = { scope in message = session.tangle(scope) }
135 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } 136 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
136 editor.onAppCommand = { id in 137 editor.onAppCommand = { id in
137 switch id { 138 switch id {
Sources/OrgstarMobile/MobileEditor.swift +4
@@ -62,6 +62,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
62 var onLink: ((OrgLink) -> Void)? 62 var onLink: ((OrgLink) -> Void)?
63 /// Edits being applied, which the typing hooks leave alone. 63 /// Edits being applied, which the typing hooks leave alone.
64 private var applyingEdits = false 64 private var applyingEdits = false
65 /// Source blocks to write to their files (C-c C-v t).
66 var onTangle: ((Tangle.Scope) -> Void)?
65 /// A block, a table's formulas or a field to edit apart (C-c ', C-c `). 67 /// A block, a table's formulas or a field to edit apart (C-c ', C-c `).
66 var onEditBlock: ((BlockEdit) -> Void)? 68 var onEditBlock: ((BlockEdit) -> Void)?
67 /// A followed timestamp's agenda days: the first (a `Days` number) and how many. 69 /// A followed timestamp's agenda days: the first (a `Days` number) and how many.
@@ -166,6 +168,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
166 } 168 }
167 case .external(.editBlock(let edit)) where onEditBlock != nil: 169 case .external(.editBlock(let edit)) where onEditBlock != nil:
168 onEditBlock?(edit) 170 onEditBlock?(edit)
171 case .external(.tangle(let scope)) where onTangle != nil:
172 onTangle?(scope)
169 case .external(.agenda(let start, let count)) where onAgenda != nil: 173 case .external(.agenda(let start, let count)) where onAgenda != nil:
170 onAgenda?(start, count) 174 onAgenda?(start, count)
171 case .external: 175 case .external:
Tests/OrgAppTests/TangleWriterTests.swift added +45
@@ -0,0 +1,45 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgApp
5
6struct TangleWriterTests {
7 @Test func writesMakesFoldersAndSetsModes() throws {
8 let folder = try Folder()
9 let source = try folder.write("notes.org", """
10 #+begin_src sh :tangle bin/run.sh :mkdirp yes :shebang "#!/bin/sh"
11 echo run
12 #+end_src
13 #+begin_src text :tangle missing/x.txt
14 x
15 #+end_src
16 """)
17 let text = try String(contentsOf: source, encoding: .utf8)
18 let output = try Tangle.run(text: text, path: source.path).get()
19 // The second file's folder is missing and it has no :mkdirp.
20 #expect(throws: TangleWriter.Failure.self) { try TangleWriter.write(output, source: source.path) }
21 let script = folder.url.appendingPathComponent("bin/run.sh")
22 #expect(try String(contentsOf: script, encoding: .utf8) == "#!/bin/sh\necho run\n")
23 let mode = try FileManager.default.attributesOfItem(atPath: script.path)[.posixPermissions] as? Int
24 #expect(mode == 0o755)
25 }
26
27 @Test func leavesUnchangedFilesAndRefusesItsOwnSource() throws {
28 let folder = try Folder()
29 let source = try folder.write("notes.org", "#+begin_src sh :tangle yes\necho a\n#+end_src\n")
30 let text = try String(contentsOf: source, encoding: .utf8)
31 let output = try Tangle.run(text: text, path: source.path).get()
32 let paths = try TangleWriter.write(output, source: source.path)
33 #expect(paths == [folder.url.appendingPathComponent("notes.sh").path])
34 let written = try FileManager.default.attributesOfItem(atPath: paths[0])[.modificationDate] as? Date
35 Thread.sleep(forTimeInterval: 1.1)
36 try TangleWriter.write(output, source: source.path)
37 #expect(try FileManager.default.attributesOfItem(atPath: paths[0])[.modificationDate] as? Date == written)
38 #expect(TangleWriter.message(output, source: source.path) == "Tangled 1 code block from notes.org")
39
40 let own = try Tangle.run(text: "#+begin_src org :tangle notes.org\nx\n#+end_src\n", path: source.path).get()
41 #expect(throws: TangleWriter.Failure(message: "Not allowed to tangle into the same file as self")) {
42 try TangleWriter.write(own, source: source.path)
43 }
44 }
45}
Tests/OrgCoreTests/TangleTests.swift added +122
@@ -0,0 +1,122 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// `org-babel-tangle` against Emacs: every file written, with its mode.
6struct TangleTests {
7 static let text = #"""
8 #+PROPERTY: header-args:python :tangle script.py
9
10 * Setup
11 #+begin_src sh :tangle yes
12 echo one
13 #+end_src
14
15 #+begin_src sh :tangle yes :padline no
16 echo two
17 indented
18
19 #+end_src
20
21 #+begin_src python
22 x = 1
23 #+end_src
24
25 * COMMENT Skipped
26 #+begin_src sh :tangle yes
27 echo skipped
28 #+end_src
29
30 * Archived :ARCHIVE:
31 #+begin_src sh :tangle yes
32 echo archived
33 #+end_src
34
35 * Tools
36 :PROPERTIES:
37 :header-args: :tangle bin/tool.sh :mkdirp yes :shebang "#!/bin/sh"
38 :END:
39 #+begin_src sh :var name="Ann's" n=3
40 echo "$name $n"
41 #+end_src
42
43 #+begin_src sh -r
44 grep x file (ref:grep)
45 #+end_src
46
47 #+name: helper
48 #+begin_src elisp :tangle no
49 (message "hi")
50 #+end_src
51
52 #+begin_src elisp :tangle init.el :noweb yes :var greeting="hello" count=2
53 <<helper>>
54 (print greeting)
55 #+end_src
56
57 #+begin_src python :noweb strip-tangle :var items=3.5 label="a\"b"
58 print(1) <<helper>>
59 #+end_src
60
61 #+begin_src text :tangle notes.txt :tangle-mode o600 :prologue "start" :epilogue "end"
62 middle
63 #+end_src
64
65 #+begin_src conf :tangle ~/../../tmp/../tmp/orgstar-never/x.conf :tangle-mode u+x,g-r
66 never written: its folder doesn't exist
67 #+end_src
68
69 #+begin_src sh :tangle no
70 echo never
71 #+end_src
72
73 """#
74
75 static func describe(_ output: Tangle.Output, root: String) -> String {
76 output.files.sorted { $0.path < $1.path }.map { file in
77 let relative = file.path.hasPrefix(root + "/") ? String(file.path.dropFirst(root.count + 1)) : file.path
78 return "== \(relative) \(String(file.mode ?? 0o644, radix: 8))\n" + file.contents
79 }.joined()
80 }
81
82 @Test func tanglesLikeOrg() throws {
83 // The conf block's folder doesn't exist, so Emacs stops there; tangling it alone shows
84 // the rest, and the error is checked apart.
85 let text = Self.text.replacingOccurrences(of: ":tangle ~/../../tmp/../tmp/orgstar-never/x.conf", with: ":tangle no")
86 let ns = text as NSString
87 let cases: [(offset: Int, arg: String, scope: Tangle.Scope)] = [
88 (0, "nil", .all),
89 (ns.range(of: "echo two").location, "'(4)", .block(ns.range(of: "echo two").location)),
90 (ns.range(of: "grep x").location, "'(16)", .target(ns.range(of: "grep x").location)),
91 (ns.range(of: "echo never").location, "'(4)", .block(ns.range(of: "echo never").location)),
92 ]
93 let form = { (arg: String) in """
94 (let* ((dir (file-truename (make-temp-file "orgstar-tangle" t)))
95 (src (expand-file-name "notes.org" dir))
96 (text (buffer-string)) (pt (point)) out)
97 (require 'ob-shell) (require 'ob-python)
98 (with-temp-file src (insert text))
99 (with-current-buffer (find-file-noselect src)
100 (goto-char pt)
101 (let ((files (org-babel-tangle \(arg))))
102 (setq out (mapconcat (lambda (f) (format "== %s %o\\n%s" (file-relative-name f dir) (file-modes f)
103 (with-temp-buffer (insert-file-contents f) (buffer-string))))
104 (sort (copy-sequence files) #'string<) ""))))
105 (erase-buffer) (insert out))
106 """ }
107 let results = try EmacsOracle.run(cases.map { EmacsOracle.Case(text: text, point: EmacsOracle.point($0.offset, in: text), form: form($0.arg)) })
108 for (c, theirs) in zip(cases, results) {
109 let ours = try Tangle.run(text: text, path: "/oracle/notes.org", scope: c.scope).get()
110 #expect(Self.describe(ours, root: "/oracle") == theirs.text, "\(c.arg)\nours:\n\(Self.describe(ours, root: "/oracle"))\nemacs:\n\(theirs.text)\n\(theirs.error)")
111 }
112 }
113
114 @Test func refusesWhatItCantDo() {
115 for header in [":comments link", ":tangle (concat \"a\" \".sh\")", ":tangle-mode go+q"] {
116 let text = "#+begin_src sh :tangle yes \(header)\necho\n#+end_src\n"
117 guard case .failure = Tangle.run(text: text, path: "/x/a.org") else { Issue.record("tangled with \(header)"); continue }
118 }
119 let modes = ["(identity #o755)": 0o755, "o700": 0o700, "rw-r-----": 0o640, "u+x": 0o744, "a=r,u+w": 0o644, "go-r": 0o600]
120 for (value, mode) in modes { #expect((try? Tangle.fileMode(value)) == mode, "\(value)") }
121 }
122}