27a9bbffac
- Add HighlighterSwift dependency for syntax-highlighted #+BEGIN_SRC blocks - New CodeBlockView: SRC (highlighted), QUOTE (accent border), EXAMPLE (monospace) - Fix OrgSocialParser.parsePostBlock: break after outer :END: so nested :PROPERTIES:/:END: inside #+BEGIN_SRC blocks no longer overwrite contentStart and eat intro text - Fix OrgSocialParser.extractText: preserve fenced block delimiters and content - Fix OrgSocialParser post-grouping: ignore ** headings inside fenced blocks - Fix PartialFeedFetcher.extractRecentPostBlocks: same fenced-block guard - Add 9 OrgBodyRenderer edge-case tests (block at start/end, multiple blocks, org headings inside block, etc.) - Add 2 parser tests for nested :PROPERTIES: inside fenced block body
492 lines
18 KiB
Swift
492 lines
18 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import OrgSocialKit
|
||
|
||
/// Helper: find a substring in the AttributedString and assert the attribute
|
||
/// closure returns true for every run overlapping it.
|
||
private func hasAttribute(
|
||
_ attr: AttributedString,
|
||
over substring: String,
|
||
matches: (AttributeContainer) -> Bool
|
||
) -> Bool {
|
||
let full = String(attr.characters)
|
||
guard let range = full.range(of: substring) else { return false }
|
||
let startOffset = full.distance(from: full.startIndex, to: range.lowerBound)
|
||
let endOffset = full.distance(from: full.startIndex, to: range.upperBound)
|
||
let startIdx = attr.characters.index(attr.characters.startIndex, offsetBy: startOffset)
|
||
let endIdx = attr.characters.index(attr.characters.startIndex, offsetBy: endOffset)
|
||
for run in attr[startIdx..<endIdx].runs where !matches(run.attributes) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – inline emphasis")
|
||
struct OrgBodyInlineTests {
|
||
|
||
@Test("bold renders as stronglyEmphasized")
|
||
func bold() {
|
||
let r = OrgBodyRenderer.render("A *bold* word")
|
||
#expect(String(r.inline.characters) == "A bold word")
|
||
#expect(hasAttribute(r.inline, over: "bold") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test("italic renders as emphasized")
|
||
func italic() {
|
||
let r = OrgBodyRenderer.render("An /italic/ word.")
|
||
#expect(String(r.inline.characters) == "An italic word.")
|
||
#expect(hasAttribute(r.inline, over: "italic") { c in
|
||
c.inlinePresentationIntent?.contains(.emphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test("underline is kept as raw text (no attribute in v1)")
|
||
func underlineIsLiteral() {
|
||
let r = OrgBodyRenderer.render("Hello _under_ there.")
|
||
// The markers are preserved because InlinePresentationIntent has no
|
||
// underline case; we'd otherwise pull in UIKit/AppKit for one feature.
|
||
#expect(String(r.inline.characters) == "Hello _under_ there.")
|
||
}
|
||
|
||
@Test("strikethrough uses inlinePresentationIntent.strikethrough")
|
||
func strike() {
|
||
let r = OrgBodyRenderer.render("Oops +typo+!")
|
||
#expect(String(r.inline.characters) == "Oops typo!")
|
||
#expect(hasAttribute(r.inline, over: "typo") { c in
|
||
c.inlinePresentationIntent?.contains(.strikethrough) == true
|
||
})
|
||
}
|
||
|
||
@Test("code and verbatim both apply .code intent")
|
||
func codeVerbatim() {
|
||
let r = OrgBodyRenderer.render("Use ~let x~ and =NSString=.")
|
||
#expect(String(r.inline.characters) == "Use let x and NSString.")
|
||
#expect(hasAttribute(r.inline, over: "let x") { c in
|
||
c.inlinePresentationIntent?.contains(.code) == true
|
||
})
|
||
#expect(hasAttribute(r.inline, over: "NSString") { c in
|
||
c.inlinePresentationIntent?.contains(.code) == true
|
||
})
|
||
}
|
||
|
||
@Test("emphasis at start of string matches")
|
||
func emphasisAtStart() {
|
||
let r = OrgBodyRenderer.render("*first* word")
|
||
#expect(String(r.inline.characters) == "first word")
|
||
#expect(hasAttribute(r.inline, over: "first") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test("emphasis at end of string matches")
|
||
func emphasisAtEnd() {
|
||
let r = OrgBodyRenderer.render("ending with /italic/")
|
||
#expect(String(r.inline.characters) == "ending with italic")
|
||
#expect(hasAttribute(r.inline, over: "italic") { c in
|
||
c.inlinePresentationIntent?.contains(.emphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test("marker with whitespace inside does not match")
|
||
func whitespaceInsideDoesNotMatch() {
|
||
let r = OrgBodyRenderer.render("not *open close* but * x * stays")
|
||
#expect(String(r.inline.characters) == "not open close but * x * stays")
|
||
// The second * ... * has whitespace adjacent to markers so stays literal.
|
||
#expect(hasAttribute(r.inline, over: "* x *") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) != true
|
||
})
|
||
}
|
||
|
||
@Test("emphasis inside word boundary does not match (no mid-word triggers)")
|
||
func noMidWord() {
|
||
let r = OrgBodyRenderer.render("foo*bar*baz")
|
||
// Preceded by `o` (word char) -> not a valid opening context per our rules.
|
||
#expect(String(r.inline.characters) == "foo*bar*baz")
|
||
}
|
||
|
||
@Test("code protects the asterisk inside")
|
||
func codeWrapsAsterisk() {
|
||
let r = OrgBodyRenderer.render("literal =*not bold*= here")
|
||
#expect(String(r.inline.characters) == "literal *not bold* here")
|
||
#expect(hasAttribute(r.inline, over: "*not bold*") { c in
|
||
c.inlinePresentationIntent?.contains(.code) == true
|
||
})
|
||
// And the inner asterisks did not produce bold on "not bold".
|
||
#expect(hasAttribute(r.inline, over: "not bold") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) != true
|
||
})
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – links and URLs")
|
||
struct OrgBodyLinksTests {
|
||
|
||
@Test("bracket link with label renders label as link")
|
||
func bracketLinkLabel() {
|
||
let r = OrgBodyRenderer.render("See [[https://example.com][the docs]] please")
|
||
#expect(String(r.inline.characters) == "See the docs please")
|
||
#expect(hasAttribute(r.inline, over: "the docs") {
|
||
$0.link == URL(string: "https://example.com")
|
||
})
|
||
}
|
||
|
||
@Test("bracket link without label uses the URL as text")
|
||
func bracketLinkBare() {
|
||
let r = OrgBodyRenderer.render("Docs: [[https://example.com/x]]")
|
||
#expect(String(r.inline.characters).contains("https://example.com/x"))
|
||
#expect(hasAttribute(r.inline, over: "https://example.com/x") {
|
||
$0.link == URL(string: "https://example.com/x")
|
||
})
|
||
}
|
||
|
||
@Test("bare URL in plain text becomes a link")
|
||
func bareURL() {
|
||
let r = OrgBodyRenderer.render("Visit https://example.com for more.")
|
||
#expect(hasAttribute(r.inline, over: "https://example.com") {
|
||
$0.link == URL(string: "https://example.com")
|
||
})
|
||
}
|
||
|
||
@Test("trailing punctuation stays outside the URL link")
|
||
func bareURLTrailingPunct() {
|
||
let r = OrgBodyRenderer.render("see https://example.com/foo.")
|
||
#expect(hasAttribute(r.inline, over: "https://example.com/foo") {
|
||
$0.link == URL(string: "https://example.com/foo")
|
||
})
|
||
// The trailing dot is NOT part of the link.
|
||
let full = String(r.inline.characters)
|
||
guard let dotRange = full.range(of: ".", options: .backwards) else { return }
|
||
let offset = full.distance(from: full.startIndex, to: dotRange.lowerBound)
|
||
let idx = r.inline.characters.index(r.inline.characters.startIndex, offsetBy: offset)
|
||
let afterIdx = r.inline.characters.index(after: idx)
|
||
let dotRun = r.inline[idx..<afterIdx]
|
||
for run in dotRun.runs {
|
||
#expect(run.attributes.link == nil)
|
||
}
|
||
}
|
||
|
||
@Test("image links are extracted, not inlined")
|
||
func imageExtracted() {
|
||
let r = OrgBodyRenderer.render("Look: [[https://cdn.test/cat.jpg][my cat]] end")
|
||
#expect(r.imageURLs.map(\.absoluteString) == ["https://cdn.test/cat.jpg"])
|
||
#expect(String(r.inline.characters).trimmingCharacters(in: .whitespaces).contains("my cat") == false)
|
||
}
|
||
|
||
@Test("bare image link is extracted")
|
||
func bareImage() {
|
||
let r = OrgBodyRenderer.render("[[https://cdn.test/a.png]]")
|
||
#expect(r.imageURLs.map(\.absoluteString) == ["https://cdn.test/a.png"])
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – mentions")
|
||
struct OrgBodyMentionsTests {
|
||
|
||
@Test("mention is extracted into side-car list and rendered inline as @nick")
|
||
func mention() {
|
||
let body = "Hola [[org-social:https://ex.com/social.org][alice]]!"
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.mentions == [OrgMention(nick: "alice",
|
||
feedURL: URL(string: "https://ex.com/social.org")!)])
|
||
// Inline text keeps the readable @nick form so the sentence stays intact.
|
||
#expect(String(r.inline.characters).contains("@alice"))
|
||
// The raw Org link syntax is gone from the inline text.
|
||
#expect(String(r.inline.characters).contains("org-social:") == false)
|
||
}
|
||
|
||
@Test("duplicate mentions are deduplicated")
|
||
func mentionDedup() {
|
||
let url = "https://ex.com/social.org"
|
||
let body = "[[org-social:\(url)][a]] and [[org-social:\(url)][a]]"
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.mentions.count == 1)
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – fenced blocks")
|
||
struct OrgBodyBlocksTests {
|
||
|
||
@Test("SRC block captured with language")
|
||
func srcWithLang() {
|
||
let body = """
|
||
before
|
||
#+BEGIN_SRC swift
|
||
let x = *not bold*
|
||
#+END_SRC
|
||
after
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
if case .src(let lang) = r.blocks[0].kind {
|
||
#expect(lang == "swift")
|
||
} else {
|
||
Issue.record("expected .src block, got \(r.blocks[0].kind)")
|
||
}
|
||
#expect(r.blocks[0].content == "let x = *not bold*")
|
||
#expect(String(r.inline.characters).contains("not bold") == false)
|
||
}
|
||
|
||
@Test("QUOTE block captured")
|
||
func quote() {
|
||
let body = """
|
||
#+BEGIN_QUOTE
|
||
something insightful
|
||
#+END_QUOTE
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
#expect(r.blocks[0].kind == .quote)
|
||
}
|
||
|
||
@Test("EXAMPLE block protects inner markers")
|
||
func example() {
|
||
let body = """
|
||
Preamble.
|
||
#+BEGIN_EXAMPLE
|
||
*foo* /bar/ ~baz~
|
||
#+END_EXAMPLE
|
||
And after.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
#expect(r.blocks[0].kind == .example)
|
||
#expect(r.blocks[0].content == "*foo* /bar/ ~baz~")
|
||
// emphasis wasn't applied to the block contents
|
||
let text = String(r.inline.characters)
|
||
#expect(text.contains("*foo*") == false)
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – fenced block edge cases")
|
||
struct OrgBodyBlockEdgeCaseTests {
|
||
|
||
@Test("block at the very start, no intro text")
|
||
func blockAtStart() {
|
||
let body = """
|
||
#+BEGIN_SRC swift
|
||
let x = 1
|
||
#+END_SRC
|
||
Trailing text.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
if case .src(let lang) = r.blocks[0].kind { #expect(lang == "swift") }
|
||
#expect(r.blocks[0].content == "let x = 1")
|
||
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
#expect(text == "Trailing text.")
|
||
#expect(text.contains("let x") == false)
|
||
#expect(text.contains("#+BEGIN_SRC") == false)
|
||
}
|
||
|
||
@Test("block at the very end, no trailing text")
|
||
func blockAtEnd() {
|
||
let body = """
|
||
Intro text.
|
||
|
||
#+BEGIN_SRC python
|
||
print("hello")
|
||
#+END_SRC
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
if case .src(let lang) = r.blocks[0].kind { #expect(lang == "python") }
|
||
#expect(r.blocks[0].content == "print(\"hello\")")
|
||
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
#expect(text == "Intro text.")
|
||
#expect(text.contains("print") == false)
|
||
}
|
||
|
||
@Test("block sandwiched between intro and trailing text")
|
||
func blockBetweenText() {
|
||
let body = """
|
||
Intro text here.
|
||
|
||
#+BEGIN_SRC bash
|
||
echo hello
|
||
#+END_SRC
|
||
|
||
Trailing text here.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
#expect(r.blocks[0].content == "echo hello")
|
||
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
#expect(text.contains("Intro text here."))
|
||
#expect(text.contains("Trailing text here."))
|
||
#expect(text.contains("echo") == false)
|
||
#expect(text.contains("#+BEGIN_SRC") == false)
|
||
}
|
||
|
||
@Test("two blocks in sequence, no text between them")
|
||
func twoBlocksAdjacent() {
|
||
let body = """
|
||
Intro.
|
||
#+BEGIN_SRC swift
|
||
let a = 1
|
||
#+END_SRC
|
||
#+BEGIN_SRC python
|
||
b = 2
|
||
#+END_SRC
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 2)
|
||
#expect(r.blocks[0].content == "let a = 1")
|
||
#expect(r.blocks[1].content == "b = 2")
|
||
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
#expect(text.contains("Intro."))
|
||
#expect(text.contains("let a") == false)
|
||
#expect(text.contains("b = 2") == false)
|
||
}
|
||
|
||
@Test("text, block, text, block, text interleaved")
|
||
func textBlockInterleaved() {
|
||
let body = """
|
||
First paragraph.
|
||
|
||
#+BEGIN_SRC emacs-lisp
|
||
(message "hello")
|
||
#+END_SRC
|
||
|
||
Middle paragraph.
|
||
|
||
#+BEGIN_QUOTE
|
||
A wise quote.
|
||
#+END_QUOTE
|
||
|
||
Last paragraph.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 2)
|
||
if case .src(let lang) = r.blocks[0].kind { #expect(lang == "emacs-lisp") }
|
||
#expect(r.blocks[0].content == "(message \"hello\")")
|
||
#expect(r.blocks[1].kind == .quote)
|
||
#expect(r.blocks[1].content == "A wise quote.")
|
||
let text = String(r.inline.characters)
|
||
#expect(text.contains("First paragraph."))
|
||
#expect(text.contains("Middle paragraph."))
|
||
#expect(text.contains("Last paragraph."))
|
||
#expect(text.contains("message") == false)
|
||
#expect(text.contains("wise quote") == false)
|
||
}
|
||
|
||
@Test("SRC block containing org headings is extracted correctly")
|
||
func srcBlockWithOrgHeadings() {
|
||
let body = """
|
||
Intro explaining the org snippet.
|
||
|
||
#+BEGIN_SRC org
|
||
** 2025-05-21T09:00:00+02:00
|
||
:PROPERTIES:
|
||
:CLIENT: org-social.el
|
||
:END:
|
||
Post content inside org snippet.
|
||
#+END_SRC
|
||
|
||
Trailing explanation.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
if case .src(let lang) = r.blocks[0].kind { #expect(lang == "org") }
|
||
#expect(r.blocks[0].content.contains("** 2025-05-21T"))
|
||
#expect(r.blocks[0].content.contains(":CLIENT:"))
|
||
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
#expect(text.contains("Intro explaining the org snippet."))
|
||
#expect(text.contains("Trailing explanation."))
|
||
#expect(text.contains("** 2025") == false)
|
||
#expect(text.contains("#+BEGIN_SRC") == false)
|
||
}
|
||
|
||
@Test("multi-line block content is fully captured")
|
||
func multiLineBlockContent() {
|
||
let body = """
|
||
Before.
|
||
#+BEGIN_EXAMPLE
|
||
line one
|
||
line two
|
||
line three
|
||
#+END_EXAMPLE
|
||
After.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
#expect(r.blocks[0].kind == .example)
|
||
#expect(r.blocks[0].content == "line one\nline two\nline three")
|
||
}
|
||
|
||
@Test("block without language tag is still extracted")
|
||
func srcWithoutLanguage() {
|
||
let body = """
|
||
Text before.
|
||
#+BEGIN_SRC
|
||
some code
|
||
#+END_SRC
|
||
Text after.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
if case .src(let lang) = r.blocks[0].kind { #expect(lang == nil) }
|
||
#expect(r.blocks[0].content == "some code")
|
||
}
|
||
|
||
@Test("block delimiters are not present in inline text")
|
||
func noDelimitersInInline() {
|
||
let body = """
|
||
Before.
|
||
#+BEGIN_SRC js
|
||
console.log("hi")
|
||
#+END_SRC
|
||
After.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
let text = String(r.inline.characters)
|
||
#expect(text.contains("#+BEGIN_SRC") == false)
|
||
#expect(text.contains("#+END_SRC") == false)
|
||
#expect(text.contains("console") == false)
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – mixed and empty")
|
||
struct OrgBodyMixedTests {
|
||
|
||
@Test("empty body returns empty output")
|
||
func empty() {
|
||
let r = OrgBodyRenderer.render("")
|
||
#expect(String(r.inline.characters) == "")
|
||
#expect(r.blocks.isEmpty)
|
||
#expect(r.mentions.isEmpty)
|
||
#expect(r.imageURLs.isEmpty)
|
||
}
|
||
|
||
@Test("mixed content: emphasis + link + bare URL + mention")
|
||
func mixed() {
|
||
let body = "*Hi* [[org-social:https://a.com/s.org][a]] see https://b.com and [[https://c.com][docs]]."
|
||
let r = OrgBodyRenderer.render(body)
|
||
let text = String(r.inline.characters)
|
||
#expect(text.contains("Hi"))
|
||
#expect(text.contains("https://b.com"))
|
||
#expect(text.contains("docs"))
|
||
#expect(text.contains("[[") == false)
|
||
#expect(r.mentions.count == 1)
|
||
#expect(hasAttribute(r.inline, over: "Hi") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
#expect(hasAttribute(r.inline, over: "https://b.com") {
|
||
$0.link == URL(string: "https://b.com")
|
||
})
|
||
#expect(hasAttribute(r.inline, over: "docs") {
|
||
$0.link == URL(string: "https://c.com")
|
||
})
|
||
}
|
||
|
||
@Test("punctuation immediately after emphasis still matches")
|
||
func punctAfter() {
|
||
let r = OrgBodyRenderer.render("This is *cool*!")
|
||
#expect(String(r.inline.characters) == "This is cool!")
|
||
#expect(hasAttribute(r.inline, over: "cool") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
}
|
||
}
|