i7t5--edmund
1240 行
44 KiB
Swift
1240 行
44 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import EdmundCore
|
|
|
|
// MARK: - Bold
|
|
|
|
@Suite("SyntaxHighlighter — Bold")
|
|
struct BoldTests {
|
|
|
|
@Test("**bold** produces a bold span")
|
|
func doubleStar() {
|
|
let spans = SyntaxHighlighter.parse("**bold**")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .bold)
|
|
#expect(s.fullRange == NSRange(location: 0, length: 8))
|
|
#expect(s.contentRange == NSRange(location: 2, length: 4))
|
|
#expect(s.delimiterRanges.count == 2)
|
|
#expect(s.delimiterRanges[0] == NSRange(location: 0, length: 2))
|
|
#expect(s.delimiterRanges[1] == NSRange(location: 6, length: 2))
|
|
}
|
|
|
|
@Test("__bold__ with underscores")
|
|
func doubleUnderscore() {
|
|
let spans = SyntaxHighlighter.parse("__bold__")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .bold)
|
|
#expect(spans[0].contentRange == NSRange(location: 2, length: 4))
|
|
}
|
|
|
|
@Test("text **bold** text has correct offset")
|
|
func boldInMiddle() {
|
|
let spans = SyntaxHighlighter.parse("hello **world** end")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .bold)
|
|
#expect(spans[0].fullRange == NSRange(location: 6, length: 9))
|
|
#expect(spans[0].contentRange == NSRange(location: 8, length: 5))
|
|
}
|
|
}
|
|
|
|
// MARK: - Italic
|
|
|
|
@Suite("SyntaxHighlighter — Italic")
|
|
struct ItalicTests {
|
|
|
|
@Test("*italic* produces an italic span")
|
|
func singleStar() {
|
|
let spans = SyntaxHighlighter.parse("*italic*")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .italic)
|
|
#expect(s.fullRange == NSRange(location: 0, length: 8))
|
|
#expect(s.contentRange == NSRange(location: 1, length: 6))
|
|
}
|
|
|
|
@Test("_italic_ with underscore")
|
|
func singleUnderscore() {
|
|
let spans = SyntaxHighlighter.parse("_italic_")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .italic)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bold + Italic
|
|
|
|
@Suite("SyntaxHighlighter — Bold+Italic")
|
|
struct BoldItalicTests {
|
|
|
|
@Test("***text*** produces boldItalic")
|
|
func tripleStar() {
|
|
let spans = SyntaxHighlighter.parse("***both***")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .boldItalic)
|
|
#expect(s.contentRange == NSRange(location: 3, length: 4))
|
|
#expect(s.delimiterRanges[0] == NSRange(location: 0, length: 3))
|
|
#expect(s.delimiterRanges[1] == NSRange(location: 7, length: 3))
|
|
}
|
|
|
|
@Test("___text___ with underscores")
|
|
func tripleUnderscore() {
|
|
let spans = SyntaxHighlighter.parse("___both___")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .boldItalic)
|
|
}
|
|
}
|
|
|
|
// MARK: - Code
|
|
|
|
@Suite("SyntaxHighlighter — Code")
|
|
struct CodeTests {
|
|
|
|
@Test("`code` produces a code span")
|
|
func inlineCode() {
|
|
let spans = SyntaxHighlighter.parse("`code`")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .code)
|
|
#expect(s.contentRange == NSRange(location: 1, length: 4))
|
|
#expect(s.delimiterRanges[0] == NSRange(location: 0, length: 1))
|
|
#expect(s.delimiterRanges[1] == NSRange(location: 5, length: 1))
|
|
}
|
|
|
|
@Test("Code spans suppress inner parsing")
|
|
func codeOpaqueToMarkdown() {
|
|
let spans = SyntaxHighlighter.parse("`**not bold**`")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .code)
|
|
}
|
|
|
|
@Test("``a`b`` uses 2-backtick delimiters (GFM §6.3)")
|
|
func doubleBacktickCode() {
|
|
let spans = SyntaxHighlighter.parse("``a`b``")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .code)
|
|
#expect(s.delimiterRanges == [NSRange(location: 0, length: 2),
|
|
NSRange(location: 5, length: 2)])
|
|
#expect(s.contentRange == NSRange(location: 2, length: 3)) // "a`b"
|
|
}
|
|
|
|
@Test("`` ` `` keeps its padding spaces in contentRange (edit mode shows source)")
|
|
func paddedBacktick() {
|
|
let spans = SyntaxHighlighter.parse("`` ` ``")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .code)
|
|
#expect(s.delimiterRanges == [NSRange(location: 0, length: 2),
|
|
NSRange(location: 5, length: 2)])
|
|
#expect(s.contentRange == NSRange(location: 2, length: 3)) // " ` "
|
|
}
|
|
}
|
|
|
|
// MARK: - Headings
|
|
|
|
@Suite("SyntaxHighlighter — Headings")
|
|
struct HeadingTests {
|
|
|
|
@Test("# Heading produces level-1 heading")
|
|
func h1() {
|
|
let spans = SyntaxHighlighter.parse("# Hello")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .heading(1))
|
|
#expect(s.contentRange == NSRange(location: 2, length: 5))
|
|
#expect(s.delimiterRanges[0] == NSRange(location: 0, length: 2))
|
|
}
|
|
|
|
@Test("## Heading produces level-2")
|
|
func h2() {
|
|
let spans = SyntaxHighlighter.parse("## Sub")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .heading(2))
|
|
}
|
|
|
|
@Test("### Heading produces level-3")
|
|
func h3() {
|
|
let spans = SyntaxHighlighter.parse("### Sub sub")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .heading(3))
|
|
}
|
|
|
|
@Test("###### deepest heading is level 6")
|
|
func h6() {
|
|
let spans = SyntaxHighlighter.parse("###### Deep")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .heading(6))
|
|
}
|
|
|
|
@Test("# without space is not a heading")
|
|
func noSpace() {
|
|
let spans = SyntaxHighlighter.parse("#notaheading")
|
|
#expect(spans.isEmpty)
|
|
}
|
|
|
|
@Test("Setext === underline: content is the first line, delimiter the underline")
|
|
func setextH1() {
|
|
let spans = SyntaxHighlighter.parse("Title\n===")
|
|
let headings = spans.filter { if case .heading = $0.kind { return true }; return false }
|
|
#expect(headings.count == 1)
|
|
let s = headings[0]
|
|
#expect(s.kind == .heading(1))
|
|
#expect(s.fullRange == NSRange(location: 0, length: 9))
|
|
#expect(s.contentRange == NSRange(location: 0, length: 5)) // "Title"
|
|
#expect(s.delimiterRanges == [NSRange(location: 6, length: 3)]) // "==="
|
|
}
|
|
|
|
@Test("Setext --- underline is level 2")
|
|
func setextH2() {
|
|
let spans = SyntaxHighlighter.parse("Title\n---")
|
|
let headings = spans.filter { if case .heading = $0.kind { return true }; return false }
|
|
#expect(headings.count == 1)
|
|
#expect(headings[0].kind == .heading(2))
|
|
}
|
|
|
|
@Test("Setext content can span multiple lines (GFM Example 51)")
|
|
func setextMultiLineContent() {
|
|
let spans = SyntaxHighlighter.parse("Foo\nbar\n===")
|
|
let headings = spans.filter { if case .heading = $0.kind { return true }; return false }
|
|
#expect(headings.count == 1)
|
|
let s = headings[0]
|
|
#expect(s.kind == .heading(1))
|
|
#expect(s.contentRange == NSRange(location: 0, length: 7)) // "Foo\nbar"
|
|
#expect(s.delimiterRanges == [NSRange(location: 8, length: 3)]) // "==="
|
|
}
|
|
|
|
@Test("Heading descends into inline children (nested styling)")
|
|
func headingDescendsInline() {
|
|
let spans = SyntaxHighlighter.parse("# **Bold heading**")
|
|
#expect(spans.count == 2)
|
|
#expect(spans[0].kind == .heading(1))
|
|
#expect(spans[1].kind == .bold)
|
|
#expect(spans[1].contentRange == NSRange(location: 4, length: 12))
|
|
}
|
|
|
|
@Test("Setext heading descends into inline children too")
|
|
func setextDescendsInline() {
|
|
let spans = SyntaxHighlighter.parse("**Bold** title\n===")
|
|
let kinds = spans.map(\.kind)
|
|
#expect(kinds.contains(.heading(1)))
|
|
#expect(kinds.contains(.bold))
|
|
}
|
|
|
|
@Test("ATX heading's optional closing sequence is a hidden second delimiter")
|
|
func atxClosingSequence() {
|
|
let spans = SyntaxHighlighter.parse("# foo ###")
|
|
let s = spans[0]
|
|
#expect(s.kind == .heading(1))
|
|
#expect(s.delimiterRanges.count == 2)
|
|
#expect(s.contentRange == NSRange(location: 2, length: 3)) // "foo"
|
|
#expect(s.delimiterRanges[1] == NSRange(location: 5, length: 4)) // " ###"
|
|
}
|
|
|
|
@Test("A closing '#' with no preceding space stays in the content")
|
|
func atxNoPrecedingSpaceNotClosing() {
|
|
let spans = SyntaxHighlighter.parse("# foo#")
|
|
let s = spans[0]
|
|
#expect(s.delimiterRanges.count == 1)
|
|
#expect(s.contentRange == NSRange(location: 2, length: 4)) // "foo#"
|
|
}
|
|
|
|
@Test("An all-hashes closing sequence with empty content is entirely delimiter")
|
|
func atxEmptyHeadingAllDelimiter() {
|
|
let spans = SyntaxHighlighter.parse("## ##")
|
|
let s = spans[0]
|
|
#expect(s.kind == .heading(2))
|
|
#expect(s.delimiterRanges.count == 2)
|
|
#expect(s.contentRange.length == 0)
|
|
}
|
|
}
|
|
|
|
// MARK: - Priority & Overlap
|
|
|
|
@Suite("SyntaxHighlighter — Priority")
|
|
struct PriorityTests {
|
|
|
|
@Test("*** is matched as boldItalic, not bold + italic")
|
|
func tripleStarPriority() {
|
|
let spans = SyntaxHighlighter.parse("***text***")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .boldItalic)
|
|
}
|
|
|
|
@Test("Multiple spans in one line")
|
|
func multipleSpans() {
|
|
let spans = SyntaxHighlighter.parse("**bold** and *italic*")
|
|
#expect(spans.count == 2)
|
|
#expect(spans[0].kind == .bold)
|
|
#expect(spans[1].kind == .italic)
|
|
}
|
|
|
|
@Test("Code before bold: code wins on its range")
|
|
func codeThenBold() {
|
|
let spans = SyntaxHighlighter.parse("`code` **bold**")
|
|
#expect(spans.count == 2)
|
|
#expect(spans[0].kind == .code)
|
|
#expect(spans[1].kind == .bold)
|
|
}
|
|
}
|
|
|
|
// MARK: - Edge Cases
|
|
|
|
@Suite("SyntaxHighlighter — Edge Cases")
|
|
struct EdgeCaseTests {
|
|
|
|
@Test("Empty string produces no spans")
|
|
func emptyString() {
|
|
#expect(SyntaxHighlighter.parse("").isEmpty)
|
|
}
|
|
|
|
@Test("Plain text produces no spans")
|
|
func plainText() {
|
|
#expect(SyntaxHighlighter.parse("hello world").isEmpty)
|
|
}
|
|
|
|
@Test("Unmatched * produces no span")
|
|
func unmatchedStar() {
|
|
#expect(SyntaxHighlighter.parse("*no close").isEmpty)
|
|
}
|
|
|
|
@Test("Unmatched ** produces no span")
|
|
func unmatchedDoubleStar() {
|
|
#expect(SyntaxHighlighter.parse("**no close").isEmpty)
|
|
}
|
|
|
|
@Test("Adjacent bold spans")
|
|
func adjacentBold() {
|
|
let spans = SyntaxHighlighter.parse("**a** **b**")
|
|
#expect(spans.count == 2)
|
|
#expect(spans[0].kind == .bold)
|
|
#expect(spans[1].kind == .bold)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mismatched Delimiters (CommonMark behavior)
|
|
//
|
|
// Per CommonMark spec, mismatched delimiters match the smaller count.
|
|
// e.g. **hi* → literal * + italic hi (the single * pair matches).
|
|
// This matches Apple's AttributedString(markdown:) behavior.
|
|
|
|
@Suite("SyntaxHighlighter — Mismatched Delimiters")
|
|
struct MismatchedDelimiterTests {
|
|
|
|
@Test("**hi* → italic hi (single * pair matches, extra * is literal)")
|
|
func doubleOpenSingleClose() {
|
|
let spans = SyntaxHighlighter.parse("**hi*")
|
|
let italics = spans.filter { $0.kind == .italic }
|
|
#expect(italics.count == 1)
|
|
}
|
|
|
|
@Test("*hi** → italic hi (single * pair matches, extra * is literal)")
|
|
func singleOpenDoubleClose() {
|
|
let spans = SyntaxHighlighter.parse("*hi**")
|
|
let italics = spans.filter { $0.kind == .italic }
|
|
#expect(italics.count == 1)
|
|
}
|
|
|
|
@Test("***hi** → bold hi (double ** pair matches, extra * is literal)")
|
|
func tripleOpenDoubleClose() {
|
|
let spans = SyntaxHighlighter.parse("***hi**")
|
|
let bolds = spans.filter { $0.kind == .bold }
|
|
#expect(bolds.count == 1)
|
|
}
|
|
|
|
@Test("***hi* → italic hi (single * pair matches, extra ** is literal)")
|
|
func tripleOpenSingleClose() {
|
|
let spans = SyntaxHighlighter.parse("***hi*")
|
|
let italics = spans.filter { $0.kind == .italic }
|
|
#expect(italics.count == 1)
|
|
}
|
|
|
|
@Test("**hi*** → bold hi (double ** pair matches, extra * is literal)")
|
|
func doubleOpenTripleClose() {
|
|
let spans = SyntaxHighlighter.parse("**hi***")
|
|
let bolds = spans.filter { $0.kind == .bold }
|
|
#expect(bolds.count == 1)
|
|
}
|
|
|
|
@Test("*hi*** → italic hi (single * pair matches, extra ** is literal)")
|
|
func singleOpenTripleClose() {
|
|
let spans = SyntaxHighlighter.parse("*hi***")
|
|
let italics = spans.filter { $0.kind == .italic }
|
|
#expect(italics.count == 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Links
|
|
|
|
@Suite("SyntaxHighlighter — Strikethrough")
|
|
struct StrikethroughTests {
|
|
|
|
@Test("~~text~~ produces a strikethrough span")
|
|
func basic() {
|
|
let spans = SyntaxHighlighter.parse("~~deleted~~")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .strikethrough)
|
|
#expect(s.fullRange == NSRange(location: 0, length: 11))
|
|
#expect(s.contentRange == NSRange(location: 2, length: 7))
|
|
#expect(s.delimiterRanges.count == 2)
|
|
#expect(s.delimiterRanges[0] == NSRange(location: 0, length: 2))
|
|
#expect(s.delimiterRanges[1] == NSRange(location: 9, length: 2))
|
|
}
|
|
|
|
@Test("Strikethrough delimiter is ~~")
|
|
func delimiters() {
|
|
let spans = SyntaxHighlighter.parse("~~hello~~")
|
|
#expect(spans[0].delimiterRanges[0].length == 2)
|
|
#expect(spans[0].delimiterRanges[1].length == 2)
|
|
}
|
|
}
|
|
|
|
@Suite("SyntaxHighlighter — Highlight")
|
|
struct HighlightTests {
|
|
|
|
@Test("==text== produces a highlight span")
|
|
func basic() {
|
|
let spans = SyntaxHighlighter.parse("==highlighted==")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .highlight)
|
|
#expect(s.fullRange == NSRange(location: 0, length: 15))
|
|
#expect(s.contentRange == NSRange(location: 2, length: 11))
|
|
#expect(s.delimiterRanges.count == 2)
|
|
#expect(s.delimiterRanges[0] == NSRange(location: 0, length: 2))
|
|
#expect(s.delimiterRanges[1] == NSRange(location: 13, length: 2))
|
|
}
|
|
|
|
@Test("Highlight inside code is ignored")
|
|
func insideCode() {
|
|
let spans = SyntaxHighlighter.parse("`==nope==`")
|
|
let highlights = spans.filter { $0.kind == .highlight }
|
|
#expect(highlights.isEmpty)
|
|
}
|
|
|
|
@Test("Single-char ==a== highlights")
|
|
func singleChar() {
|
|
let spans = SyntaxHighlighter.parse("==a==")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .highlight)
|
|
#expect(spans[0].contentRange == NSRange(location: 2, length: 1))
|
|
}
|
|
|
|
@Test("Whitespace-flanked content is not a highlight",
|
|
arguments: ["== spaced ==", "==lead ==", "== trail=="])
|
|
func flanking(_ text: String) {
|
|
let highlights = SyntaxHighlighter.parse(text).filter { $0.kind == .highlight }
|
|
#expect(highlights.isEmpty)
|
|
}
|
|
}
|
|
|
|
@Suite("SyntaxHighlighter — Links")
|
|
struct LinkTests {
|
|
|
|
@Test("Basic link [text](url) produces a link span")
|
|
func basicLink() {
|
|
let spans = SyntaxHighlighter.parse("[hello](https://example.com)")
|
|
let links = spans.filter { if case .link = $0.kind { return true }; return false }
|
|
#expect(links.count == 1)
|
|
#expect(links[0].contentRange == NSRange(location: 1, length: 5)) // "hello"
|
|
}
|
|
|
|
@Test("Link destination is captured")
|
|
func linkDestination() {
|
|
let spans = SyntaxHighlighter.parse("[click](https://example.com)")
|
|
let links = spans.filter { if case .link = $0.kind { return true }; return false }
|
|
#expect(links.count == 1)
|
|
if case .link(let dest) = links[0].kind {
|
|
#expect(dest == "https://example.com")
|
|
}
|
|
}
|
|
|
|
@Test("Link delimiters are [ and ](url)")
|
|
func linkDelimiters() {
|
|
let spans = SyntaxHighlighter.parse("[hi](url)")
|
|
let links = spans.filter { if case .link = $0.kind { return true }; return false }
|
|
#expect(links.count == 1)
|
|
#expect(links[0].delimiterRanges.count == 2)
|
|
// First delimiter: "["
|
|
#expect(links[0].delimiterRanges[0] == NSRange(location: 0, length: 1))
|
|
// Second delimiter: "](url)"
|
|
#expect(links[0].delimiterRanges[1] == NSRange(location: 3, length: 6))
|
|
}
|
|
|
|
@Test("Bold inside link text is detected")
|
|
func boldInsideLink() {
|
|
let spans = SyntaxHighlighter.parse("[**bold**](url)")
|
|
let links = spans.filter { if case .link = $0.kind { return true }; return false }
|
|
let bolds = spans.filter { $0.kind == .bold }
|
|
#expect(links.count == 1)
|
|
#expect(bolds.count == 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Blockquotes
|
|
|
|
@Suite("SyntaxHighlighter — Blockquotes")
|
|
struct BlockquoteTests {
|
|
|
|
@Test("Basic blockquote > text produces a blockquote span")
|
|
func basicBlockquote() {
|
|
let spans = SyntaxHighlighter.parse("> hello")
|
|
let quotes = spans.filter { $0.kind == .blockquote(depth: 0) }
|
|
#expect(quotes.count == 1)
|
|
}
|
|
|
|
@Test("Blockquote delimiter is the > prefix")
|
|
func blockquoteDelimiter() {
|
|
let spans = SyntaxHighlighter.parse("> hello")
|
|
let quotes = spans.filter { $0.kind == .blockquote(depth: 0) }
|
|
#expect(quotes.count == 1)
|
|
#expect(quotes[0].delimiterRanges.count >= 1)
|
|
// Content should be "hello"
|
|
#expect(quotes[0].contentRange.length == 5)
|
|
}
|
|
|
|
@Test("Bold inside blockquote is detected")
|
|
func boldInsideBlockquote() {
|
|
let spans = SyntaxHighlighter.parse("> **bold**")
|
|
let quotes = spans.filter { $0.kind == .blockquote(depth: 0) }
|
|
let bolds = spans.filter { $0.kind == .bold }
|
|
#expect(quotes.count == 1)
|
|
#expect(bolds.count == 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - List Items
|
|
|
|
@Suite("SyntaxHighlighter — List Items")
|
|
struct ListItemTests {
|
|
|
|
@Test("Unordered list item - text produces a listItem span")
|
|
func unorderedListItem() {
|
|
let spans = SyntaxHighlighter.parse("- hello")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(let ordered, _) = items[0].kind {
|
|
#expect(!ordered)
|
|
}
|
|
}
|
|
|
|
@Test("Ordered list item 1. text produces a listItem span")
|
|
func orderedListItem() {
|
|
let spans = SyntaxHighlighter.parse("1. hello")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(let ordered, _) = items[0].kind {
|
|
#expect(ordered)
|
|
}
|
|
}
|
|
|
|
@Test("Unordered list delimiter is - prefix")
|
|
func unorderedDelimiter() {
|
|
let spans = SyntaxHighlighter.parse("- hello")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
#expect(items[0].contentRange.length == 5) // "hello"
|
|
}
|
|
|
|
@Test("Bold inside list item is detected")
|
|
func boldInsideListItem() {
|
|
let spans = SyntaxHighlighter.parse("- **bold**")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
let bolds = spans.filter { $0.kind == .bold }
|
|
#expect(items.count == 1)
|
|
#expect(bolds.count == 1)
|
|
}
|
|
|
|
@Test("Unchecked todo item - [ ] produces listItem with unchecked checkbox")
|
|
func uncheckedTodo() {
|
|
let spans = SyntaxHighlighter.parse("- [ ] todo")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(_, let checkbox) = items[0].kind {
|
|
#expect(checkbox == .unchecked)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
}
|
|
|
|
@Test("Checked todo item - [x] produces listItem with checked checkbox")
|
|
func checkedTodo() {
|
|
let spans = SyntaxHighlighter.parse("- [x] done")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(_, let checkbox) = items[0].kind {
|
|
#expect(checkbox == .checked)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
}
|
|
|
|
@Test("Unchecked todo whose body contains [x] stays unchecked")
|
|
func uncheckedTodoWithBracketXInBody() {
|
|
// swift-markdown reports this item's checkbox as `.checked` because it
|
|
// scans the whole line for `[x]`; the state must come from the leading
|
|
// `[ ]` marker instead. (Regression: such items rendered struck-through.)
|
|
for line in ["- [ ] body with [x] later",
|
|
"- [ ] body with `[x]` in code",
|
|
"- [ ] mentions `- [x]` syntax"] {
|
|
let spans = SyntaxHighlighter.parse(line)
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(_, let checkbox) = items.first?.kind {
|
|
#expect(checkbox == .unchecked, "expected unchecked for \(line.debugDescription)")
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem for \(line.debugDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("Indented list item (2 spaces) produces a listItem span")
|
|
func indentedTwoSpaces() {
|
|
let spans = SyntaxHighlighter.parse(" - hello")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
}
|
|
|
|
@Test("Indented list item (4 spaces) produces a listItem span")
|
|
func indentedFourSpaces() {
|
|
let spans = SyntaxHighlighter.parse(" - hello")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
// Should NOT also produce a codeBlock span
|
|
let codeBlocks = spans.filter { if case .codeBlock = $0.kind { return true }; return false }
|
|
#expect(codeBlocks.count == 0)
|
|
}
|
|
|
|
@Test("Indented list item (8 spaces) produces a listItem span")
|
|
func indentedEightSpaces() {
|
|
let spans = SyntaxHighlighter.parse(" - hello")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
}
|
|
|
|
@Test("Indented list item preserves inline formatting")
|
|
func indentedListInlineFormatting() {
|
|
let spans = SyntaxHighlighter.parse(" - *italic* and **bold**")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
let italics = spans.filter { $0.kind == .italic }
|
|
#expect(italics.count == 1)
|
|
let bolds = spans.filter { $0.kind == .bold }
|
|
#expect(bolds.count == 1)
|
|
}
|
|
|
|
@Test("Indented unchecked todo (4 spaces) detects unchecked checkbox")
|
|
func indentedUncheckedTodo() {
|
|
let spans = SyntaxHighlighter.parse(" - [ ] deep todo")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(_, let checkbox) = items[0].kind {
|
|
#expect(checkbox == .unchecked)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
}
|
|
|
|
@Test("Indented checked todo (4 spaces) detects checked checkbox")
|
|
func indentedCheckedTodo() {
|
|
let spans = SyntaxHighlighter.parse(" - [x] deep done")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(_, let checkbox) = items[0].kind {
|
|
#expect(checkbox == .checked)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
}
|
|
|
|
@Test("Deeply indented todo (8 spaces) detects checkbox")
|
|
func deeplyIndentedTodo() {
|
|
let spans = SyntaxHighlighter.parse(" - [ ] level 4 todo")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(_, let checkbox) = items[0].kind {
|
|
#expect(checkbox == .unchecked)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
}
|
|
|
|
@Test("Indented ordered item (4 spaces) produces an ordered listItem span")
|
|
func indentedOrderedFourSpaces() {
|
|
let spans = SyntaxHighlighter.parse(" 1. hello")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(let ordered, _) = items[0].kind {
|
|
#expect(ordered == true)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
// Should NOT also produce a codeBlock span.
|
|
let codeBlocks = spans.filter { if case .codeBlock = $0.kind { return true }; return false }
|
|
#expect(codeBlocks.count == 0)
|
|
}
|
|
|
|
@Test("Deeply indented ordered item (8 spaces, paren) is ordered")
|
|
func deeplyIndentedOrderedParen() {
|
|
let spans = SyntaxHighlighter.parse(" 2) deep")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(let ordered, _) = items[0].kind {
|
|
#expect(ordered == true)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
}
|
|
|
|
@Test("Tab-indented ordered item is ordered")
|
|
func tabIndentedOrdered() {
|
|
let spans = SyntaxHighlighter.parse("\t1. tabbed")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
if case .listItem(let ordered, _) = items[0].kind {
|
|
#expect(ordered == true)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
}
|
|
|
|
@Test("Indented todo content excludes the checkbox delimiter")
|
|
func indentedTodoContentRange() {
|
|
let text = " - [ ] task"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
#expect(items.count == 1)
|
|
// Content should be "task" — the " - [ ] " prefix is the delimiter.
|
|
let content = (text as NSString).substring(with: items[0].contentRange)
|
|
#expect(content == "task")
|
|
}
|
|
|
|
@Test("Indented todo with uppercase [X] is checked")
|
|
func indentedUppercaseChecked() {
|
|
let spans = SyntaxHighlighter.parse(" - [X] done")
|
|
let items = spans.filter { if case .listItem = $0.kind { return true }; return false }
|
|
if case .listItem(_, let checkbox) = items[0].kind {
|
|
#expect(checkbox == .checked)
|
|
} else {
|
|
#expect(Bool(false), "Expected listItem")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Tables
|
|
|
|
@Suite("SyntaxHighlighter — Tables")
|
|
struct TableTests {
|
|
|
|
@Test("Table produces a table span")
|
|
func basicTable() {
|
|
let text = "| A | B |\n| --- | --- |\n| 1 | 2 |"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let tables = spans.filter { $0.kind == .table }
|
|
#expect(tables.count == 1)
|
|
}
|
|
|
|
@Test("Table full range covers entire table text")
|
|
func tableFullRange() {
|
|
let text = "| A | B |\n| --- | --- |\n| 1 | 2 |"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let tables = spans.filter { $0.kind == .table }
|
|
#expect(tables.count == 1)
|
|
#expect(tables[0].fullRange == NSRange(location: 0, length: (text as NSString).length))
|
|
}
|
|
|
|
@Test("Table with single column")
|
|
func singleColumn() {
|
|
let text = "| H |\n| --- |\n| V |"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let tables = spans.filter { $0.kind == .table }
|
|
#expect(tables.count == 1)
|
|
}
|
|
|
|
@Test("Table delimiter ranges capture separator row")
|
|
func tableSeparatorInDelimiters() {
|
|
let text = "| A |\n| --- |\n| 1 |"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let tables = spans.filter { $0.kind == .table }
|
|
#expect(tables.count == 1)
|
|
// There should be delimiter ranges between head and body (the separator)
|
|
#expect(!tables[0].delimiterRanges.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Code Blocks
|
|
|
|
@Suite("SyntaxHighlighter — Code Blocks")
|
|
struct CodeBlockTests {
|
|
|
|
@Test("Fenced code block with backticks produces codeBlock span")
|
|
func backtickFence() {
|
|
let text = "```\nhello\n```"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let codeBlocks = spans.filter {
|
|
if case .codeBlock = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(codeBlocks.count == 1)
|
|
}
|
|
|
|
@Test("Fenced code block with language annotation")
|
|
func languageAnnotation() {
|
|
let text = "```swift\nlet x = 1\n```"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let codeBlocks = spans.filter {
|
|
if case .codeBlock = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(codeBlocks.count == 1)
|
|
if case .codeBlock(let lang) = codeBlocks[0].kind {
|
|
#expect(lang == "swift")
|
|
}
|
|
}
|
|
|
|
@Test("Code block content range excludes fence lines")
|
|
func contentExcludesFences() {
|
|
let text = "```\nhello\n```"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let codeBlocks = spans.filter {
|
|
if case .codeBlock = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(codeBlocks.count == 1)
|
|
let content = (text as NSString).substring(with: codeBlocks[0].contentRange)
|
|
#expect(content == "hello")
|
|
}
|
|
|
|
@Test("Indented code block: no delimiters, all content")
|
|
func indentedCode() {
|
|
let text = " let x = 1\n let y = 2"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let codeBlocks = spans.filter {
|
|
if case .codeBlock = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(codeBlocks.count == 1)
|
|
guard let span = codeBlocks.first else { return }
|
|
if case .codeBlock(let lang) = span.kind { #expect(lang == nil) }
|
|
#expect(span.contentRange == span.fullRange)
|
|
#expect(span.delimiterRanges.isEmpty)
|
|
// The first line's leading indent is inside the span (swift-markdown's
|
|
// node range starts after it; the walker expands back to line start).
|
|
#expect(span.fullRange.location == 0)
|
|
}
|
|
|
|
@Test("Code block with tilde fences")
|
|
func tildeFence() {
|
|
let text = "~~~\ncode\n~~~"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let codeBlocks = spans.filter {
|
|
if case .codeBlock = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(codeBlocks.count == 1)
|
|
}
|
|
|
|
@Test("Code block with multiple lines of content")
|
|
func multipleLines() {
|
|
let text = "```\nline1\nline2\nline3\n```"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let codeBlocks = spans.filter {
|
|
if case .codeBlock = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(codeBlocks.count == 1)
|
|
let content = (text as NSString).substring(with: codeBlocks[0].contentRange)
|
|
#expect(content.contains("line1"))
|
|
#expect(content.contains("line2"))
|
|
#expect(content.contains("line3"))
|
|
}
|
|
|
|
@Test("Code block delimiter ranges cover fence lines")
|
|
func delimiterRanges() {
|
|
let text = "```\nhello\n```"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let codeBlocks = spans.filter {
|
|
if case .codeBlock = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(codeBlocks.count == 1)
|
|
#expect(codeBlocks[0].delimiterRanges.count == 2)
|
|
// Opening: "```\n"
|
|
let openDelim = (text as NSString).substring(with: codeBlocks[0].delimiterRanges[0])
|
|
#expect(openDelim == "```\n")
|
|
}
|
|
}
|
|
|
|
// MARK: - Thematic Break
|
|
|
|
@Suite("SyntaxHighlighter — Thematic Break")
|
|
struct ThematicBreakTests {
|
|
|
|
@Test("--- produces a thematicBreak span")
|
|
func tripleDash() {
|
|
let spans = SyntaxHighlighter.parse("---")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .thematicBreak)
|
|
#expect(s.fullRange == NSRange(location: 0, length: 3))
|
|
#expect(s.contentRange == s.fullRange)
|
|
#expect(s.delimiterRanges == [s.fullRange])
|
|
}
|
|
|
|
@Test("*** produces a thematicBreak span")
|
|
func tripleAsterisk() {
|
|
let spans = SyntaxHighlighter.parse("***")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .thematicBreak)
|
|
}
|
|
|
|
@Test("___ produces a thematicBreak span")
|
|
func tripleUnderscore() {
|
|
let spans = SyntaxHighlighter.parse("___")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .thematicBreak)
|
|
}
|
|
|
|
@Test("Thematic break with extra dashes")
|
|
func extraDashes() {
|
|
let spans = SyntaxHighlighter.parse("-----")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .thematicBreak)
|
|
#expect(spans[0].fullRange == NSRange(location: 0, length: 5))
|
|
}
|
|
|
|
@Test("Thematic break between paragraphs")
|
|
func betweenParagraphs() {
|
|
let text = "above\n\n---\n\nbelow"
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let breaks = spans.filter { $0.kind == .thematicBreak }
|
|
#expect(breaks.count == 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Images
|
|
|
|
@Suite("SyntaxHighlighter — Images")
|
|
struct ImageTests {
|
|
|
|
@Test(" produces an image span")
|
|
func basicImage() {
|
|
let spans = SyntaxHighlighter.parse("")
|
|
let images = spans.filter {
|
|
if case .image = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(images.count == 1)
|
|
if case .image(let dest, _, _) = images[0].kind {
|
|
#expect(dest == "https://example.com/img.png")
|
|
}
|
|
}
|
|
|
|
@Test("Image content range covers alt text")
|
|
func imageContentRange() {
|
|
let text = ""
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let images = spans.filter {
|
|
if case .image = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(images.count == 1)
|
|
let content = (text as NSString).substring(with: images[0].contentRange)
|
|
#expect(content == "alt")
|
|
}
|
|
|
|
@Test("Image delimiter ranges cover ")
|
|
func imageDelimiterRanges() {
|
|
let text = ""
|
|
let spans = SyntaxHighlighter.parse(text)
|
|
let images = spans.filter {
|
|
if case .image = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(images.count == 1)
|
|
#expect(images[0].delimiterRanges.count == 2)
|
|
// Opening delimiter: "!["
|
|
let openDelim = (text as NSString).substring(with: images[0].delimiterRanges[0])
|
|
#expect(openDelim == ""
|
|
let closeDelim = (text as NSString).substring(with: images[0].delimiterRanges[1])
|
|
#expect(closeDelim == "](url)")
|
|
}
|
|
|
|
@Test("Image with empty alt text")
|
|
func emptyAlt() {
|
|
let spans = SyntaxHighlighter.parse("")
|
|
let images = spans.filter {
|
|
if case .image = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(images.count == 1)
|
|
}
|
|
|
|
@Test("Image mixed with text")
|
|
func imageInText() {
|
|
let spans = SyntaxHighlighter.parse("see  here")
|
|
let images = spans.filter {
|
|
if case .image = $0.kind { return true }
|
|
return false
|
|
}
|
|
#expect(images.count == 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Line Break
|
|
|
|
@Suite("SyntaxHighlighter — Line Break")
|
|
struct LineBreakTests {
|
|
|
|
@Test("Trailing backslash produces lineBreak span")
|
|
func trailingBackslash() {
|
|
let spans = SyntaxHighlighter.parse("hello\\")
|
|
let breaks = spans.filter { $0.kind == .lineBreak }
|
|
#expect(breaks.count == 1)
|
|
#expect(breaks[0].fullRange == NSRange(location: 5, length: 1))
|
|
}
|
|
|
|
@Test("No trailing backslash means no lineBreak")
|
|
func noBackslash() {
|
|
let spans = SyntaxHighlighter.parse("hello")
|
|
let breaks = spans.filter { $0.kind == .lineBreak }
|
|
#expect(breaks.count == 0)
|
|
}
|
|
|
|
@Test("Double backslash is escaped, not a lineBreak")
|
|
func escapedBackslash() {
|
|
let spans = SyntaxHighlighter.parse("hello\\\\")
|
|
let breaks = spans.filter { $0.kind == .lineBreak }
|
|
#expect(breaks.count == 0)
|
|
}
|
|
|
|
@Test("Multi-line text does not produce lineBreak")
|
|
func multiLine() {
|
|
let spans = SyntaxHighlighter.parse("hello\\\nworld")
|
|
let breaks = spans.filter { $0.kind == .lineBreak }
|
|
#expect(breaks.count == 0)
|
|
}
|
|
|
|
@Test("LineBreak delimiter range is the backslash")
|
|
func delimiterRange() {
|
|
let spans = SyntaxHighlighter.parse("text\\")
|
|
let breaks = spans.filter { $0.kind == .lineBreak }
|
|
#expect(breaks.count == 1)
|
|
#expect(breaks[0].delimiterRanges == [NSRange(location: 4, length: 1)])
|
|
}
|
|
}
|
|
|
|
// MARK: - Inline Math
|
|
|
|
@Suite("SyntaxHighlighter — Inline Math")
|
|
struct InlineMathTests {
|
|
|
|
private func mathSpans(_ text: String) -> [SyntaxHighlighter.Span] {
|
|
SyntaxHighlighter.parse(text).filter {
|
|
if case .math = $0.kind { return true }; return false
|
|
}
|
|
}
|
|
|
|
@Test("$x$ produces an inline math span with correct ranges")
|
|
func basicInline() {
|
|
let spans = mathSpans("$x$")
|
|
#expect(spans.count == 1)
|
|
let s = spans[0]
|
|
#expect(s.kind == .math(display: false))
|
|
#expect(s.fullRange == NSRange(location: 0, length: 3))
|
|
#expect(s.contentRange == NSRange(location: 1, length: 1))
|
|
#expect(s.delimiterRanges == [NSRange(location: 0, length: 1),
|
|
NSRange(location: 2, length: 1)])
|
|
}
|
|
|
|
@Test("Math in the middle of text has the right offset")
|
|
func mathInMiddle() {
|
|
let spans = mathSpans("energy $E=mc^2$ here")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].fullRange == NSRange(location: 7, length: 8))
|
|
#expect(spans[0].contentRange == NSRange(location: 8, length: 6))
|
|
}
|
|
|
|
@Test("Prose dollar amounts are not matched")
|
|
func proseDollarsIgnored() {
|
|
#expect(mathSpans("it cost $5 to $10 today").isEmpty)
|
|
}
|
|
|
|
@Test("Escaped \\$ is not a delimiter")
|
|
func escapedDollar() {
|
|
#expect(mathSpans("price is \\$5 each").isEmpty)
|
|
}
|
|
|
|
@Test("$ inside inline code is not matched")
|
|
func dollarsInCode() {
|
|
let spans = mathSpans("`$x$`")
|
|
#expect(spans.isEmpty)
|
|
}
|
|
|
|
@Test("$$x$$ on one line is display math, not inline")
|
|
func singleLineDisplay() {
|
|
let spans = mathSpans("$$x$$")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .math(display: true))
|
|
#expect(spans[0].contentRange == NSRange(location: 2, length: 1))
|
|
}
|
|
|
|
@Test("Opening $ followed by space is not math")
|
|
func openFollowedBySpace() {
|
|
#expect(mathSpans("a $ b $ c").isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Display Math
|
|
|
|
@Suite("SyntaxHighlighter — Display Math")
|
|
struct DisplayMathTests {
|
|
|
|
private func mathSpans(_ text: String) -> [SyntaxHighlighter.Span] {
|
|
SyntaxHighlighter.parse(text).filter {
|
|
if case .math = $0.kind { return true }; return false
|
|
}
|
|
}
|
|
|
|
@Test("Multi-line $$…$$ block produces a display-math span")
|
|
func multiLineDisplay() {
|
|
let text = "$$\nx = y\n$$"
|
|
let spans = mathSpans(text)
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .math(display: true))
|
|
// full range spans the opening $$ through the closing $$.
|
|
#expect(spans[0].fullRange == NSRange(location: 0, length: (text as NSString).length))
|
|
// content is the text between the delimiters (newlines included).
|
|
let content = (text as NSString).substring(with: spans[0].contentRange)
|
|
#expect(content == "\nx = y\n")
|
|
}
|
|
|
|
@Test("Display delimiters are the two $$")
|
|
func displayDelimiters() {
|
|
let spans = mathSpans("$$a$$")
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].delimiterRanges == [NSRange(location: 0, length: 2),
|
|
NSRange(location: 3, length: 2)])
|
|
}
|
|
|
|
@Test("A normal paragraph is not display math")
|
|
func paragraphNotDisplay() {
|
|
#expect(mathSpans("just a paragraph").isEmpty)
|
|
}
|
|
|
|
@Test("Inline $$…$$ sharing a line with prose is display math")
|
|
func inlineDisplayInProse() {
|
|
let text = "text $$x$$ more"
|
|
let spans = mathSpans(text)
|
|
#expect(spans.count == 1)
|
|
#expect(spans[0].kind == .math(display: true))
|
|
#expect(spans[0].fullRange == NSRange(location: 5, length: 5))
|
|
#expect((text as NSString).substring(with: spans[0].contentRange) == "x")
|
|
}
|
|
|
|
@Test("Two $$…$$ runs on one line produce two display spans")
|
|
func twoRunsOneLine() {
|
|
let spans = mathSpans("$$a$$ and $$b$$")
|
|
#expect(spans.count == 2)
|
|
#expect(spans.allSatisfy { $0.kind == .math(display: true) })
|
|
}
|
|
|
|
@Test("$$ with space after the delimiter is not display math")
|
|
func spaceAfterDelimiterNotDisplay() {
|
|
#expect(mathSpans("a $$ x $$ b").isEmpty)
|
|
}
|
|
|
|
@Test("Loose $$ in prose (close preceded by space) is not display math")
|
|
func looseProseDollarsNotDisplay() {
|
|
#expect(mathSpans("cost $$5 for $$10 today").isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Autolinks (GFM extension)
|
|
|
|
@Suite("SyntaxHighlighter — Autolinks")
|
|
struct AutolinkTests {
|
|
|
|
private func links(_ text: String) -> [(text: String, dest: String)] {
|
|
SyntaxHighlighter.parse(text).compactMap { s in
|
|
guard case .link(let d) = s.kind else { return nil }
|
|
return ((text as NSString).substring(with: s.contentRange), d)
|
|
}
|
|
}
|
|
|
|
@Test("Bare www autolink gets an http:// destination")
|
|
func www() {
|
|
let l = links("visit www.example.com now")
|
|
#expect(l.count == 1)
|
|
#expect(l[0].text == "www.example.com")
|
|
#expect(l[0].dest == "http://www.example.com")
|
|
}
|
|
|
|
@Test("Scheme autolink keeps its own destination")
|
|
func scheme() {
|
|
let l = links("see https://example.com/page?x=1 there")
|
|
#expect(l.map(\.dest) == ["https://example.com/page?x=1"])
|
|
}
|
|
|
|
@Test("Trailing punctuation is trimmed")
|
|
func trailingPunct() {
|
|
#expect(links("go to www.example.com.").map(\.text) == ["www.example.com"])
|
|
#expect(links("really, www.example.com!").map(\.text) == ["www.example.com"])
|
|
}
|
|
|
|
@Test("Unbalanced trailing paren is trimmed; balanced is kept")
|
|
func parens() {
|
|
#expect(links("(see www.example.com)").map(\.text) == ["www.example.com"])
|
|
#expect(links("https://en.wikipedia.org/wiki/Markdown_(language)").map(\.text)
|
|
== ["https://en.wikipedia.org/wiki/Markdown_(language)"])
|
|
}
|
|
|
|
@Test("Trailing &entity; is trimmed")
|
|
func entity() {
|
|
#expect(links("www.example.com/foo&").map(\.text) == ["www.example.com/foo"])
|
|
}
|
|
|
|
@Test("Email autolink gets a mailto destination")
|
|
func email() {
|
|
let l = links("mail foo.bar+baz@sub.example.com please")
|
|
#expect(l.map(\.dest) == ["mailto:foo.bar+baz@sub.example.com"])
|
|
}
|
|
|
|
@Test("Invalid domains don't autolink")
|
|
func invalidDomain() {
|
|
#expect(links("http://nodot").isEmpty)
|
|
#expect(links("www.ex_ample.com").isEmpty) // _ in the last two labels
|
|
#expect(links("it cost $5 www").isEmpty)
|
|
}
|
|
|
|
@Test("A URL mid-word doesn't autolink")
|
|
func midWord() {
|
|
#expect(links("xhttp://example.com").isEmpty)
|
|
}
|
|
|
|
@Test("No autolink inside code spans")
|
|
func insideCode() {
|
|
#expect(links("`www.example.com`").isEmpty)
|
|
}
|
|
|
|
@Test("No autolink inside a real markdown link; a bare one next to it still links")
|
|
func besideRealLink() {
|
|
let l = links("[x](http://a.com) http://b.com")
|
|
// The [x](…) link comes from the walker; the autolink pass adds b only.
|
|
let autos = l.filter { $0.dest == "http://b.com" }
|
|
#expect(autos.count == 1)
|
|
#expect(!l.contains { $0.text.contains("a.com") && $0.dest == "http://a.com" && $0.text == "http://a.com" })
|
|
}
|
|
|
|
@Test("No autolink inside an <img> src attribute")
|
|
func insideImgTag() {
|
|
let spans = SyntaxHighlighter.parse("<img src=\"http://example.com/x.png\">")
|
|
let autos = spans.filter {
|
|
if case .link = $0.kind { return true }; return false
|
|
}
|
|
#expect(autos.isEmpty)
|
|
}
|
|
}
|