Commit 0cc0e6b91b
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/Footnotes.swift +2 −1
| @@ -127,7 +127,8 @@ extension EmacsBuffer { | ||
| 127 | 127 | return point >= title.range.lowerBound && tags.map { point < $0.range.lowerBound } ?? true |
| 128 | 128 | } |
| 129 | 129 | let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp, |
| 130 | .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget] | |
| 130 | .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget, | |
| 131 | .citation, .citationReference, .exportSnippet, .inlineBabelCall, .itemTag] | |
| 131 | 132 | let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer] |
| 132 | 133 | let element = chain.last { !objects.contains($0.kind) && !containers.contains($0.kind) } |
| 133 | 134 | switch element?.kind { |
Sources/OrgCore/Commands/Narrowing.swift +2 −1
| @@ -32,7 +32,8 @@ public enum Narrowing { | ||
| 32 | 32 | probe = buffer.lineStart(buffer.point) |
| 33 | 33 | } |
| 34 | 34 | let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp, |
| 35 | .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget] | |
| 35 | .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget, | |
| 36 | .citation, .citationReference, .exportSnippet, .inlineBabelCall, .itemTag] | |
| 36 | 37 | var chain: [SyntaxNode] = [] |
| 37 | 38 | var node = tree.root |
| 38 | 39 | while let child = node.child(containing: probe) { |
Sources/OrgCore/Export/HTMLExport.swift +22 −20
| @@ -260,6 +260,9 @@ final class HTMLRenderer { | ||
| 260 | 260 | return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0)) |
| 261 | 261 | } |
| 262 | 262 | return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" |
| 263 | case .tableEl: | |
| 264 | let lines = node.text.components(separatedBy: "\n").filter { !$0.lowercased().contains("#+tblfm") } | |
| 265 | return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" | |
| 263 | 266 | case .dynamicBlock: |
| 264 | 267 | let lines = node.text.components(separatedBy: "\n") |
| 265 | 268 | return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n")) |
| @@ -434,7 +437,8 @@ final class HTMLRenderer { | ||
| 434 | 437 | private func list(_ items: [SyntaxNode]) -> String { |
| 435 | 438 | guard let first = items.first else { return "" } |
| 436 | 439 | let ordered = isOrdered(first) |
| 437 | let description = !ordered && firstLine(first).contains(" :: ") | |
| 440 | // `org-list-get-list-type`: descriptive when the first item has a tag. | |
| 441 | let description = !ordered && first.firstChild(.itemTag) != nil | |
| 438 | 442 | if description { |
| 439 | 443 | var out = "<dl>\n" |
| 440 | 444 | for item in items { |
| @@ -449,32 +453,25 @@ final class HTMLRenderer { | ||
| 449 | 453 | return out + "</\(tag)>\n" |
| 450 | 454 | } |
| 451 | 455 | |
| 452 | private func firstLine(_ item: SyntaxNode) -> String { | |
| 453 | item.firstChild(.paragraph)?.text.components(separatedBy: "\n").first ?? "" | |
| 454 | } | |
| 455 | ||
| 456 | /// A description item's term, `(no term)` without one, and the rest of the item. | |
| 456 | 457 | private func descriptionParts(_ item: SyntaxNode) -> (String, String) { |
| 457 | guard let paragraph = item.firstChild(.paragraph) else { return ("", "") } | |
| 458 | let text = paragraph.text | |
| 459 | guard let separator = text.range(of: " :: ") ?? text.range(of: " ::\n") else { return ("", inline(paragraph)) } | |
| 460 | let term = inlineString(String(text[..<separator.lowerBound])) | |
| 461 | var rest = inlineParagraphText(String(text[separator.upperBound...])) | |
| 462 | for child in item.children where child.kind != .paragraph || child.range != paragraph.range { | |
| 463 | rest += element(child) | |
| 458 | var term = item.firstChild(.itemTag).map { inline($0).trimmingCharacters(in: .whitespaces) } ?? "(no term)" | |
| 459 | if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text { | |
| 460 | term = "<code>\(box == "[ ]" ? "[ ]" : box.uppercased())</code> " + term | |
| 461 | } | |
| 462 | var rest = "" | |
| 463 | for (index, child) in item.children.filter({ $0.kind != .itemTag }).enumerated() { | |
| 464 | rest += index == 0 && child.kind == .paragraph ? inline(child).trimmingCharacters(in: .whitespacesAndNewlines) : element(child) | |
| 464 | 465 | } |
| 465 | 466 | return (term, rest.trimmingCharacters(in: .whitespacesAndNewlines)) |
| 466 | 467 | } |
| 467 | 468 | |
| 468 | /// Paragraph text, possibly spanning lines, rendered inline. | |
| 469 | private func inlineParagraphText(_ text: String) -> String { | |
| 470 | let tree = OrgParser.parse(text, defaults: settings) | |
| 471 | let paragraphs = tree.root.descendants().filter { $0.kind == .paragraph } | |
| 472 | guard !paragraphs.isEmpty else { return Self.escape(text.trimmingCharacters(in: .whitespacesAndNewlines)) } | |
| 473 | return paragraphs.map { inline($0).trimmingCharacters(in: .whitespacesAndNewlines) }.joined(separator: "\n") | |
| 474 | } | |
| 475 | ||
| 476 | 469 | private func listItem(_ item: SyntaxNode) -> String { |
| 470 | // ox-html gives an ordered item's counter as its value. | |
| 477 | 471 | var open = "<li>" |
| 472 | if isOrdered(item), let counter = item.tokens.first(where: { $0.kind == .counter })?.text.firstMatch(of: /([0-9]+|[A-Za-z])\]/) { | |
| 473 | open = "<li value=\"\(counter.1)\">" | |
| 474 | } | |
| 478 | 475 | var prefix = "" |
| 479 | 476 | if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text { |
| 480 | 477 | switch box { |
| @@ -680,6 +677,11 @@ final class HTMLRenderer { | ||
| 680 | 677 | hasMath = true |
| 681 | 678 | return Self.escape(node.text) |
| 682 | 679 | case .statisticsCookie: return Self.escape(node.text) |
| 680 | // `org-html-export-snippet`: only HTML snippets. | |
| 681 | case .exportSnippet: | |
| 682 | guard node.tokens.first?.text == "@@html:" else { return "" } | |
| 683 | return node.tokens.filter { $0.kind != .marker }.map(\.text).joined() | |
| 684 | case .citation, .inlineBabelCall: return Self.escape(node.text) | |
| 683 | 685 | default: return inline(node) |
| 684 | 686 | } |
| 685 | 687 | } |
Sources/OrgCore/Export/MarkdownExport.swift +13 −6
| @@ -96,6 +96,9 @@ final class MarkdownRenderer { | ||
| 96 | 96 | return block(node, indent: indent) |
| 97 | 97 | case .horizontalRule: |
| 98 | 98 | return indent + "---" |
| 99 | case .tableEl: | |
| 100 | let lines = node.text.components(separatedBy: "\n").filter { !$0.isEmpty && !$0.lowercased().contains("#+tblfm") } | |
| 101 | return fence(lines, language: "", indent: indent) | |
| 99 | 102 | case .fixedWidth: |
| 100 | 103 | let lines = node.text.split(separator: "\n").map { line -> String in |
| 101 | 104 | let trimmed = line.drop { $0 == " " || $0 == "\t" } |
| @@ -126,18 +129,17 @@ final class MarkdownRenderer { | ||
| 126 | 129 | } |
| 127 | 130 | let childIndent = indent + String(repeating: " ", count: (ordered ? "\(number). " : "- ").count) |
| 128 | 131 | var parts: [String] = [] |
| 129 | for (index, child) in item.children.enumerated() { | |
| 132 | let term = item.firstChild(.itemTag).map { "**" + inline($0).trimmingCharacters(in: .whitespaces) + "**: " } | |
| 133 | for (index, child) in item.children.filter({ $0.kind != .itemTag }).enumerated() { | |
| 130 | 134 | if index == 0, child.kind == .paragraph { |
| 131 | var text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines) | |
| 135 | let text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines) | |
| 132 | 136 | .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + childIndent) |
| 133 | if let separator = text.range(of: " :: ") { | |
| 134 | text = "**" + text[..<separator.lowerBound] + "**: " + text[separator.upperBound...] | |
| 135 | } | |
| 136 | parts.append(text) | |
| 137 | parts.append((term ?? "") + text) | |
| 137 | 138 | } else { |
| 138 | 139 | parts.append(element(child, indent: childIndent)) |
| 139 | 140 | } |
| 140 | 141 | } |
| 142 | if let term, parts.first.map({ !$0.hasPrefix(term) }) ?? true { parts.insert(term.trimmingCharacters(in: .whitespaces), at: 0) } | |
| 141 | 143 | let body = parts.isEmpty ? "" : parts[0].trimmingCharacters(in: .whitespaces) + parts.dropFirst().map { "\n" + $0 }.joined() |
| 142 | 144 | out.append(indent + marker + " " + body) |
| 143 | 145 | } |
| @@ -250,6 +252,11 @@ final class MarkdownRenderer { | ||
| 250 | 252 | return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>" |
| 251 | 253 | case .lineBreak: return " \n" |
| 252 | 254 | case .target, .macro: return "" |
| 255 | // ox-md uses ox-html's translator: only HTML snippets. | |
| 256 | case .exportSnippet: | |
| 257 | guard node.tokens.first?.text == "@@html:" else { return "" } | |
| 258 | return node.tokens.filter { $0.kind != .marker }.map(\.text).joined() | |
| 259 | case .citation, .inlineBabelCall: return node.text | |
| 253 | 260 | default: return inline(node) |
| 254 | 261 | } |
| 255 | 262 | } |
Sources/OrgCore/Keymap/VimExtras.swift +5 −3
| @@ -286,7 +286,7 @@ extension Vim { | ||
| 286 | 286 | case .plainList, .table, .drawer, .propertyDrawer, .dynamicBlock, .footnoteDefinition, .inlineTask: |
| 287 | 287 | return OrgElement(begin: r.lowerBound, end: post, contents: r.lowerBound..<trimmed(r.lowerBound, r.upperBound), greater: true, node: n) |
| 288 | 288 | case .item: |
| 289 | let first = n.children.first { ![.bullet, .checkbox].contains($0.kind) }?.range.lowerBound ?? r.upperBound | |
| 289 | let first = n.children.first { ![.bullet, .checkbox, .itemTag].contains($0.kind) }?.range.lowerBound ?? r.upperBound | |
| 290 | 290 | return OrgElement(begin: r.lowerBound, end: post, contents: min(first, r.upperBound)..<trimmed(first, r.upperBound), greater: true, node: n) |
| 291 | 291 | case .block: |
| 292 | 292 | let name = buffer.substring(r.lowerBound..<buffer.lineEnd(r.lowerBound)).lowercased() |
| @@ -324,7 +324,8 @@ extension Vim { | ||
| 324 | 324 | func atPoint() -> OrgElement? { |
| 325 | 325 | let objects: Set<SyntaxKind> = [.title, .heading, .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, |
| 326 | 326 | .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, |
| 327 | .latexFragment, .lineBreak, .superscript, .subscript, .entity, .tableCell, .nodeProperty] | |
| 327 | .latexFragment, .lineBreak, .superscript, .subscript, .entity, .tableCell, .nodeProperty, | |
| 328 | .citation, .citationReference, .exportSnippet, .inlineBabelCall, .itemTag] | |
| 328 | 329 | var elements = chain.filter { !objects.contains($0.kind) } |
| 329 | 330 | if chain.contains(where: { $0.kind == .heading }), let i = elements.lastIndex(where: { $0.kind == .section }) { |
| 330 | 331 | elements = Array(elements[...i]) |
| @@ -365,7 +366,8 @@ extension Vim { | ||
| 365 | 366 | return (inner ? innerRange(e) : e.begin..<e.end, !inner) |
| 366 | 367 | default: |
| 367 | 368 | let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .timestamp, .footnoteReference, |
| 368 | .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, .latexFragment, .entity, .superscript, .subscript] | |
| 369 | .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, .latexFragment, .entity, .superscript, .subscript, | |
| 370 | .citation, .citationReference, .exportSnippet, .inlineBabelCall] | |
| 369 | 371 | guard let found = chain.last(where: { objects.contains($0.kind) }) else { |
| 370 | 372 | guard let e = atPoint() else { return nil } |
| 371 | 373 | return (inner ? innerRange(e) : e.begin..<e.end, false) |
Sources/OrgCore/Parser/Incremental.swift +8
| @@ -100,6 +100,14 @@ private struct ReparseContext { | ||
| 100 | 100 | // An element that starts mid-line (an item's paragraph) follows tokens that would absorb |
| 101 | 101 | // whitespace typed at its start. |
| 102 | 102 | guard start == leaf.range.lowerBound || edit.range.lowerBound > leaf.range.lowerBound else { return nil } |
| 103 | // An item's first paragraph: its line can turn into a counter, checkbox or tag. | |
| 104 | if start != leaf.range.lowerBound { | |
| 105 | let oldLine = slice(oldText, leaf.range.lowerBound, lineEnd(oldText, leaf.range.lowerBound)) | |
| 106 | let newLine = slice(newText, leaf.range.lowerBound, lineEnd(newText, leaf.range.lowerBound)) | |
| 107 | for line in [oldLine, newLine] where line.contains("::") || line.hasPrefix("[@") || line.hasPrefix("[") { | |
| 108 | return nil | |
| 109 | } | |
| 110 | } | |
| 103 | 111 | let oldEnd = leaf.range.upperBound |
| 104 | 112 | let newEnd = oldEnd + delta |
| 105 | 113 | let newLength = newText.utf16.count |
Sources/OrgCore/Parser/Inline.swift +161 −1
| @@ -44,6 +44,11 @@ enum InlineMatch { | ||
| 44 | 44 | case object(SyntaxKind, Range<Int>) |
| 45 | 45 | /// An object whose contents hold objects, between markers. |
| 46 | 46 | case container(SyntaxKind, contents: Range<Int>, whole: Range<Int>) |
| 47 | /// `[cite/style:prefix; refs…; suffix]`: the common prefix and suffix, and each reference | |
| 48 | /// with where its key is. | |
| 49 | case citation(whole: Range<Int>, opener: Int, prefix: Range<Int>?, references: [(range: Range<Int>, key: Range<Int>)], suffix: Range<Int>?) | |
| 50 | /// `@@backend:value@@`. | |
| 51 | case exportSnippet(whole: Range<Int>, value: Range<Int>) | |
| 47 | 52 | |
| 48 | 53 | var end: Int { |
| 49 | 54 | switch self { |
| @@ -51,6 +56,8 @@ enum InlineMatch { | ||
| 51 | 56 | case .link(_, _, let whole): whole.upperBound |
| 52 | 57 | case .object(_, let range): range.upperBound |
| 53 | 58 | case .container(_, _, let whole): whole.upperBound |
| 59 | case .citation(let whole, _, _, _, _): whole.upperBound | |
| 60 | case .exportSnippet(let whole, _): whole.upperBound | |
| 54 | 61 | } |
| 55 | 62 | } |
| 56 | 63 | } |
| @@ -106,7 +113,7 @@ struct InlineScanner { | ||
| 106 | 113 | /// The first characters any recognizer accepts. |
| 107 | 114 | static func mayStartObject(_ c: Unicode.Scalar) -> Bool { |
| 108 | 115 | switch c { |
| 109 | case "[", "<", "{", "\\", "^", "s", "h", "m", "f", "*", "/", "_", "+", "=", "~", "$": true | |
| 116 | case "[", "<", "{", "\\", "^", "s", "h", "m", "f", "*", "/", "_", "+", "=", "~", "$", "@", "c": true | |
| 110 | 117 | default: false |
| 111 | 118 | } |
| 112 | 119 | } |
| @@ -163,6 +170,43 @@ struct InlineScanner { | ||
| 163 | 170 | b.start(kind) |
| 164 | 171 | emitText(range, into: &b) |
| 165 | 172 | b.finish() |
| 173 | case .citation(let whole, let opener, let prefix, let references, let suffix): | |
| 174 | b.start(.citation) | |
| 175 | var at = whole.lowerBound | |
| 176 | func marker(to end: Int) { | |
| 177 | if at < end { b.token(.marker, string(at..<end)) } | |
| 178 | at = max(at, end) | |
| 179 | } | |
| 180 | marker(to: opener) | |
| 181 | if let prefix { | |
| 182 | scan(prefix, into: &b, inLink: true) | |
| 183 | at = prefix.upperBound | |
| 184 | } | |
| 185 | for reference in references { | |
| 186 | marker(to: reference.range.lowerBound) | |
| 187 | b.start(.citationReference) | |
| 188 | scan(reference.range.lowerBound..<reference.key.lowerBound, into: &b, inLink: true) | |
| 189 | b.token(.citationKey, string(reference.key)) | |
| 190 | let separator = reference.range.upperBound > reference.key.upperBound && chars[reference.range.upperBound - 1] == ";" | |
| 191 | let suffixEnd = separator ? reference.range.upperBound - 1 : reference.range.upperBound | |
| 192 | scan(reference.key.upperBound..<suffixEnd, into: &b, inLink: true) | |
| 193 | if separator { b.token(.marker, string(suffixEnd..<reference.range.upperBound)) } | |
| 194 | b.finish() | |
| 195 | at = reference.range.upperBound | |
| 196 | } | |
| 197 | if let suffix { | |
| 198 | marker(to: suffix.lowerBound) | |
| 199 | scan(suffix, into: &b, inLink: true) | |
| 200 | at = suffix.upperBound | |
| 201 | } | |
| 202 | marker(to: whole.upperBound) | |
| 203 | b.finish() | |
| 204 | case .exportSnippet(let whole, let value): | |
| 205 | b.start(.exportSnippet) | |
| 206 | b.token(.marker, string(whole.lowerBound..<value.lowerBound)) | |
| 207 | emitText(value, into: &b) | |
| 208 | b.token(.marker, string(value.upperBound..<whole.upperBound)) | |
| 209 | b.finish() | |
| 166 | 210 | } |
| 167 | 211 | } |
| 168 | 212 | |
| @@ -194,6 +238,7 @@ struct InlineScanner { | ||
| 194 | 238 | switch chars[i] { |
| 195 | 239 | case "[": |
| 196 | 240 | if !inLink, let link = bracketLink(i, limit) { return link } |
| 241 | if !inLink, let cite = citation(i, limit) { return cite } | |
| 197 | 242 | if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) } |
| 198 | 243 | if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) } |
| 199 | 244 | if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) } |
| @@ -217,6 +262,10 @@ struct InlineScanner { | ||
| 217 | 262 | } |
| 218 | 263 | case "s": |
| 219 | 264 | if !afterWord, let end = inlineSourceBlock(i, limit) { return .object(.inlineSourceBlock, i..<end) } |
| 265 | case "c": | |
| 266 | if !afterWord, let end = inlineBabelCall(i, limit) { return .object(.inlineBabelCall, i..<end) } | |
| 267 | case "@": | |
| 268 | if let snippet = exportSnippet(i, limit) { return snippet } | |
| 220 | 269 | case "h", "m", "f": |
| 221 | 270 | if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) } |
| 222 | 271 | default: |
| @@ -318,6 +367,117 @@ struct InlineScanner { | ||
| 318 | 367 | return j + 1 |
| 319 | 368 | } |
| 320 | 369 | |
| 370 | /// `org-element-citation-parser`: `[cite` and an optional `/style`, `:`, then up to the | |
| 371 | /// matching `]` at least one `@key`. A common prefix ends at the `;` before the first key, | |
| 372 | /// a common suffix starts at the last `;` with no key after it. | |
| 373 | func citation(_ i: Int, _ limit: Int) -> InlineMatch? { | |
| 374 | guard hasPrefix("[cite", at: i, limit) else { return nil } | |
| 375 | var k = i + 5 | |
| 376 | if k < limit, chars[k] == "/" { | |
| 377 | let style = k + 1 | |
| 378 | k = style | |
| 379 | while k < limit, chars[k] == "/" || chars[k] == "_" || chars[k] == "-" || isWordScalar(chars[k]) && chars[k].isASCII { k += 1 } | |
| 380 | guard k > style else { return nil } | |
| 381 | } | |
| 382 | guard k < limit, chars[k] == ":" else { return nil } | |
| 383 | k += 1 | |
| 384 | while k < limit, chars[k] == " " || chars[k] == "\t" || chars[k] == "\n" { k += 1 } | |
| 385 | let start = k | |
| 386 | // `scan-lists` with only square brackets paired. | |
| 387 | var depth = 0 | |
| 388 | var closing: Int? | |
| 389 | var j = i | |
| 390 | while j < limit { | |
| 391 | if chars[j] == "[" { depth += 1 } | |
| 392 | if chars[j] == "]" { | |
| 393 | depth -= 1 | |
| 394 | if depth == 0 { closing = j + 1; break } | |
| 395 | } | |
| 396 | j += 1 | |
| 397 | } | |
| 398 | guard let closing, let first = citationKey(from: start, to: closing) else { return nil } | |
| 399 | var prefix: Range<Int>? | |
| 400 | var contentsBegin = start | |
| 401 | if let semi = (start..<first.lowerBound).last(where: { chars[$0] == ";" }) { | |
| 402 | if start < semi { prefix = start..<semi } | |
| 403 | contentsBegin = semi + 1 | |
| 404 | } | |
| 405 | var end = closing - 1 | |
| 406 | while end > first.upperBound, [" ", "\t", "\n", "\r"].contains(chars[end - 1]) { end -= 1 } | |
| 407 | var suffix: Range<Int>? | |
| 408 | var contentsEnd = end | |
| 409 | if let semi = (first.upperBound..<end).last(where: { chars[$0] == ";" }), citationKey(from: semi, to: end) == nil { | |
| 410 | if semi + 1 < end { suffix = (semi + 1)..<end } | |
| 411 | contentsEnd = semi + 1 | |
| 412 | } | |
| 413 | var references: [(range: Range<Int>, key: Range<Int>)] = [] | |
| 414 | var at = contentsBegin | |
| 415 | while at < contentsEnd, let key = citationKey(from: at, to: contentsEnd) { | |
| 416 | let separator = (key.upperBound..<contentsEnd).first { chars[$0] == ";" } | |
| 417 | let referenceEnd = separator.map { $0 + 1 } ?? contentsEnd | |
| 418 | references.append((at..<referenceEnd, key)) | |
| 419 | at = referenceEnd | |
| 420 | } | |
| 421 | return .citation(whole: i..<closing, opener: start, prefix: prefix, references: references, suffix: suffix) | |
| 422 | } | |
| 423 | ||
| 424 | /// `org-element-citation-key-re`: `@` and word or symbol characters. | |
| 425 | func citationKey(from: Int, to limit: Int) -> Range<Int>? { | |
| 426 | var k = from | |
| 427 | while k < limit { | |
| 428 | if chars[k] == "@" { | |
| 429 | var e = k + 1 | |
| 430 | while e < limit, isWordScalar(chars[e]) || "-.:?!`'/*@+|(){}<>&_^$#%~".unicodeScalars.contains(chars[e]) { e += 1 } | |
| 431 | if e > k + 1 { return k..<e } | |
| 432 | } | |
| 433 | k += 1 | |
| 434 | } | |
| 435 | return nil | |
| 436 | } | |
| 437 | ||
| 438 | /// `org-element-export-snippet-parser`: `@@backend:value@@`. | |
| 439 | func exportSnippet(_ i: Int, _ limit: Int) -> InlineMatch? { | |
| 440 | guard hasPrefix("@@", at: i, limit) else { return nil } | |
| 441 | var k = i + 2 | |
| 442 | while k < limit, chars[k] == "-" || (chars[k].isASCII && isWordScalar(chars[k])) { k += 1 } | |
| 443 | guard k > i + 2, k < limit, chars[k] == ":" else { return nil } | |
| 444 | let value = k + 1 | |
| 445 | var e = value | |
| 446 | while e + 1 < limit { | |
| 447 | if chars[e] == "@", chars[e + 1] == "@" { return .exportSnippet(whole: i..<(e + 2), value: value..<e) } | |
| 448 | e += 1 | |
| 449 | } | |
| 450 | return nil | |
| 451 | } | |
| 452 | ||
| 453 | /// `org-element-inline-babel-call-parser`: `call_NAME`, an optional `[inside header]`, | |
| 454 | /// `(arguments)` and an optional `[end header]`, each balanced in its own brackets. | |
| 455 | func inlineBabelCall(_ i: Int, _ limit: Int) -> Int? { | |
| 456 | guard hasPrefix("call_", at: i, limit) else { return nil } | |
| 457 | var k = i + 5 | |
| 458 | while k < limit, !" \t\n[(".unicodeScalars.contains(chars[k]) { k += 1 } | |
| 459 | guard k > i + 5, k < limit, chars[k] == "(" || chars[k] == "[" else { return nil } | |
| 460 | func paired(_ open: Unicode.Scalar, _ close: Unicode.Scalar) -> Int? { | |
| 461 | guard k < limit, chars[k] == open else { return nil } | |
| 462 | var depth = 0 | |
| 463 | var j = k | |
| 464 | while j < limit { | |
| 465 | if chars[j] == open { depth += 1 } | |
| 466 | if chars[j] == close { | |
| 467 | depth -= 1 | |
| 468 | if depth == 0 { return j + 1 } | |
| 469 | } | |
| 470 | j += 1 | |
| 471 | } | |
| 472 | return nil | |
| 473 | } | |
| 474 | if let end = paired("[", "]") { k = end } | |
| 475 | guard let arguments = paired("(", ")") else { return nil } | |
| 476 | k = arguments | |
| 477 | if let end = paired("[", "]") { k = end } | |
| 478 | return k | |
| 479 | } | |
| 480 | ||
| 321 | 481 | /// `<<target>>`. |
| 322 | 482 | func target(_ i: Int, _ limit: Int) -> Int? { |
| 323 | 483 | guard hasPrefix("<<", at: i, limit), i + 2 < limit, chars[i + 2] != "<" else { return nil } |
Sources/OrgCore/Parser/Lines.swift +6
| @@ -52,6 +52,10 @@ enum LineClass: Equatable { | ||
| 52 | 52 | case fixedWidth |
| 53 | 53 | case horizontalRule |
| 54 | 54 | case tableRow |
| 55 | /// `+--+--+`, which may start a table.el table. | |
| 56 | case tableElRule | |
| 57 | /// `%%(…)` at the start of a line. | |
| 58 | case diarySexp | |
| 55 | 59 | case footnoteDefinition |
| 56 | 60 | case clock |
| 57 | 61 | case planning |
| @@ -136,6 +140,8 @@ private func lineClass(_ rest: Substring, columnZero: Bool, alphabetical: Bool) | ||
| 136 | 140 | } |
| 137 | 141 | |
| 138 | 142 | if rest.first == "|" { return .tableRow } |
| 143 | if rest.first == "+", trimmed.wholeMatch(of: /\+(?:-+\+)+/) != nil { return .tableElRule } | |
| 144 | if columnZero, rest.hasPrefix("%%(") { return .diarySexp } | |
| 139 | 145 | if trimmed.count >= 5, trimmed.allSatisfy({ $0 == "-" }) { return .horizontalRule } |
| 140 | 146 | |
| 141 | 147 | if columnZero, rest.hasPrefix("[fn:"), let close = rest.firstIndex(of: "]"), |
Sources/OrgCore/Parser/Parser.swift +47
| @@ -206,6 +206,22 @@ struct Parser { | ||
| 206 | 206 | single(.clock) |
| 207 | 207 | case .tableRow: |
| 208 | 208 | table(limit: limit, floor: floor) |
| 209 | case .tableElRule: | |
| 210 | if let end = tableElEnd(limit: limit, floor: floor) { | |
| 211 | builder.start(.tableEl) | |
| 212 | while i < end { | |
| 213 | line(i) | |
| 214 | i += 1 | |
| 215 | } | |
| 216 | while i < limit, info[i].cls == .keyword(key: "TBLFM"), within(floor, i) { | |
| 217 | single(.tableFormula) | |
| 218 | } | |
| 219 | builder.finish() | |
| 220 | } else { | |
| 221 | paragraph(limit: limit, floor: floor) | |
| 222 | } | |
| 223 | case .diarySexp: | |
| 224 | single(.diarySexp) | |
| 209 | 225 | case .footnoteDefinition: |
| 210 | 226 | footnoteDefinition(limit: limit) |
| 211 | 227 | case .listItem: |
| @@ -282,6 +298,22 @@ struct Parser { | ||
| 282 | 298 | builder.finish() |
| 283 | 299 | } |
| 284 | 300 | |
| 301 | /// Where the table.el table starting at line `i` ends, as `org-element--current-element` | |
| 302 | /// decides: a full rule first and last, at least two lines, every line starting with `+` | |
| 303 | /// or `|`. Nil for none. | |
| 304 | func tableElEnd(limit: Int, floor: Int?) -> Int? { | |
| 305 | guard i + 1 < limit else { return nil } | |
| 306 | func isTableLine(_ k: Int) -> Bool { | |
| 307 | guard within(floor, k) else { return false } | |
| 308 | let first = lines[k].content.drop { $0 == " " || $0 == "\t" }.first | |
| 309 | return first == "+" || first == "|" | |
| 310 | } | |
| 311 | var k = i + 1 | |
| 312 | while k < limit, isTableLine(k) { k += 1 } | |
| 313 | if k == i + 1 { return nil } | |
| 314 | return info[k - 1].cls == .tableElRule ? k : nil | |
| 315 | } | |
| 316 | ||
| 285 | 317 | /// A rule row (`|---+---|`) is one text token. Other rows alternate `|` markers and cells; |
| 286 | 318 | /// every pair of pipes gets a cell, even an empty one, so columns line up. |
| 287 | 319 | mutating func tableRow() { |
| @@ -350,10 +382,25 @@ struct Parser { | ||
| 350 | 382 | let bullet = rest.prefix { $0 != " " && $0 != "\t" } |
| 351 | 383 | builder.token(.bullet, bullet) |
| 352 | 384 | rest = whitespace(rest.dropFirst(bullet.count)) |
| 385 | if let counter = rest.prefixMatch(of: /\[@(?:start:)?(?:[0-9]+|[A-Za-z])\]/) { | |
| 386 | builder.token(.counter, rest[counter.range]) | |
| 387 | rest = whitespace(rest[counter.range.upperBound...]) | |
| 388 | } | |
| 353 | 389 | if let box = checkbox(rest) { |
| 354 | 390 | builder.token(.checkbox, box) |
| 355 | 391 | rest = whitespace(rest.dropFirst(box.count)) |
| 356 | 392 | } |
| 393 | // A description item's tag: up to the last ` ::` on the line, for `-`, `+` and `*`. | |
| 394 | if "-+*".contains(bullet), let m = rest.prefixMatch(of: /(.*)([ \t]+::)(?:[ \t]+|$)/.anchorsMatchLineEndings()) { | |
| 395 | builder.start(.itemTag) | |
| 396 | inline(rest[m.1.startIndex..<m.1.endIndex]) | |
| 397 | builder.finish() | |
| 398 | let separator = rest[m.2.startIndex..<m.2.endIndex] | |
| 399 | let blank = separator.prefix { $0 == " " || $0 == "\t" } | |
| 400 | builder.token(.whitespace, blank) | |
| 401 | builder.token(.marker, separator.dropFirst(blank.count)) | |
| 402 | rest = whitespace(rest[m.2.endIndex...]) | |
| 403 | } | |
| 357 | 404 | // The rest of the bullet line and its continuation lines are the item's first paragraph. |
| 358 | 405 | var end = i + 1 |
| 359 | 406 | while end < limit, within(base, end), continuesParagraph(end) { end += 1 } |
Sources/OrgCore/Syntax/SyntaxKind.swift +6
| @@ -3,6 +3,8 @@ public enum SyntaxKind: String, Sendable { | ||
| 3 | 3 | case text, newline, whitespace |
| 4 | 4 | case stars, todoKeyword, priority, tags |
| 5 | 5 | case marker, linkPath, bullet, checkbox |
| 6 | /// `[@3]` on an item; a citation reference's `@key`. | |
| 7 | case counter, citationKey | |
| 6 | 8 | |
| 7 | 9 | // Elements |
| 8 | 10 | case document, zerothSection, section, heading, title |
| @@ -10,10 +12,14 @@ public enum SyntaxKind: String, Sendable { | ||
| 10 | 12 | case paragraph, plainList, item, table, tableRow, tableCell, tableFormula |
| 11 | 13 | case block, dynamicBlock, keyword, affiliatedKeyword |
| 12 | 14 | case comment, fixedWidth, horizontalRule, footnoteDefinition, latexEnvironment, inlineTask |
| 15 | case diarySexp, tableEl | |
| 16 | /// A description item's term, before ` :: `. | |
| 17 | case itemTag | |
| 13 | 18 | |
| 14 | 19 | // Objects |
| 15 | 20 | case bold, italic, underline, strikeThrough, verbatim, code |
| 16 | 21 | case link, linkDescription, timestamp, footnoteReference, statisticsCookie |
| 17 | 22 | case target, macro, inlineSourceBlock, latexFragment, lineBreak, superscript |
| 18 | 23 | case `subscript`, entity, radioTarget |
| 24 | case citation, citationReference, exportSnippet, inlineBabelCall | |
| 19 | 25 | } |
Sources/OrgPresentation/Presentation.swift +5 −2
| @@ -155,12 +155,15 @@ public enum Presentation { | ||
| 155 | 155 | case .target, .radioTarget: .target |
| 156 | 156 | case .macro: .macro |
| 157 | 157 | case .latexFragment, .latexEnvironment: .latex |
| 158 | case .inlineSourceBlock: .inlineSource | |
| 158 | case .inlineSourceBlock, .inlineBabelCall, .exportSnippet: .inlineSource | |
| 159 | case .citation: .link | |
| 160 | case .itemTag: .bold | |
| 161 | case .diarySexp: .timestamp | |
| 159 | 162 | case .comment: .comment |
| 160 | 163 | case .keyword, .affiliatedKeyword: .keyword |
| 161 | 164 | case .planning, .propertyDrawer, .drawer, .clock: .metadata |
| 162 | 165 | case .block, .dynamicBlock, .fixedWidth: .block |
| 163 | case .table: .table | |
| 166 | case .table, .tableEl: .table | |
| 164 | 167 | case .horizontalRule: .rule |
| 165 | 168 | default: nil |
| 166 | 169 | } |
Sources/OrgPresentation/SpellCheck.swift +3 −2
| @@ -12,8 +12,9 @@ public enum SpellCheck { | ||
| 12 | 12 | } |
| 13 | 13 | let whole: Set<SyntaxKind> = [.code, .verbatim, .link, .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget, |
| 14 | 14 | .macro, .inlineSourceBlock, .latexFragment, .entity, .latexEnvironment, .keyword, .affiliatedKeyword, |
| 15 | .planning, .clock, .propertyDrawer, .fixedWidth, .tableFormula, .horizontalRule] | |
| 16 | let tokens: Set<SyntaxKind> = [.stars, .todoKeyword, .priority, .tags, .bullet, .checkbox] | |
| 15 | .planning, .clock, .propertyDrawer, .fixedWidth, .tableFormula, .horizontalRule, | |
| 16 | .citation, .exportSnippet, .inlineBabelCall, .diarySexp] | |
| 17 | let tokens: Set<SyntaxKind> = [.stars, .todoKeyword, .priority, .tags, .bullet, .checkbox, .counter] | |
| 17 | 18 | var ranges: [Range<Int>] = [] |
| 18 | 19 | for node in tree.root.descendants() { |
| 19 | 20 | if whole.contains(node.kind) { |
Tests/OrgCoreTests/ParserGapsTests.swift added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | /// Citations, export snippets, inline Babel calls, diary sexps, table.el tables, and item | |
| 6 | /// counters and tags, where org-element finds them. | |
| 7 | struct ParserGapsTests { | |
| 8 | static let text = #""" | |
| 9 | As [cite:@doe2020] and [cite/t/b:see @a p. 2; @b;and @c-d:e] say, also [cite:pre; @x; post]. | |
| 10 | Not a cite: [cite:no key], [cite/:@x], [cite:@k [nested] here], [cite:@open | |
| 11 | and on @@html:<b>bold</b>@@ or @@latex:\emph{x}@@, not @@x@@ or @@:y@@ or @@html:open. | |
| 12 | Calls: call_square(4) call_f[:results raw](x=1, y=(2))[:exports both] xcall_no(1) call_f[x] call_(1). | |
| 13 | %%(diary-float t 4 2) Thanksgiving | |
| 14 | %%(indented) is text | |
| 15 | ||
| 16 | +----+-----+ | |
| 17 | | a | b | | |
| 18 | +----+-----+ | |
| 19 | | c | d | | |
| 20 | +----+-----+ | |
| 21 | ||
| 22 | +--+--+ | |
| 23 | Single rule is a paragraph. | |
| 24 | ||
| 25 | +--+ | |
| 26 | |x | | |
| 27 | not closed by a rule | |
| 28 | ||
| 29 | | org | table | | |
| 30 | +---+---+ | |
| 31 | | other | | |
| 32 | +-+ | |
| 33 | +-+ | |
| 34 | ||
| 35 | - term :: definition with *bold* | |
| 36 | - a :: b :: c | |
| 37 | - [X] boxed :: yes | |
| 38 | - [@3] counted | |
| 39 | - [@start:7] [X] both | |
| 40 | - no tag::here | |
| 41 | - tag :: | |
| 42 | 1. one :: two | |
| 43 | 2. [@5] five | |
| 44 | - plain | |
| 45 | - *bold* term :: x | |
| 46 | """# + "\n" | |
| 47 | ||
| 48 | static let kinds: [(lisp: String, ours: SyntaxKind)] = [ | |
| 49 | ("citation", .citation), ("citation-reference", .citationReference), ("export-snippet", .exportSnippet), | |
| 50 | ("inline-babel-call", .inlineBabelCall), ("diary-sexp", .diarySexp), ("paragraph", .paragraph), ("item", .item), | |
| 51 | ] | |
| 52 | ||
| 53 | @Test func objectsAndElementsMatchOrgElement() throws { | |
| 54 | let tree = OrgParser.parse(Self.text) | |
| 55 | var forms = Self.kinds.map { kind in | |
| 56 | "(mapconcat (lambda (o) (format \"%d\" (1- (org-element-begin o)))) (org-element-map (org-element-parse-buffer) '\(kind.lisp) #'identity) \",\")" | |
| 57 | } | |
| 58 | forms.append("(mapconcat (lambda (o) (format \"%d:%s\" (1- (org-element-begin o)) (org-element-property :type o))) (org-element-map (org-element-parse-buffer) 'table #'identity) \",\")") | |
| 59 | forms.append(""" | |
| 60 | (mapconcat (lambda (o) (format "%s|%s|%s" (let ((tag (org-element-interpret-data (org-element-property :tag o)))) (if (string-empty-p tag) "-" tag)) | |
| 61 | (or (org-element-property :counter o) "-") (or (org-element-property :checkbox o) "-"))) | |
| 62 | (org-element-map (org-element-parse-buffer) 'item #'identity) ",") | |
| 63 | """) | |
| 64 | let emacs = try EmacsOracle.evaluate(Self.text, "(with-current-buffer (find-file-noselect \"oracle.org\") (org-mode) (list \(forms.joined(separator: " "))))") | |
| 65 | let nodes = tree.root.descendants() | |
| 66 | for (i, kind) in Self.kinds.enumerated() { | |
| 67 | let ours = nodes.filter { $0.kind == kind.ours }.map { String($0.range.lowerBound) } | |
| 68 | #expect(ours == emacs[i].split(separator: ",").map(String.init), "\(kind.lisp)") | |
| 69 | } | |
| 70 | let tables = nodes.filter { $0.kind == .table || $0.kind == .tableEl }.map { "\($0.range.lowerBound):\($0.kind == .table ? "org" : "table.el")" } | |
| 71 | #expect(tables == emacs[Self.kinds.count].split(separator: ",").map(String.init), "tables") | |
| 72 | let items = nodes.filter { $0.kind == .item }.map { item -> String in | |
| 73 | let tag = item.firstChild(.itemTag)?.text ?? "-" | |
| 74 | let counter = item.tokens.first { $0.kind == .counter }.map { $0.text.replacingOccurrences(of: "[@start:", with: "").replacingOccurrences(of: "[@", with: "").replacingOccurrences(of: "]", with: "") } ?? "-" | |
| 75 | let box = item.tokens.first { $0.kind == .checkbox }.map { ["[X]": "on", "[x]": "on", "[ ]": "off", "[-]": "trans"][$0.text]! } ?? "-" | |
| 76 | return "\(tag)|\(counter)|\(box)" | |
| 77 | } | |
| 78 | #expect(items == emacs[Self.kinds.count + 1].split(separator: ",").map(String.init), "items") | |
| 79 | } | |
| 80 | ||
| 81 | @Test func citationParts() throws { | |
| 82 | let tree = OrgParser.parse("[cite/t:see @a p. 2; @b; post]\n") | |
| 83 | let cite = try #require(tree.root.descendants().first { $0.kind == .citation }) | |
| 84 | #expect(cite.text == "[cite/t:see @a p. 2; @b; post]") | |
| 85 | let references = cite.children.filter { $0.kind == .citationReference } | |
| 86 | #expect(references.map(\.text) == ["see @a p. 2;", " @b;"]) | |
| 87 | #expect(references.compactMap { $0.tokens.first { $0.kind == .citationKey }?.text } == ["@a", "@b"]) | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | /// The new syntax through ox-html, compared as the conformance files are. | |
| 92 | struct ParserGapsExportTests { | |
| 93 | static let text = """ | |
| 94 | Raw @@html:<b>bold</b>@@ and @@latex:\\emph{x}@@ here. | |
| 95 | ||
| 96 | %%(diary-float t 4 2) Thanksgiving | |
| 97 | ||
| 98 | - term :: definition here | |
| 99 | - untagged | |
| 100 | - [X] boxed :: yes | |
| 101 | ||
| 102 | ||
| 103 | 1. one | |
| 104 | 2. two | |
| 105 | ||
| 106 | ||
| 107 | - [@3] unordered counter | |
| 108 | """ + "\n" | |
| 109 | ||
| 110 | @Test func htmlMatchesOxHTML() throws { | |
| 111 | let emacs = try EmacsOracle.evaluate(Self.text, """ | |
| 112 | (list (progn (require 'ox-html) | |
| 113 | (with-current-buffer (find-file-noselect "oracle.org") | |
| 114 | (org-export-as 'html nil nil t '(:with-toc nil :section-numbers nil))))) | |
| 115 | """) | |
| 116 | let ours = Skeleton.reduce(HTMLExport.body(Self.text)) | |
| 117 | #expect(ours == Skeleton.reduce(emacs[0]), "ours:\n\(ours)\nemacs:\n\(Skeleton.reduce(emacs[0]))") | |
| 118 | // Org 9.8.7's ox-html fails on an ordered item's counter (`format` given a number); | |
| 119 | // the value is what it means to write. | |
| 120 | #expect(HTMLExport.body("1. one\n2. [@5] five\n").contains("<li value=\"5\">five</li>")) | |
| 121 | } | |
| 122 | } | |
Tests/OrgCoreTests/RoundTripTests.swift +2
| @@ -24,6 +24,8 @@ let fragments = [ | ||
| 24 | 24 | "<2026-10-04 Sun 10:00 +1w -2d>", "[2026-10-04]--[2026-10-05]", "<", ">", "[fn:2]", "[fn::in [x]]", "[1/3]", |
| 25 | 25 | "[50%]", "<<t>>", "{{{m(a)}}}", "\\(x\\)", "a\\\\", "x^2", "^{y}", "src_sh{echo}", "https://e.com.", |
| 26 | 26 | "<https://e.com>", "| *a* | b |", "||", "- [X] done", "+ [ ] todo", "-", |
| 27 | "[cite:@a; @b]", "[cite/t:see @k p. 2;]", "@@html:<b>@@", "call_f(x=1)[:results raw]", "%%(diary-float t 4 2)", | |
| 28 | "+---+---+", "+-", "- term :: def", "1. [@3] three", "- [@2] [X] x :: y", " :: ", "@", "call_", | |
| 27 | 29 | ] |
| 28 | 30 | |
| 29 | 31 | func randomDocument(_ rng: inout SeededGenerator) -> String { |