Convert from git submodule to plain vendored copy. No push access to upstream nodes-app/swift-markdown-engine meant our local fix commit (05c1720) was stranded and unreachable from any remote on fresh clones/CI. Vendoring as plain files folds it into normal repo history instead.
435 lines
16 KiB
Swift
435 lines
16 KiB
Swift
//
|
||
// InlineParserTests.swift
|
||
// MarkdownEngineTests
|
||
//
|
||
// Phase 2 — test-first specification of the inline parser. Ranges are
|
||
// relative to the parsed string.
|
||
//
|
||
|
||
import Foundation
|
||
import Testing
|
||
@testable import MarkdownEngine
|
||
|
||
@Suite("Phase 2 — inline parser")
|
||
struct InlineParserTests {
|
||
|
||
private func r(_ location: Int, _ length: Int) -> NSRange {
|
||
NSRange(location: location, length: length)
|
||
}
|
||
|
||
@Test("empty string yields no nodes")
|
||
func empty() {
|
||
#expect(InlineParser.parse("") == [])
|
||
}
|
||
|
||
@Test("plain text is a single text node")
|
||
func plainText() {
|
||
#expect(InlineParser.parse("hello") == [.text(r(0, 5))])
|
||
}
|
||
|
||
@Test("a code span splits the surrounding text")
|
||
func codeSpan() {
|
||
#expect(InlineParser.parse("a `code` b") == [
|
||
.text(r(0, 2)),
|
||
.code(range: r(2, 6), content: r(3, 4)),
|
||
.text(r(8, 2)),
|
||
])
|
||
}
|
||
|
||
@Test("an unclosed backtick run stays literal text")
|
||
func unclosedBacktick() {
|
||
#expect(InlineParser.parse("a `b") == [.text(r(0, 4))])
|
||
}
|
||
|
||
// MARK: - Emphasis (asterisks)
|
||
|
||
@Test("single asterisks → italic")
|
||
func italic() {
|
||
#expect(InlineParser.parse("*x*") == [
|
||
.emphasis(.italic, range: r(0, 3), markers: [r(0, 1), r(2, 1)], children: [.text(r(1, 1))]),
|
||
])
|
||
}
|
||
|
||
@Test("double asterisks → bold")
|
||
func bold() {
|
||
#expect(InlineParser.parse("**x**") == [
|
||
.emphasis(.bold, range: r(0, 5), markers: [r(0, 2), r(3, 2)], children: [.text(r(2, 1))]),
|
||
])
|
||
}
|
||
|
||
@Test("triple asterisks → bold+italic")
|
||
func boldItalic() {
|
||
#expect(InlineParser.parse("***x***") == [
|
||
.emphasis(.boldItalic, range: r(0, 7), markers: [r(0, 3), r(4, 3)], children: [.text(r(3, 1))]),
|
||
])
|
||
}
|
||
|
||
@Test("nested emphasis builds a tree")
|
||
func nestedEmphasis() {
|
||
#expect(InlineParser.parse("**a *b* c**") == [
|
||
.emphasis(.bold, range: r(0, 11), markers: [r(0, 2), r(9, 2)], children: [
|
||
.text(r(2, 2)),
|
||
.emphasis(.italic, range: r(4, 3), markers: [r(4, 1), r(6, 1)], children: [.text(r(5, 1))]),
|
||
.text(r(7, 2)),
|
||
]),
|
||
])
|
||
}
|
||
|
||
@Test("intraword asterisks still emphasize")
|
||
func intrawordAsterisk() {
|
||
#expect(InlineParser.parse("a*b*c") == [
|
||
.text(r(0, 1)),
|
||
.emphasis(.italic, range: r(1, 3), markers: [r(1, 1), r(3, 1)], children: [.text(r(2, 1))]),
|
||
.text(r(4, 1)),
|
||
])
|
||
}
|
||
|
||
// MARK: - Emphasis (underscores)
|
||
|
||
@Test("single underscores → italic")
|
||
func underscoreItalic() {
|
||
#expect(InlineParser.parse("_x_") == [
|
||
.emphasis(.italic, range: r(0, 3), markers: [r(0, 1), r(2, 1)], children: [.text(r(1, 1))]),
|
||
])
|
||
}
|
||
|
||
@Test("intraword underscores stay literal (GFM)")
|
||
func intrawordUnderscore() {
|
||
#expect(InlineParser.parse("a_b_c") == [.text(r(0, 5))])
|
||
}
|
||
|
||
// MARK: - Emphasis × code-span precedence
|
||
|
||
@Test("emphasis wraps a code span")
|
||
func emphasisWrapsCode() {
|
||
#expect(InlineParser.parse("*a `c` b*") == [
|
||
.emphasis(.italic, range: r(0, 9), markers: [r(0, 1), r(8, 1)], children: [
|
||
.text(r(1, 2)),
|
||
.code(range: r(3, 3), content: r(4, 1)),
|
||
.text(r(6, 2)),
|
||
]),
|
||
])
|
||
}
|
||
|
||
@Test("delimiters inside a code span are ignored")
|
||
func delimitersInsideCodeIgnored() {
|
||
#expect(InlineParser.parse("`*x*`") == [.code(range: r(0, 5), content: r(1, 3))])
|
||
}
|
||
|
||
// MARK: - Wiki-links & image embeds
|
||
|
||
@Test("plain wiki-link")
|
||
func wikiLink() {
|
||
#expect(InlineParser.parse("[[Name]]") == [
|
||
.wikiLink(range: r(0, 8), name: r(2, 4), id: nil, markers: [r(0, 2), r(6, 2)]),
|
||
])
|
||
}
|
||
|
||
@Test("wiki-link with id")
|
||
func wikiLinkWithId() {
|
||
#expect(InlineParser.parse("[[Name|abc]]") == [
|
||
.wikiLink(range: r(0, 12), name: r(2, 4), id: r(7, 3), markers: [r(0, 2), r(10, 2)]),
|
||
])
|
||
}
|
||
|
||
@Test("image embed")
|
||
func imageEmbed() {
|
||
#expect(InlineParser.parse("![[Pic]]") == [
|
||
.imageEmbed(range: r(0, 8), target: r(3, 3), markers: [r(0, 3), r(6, 2)]),
|
||
])
|
||
}
|
||
|
||
// MARK: - Links & images
|
||
|
||
@Test("markdown link, text recursively parsed")
|
||
func markdownLink() {
|
||
#expect(InlineParser.parse("[text](url)") == [
|
||
.link(range: r(0, 11), textRange: r(1, 4), url: r(7, 3),
|
||
markers: [r(0, 1), r(5, 1), r(6, 1), r(10, 1)], children: [.text(r(1, 4))]),
|
||
])
|
||
}
|
||
|
||
@Test("markdown link permits inline code inside its label")
|
||
func linkContainsInlineCode() {
|
||
#expect(InlineParser.parse("[`App`](/tmp/App.swift:56)") == [
|
||
.link(
|
||
range: r(0, 26),
|
||
textRange: r(1, 5),
|
||
url: r(8, 17),
|
||
markers: [r(0, 1), r(6, 1), r(7, 1), r(25, 1)],
|
||
children: [.code(range: r(1, 5), content: r(2, 3))]
|
||
),
|
||
])
|
||
}
|
||
|
||
@Test("markdown link permits multiple inline code spans in its label")
|
||
func linkContainsMultipleInlineCodeSpans() {
|
||
#expect(InlineParser.parse("[`a` and `b`](u)") == [
|
||
.link(
|
||
range: r(0, 16),
|
||
textRange: r(1, 11),
|
||
url: r(14, 1),
|
||
markers: [r(0, 1), r(12, 1), r(13, 1), r(15, 1)],
|
||
children: [
|
||
.code(range: r(1, 3), content: r(2, 1)),
|
||
.text(r(4, 5)),
|
||
.code(range: r(9, 3), content: r(10, 1)),
|
||
]
|
||
),
|
||
])
|
||
}
|
||
|
||
@Test("claimed span crossing a link-label boundary rejects the link")
|
||
func codeCrossingLinkLabelBoundaryRejectsLink() {
|
||
#expect(InlineParser.parse("[a `b](u)`") == [
|
||
.text(r(0, 3)),
|
||
.code(range: r(3, 7), content: r(4, 5)),
|
||
])
|
||
}
|
||
|
||
@Test("markdown link permits escaped punctuation inside its label")
|
||
func linkContainsEscapedPunctuation() {
|
||
#expect(InlineParser.parse(#"[\*](u)"#) == [
|
||
.link(
|
||
range: r(0, 7),
|
||
textRange: r(1, 2),
|
||
url: r(5, 1),
|
||
markers: [r(0, 1), r(3, 1), r(4, 1), r(6, 1)],
|
||
children: [.escape(range: r(1, 2), character: r(2, 1), marker: r(1, 1))]
|
||
),
|
||
])
|
||
}
|
||
|
||
@Test("markdown-looking text inside code remains inert")
|
||
func codeContainingLinkStaysOpaque() {
|
||
#expect(InlineParser.parse("`[a](b)`") == [
|
||
.code(range: r(0, 8), content: r(1, 6)),
|
||
])
|
||
}
|
||
|
||
@Test("link URL keeps balanced parentheses (bug 4)")
|
||
func linkWithBalancedParens() {
|
||
#expect(InlineParser.parse("[a](b(c))") == [
|
||
.link(range: r(0, 9), textRange: r(1, 1), url: r(4, 4),
|
||
markers: [r(0, 1), r(2, 1), r(3, 1), r(8, 1)], children: [.text(r(1, 1))]),
|
||
])
|
||
}
|
||
|
||
@Test("image")
|
||
func image() {
|
||
#expect(InlineParser.parse("") == [
|
||
.image(range: r(0, 9), alt: r(2, 3), url: r(7, 1), markers: [r(0, 2), r(5, 1), r(6, 1), r(8, 1)]),
|
||
])
|
||
}
|
||
|
||
@Test("emphasis inside link text")
|
||
func linkContainsEmphasis() {
|
||
#expect(InlineParser.parse("[*x*](u)") == [
|
||
.link(range: r(0, 8), textRange: r(1, 3), url: r(6, 1),
|
||
markers: [r(0, 1), r(4, 1), r(5, 1), r(7, 1)],
|
||
children: [.emphasis(.italic, range: r(1, 3), markers: [r(1, 1), r(3, 1)], children: [.text(r(2, 1))])]),
|
||
])
|
||
}
|
||
|
||
@Test("emphasis wraps a link")
|
||
func emphasisWrapsLink() {
|
||
#expect(InlineParser.parse("*[a](b)*") == [
|
||
.emphasis(.italic, range: r(0, 8), markers: [r(0, 1), r(7, 1)], children: [
|
||
.link(range: r(1, 6), textRange: r(2, 1), url: r(5, 1),
|
||
markers: [r(1, 1), r(3, 1), r(4, 1), r(6, 1)], children: [.text(r(2, 1))]),
|
||
]),
|
||
])
|
||
}
|
||
|
||
// MARK: - Inline LaTeX
|
||
|
||
@Test("inline math")
|
||
func inlineLatex() {
|
||
#expect(InlineParser.parse("$a+b$") == [
|
||
.inlineLatex(range: r(0, 5), content: r(1, 3), markers: [r(0, 1), r(4, 1)]),
|
||
])
|
||
}
|
||
|
||
@Test("currency-looking $…$ is not math")
|
||
func currencyNotLatex() {
|
||
#expect(InlineParser.parse("$50$") == [.text(r(0, 4))])
|
||
}
|
||
|
||
@Test("a $…$ span that would cross a code span is not math (bug 3)")
|
||
func dollarAcrossCodeNotLatex() {
|
||
#expect(InlineParser.parse("$x `c` y$") == [
|
||
.text(r(0, 3)),
|
||
.code(range: r(3, 3), content: r(4, 1)),
|
||
.text(r(6, 3)),
|
||
])
|
||
}
|
||
|
||
// MARK: - Strikethrough (extension-supplied `~~…~~` span)
|
||
|
||
private var strikeRegistry: ExtensionRegistry {
|
||
ExtensionRegistry(extensions: [StrikethroughExtension()])
|
||
}
|
||
|
||
private func strike(range: NSRange, markers: [NSRange], children: [InlineNode]) -> InlineNode {
|
||
.ext(ExtensionInlineNode(
|
||
extensionID: StrikethroughExtension.identifier,
|
||
range: range,
|
||
contentRange: NSRange(location: NSMaxRange(markers[0]),
|
||
length: markers[1].location - NSMaxRange(markers[0])),
|
||
markers: markers, children: children))
|
||
}
|
||
|
||
@Test("without a registered extension, ~~x~~ stays literal text")
|
||
func strikethroughUnregisteredStaysLiteral() {
|
||
#expect(InlineParser.parse("~~x~~") == [.text(r(0, 5))])
|
||
}
|
||
|
||
@Test("strikethrough, content recursively parsed")
|
||
func strikethrough() {
|
||
#expect(InlineParser.parse("~~x~~", registry: strikeRegistry) == [
|
||
strike(range: r(0, 5), markers: [r(0, 2), r(3, 2)], children: [.text(r(2, 1))]),
|
||
])
|
||
}
|
||
|
||
@Test("triple tildes do not strike")
|
||
func tripleTildeNotStrike() {
|
||
#expect(InlineParser.parse("~~~x~~~", registry: strikeRegistry) == [.text(r(0, 7))])
|
||
}
|
||
|
||
@Test("~~abc~~~ stays literal (closer must not extend a longer run)")
|
||
func strikethroughRejectsCloserRun() {
|
||
#expect(InlineParser.parse("~~abc~~~", registry: strikeRegistry) == [.text(r(0, 8))])
|
||
}
|
||
|
||
@Test("strikethrough wraps emphasis")
|
||
func strikeWrapsEmphasis() {
|
||
#expect(InlineParser.parse("~~*x*~~", registry: strikeRegistry) == [
|
||
strike(range: r(0, 7), markers: [r(0, 2), r(5, 2)], children: [
|
||
.emphasis(.italic, range: r(2, 3), markers: [r(2, 1), r(4, 1)], children: [.text(r(3, 1))]),
|
||
]),
|
||
])
|
||
}
|
||
|
||
@Test("an extension sharing a built-in trigger char is reachable when the built-in fails")
|
||
func extensionReachableAfterBuiltInFails() {
|
||
// `$50$` is rejected by the built-in math heuristic (currency); a
|
||
// registered `$…$` extension must still get its chance (fall-through).
|
||
struct DollarSpan: MarkdownExtension {
|
||
var id: String { "dollar-span" }
|
||
var inline: InlineSyntax? { InlineSyntax(open: "$", close: "$", parsesContent: false) }
|
||
func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any] { [:] }
|
||
func html(childrenHTML: String) -> String { childrenHTML }
|
||
}
|
||
let registry = ExtensionRegistry(extensions: [DollarSpan()])
|
||
let nodes = InlineParser.parse("$50$", registry: registry)
|
||
guard case .ext(let node) = nodes.first else {
|
||
Issue.record("expected extension span, got \(nodes)")
|
||
return
|
||
}
|
||
#expect(node.extensionID == "dollar-span")
|
||
// And the built-in still wins when it matches: real math parses as latex.
|
||
let mathNodes = InlineParser.parse("$x^2 + y$", registry: registry)
|
||
guard case .inlineLatex = mathNodes.first else {
|
||
Issue.record("built-in latex must win over the extension, got \(mathNodes)")
|
||
return
|
||
}
|
||
}
|
||
|
||
@Test("both extensions registered: ~~ and == coexist and nest")
|
||
func strikeAndHighlightCoexist() {
|
||
let registry = ExtensionRegistry(extensions: [HighlightExtension(), StrikethroughExtension()])
|
||
let nodes = InlineParser.parse("~~a~~ ==b==", registry: registry)
|
||
#expect(nodes.count == 3) // strike, " ", highlight
|
||
if case .ext(let first) = nodes[0] { #expect(first.extensionID == StrikethroughExtension.identifier) }
|
||
if case .ext(let last) = nodes[2] { #expect(last.extensionID == HighlightExtension.identifier) }
|
||
}
|
||
|
||
// MARK: - Highlight (extension-supplied `==…==` span)
|
||
|
||
private var highlightRegistry: ExtensionRegistry {
|
||
ExtensionRegistry(extensions: [HighlightExtension()])
|
||
}
|
||
|
||
private func hi(range: NSRange, markers: [NSRange], children: [InlineNode]) -> InlineNode {
|
||
.ext(ExtensionInlineNode(
|
||
extensionID: HighlightExtension.identifier,
|
||
range: range,
|
||
contentRange: NSRange(location: NSMaxRange(markers[0]),
|
||
length: markers[1].location - NSMaxRange(markers[0])),
|
||
markers: markers, children: children))
|
||
}
|
||
|
||
@Test("without a registered extension, ==x== stays literal text")
|
||
func highlightUnregisteredStaysLiteral() {
|
||
#expect(InlineParser.parse("==x==") == [.text(r(0, 5))])
|
||
}
|
||
|
||
@Test("highlight, content recursively parsed")
|
||
func highlight() {
|
||
#expect(InlineParser.parse("==x==", registry: highlightRegistry) == [
|
||
hi(range: r(0, 5), markers: [r(0, 2), r(3, 2)], children: [.text(r(2, 1))]),
|
||
])
|
||
}
|
||
|
||
@Test("triple equals do not highlight")
|
||
func tripleEqualsNotHighlight() {
|
||
#expect(InlineParser.parse("===x===", registry: highlightRegistry) == [.text(r(0, 7))])
|
||
}
|
||
|
||
@Test("==abc=== matches ==abc==, trailing = is plain text")
|
||
func highlightToleratesTrailingTripleEquals() {
|
||
#expect(InlineParser.parse("==abc===", registry: highlightRegistry) == [
|
||
hi(range: r(0, 7), markers: [r(0, 2), r(5, 2)], children: [.text(r(2, 3))]),
|
||
.text(r(7, 1)),
|
||
])
|
||
}
|
||
|
||
@Test("highlight wraps emphasis")
|
||
func highlightWrapsEmphasis() {
|
||
#expect(InlineParser.parse("==*x*==", registry: highlightRegistry) == [
|
||
hi(range: r(0, 7), markers: [r(0, 2), r(5, 2)], children: [
|
||
.emphasis(.italic, range: r(2, 3), markers: [r(2, 1), r(4, 1)], children: [.text(r(3, 1))]),
|
||
]),
|
||
])
|
||
}
|
||
|
||
@Test("a lone = inside content aborts the highlight candidate")
|
||
func highlightLoneEqualsAborts() {
|
||
#expect(InlineParser.parse("==a=b==", registry: highlightRegistry) == [.text(r(0, 7))])
|
||
}
|
||
|
||
@Test("highlight never crosses a code span")
|
||
func highlightDoesNotCrossCodeSpan() {
|
||
// The backtick run is claimed first; the == candidate overlapping it is rejected.
|
||
#expect(InlineParser.parse("==a `b==` c", registry: highlightRegistry) == [
|
||
.text(r(0, 4)),
|
||
.code(range: r(4, 5), content: r(5, 3)),
|
||
.text(r(9, 2)),
|
||
])
|
||
}
|
||
|
||
// MARK: - Backslash escapes
|
||
|
||
@Test("escaped punctuation becomes an escape node")
|
||
func backslashEscape() {
|
||
#expect(InlineParser.parse(#"\*x"#) == [
|
||
.escape(range: r(0, 2), character: r(1, 1), marker: r(0, 1)),
|
||
.text(r(2, 1)),
|
||
])
|
||
}
|
||
|
||
@Test("escaped asterisks do not emphasize")
|
||
func escapedStarsNotEmphasis() {
|
||
#expect(InlineParser.parse(#"\*a\*"#) == [
|
||
.escape(range: r(0, 2), character: r(1, 1), marker: r(0, 1)),
|
||
.text(r(2, 1)),
|
||
.escape(range: r(3, 2), character: r(4, 1), marker: r(3, 1)),
|
||
])
|
||
}
|
||
|
||
@Test("backslash inside a code span is literal (no escape)")
|
||
func escapeInsideCodeIgnored() {
|
||
#expect(InlineParser.parse(#"`\*`"#) == [.code(range: r(0, 4), content: r(1, 2))])
|
||
}
|
||
}
|