Commit 791ada4f3c
Verified · cmc
Layout: unified · split
Sources/OrgApp/TangleWriter.swift added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 7 | public 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 | ||
| 55 | extension 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 | 239 | /// Show the agenda for `days` days from `start` (a `Days` number), as |
| 240 | 240 | /// `org-follow-timestamp-link` does. |
| 241 | 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 | 246 | /// A named operation. Keys, menus, the palette and touch controls all run commands. |
| @@ -267,7 +269,7 @@ public enum Commands { | ||
| 267 | 269 | TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), |
| 268 | 270 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 269 | 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 | 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 | 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 | 352 | /// `org-babel-expand-noweb-references` for evaluation: each `<<name>>` becomes the body of |
| 348 | 353 | /// the block named so, or of every block with that `:noweb-ref`, joined by `:noweb-sep`, or |
| 349 | 354 | /// the contents of the heading with that ID; the text before it on its line goes before |
| 350 | 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 | 359 | guard depth < 32 else { throw Failure.message("Noweb references nest too deeply") } |
| 353 | 360 | let body = normalizedBody(block) |
| 354 | 361 | let prefixing = params.single[":noweb-prefix"].map { $0 != "no" && $0 != "nil" } ?? true |
| 355 | 362 | func expand(_ other: SrcBlockInfo) throws -> String { |
| 356 | 363 | let otherParams = try Params(block: other, model: model, text: text) |
| 357 | return nowebEvaluates(otherParams.single[":noweb"]) | |
| 358 | ? try expandNoweb(other, params: otherParams, model: model, text: text, depth: depth + 1) | |
| 364 | let expands = tangling ? nowebTangles(otherParams.single[":noweb"]) : nowebEvaluates(otherParams.single[":noweb"]) | |
| 365 | return expands | |
| 366 | ? try expandNoweb(other, params: otherParams, model: model, text: text, tangling: tangling, depth: depth + 1) | |
| 359 | 367 | : normalizedBody(other) |
| 360 | 368 | } |
| 361 | 369 | func inCommentedHeading(_ b: SrcBlockInfo) -> Bool { |
| @@ -442,17 +450,20 @@ public enum Babel { | ||
| 442 | 450 | init(block: SrcBlockInfo, model: DocumentModel, text: NSString, extra: [[(String, String)]] = [], inline: Bool = false) throws { |
| 443 | 451 | var layers: [[(String, String)]] = [] |
| 444 | 452 | let language = block.language ?? "" |
| 445 | func properties(_ entries: [Property]) { | |
| 446 | for key in ["header-args", "header-args:\(language)"] { | |
| 453 | // `org-babel-params-from-properties`: the inherited `header-args`, then the inherited | |
| 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 | 462 | for entry in entries where entry.key.lowercased() == key { |
| 448 | let pairs = Params.parse(entry.value) | |
| 449 | layers.append(pairs) | |
| 463 | value = entry.additive ? [value, entry.value].compactMap { $0 }.joined(separator: " ") : entry.value | |
| 450 | 464 | } |
| 451 | 465 | } |
| 452 | } | |
| 453 | properties(model.fileProperties) | |
| 454 | if let heading = block.heading { | |
| 455 | for index in model.ancestors(of: heading) + [heading] { properties(model.headings[index].properties) } | |
| 466 | if let value { layers.append(Params.parse(value)) } | |
| 456 | 467 | } |
| 457 | 468 | let firstLine = inline ? "" : text.substring(with: text.lineRange(for: NSRange(location: block.range.lowerBound, length: 0))) |
| 458 | 469 | if let r = firstLine.range(of: "(?i)#\\+begin_src[ \\t]+\\S+", options: .regularExpression) { |
| @@ -485,11 +496,13 @@ public enum Babel { | ||
| 485 | 496 | results.insert(word, at: 0) |
| 486 | 497 | } |
| 487 | 498 | case ":var": |
| 488 | guard let eq = value.firstIndex(of: "=") else { throw Failure.message(":var \(value) needs a name and a value") } | |
| 489 | let name = value[..<eq].trimmingCharacters(in: .whitespaces) | |
| 490 | let assigned = value[value.index(after: eq)...].trimmingCharacters(in: .whitespaces) | |
| 491 | vars.removeAll { $0.name == name } | |
| 492 | vars.append((name, assigned)) | |
| 499 | for value in Params.splitVars(value) { | |
| 500 | guard let eq = value.firstIndex(of: "=") else { throw Failure.message(":var \(value) needs a name and a value") } | |
| 501 | let name = value[..<eq].trimmingCharacters(in: .whitespaces) | |
| 502 | let assigned = value[value.index(after: eq)...].trimmingCharacters(in: .whitespaces) | |
| 503 | vars.removeAll { $0.name == name } | |
| 504 | vars.append((name, assigned)) | |
| 505 | } | |
| 493 | 506 | case ":wrap": |
| 494 | 507 | wrap = value |
| 495 | 508 | default: |
| @@ -501,6 +514,59 @@ public enum Babel { | ||
| 501 | 514 | |
| 502 | 515 | /// `org-babel-parse-header-arguments`: split before each `:key` outside quotes and |
| 503 | 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 | 570 | static func parse(_ text: String) -> [(String, String)] { |
| 505 | 571 | var parts: [String] = [] |
| 506 | 572 | var current = "" |
| @@ -508,7 +574,7 @@ public enum Babel { | ||
| 508 | 574 | var quoted = false |
| 509 | 575 | var previous: Character = " " |
| 510 | 576 | for c in text { |
| 511 | if c == "\"" { quoted.toggle() } | |
| 577 | if c == "\"", previous != "\\" { quoted.toggle() } | |
| 512 | 578 | if !quoted { |
| 513 | 579 | if c == "(" { depth += 1 } |
| 514 | 580 | if c == ")" { depth = max(0, depth - 1) } |
| @@ -528,7 +594,7 @@ public enum Babel { | ||
| 528 | 594 | let key = String(trimmed.prefix { !$0.isWhitespace }).lowercased() |
| 529 | 595 | var value = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces) |
| 530 | 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 | 599 | pairs.append((key, value)) |
| 534 | 600 | } |
| @@ -581,7 +647,7 @@ public enum Babel { | ||
| 581 | 647 | |
| 582 | 648 | /// `org-babel-ref-resolve` for literals and named tables and lists in the file. |
| 583 | 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 | 651 | if let n = number(value) { return .number(n) } |
| 586 | 652 | if value.contains("(") || value.contains("[") { throw Failure.message("References like \(value) aren't supported yet.") } |
| 587 | 653 | // A named element: `#+NAME: value` above a table or list. |
Sources/OrgCore/Compute/Tangle.swift added +296
| @@ -0,0 +1,296 @@ | ||
| 1 | import 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 | ||
| 8 | public 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. | |
| 262 | public 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 | 789 | keys = "C-c '" |
| 790 | 790 | command = "org.edit-special" |
| 791 | 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 | 798 | keys = "C-c `" |
| 793 | 799 | command = "org.table.edit-field" |
| 794 | 800 | when = "table" |
Sources/Orgstar/ContentView.swift +2
| @@ -423,6 +423,8 @@ struct ContentView: View { | ||
| 423 | 423 | switch request { |
| 424 | 424 | case .openLink(let link): |
| 425 | 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 | 428 | case .agenda(let start, let days): |
| 427 | 429 | commands.agendaRequest = (start, days) |
| 428 | 430 | openWindow(id: "agenda") |
Sources/OrgstarMobile/EditorScreen.swift +1
| @@ -132,6 +132,7 @@ struct EditorScreen: View { | ||
| 132 | 132 | } |
| 133 | 133 | } |
| 134 | 134 | editor.onEditBlock = { edit in sheet = .block(edit) } |
| 135 | editor.onTangle = { scope in message = session.tangle(scope) } | |
| 135 | 136 | editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } |
| 136 | 137 | editor.onAppCommand = { id in |
| 137 | 138 | switch id { |
Sources/OrgstarMobile/MobileEditor.swift +4
| @@ -62,6 +62,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 62 | 62 | var onLink: ((OrgLink) -> Void)? |
| 63 | 63 | /// Edits being applied, which the typing hooks leave alone. |
| 64 | 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 | 67 | /// A block, a table's formulas or a field to edit apart (C-c ', C-c `). |
| 66 | 68 | var onEditBlock: ((BlockEdit) -> Void)? |
| 67 | 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 | 169 | case .external(.editBlock(let edit)) where onEditBlock != nil: |
| 168 | 170 | onEditBlock?(edit) |
| 171 | case .external(.tangle(let scope)) where onTangle != nil: | |
| 172 | onTangle?(scope) | |
| 169 | 173 | case .external(.agenda(let start, let count)) where onAgenda != nil: |
| 170 | 174 | onAgenda?(start, count) |
| 171 | 175 | case .external: |
Tests/OrgAppTests/TangleWriterTests.swift added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import Testing | |
| 4 | @testable import OrgApp | |
| 5 | ||
| 6 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | /// `org-babel-tangle` against Emacs: every file written, with its mode. | |
| 6 | struct 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 | } | |