import AppKit import Foundation import Testing @testable import Kanban /// The Edit editor's syntax highlighting (05-card-window.md ▸ Edit). /// /// The suite exists for one promise above all others — **"highlighting is presentation only: the text /// is the raw Markdown, character for character"** — and the whole reason the highlighter emits /// `[Span]` rather than an attributed string is so that promise is checkable rather than merely /// intended. The invariants below (in bounds, in order, non-overlapping, string unchanged after a /// full application) hold for *every* input, so they are asserted over a corpus of deliberately /// broken Markdown as well as over the tidy examples. // MARK: - Helpers private func spans(_ text: String) -> [MarkdownHighlighter.Span] { MarkdownHighlighter.spans(in: text) } /// The substrings a token claims, in order — assertions read as "what is bold here?" rather than as /// arithmetic over offsets. private func text(of text: String, token: MarkdownHighlighter.Token) -> [String] { let ns = text as NSString return spans(text).filter { $0.token == token }.map { ns.substring(with: $0.range) } } /// Every span's substring, whatever its token. private func claimed(_ text: String, where predicate: (MarkdownHighlighter.Token) -> Bool) -> [String] { let ns = text as NSString return spans(text).filter { predicate($0.token) }.map { ns.substring(with: $0.range) } } // MARK: - Invariants @Suite("Markdown highlighter ▸ invariants") struct MarkdownHighlighterInvariantTests { /// Tidy Markdown, half-typed Markdown, and text that is not Markdown at all — the editor holds /// all three, usually within a second of each other. static let corpus: [String] = [ "", "\n", "plain prose with no markup at all", "# Heading\n\nBody *text* here.\n", "**bo", "[label](", "`unclosed code", "~~~\nfence with no close\n", "***", "- [ ] task\n- [x] done\n - nested\n", "> quoted **bold**\n>> deeper\n", "| a | b |\n| - | - |\n| 1 | 2 |\n", "snake_case_identifier and 2 * 3 * 4\n", "```swift\nlet x = **not bold**\n```\n", " indented code\n", "emoji 🇬🇧 and combining é in *italics*\n", " and ![alt](shot.png)\n" ] @Test("Every span lands inside the text, in order, without overlapping") func spansPartitionCleanly() { for sample in Self.corpus { let length = (sample as NSString).length var previousEnd = 0 for span in spans(sample) { #expect(span.range.location >= 0) #expect(span.range.upperBound <= length, "a span past the end of \(sample.debugDescription)") #expect(span.range.location >= previousEnd, "spans overlap or go backwards in \(sample.debugDescription)") previousEnd = span.range.upperBound } } } @MainActor @Test("Applying the whole pass never changes a single character") func applyingNeverAltersTheString() { for sample in Self.corpus { let storage = NSTextStorage(string: sample) MarkdownHighlighter.highlight(storage, pointSize: 13) #expect(storage.string == sample, "the highlighter rewrote \(sample.debugDescription)") // And again, because an idempotent pass is what running on every keystroke amounts to. MarkdownHighlighter.highlight(storage, pointSize: 13) #expect(storage.string == sample) } } @MainActor @Test("A pass leaves no attributes from the pass before it") func attributesAreRebuiltRatherThanAccumulated() { let storage = NSTextStorage(string: "# Heading\n") MarkdownHighlighter.highlight(storage, pointSize: 13) // The user deletes the `#`: what was a heading is now prose, and must be drawn as prose. storage.replaceCharacters(in: NSRange(location: 0, length: 2), with: "") MarkdownHighlighter.highlight(storage, pointSize: 13) let base = MarkdownHighlighter.baseAttributes(pointSize: 13) let font = storage.attribute(.font, at: 0, effectiveRange: nil) as? NSFont #expect(font == base[.font] as? NSFont, "a stale heading font would survive the edit that ended the heading") } } // MARK: - Blocks @Suite("Markdown highlighter ▸ blocks") struct MarkdownHighlighterBlockTests { @Test("A heading is its marker, dimmed, and its text, emphasized") func headings() { #expect(text(of: "# Title\n", token: .heading(level: 1)) == [" Title"]) #expect(text(of: "### Deeper\n", token: .heading(level: 3)) == [" Deeper"]) #expect(text(of: "# Title\n", token: .structural) == ["#"]) // Seven hashes is not a heading in CommonMark, and is not one here either. #expect(text(of: "####### nope\n", token: .heading(level: 7)).isEmpty) #expect(text(of: "#nospace\n", token: .heading(level: 1)).isEmpty) } @Test("List markers and task boxes are the marker, not the text") func listMarkers() { #expect(text(of: "- item\n", token: .listMarker) == ["-"]) #expect(text(of: "1. item\n", token: .listMarker) == ["1."]) #expect(text(of: " * nested\n", token: .listMarker) == ["*"]) // The checkbox belongs to the marker: `- [x] done` reads as one control plus a label. #expect(text(of: "- [x] done\n", token: .listMarker) == ["-", " [x]"]) #expect(text(of: "- [ ] todo\n", token: .listMarker) == ["-", " [ ]"]) } @Test("A thematic break is structure, and is not three list markers") func thematicBreaks() { #expect(text(of: "---\n", token: .structural) == ["---"]) #expect(text(of: "***\n", token: .structural) == ["***"]) #expect(text(of: "---\n", token: .listMarker).isEmpty) } @Test("A quote's chevrons dim and its content still highlights") func quotes() { #expect(text(of: "> quoted **bold**\n", token: .structural).contains(">")) #expect(text(of: "> quoted **bold**\n", token: .strong) == ["bold"]) } @Test("A fenced block is code from fence to fence, whatever is inside it") func fencedCode() { let sample = "```swift\nlet x = **not bold**\n# not a heading\n```\nafter\n" #expect(text(of: sample, token: .strong).isEmpty, "markup inside a fence is code, not markup") #expect(text(of: sample, token: .heading(level: 1)).isEmpty) #expect(text(of: sample, token: .code).contains("let x = **not bold**")) #expect(text(of: sample, token: .code).contains("# not a heading")) // The info string is dimmed with the fence rather than tinted as code. #expect(text(of: sample, token: .linkTarget) == ["swift"]) // And the block ends: text after the closing fence is ordinary again. #expect(!text(of: sample, token: .code).contains("after")) } @Test("A tilde fence is not closed by a backtick fence") func fenceMarkersMustMatch() { let sample = "~~~\ncode\n```\nstill code\n~~~\nout\n" #expect(text(of: sample, token: .code).contains("still code")) #expect(!text(of: sample, token: .code).contains("out")) } @Test("An unclosed fence simply runs to the end — an editor is full of half-typed blocks") func anUnclosedFenceDoesNotBreakTheRest() { let sample = "```\ncode\nmore code\n" #expect(text(of: sample, token: .code) == ["code", "more code"]) } @Test("Indented code is code") func indentedCode() { #expect(text(of: " let x = 1\n", token: .code) == [" let x = 1"]) #expect(text(of: "\tlet x = 1\n", token: .code) == ["\tlet x = 1"]) } } // MARK: - Inlines @Suite("Markdown highlighter ▸ inlines") struct MarkdownHighlighterInlineTests { @Test("Bold, italic and strikethrough style their content and dim their delimiters") func emphasis() { #expect(text(of: "a **bold** b\n", token: .strong) == ["bold"]) #expect(text(of: "a **bold** b\n", token: .structural) == ["**", "**"]) #expect(text(of: "a *italic* b\n", token: .emphasis) == ["italic"]) #expect(text(of: "a _italic_ b\n", token: .emphasis) == ["italic"]) #expect(text(of: "a ~~struck~~ b\n", token: .strikethrough) == ["struck"]) // `**` is tried before `*`, so bold is bold rather than two adjacent italics. #expect(text(of: "**bold**\n", token: .emphasis).isEmpty) } @Test("Intraword underscores are not emphasis") func underscoresInWords() { #expect(text(of: "snake_case_name here\n", token: .emphasis).isEmpty) #expect(text(of: "2 * 3 * 4\n", token: .emphasis).isEmpty, "spaced asterisks are arithmetic") } @Test("A code span tints its content and outranks the markup inside it") func codeSpans() { #expect(text(of: "use `let x = **y**` here\n", token: .code) == ["let x = **y**"]) #expect(text(of: "use `let x = **y**` here\n", token: .strong).isEmpty) #expect(claimed("`a`\n") { $0 == .structural } == ["`", "`"]) } @Test("A link's text reads as text and its target dims") func links() { let sample = "see [the docs](https://example.com/x) now\n" #expect(text(of: sample, token: .linkText) == ["the docs"]) #expect(text(of: sample, token: .linkTarget) == ["https://example.com/x"]) // The brackets, the parens and an image's `!` are all structure. #expect(text(of: sample, token: .structural) == ["[", "](", ")"]) #expect(text(of: "![alt](shot.png)\n", token: .structural) == ["![", "](", ")"]) #expect(text(of: "\n", token: .linkTarget) == [""]) } @Test("Emphasis inside a link's text does not eat the link") func overlappingConstructsResolveByPrecedence() { let sample = "[a **b** c](url)\n" #expect(text(of: sample, token: .linkText) == ["a **b** c"]) #expect(text(of: sample, token: .linkTarget) == ["url"]) #expect(text(of: sample, token: .strong).isEmpty, "the link claimed the run first") } @Test("Half-typed markup styles what is there and invents nothing") func halfTypedMarkup() { // The delimiters dim as they are typed; the run styles when it closes. Nothing about this // is an error state, which is the whole reason the editor scans lines rather than parsing. #expect(text(of: "**bo\n", token: .strong).isEmpty) #expect(text(of: "[label](\n", token: .linkText).isEmpty) #expect(text(of: "`unclosed\n", token: .code).isEmpty) } @Test("Offsets survive text no ASCII assumption would") func unicodeOffsets() { // NSRange is UTF-16, and an emoji flag is two code units before the markup even starts — // a highlighter counting characters would style the wrong run here. let sample = "🇬🇧 flag then **bold**\n" #expect(text(of: sample, token: .strong) == ["bold"]) #expect(text(of: "é *accented* text\n", token: .emphasis) == ["accented"]) } }