krz/orgstar

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

Commit 2849ed6283

2849ed62830a40327654edbb1f70db1f0d46c218

parent: 0477b315ee

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 05:49 UTC

Tangle with :comments

:comments link, yes, both, org and noweb write what
org-babel-spec-to-string does: the prose before the block, and the
[[file:...::search][name]] and "name ends here" lines, in the
language's comment syntax. Links are relative to the tangled file's
folder; source names count blocks under each heading. With noweb,
each named reference is wrapped in its own link comments.

Languages whose comment syntax isn't known, and noweb comments on
:noweb-ref blocks, are refused.

Layout: unified · split

Sources/OrgCore/Compute/Babel.swift +14 −3
@@ -431,16 +431,27 @@ public enum Babel {
431 /// each line it brings. 431 /// each line it brings.
432 /// `tangling` expands the blocks it brings in as tangling would (`:noweb tangle`), not as 432 /// `tangling` expands the blocks it brings in as tangling would (`:noweb tangle`), not as
433 /// evaluation does. 433 /// evaluation does.
434 static func expandNoweb(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text: NSString, tangling: Bool = false, depth: Int = 0) throws -> String { 434 /// `links` gives a block's begin and end comments, for `:comments noweb` when tangling.
435 static func expandNoweb(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text: NSString, tangling: Bool = false, depth: Int = 0,
436 links: ((SrcBlockInfo, Params) throws -> (begin: String, end: String))? = nil) throws -> String {
435 guard depth < 32 else { throw Failure.message("Noweb references nest too deeply") } 437 guard depth < 32 else { throw Failure.message("Noweb references nest too deeply") }
436 let body = normalizedBody(block) 438 let body = normalizedBody(block)
437 let prefixing = params.single[":noweb-prefix"].map { $0 != "no" && $0 != "nil" } ?? true 439 let prefixing = params.single[":noweb-prefix"].map { $0 != "no" && $0 != "nil" } ?? true
440 let commenting = tangling && links != nil && params.single[":comments"] == "noweb"
438 func expand(_ other: SrcBlockInfo) throws -> String { 441 func expand(_ other: SrcBlockInfo) throws -> String {
439 let otherParams = try Params(block: other, model: model, text: text) 442 let otherParams = try Params(block: other, model: model, text: text)
440 let expands = tangling ? nowebTangles(otherParams.single[":noweb"]) : nowebEvaluates(otherParams.single[":noweb"]) 443 let expands = tangling ? nowebTangles(otherParams.single[":noweb"]) : nowebEvaluates(otherParams.single[":noweb"])
441 return expands 444 let b = expands
442 ? try expandNoweb(other, params: otherParams, model: model, text: text, tangling: tangling, depth: depth + 1) 445 ? try expandNoweb(other, params: otherParams, model: model, text: text, tangling: tangling, depth: depth + 1, links: links)
443 : normalizedBody(other) 446 : normalizedBody(other)
447 guard commenting, let links else { return b }
448 guard other.name != nil else { throw Failure.message(":comments noweb with :noweb-ref blocks isn't supported yet; nothing was tangled.") }
449 // `c-wrap`: commented in this block's language, trimmed.
450 func wrap(_ s: String) throws -> String {
451 try Tangle.commentRegion(s, language: block.language ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
452 }
453 let (begin, end) = try links(other, otherParams)
454 return try wrap(begin) + "\n" + b + "\n" + (try wrap(end)) + "\n"
444 } 455 }
445 func inCommentedHeading(_ b: SrcBlockInfo) -> Bool { 456 func inCommentedHeading(_ b: SrcBlockInfo) -> Bool {
446 guard let h = b.heading else { return false } 457 guard let h = b.heading else { return false }
Sources/OrgCore/Compute/Tangle.swift +109 −12
@@ -2,8 +2,9 @@ import Foundation
2 2
3// `org-babel-tangle` (Org 9.8.7 ob-tangle.el): source blocks written to the files their 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`, 4// `:tangle` names, with `:noweb`, `:padline`, `:shebang`, `:mkdirp`, `:tangle-mode`,
5// `:prologue` and `:epilogue`, and `:var` for shells, Python and Emacs Lisp. Comments 5// `:prologue` and `:epilogue`, `:var` for shells, Python and Emacs Lisp, and `:comments` for
6// (`:comments`) and header values Org would evaluate as Lisp aren't supported. 6// languages whose comment syntax is known. Header values Org would evaluate as Lisp aren't
7// supported.
7 8
8public enum Tangle { 9public 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 /// All blocks (C-c C-v t), the block at an offset (C-u C-c C-v t), or the blocks going to
@@ -42,16 +43,29 @@ public enum Tangle {
42 do { 43 do {
43 var order: [String] = [] 44 var order: [String] = []
44 var specs: [String: [(language: String?, body: String, params: Babel.Params)]] = [:] 45 var specs: [String: [(language: String?, body: String, params: Babel.Params)]] = [:]
45 func add(_ block: SrcBlockInfo, _ params: Babel.Params) throws { 46 // `org-babel-tangle-collect-blocks`: blocks numbered from 1 under each heading.
47 var counters: [Int: Int] = [:]
48 var lastHeading: Int?? = .none
49 var counter = 0
50 for block in model.srcBlocks {
51 if lastHeading == .some(block.heading) { counter += 1 } else { (counter, lastHeading) = (1, .some(block.heading)) }
52 counters[block.range.lowerBound] = counter
53 }
54 func add(_ block: SrcBlockInfo, _ params: Babel.Params, counter: Int) throws {
46 guard let file = try fileName(path: path, language: block.language, tangle: params.single[":tangle"] ?? "no") else { return } 55 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) 56 let parsed = tree ?? OrgParser.parse(text)
57 let links = { (other: SrcBlockInfo, otherParams: Babel.Params) in
58 try Self.links(other, params: otherParams, counter: counters[other.range.lowerBound] ?? 1, path: path, model: model, tree: parsed, text: ns)
59 }
60 let body = try spec(block, body: try body(block, params: params, model: model, text: ns, links: links), params: params, counter: counter,
61 path: path, model: model, tree: parsed, text: ns)
48 if specs[file] == nil { order.append(file) } 62 if specs[file] == nil { order.append(file) }
49 specs[file, default: []].append((block.language, body, params)) 63 specs[file, default: []].append((block.language, body, params))
50 } 64 }
51 switch scope { 65 switch scope {
52 case .block(let offset): 66 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") } 67 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)) 68 try add(block, try params(block, model: model, text: ns), counter: 1)
55 case .all, .target: 69 case .all, .target:
56 var target: String? 70 var target: String?
57 if case .target(let offset) = scope { 71 if case .target(let offset) = scope {
@@ -63,7 +77,7 @@ public enum Tangle {
63 let tangle = params.single[":tangle"] ?? "no" 77 let tangle = params.single[":tangle"] ?? "no"
64 if tangle == "no" || (block.language == nil && tangle == "yes") { continue } 78 if tangle == "no" || (block.language == nil && tangle == "yes") { continue }
65 if let target, target != tangle { continue } 79 if let target, target != tangle { continue }
66 try add(block, params) 80 try add(block, params, counter: counters[block.range.lowerBound] ?? 1)
67 } 81 }
68 } 82 }
69 var files: [File] = [] 83 var files: [File] = []
@@ -85,7 +99,7 @@ public enum Tangle {
85 contents += shebang + "\n" 99 contents += shebang + "\n"
86 sheBanged = true 100 sheBanged = true
87 } 101 }
88 contents += spec.body + "\n" 102 contents += spec.body
89 blocks += 1 103 blocks += 1
90 } 104 }
91 files.append(File(path: file, contents: contents, mode: mode, makeDirectories: makeDirectories)) 105 files.append(File(path: file, contents: contents, mode: mode, makeDirectories: makeDirectories))
@@ -117,9 +131,6 @@ public enum Tangle {
117 for (key, value) in params.single where value.hasPrefix("(") && key != ":tangle-mode" { 131 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.") 132 throw Babel.Failure.message("\(key) \(value) is Lisp to evaluate, which tangling doesn't support yet; nothing was tangled.")
119 } 133 }
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 134 return params
124 } 135 }
125 136
@@ -146,14 +157,15 @@ public enum Tangle {
146 /// `org-babel-tangle-single-block`'s body: noweb references expanded or stripped, the 157 /// `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 158 /// language's expansion with `:var`, `:prologue` and `:epilogue`, `-r` coderefs taken
148 /// out, and indentation and surrounding blank space trimmed. 159 /// out, and indentation and surrounding blank space trimmed.
149 static func body(_ block: SrcBlockInfo, params: Babel.Params, model: DocumentModel, text: NSString) throws -> String { 160 static func body(_ block: SrcBlockInfo, params: Babel.Params, model: DocumentModel, text: NSString,
161 links: ((SrcBlockInfo, Babel.Params) throws -> (begin: String, end: String))? = nil) throws -> String {
150 var body = Babel.normalizedBody(block) 162 var body = Babel.normalizedBody(block)
151 let noweb = params.single[":noweb"] 163 let noweb = params.single[":noweb"]
152 if Babel.nowebTangles(noweb) { 164 if Babel.nowebTangles(noweb) {
153 if noweb == "strip-tangle" { 165 if noweb == "strip-tangle" {
154 body = body.replacingOccurrences(of: "<<([^ \\t\\n](?:.*?[^ \\t\\n])?)>>", with: "", options: .regularExpression) 166 body = body.replacingOccurrences(of: "<<([^ \\t\\n](?:.*?[^ \\t\\n])?)>>", with: "", options: .regularExpression)
155 } else { 167 } else {
156 body = try Babel.expandNoweb(block, params: params, model: model, text: text, tangling: true) 168 body = try Babel.expandNoweb(block, params: params, model: model, text: text, tangling: true, links: links)
157 } 169 }
158 } 170 }
159 if params.single[":no-expand"] == nil { 171 if params.single[":no-expand"] == nil {
@@ -174,6 +186,91 @@ public enum Tangle {
174 : body.replacingOccurrences(of: "^[ \\t\\n\\r]+", with: "", options: .regularExpression) 186 : body.replacingOccurrences(of: "^[ \\t\\n\\r]+", with: "", options: .regularExpression)
175 } 187 }
176 188
189 /// `org-babel-spec-to-string`: the block's text in the file, with its `:comments`.
190 static func spec(_ block: SrcBlockInfo, body: String, params: Babel.Params, counter: Int, path: String, model: DocumentModel, tree: OrgTree, text: NSString) throws -> String {
191 let comments = params.single[":comments"] ?? "no"
192 guard comments != "no" else { return body + "\n" }
193 let language = block.language ?? ""
194 let linked = ["both", "link", "yes", "noweb"].contains(comments)
195 var out = ""
196 if comments == "org" || comments == "both" {
197 let prose = orgComment(block, model: model, text: text)
198 if prose.contains(where: { !$0.isWhitespace }) { out += try commentRegion(prose, language: language) + "\n" }
199 }
200 let link = linked ? try links(block, params: params, counter: counter, path: path, model: model, tree: tree, text: text) : nil
201 if let link { out += try commentRegion(link.begin, language: language) + "\n" }
202 out += body + "\n"
203 if let link { out += try commentRegion(link.end, language: language) + "\n" }
204 return out
205 }
206
207 /// `org-babel-tangle-comment-format-beg` and `-end` for a block: `[[LINK][NAME]]` and
208 /// `NAME ends here`, the link relative to the tangled file's folder.
209 static func links(_ block: SrcBlockInfo, params: Babel.Params, counter: Int, path: String, model: DocumentModel, tree: OrgTree, text: NSString) throws -> (begin: String, end: String) {
210 let directory = (path as NSString).deletingLastPathComponent
211 let tangle = params.single[":tangle"] ?? "no"
212 let target = tangle.contains("/") ? (expand(tangle, in: directory) as NSString).deletingLastPathComponent : directory
213 let start = beginLine(block, text: text)
214 let context = EditContext(revision: 0, text: text as String, tree: tree, selection: [start..<start])
215 let stored = Links.store(in: context, path: relativePath(path, from: target)).link
216 let name = block.name ?? ((block.heading.map { model.headings[$0].title.trimmingCharacters(in: .whitespaces) } ?? "No heading") + ":\(counter)")
217 return ("[[\(stored)][\(name)]]", "\(name) ends here")
218 }
219
220 /// `file-relative-name`.
221 static func relativePath(_ path: String, from directory: String) -> String {
222 let to = (path as NSString).standardizingPath.split(separator: "/")
223 let from = (directory as NSString).standardizingPath.split(separator: "/")
224 let common = zip(to, from).prefix { $0 == $1 }.count
225 return (Array(repeating: "..", count: from.count - common) + to.dropFirst(common).map(String.init)).joined(separator: "/")
226 }
227
228 /// The start of the block's `#+begin_src` line.
229 static func beginLine(_ block: SrcBlockInfo, text: NSString) -> Int {
230 let regex = try! NSRegularExpression(pattern: "^[ \\t]*#\\+begin_src", options: [.anchorsMatchLines, .caseInsensitive])
231 return regex.firstMatch(in: text as String, range: NSRange(block.range))?.range.location ?? block.range.lowerBound
232 }
233
234 /// The prose `:comments org` takes: from the heading's title, or the end of the block
235 /// before, to the block, without its common indentation.
236 static func orgComment(_ block: SrcBlockInfo, model: DocumentModel, text: NSString) -> String {
237 let begin = beginLine(block, text: text)
238 var start = 0
239 if let heading = block.heading {
240 let line = text.substring(with: NSRange(model.headings[heading].headingRange))
241 start = model.headings[heading].headingRange.lowerBound + (line.firstMatch(of: #/^\*+ /#).map { $0.output.utf16.count } ?? 0)
242 }
243 let end = try! NSRegularExpression(pattern: "^[ \\t]*#\\+end_src", options: [.anchorsMatchLines, .caseInsensitive])
244 for other in model.srcBlocks where other.range.lowerBound < begin {
245 if let m = end.firstMatch(in: text as String, range: NSRange(other.range)), NSMaxRange(m.range) <= begin { start = max(start, NSMaxRange(m.range)) }
246 }
247 return removeIndentation(text.substring(with: NSRange(location: start, length: max(0, begin - start))))
248 }
249
250 /// Comment syntax of the language's major mode (`org-src-get-lang-mode`), as
251 /// `comment-region` writes it.
252 static let commentSyntax: [String: (start: String, end: String)] = {
253 var table: [String: (String, String)] = [:]
254 for language in ["emacs-lisp", "elisp", "lisp", "scheme"] { table[language] = (";; ", "") }
255 for language in ["python", "ruby", "perl", "conf", "toml", "makefile", "awk", "tcl"] { table[language] = ("# ", "") }
256 for language in shells.subtracting(["fish"]) { table[language] = ("# ", "") }
257 for language in ["js", "java", "cpp", "C++"] { table[language] = ("// ", "") }
258 for language in ["c", "C", "css"] { table[language] = ("/* ", " */") }
259 for language in ["lua", "sql", "sqlite"] { table[language] = ("-- ", "") }
260 for language in ["latex"] { table[language] = ("%% ", "") }
261 return table
262 }()
263
264 /// `comment-region` over `text`: each line that isn't blank, commented.
265 static func commentRegion(_ text: String, language: String) throws -> String {
266 guard let syntax = commentSyntax[language] else {
267 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.")
268 }
269 return text.components(separatedBy: "\n").map { line in
270 line.contains(where: { !$0.isWhitespace }) ? syntax.start + line + syntax.end : line
271 }.joined(separator: "\n")
272 }
273
177 /// `org-remove-indentation`. 274 /// `org-remove-indentation`.
178 static func removeIndentation(_ s: String) -> String { 275 static func removeIndentation(_ s: String) -> String {
179 let lines = s.components(separatedBy: "\n") 276 let lines = s.components(separatedBy: "\n")
Tests/OrgCoreTests/TangleTests.swift +98 −2
@@ -111,9 +111,105 @@ struct TangleTests {
111 } 111 }
112 } 112 }
113 113
114 static let comments = """
115 #+PROPERTY: header-args :comments link
116 Before any heading.
117 #+begin_src sh :tangle top.sh
118 echo top
119 #+end_src
120 * Setup section :tag:
121 Some prose before the block.
122 Indented prose.
123 #+begin_src emacs-lisp :tangle out.el
124 (setq x 1)
125 #+end_src
126 Between text.
127 #+NAME: named-py
128 #+begin_src python :tangle out.py :comments both
129 print(1)
130 #+end_src
131 * TODO [#A] Other [1/2]
132 #+begin_src sh :tangle sub/out.sh :comments org :mkdirp yes
133 echo hi
134 #+end_src
135 C prose one.
136 C prose two.
137 #+begin_src c :tangle out.c :comments both
138 int x;
139 #+end_src
140 #+begin_src js :tangle out.js
141 let y;
142 #+end_src
143 #+begin_src conf :tangle out.conf :comments noweb :noweb yes
144 a=<<named-py>>
145 #+end_src
146 #+begin_src lua :tangle out.lua
147 x = 1
148 #+end_src
149 #+begin_src sh :tangle out.el :comments no
150 not commented
151 #+end_src
152 ** Child
153 :PROPERTIES:
154 :CUSTOM_ID: kid
155 :END:
156 #+begin_src ruby :tangle out.rb
157 puts 1
158 #+end_src
159 #+begin_src sql :tangle out.sql
160 select 1;
161 #+end_src
162 #+begin_src css :tangle out.css
163 a {}
164 #+end_src
165 #+begin_src java :tangle out.java
166 class A {}
167 #+end_src
168 #+begin_src perl :tangle out.pl
169 print 1;
170 #+end_src
171 #+begin_src latex :tangle out.tex
172 \\relax
173 #+end_src
174 #+begin_src scheme :tangle out.scm
175 (display 1)
176 #+end_src
177 #+begin_src makefile :tangle Makefile
178 all:
179 #+end_src
180 #+begin_src toml :tangle out.toml
181 a = 1
182 #+end_src
183 #+begin_src cpp :tangle out.cpp
184 int y;
185 #+end_src
186 #+begin_src bash :tangle out.bash
187 echo b
188 #+end_src
189 """ + "\n"
190
191 @Test func commentsLikeOrg() throws {
192 let form = """
193 (let* ((dir (file-truename (make-temp-file "orgstar-tangle" t)))
194 (src (expand-file-name "notes.org" dir))
195 (text (buffer-string)) out)
196 (require 'ob-shell) (require 'ob-python)
197 (with-temp-file src (insert text))
198 (with-current-buffer (find-file-noselect src)
199 (let ((files (org-babel-tangle)))
200 (setq out (mapconcat (lambda (f) (format "== %s %o\\n%s" (file-relative-name f dir) (file-modes f)
201 (with-temp-buffer (insert-file-contents f) (buffer-string))))
202 (sort (copy-sequence files) #'string<) ""))))
203 (erase-buffer) (insert out))
204 """
205 let theirs = try EmacsOracle.run([EmacsOracle.Case(text: Self.comments, point: 1, form: form)])[0]
206 let ours = try Tangle.run(text: Self.comments, path: "/oracle/notes.org").get()
207 #expect(Self.describe(ours, root: "/oracle") == theirs.text, "ours:\n\(Self.describe(ours, root: "/oracle"))\nemacs:\n\(theirs.text)\n\(theirs.error)")
208 }
209
114 @Test func refusesWhatItCantDo() { 210 @Test func refusesWhatItCantDo() {
115 for header in [":comments link", ":tangle (concat \"a\" \".sh\")", ":tangle-mode go+q"] { 211 for (language, header) in [("haskell", ":comments link"), ("sh", ":tangle (concat \"a\" \".sh\")"), ("sh", ":tangle-mode go+q")] {
116 let text = "#+begin_src sh :tangle yes \(header)\necho\n#+end_src\n" 212 let text = "#+begin_src \(language) :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 } 213 guard case .failure = Tangle.run(text: text, path: "/x/a.org") else { Issue.record("tangled with \(header)"); continue }
118 } 214 }
119 let modes = ["(identity #o755)": 0o755, "o700": 0o700, "rw-r-----": 0o640, "u+x": 0o744, "a=r,u+w": 0o644, "go-r": 0o600] 215 let modes = ["(identity #o755)": 0o755, "o700": 0o700, "rw-r-----": 0o640, "u+x": 0o744, "a=r,u+w": 0o644, "go-r": 0o600]