| @@ -2,8 +2,9 @@ import Foundation |
| 2 | 2 | |
| 3 | 3 | // `org-babel-tangle` (Org 9.8.7 ob-tangle.el): source blocks written to the files their |
| 4 | 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. |
| 5 | // `:prologue` and `:epilogue`, `:var` for shells, Python and Emacs Lisp, and `:comments` for |
| 6 | // languages whose comment syntax is known. Header values Org would evaluate as Lisp aren't |
| 7 | // supported. |
| 7 | 8 | |
| 8 | 9 | public enum Tangle { |
| 9 | 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 | 43 | do { |
| 43 | 44 | var order: [String] = [] |
| 44 | 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 | 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 | 62 | if specs[file] == nil { order.append(file) } |
| 49 | 63 | specs[file, default: []].append((block.language, body, params)) |
| 50 | 64 | } |
| 51 | 65 | switch scope { |
| 52 | 66 | case .block(let offset): |
| 53 | 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 | 69 | case .all, .target: |
| 56 | 70 | var target: String? |
| 57 | 71 | if case .target(let offset) = scope { |
| @@ -63,7 +77,7 @@ public enum Tangle { |
| 63 | 77 | let tangle = params.single[":tangle"] ?? "no" |
| 64 | 78 | if tangle == "no" || (block.language == nil && tangle == "yes") { continue } |
| 65 | 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 | 83 | var files: [File] = [] |
| @@ -85,7 +99,7 @@ public enum Tangle { |
| 85 | 99 | contents += shebang + "\n" |
| 86 | 100 | sheBanged = true |
| 87 | 101 | } |
| 88 | | contents += spec.body + "\n" |
| 102 | contents += spec.body |
| 89 | 103 | blocks += 1 |
| 90 | 104 | } |
| 91 | 105 | files.append(File(path: file, contents: contents, mode: mode, makeDirectories: makeDirectories)) |
| @@ -117,9 +131,6 @@ public enum Tangle { |
| 117 | 131 | for (key, value) in params.single where value.hasPrefix("(") && key != ":tangle-mode" { |
| 118 | 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 | 134 | return params |
| 124 | 135 | } |
| 125 | 136 | |
| @@ -146,14 +157,15 @@ public enum Tangle { |
| 146 | 157 | /// `org-babel-tangle-single-block`'s body: noweb references expanded or stripped, the |
| 147 | 158 | /// language's expansion with `:var`, `:prologue` and `:epilogue`, `-r` coderefs taken |
| 148 | 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 | 162 | var body = Babel.normalizedBody(block) |
| 151 | 163 | let noweb = params.single[":noweb"] |
| 152 | 164 | if Babel.nowebTangles(noweb) { |
| 153 | 165 | if noweb == "strip-tangle" { |
| 154 | 166 | body = body.replacingOccurrences(of: "<<([^ \\t\\n](?:.*?[^ \\t\\n])?)>>", with: "", options: .regularExpression) |
| 155 | 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 | 171 | if params.single[":no-expand"] == nil { |
| @@ -174,6 +186,91 @@ public enum Tangle { |
| 174 | 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 | 274 | /// `org-remove-indentation`. |
| 178 | 275 | static func removeIndentation(_ s: String) -> String { |
| 179 | 276 | let lines = s.components(separatedBy: "\n") |