diff --git a/Package.swift b/Package.swift index 7d70f2b..2c317fa 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,7 @@ let package = Package( .library(name: "MarkdownSyntax", targets: ["MarkdownSyntax"]), ], dependencies: [ - .package(url: "https://github.com/swiftlang/swift-cmark", .upToNextMajor(from: "0.7.1")) + .package(url: "https://github.com/swiftlang/swift-cmark", .upToNextMajor(from: "0.8.0")) ], targets: [ .target( diff --git a/Sources/MarkdownSyntax/CMark/CMNode+PositionAdjustment.swift b/Sources/MarkdownSyntax/CMark/CMNode+PositionAdjustment.swift index c533a5b..9a7fc7a 100644 --- a/Sources/MarkdownSyntax/CMark/CMNode+PositionAdjustment.swift +++ b/Sources/MarkdownSyntax/CMark/CMNode+PositionAdjustment.swift @@ -7,14 +7,90 @@ // This file compensates for position calculation differences between swift-cmark-gfm // and swift-cmark. The new library changed how it reports positions for certain elements: // -// 1. GFM autolinks (autolink.c:275): Changed from `start - rewind` to `max_rewind - rewind` -// Result: Off-by-one error in start column +// 1. GFM bare autolinks (autolink.c:275): changed from `start - rewind` to +// `max_rewind - rewind`. Result: the reported start is one character too early, and +// the surrounding text nodes are left with stale, overlapping positions. // -// 2. Footnote definitions: Positions now exclude the [^label]: prefix +// 2. Footnote definitions: positions now exclude the [^label]: prefix +// +// Angle autolinks (``) and ordinary links are reported correctly +// and must NOT be adjusted — which is why the classification below reads the source +// syntax instead of comparing the label against the destination. // extension CMNode { + /// How a `.link` node was written in the source markdown. + enum LinkSyntax { + /// `[label](destination)` + case inline + /// `[label][id]`, `[label][]`, or the shortcut `[id]` + case reference + /// `` + case angleAutolink + /// A bare GFM URL, e.g. `https://example.com` or `www.example.com` + case bareAutolink + + /// The public classification, which does not distinguish the two autolink forms. + var kind: LinkKind { + switch self { + case .inline: return .inline + case .reference: return .reference + case .angleAutolink, .bareAutolink: return .autolink + } + } + + var isAutolink: Bool { + kind == .autolink + } + } + + /// Classifies a `.link` node from the **source syntax** at its own position. + /// + /// Comparing the label against the destination cannot work: `[harbor](#harbor)` is an + /// ordinary inline link whose label happens to be a suffix of its destination. + func linkSyntax(in text: String, using lineOffsets: [String.Index]) -> LinkSyntax { + let pos = position(in: text, using: lineOffsets) + + guard + let start = pos.start.offset, + let end = pos.end.offset, + start >= text.startIndex, + end < text.endIndex, + start <= end + else { + // The source could not be inspected (cmark reported an unusable position). + // Fall back to the shape of the node itself: an autolink's only child is its URL. + return isAutolinkShaped ? .bareAutolink : .inline + } + + // Compare in the UTF-8 view: these indices come from UTF-8 offset arithmetic and + // are not guaranteed to sit on a Character boundary. + let first = text.utf8[start] + let last = text.utf8[end] + + guard first == UInt8(ascii: "[") else { + let isAngle = first == UInt8(ascii: "<") && last == UInt8(ascii: ">") + return isAngle ? .angleAutolink : .bareAutolink + } + + switch last { + case UInt8(ascii: ")"): return .inline + case UInt8(ascii: "]"): return .reference + // A bare URL that happens to follow a stray `[`, e.g. `[https://example.com`. + default: return .bareAutolink + } + } + + /// Last-resort shape test used only when the source position is unusable. + private var isAutolinkShaped: Bool { + guard let childText = firstChild?.literal, let destination = linkDestination else { + return false + } + // GFM expands www.example.com to http://www.example.com + return childText == destination || destination.hasSuffix(childText) + } + /// Adjusts position to restore syntax delimiters that swift-cmark now excludes. /// /// - Parameters: @@ -32,21 +108,30 @@ extension CMNode { } switch type { - case .link where isAutolink(): - return adjustAutolinkPosition(pos, in: text, startOffset: startOffset, endOffset: endOffset) - + case .link: + switch linkSyntax(in: text, using: lineOffsets) { + case .bareAutolink: + return adjustBareAutolinkPosition(pos, in: text, startOffset: startOffset, endOffset: endOffset) + case .inline, .reference, .angleAutolink: + return pos + } + + case .text: + return adjustTextPosition(pos, in: text, using: lineOffsets) + case .footnoteDefinition: return adjustFootnotePosition(pos, in: text, startOffset: startOffset) - + default: return pos } } - /// Adjusts autolink position for GFM bare URLs only. - /// Angle bracket autolinks like don't need adjustment. - /// Fix off-by-one: GFM bare URL position includes character before URL - private func adjustAutolinkPosition( + /// Adjusts position for GFM bare URL autolinks only. + /// + /// Angle autolinks like `` are already reported correctly, + /// brackets included, and are filtered out before we get here. + private func adjustBareAutolinkPosition( _ pos: Position, in text: String, startOffset: String.Index, @@ -65,15 +150,47 @@ extension CMNode { ) } - /// Checks if this is a bare URL autolink (not an explicit Markdown link). - /// GFM autolinks have their URL as the child text content. - private func isAutolink() -> Bool { - guard let childText = firstChild?.literal, let linkURLString = linkDestination else { - return false + /// Repairs the text nodes the GFM autolink extension leaves behind. + /// + /// Splitting a paragraph's text around a bare URL leaves two stale positions: + /// the autolink's own child starts one character too early, and the text node in + /// front of it still ends inside the URL — overlapping the link. + private func adjustTextPosition( + _ pos: Position, + in text: String, + using lineOffsets: [String.Index] + ) -> Position { + // The autolink's own child spans the URL exactly, so it inherits the link's start. + if let parent, parent.type == .link, + parent.linkSyntax(in: text, using: lineOffsets) == .bareAutolink { + let linkPosition = parent.adjustedPosition(in: text, using: lineOffsets) + guard let linkStart = linkPosition.start.offset, let start = pos.start.offset, + start < linkStart else { return pos } + return Position(start: linkPosition.start, end: pos.end, indent: pos.indent) } - // GFM expands www.example.com to http://www.example.com - return childText == linkURLString || linkURLString.hasSuffix(childText) + // The text run in front of a bare autolink still ends inside the URL. + if let next, next.type == .link, + next.linkSyntax(in: text, using: lineOffsets) == .bareAutolink { + let linkPosition = next.adjustedPosition(in: text, using: lineOffsets) + guard let linkStart = linkPosition.start.offset, + let start = pos.start.offset, let end = pos.end.offset, + end >= linkStart, linkStart > start, linkStart > text.startIndex + else { return pos } + + let adjustedEnd = text.utf8.index(before: linkStart) + return Position( + start: pos.start, + end: Point( + line: linkPosition.start.line, + column: linkPosition.start.column - 1, + offset: adjustedEnd + ), + indent: pos.indent + ) + } + + return pos } /// Adjusts footnote definition position to include [^label]: prefix. @@ -101,4 +218,3 @@ extension CMNode { return pos } } - diff --git a/Sources/MarkdownSyntax/Markdown.swift b/Sources/MarkdownSyntax/Markdown.swift index 5b63a2e..2b95b5e 100644 --- a/Sources/MarkdownSyntax/Markdown.swift +++ b/Sources/MarkdownSyntax/Markdown.swift @@ -92,13 +92,17 @@ public final actor Markdown { let url = node.linkUrl, let title = node.linkTitle, let children = parsePhrasingContent(node.children) as? [StaticPhrasingContent] else { break } - items.append(Link(url: url, title: title, children: children, position: position(for: node))) + let kind = node.linkSyntax(in: text, using: lineOffsets).kind + // An autolink is its own label, so it has none of its own. + let label = kind == .autolink ? nil : labelPosition(from: children.first?.position, to: children.last?.position) + items.append(Link(url: url, title: title, kind: kind, children: children, position: position(for: node), labelPosition: label)) case .image: guard let url = node.linkUrl, let title = node.linkTitle else { break } let children = parsePhrasingContent(node.children) let alt = node.getAll(where: { $0.type == .text }).compactMap({$0.literal}).joined(separator: "") - items.append(Image(url: url, title: title, alt: alt, children: children, position: position(for: node))) + let label = labelPosition(from: children.first?.position, to: children.last?.position) + items.append(Image(url: url, title: title, alt: alt, children: children, position: position(for: node), labelPosition: label)) case .footnoteReference: guard let value = node.literal else { break } @@ -203,10 +207,18 @@ public final actor Markdown { func position(for node: CMNode) -> Position { switch node.type { - case .link, .footnoteDefinition: + case .link, .footnoteDefinition, .text: return node.adjustedPosition(in: text, using: lineOffsets) default: return node.position(in: text, using: lineOffsets) } } + + /// Position spanning a node's children — the label between a link's or image's brackets. + /// + /// `nil` when there are no children, e.g. `[](url)`. + func labelPosition(from first: Position?, to last: Position?) -> Position? { + guard let first, let last else { return nil } + return Position(start: first.start, end: last.end, indent: nil) + } } diff --git a/Sources/MarkdownSyntax/Nodes/Image.swift b/Sources/MarkdownSyntax/Nodes/Image.swift index 739a435..f96a70b 100644 --- a/Sources/MarkdownSyntax/Nodes/Image.swift +++ b/Sources/MarkdownSyntax/Nodes/Image.swift @@ -12,14 +12,28 @@ public struct Image: StaticPhrasingContent, PhrasingContent, Parent, Resource, A public let url: URL public let title: String? public let alt: String? + public let children: [PhrasingContent] public let position: Position - public init(url: URL, title: String?, alt: String?, children: [PhrasingContent], position: Position) { + /// Position of the alt text between the brackets. + /// + /// A sub-range of ``position``. `nil` when the image has an empty label, such as `![](url)`. + public let labelPosition: Position? + + public init( + url: URL, + title: String?, + alt: String?, + children: [PhrasingContent], + position: Position, + labelPosition: Position? = nil + ) { self.url = url self.title = title self.alt = alt self.children = children self.position = position + self.labelPosition = labelPosition } } diff --git a/Sources/MarkdownSyntax/Nodes/Link.swift b/Sources/MarkdownSyntax/Nodes/Link.swift index 3bdda7f..9a6b001 100644 --- a/Sources/MarkdownSyntax/Nodes/Link.swift +++ b/Sources/MarkdownSyntax/Nodes/Link.swift @@ -11,13 +11,32 @@ import struct Foundation.URL public struct Link: PhrasingContent, Parent, Resource { public let url: URL public let title: String? + + /// How the link was written in the source. Derived from the source syntax. + public let kind: LinkKind + public let children: [StaticPhrasingContent] public let position: Position - public init(url: URL, title: String?, children: [StaticPhrasingContent], position: Position) { + /// Position of the text between the brackets — the part a reader reads. + /// + /// A sub-range of ``position``. `nil` when the link has no label of its own: an + /// autolink (`` or a bare GFM URL), or an empty label such as `[](url)`. + public let labelPosition: Position? + + public init( + url: URL, + title: String?, + kind: LinkKind = .inline, + children: [StaticPhrasingContent], + position: Position, + labelPosition: Position? = nil + ) { self.url = url self.title = title + self.kind = kind self.children = children self.position = position + self.labelPosition = labelPosition } } diff --git a/Sources/MarkdownSyntax/Types/LinkKind.swift b/Sources/MarkdownSyntax/Types/LinkKind.swift new file mode 100644 index 0000000..1d5884f --- /dev/null +++ b/Sources/MarkdownSyntax/Types/LinkKind.swift @@ -0,0 +1,25 @@ +// +// LinkKind.swift +// MarkdownSyntax +// +// Created by Heberti Almeida on 2026-09-09. +// Copyright © 2026 Heberti Almeida. All rights reserved. +// + +/// How a link was written in the source markdown. +/// +/// Determined from the **source syntax** at the node's own position, never guessed +/// from the destination string — `[harbor](#harbor)` is an inline link even though +/// its label is a suffix of its destination. +public enum LinkKind: String, Equatable, Sendable { + + /// `[label](destination)` — a label followed by a parenthesised destination. + case inline + + /// `[label][id]`, `[label][]`, or the shortcut form `[id]`. + case reference + + /// `` or a bare GFM URL such as `https://example.com`. + /// An autolink has no label of its own, so `Link.labelPosition` is `nil`. + case autolink +} diff --git a/Tests/MarkdownSyntaxTests/LinkLabelPositionTests.swift b/Tests/MarkdownSyntaxTests/LinkLabelPositionTests.swift new file mode 100644 index 0000000..507ab7d --- /dev/null +++ b/Tests/MarkdownSyntaxTests/LinkLabelPositionTests.swift @@ -0,0 +1,262 @@ +import XCTest +@testable import MarkdownSyntax + +/// Positions and source classification for links and images. +/// +/// Covers three regressions from the swift-cmark migration: +/// 1. an inline link whose label is a suffix of its destination was mistaken for a +/// GFM bare autolink and lost its opening `[`; +/// 2. angle autolinks were shifted a character to the right, dropping `<`; +/// 3. a bare autolink's own child, and the text run in front of it, kept stale +/// positions that overlapped the link. +final class LinkLabelPositionTests: XCTestCase { + + // MARK: Helpers + + private func paragraphChildren(_ input: String) async throws -> [PhrasingContent] { + let tree = try await Markdown(text: input).parse() + let paragraph = tree.children.first as? Paragraph + return paragraph?.children ?? [] + } + + private func slice(_ position: Position?, in input: String) -> String? { + position?.range.map { String(input[$0]) } + } + + // MARK: Bug 1 — a label that is a suffix of its destination is not an autolink + + func testLinkWithLabelMatchingFragmentKeepsOpeningBracket() async throws { + // given + let input = "go [harbor](#harbor) now" + + // when + let link = try await paragraphChildren(input)[1] as? Link + + // then + XCTAssertEqual(slice(link?.position, in: input), "[harbor](#harbor)") + XCTAssertEqual(link?.kind, .inline) + XCTAssertEqual(slice(link?.labelPosition, in: input), "harbor") + } + + func testLinkWithHyphenatedLabelMatchingFragmentKeepsOpeningBracket() async throws { + // given + let input = "go [chapter-two](#chapter-two) now" + + // when + let link = try await paragraphChildren(input)[1] as? Link + + // then + XCTAssertEqual(slice(link?.position, in: input), "[chapter-two](#chapter-two)") + XCTAssertEqual(link?.kind, .inline) + } + + func testLinkWithLabelSuffixOfDestinationKeepsOpeningBracket() async throws { + // given + let input = "go [example.com](https://example.com) now" + + // when + let link = try await paragraphChildren(input)[1] as? Link + + // then + XCTAssertEqual(slice(link?.position, in: input), "[example.com](https://example.com)") + XCTAssertEqual(link?.kind, .inline) + } + + func testLinkWithLabelThatIsNotASuffixIsUnaffected() async throws { + // given + let input = "go [the harbor](#harbor) now" + + // when + let link = try await paragraphChildren(input)[1] as? Link + + // then + XCTAssertEqual(slice(link?.position, in: input), "[the harbor](#harbor)") + XCTAssertEqual(slice(link?.labelPosition, in: input), "the harbor") + } + + // MARK: Bug 2 — angle autolinks keep both brackets + + func testAngleAutoLinkInsideParagraphKeepsBrackets() async throws { + // given + let input = "angle autolink" + + // when + let children = try await paragraphChildren(input) + let link = children[1] as? Link + + // then + XCTAssertEqual(slice(link?.position, in: input), "") + XCTAssertEqual(link?.kind, .autolink) + XCTAssertNil(link?.labelPosition) + XCTAssertEqual(slice(children[0].position, in: input), "angle ") + XCTAssertEqual(slice(children[2].position, in: input), " autolink") + } + + // MARK: Bug 3 — a bare autolink's child and preceding sibling + + func testBareAutoLinkChildAndSiblingDoNotOverlapTheLink() async throws { + // given + let input = "bare https://www.example.com autolink" + + // when + let children = try await paragraphChildren(input) + let link = children[1] as? Link + + // then + XCTAssertEqual(slice(link?.position, in: input), "https://www.example.com") + XCTAssertEqual(link?.kind, .autolink) + XCTAssertEqual(slice(children[0].position, in: input), "bare ") + XCTAssertEqual(slice(link?.children.first?.position, in: input), "https://www.example.com") + XCTAssertEqual(slice(children[2].position, in: input), " autolink") + } + + func testBareAutoLinkAfterInlineMarkupDoesNotOverlapTheLink() async throws { + // given + let input = "a **b** https://www.example.com c" + + // when + let children = try await paragraphChildren(input) + let link = children[3] as? Link + + // then + XCTAssertEqual(slice(children[2].position, in: input), " ") + XCTAssertEqual(slice(link?.position, in: input), "https://www.example.com") + XCTAssertEqual(slice(link?.children.first?.position, in: input), "https://www.example.com") + } + + func testWwwAutoLinkChildMatchesTheLink() async throws { + // given + let input = "testing www.example.com is a autolink" + + // when + let children = try await paragraphChildren(input) + let link = children[1] as? Link + + // then + XCTAssertEqual(slice(children[0].position, in: input), "testing ") + XCTAssertEqual(slice(link?.position, in: input), "www.example.com") + XCTAssertEqual(slice(link?.children.first?.position, in: input), "www.example.com") + } + + // MARK: labelPosition + + func testInlineLinkLabelPosition() async throws { + // given + let input = "[party](https://google.com)" + + // when + let link = try await paragraphChildren(input).first as? Link + + // then + XCTAssertEqual(slice(link?.labelPosition, in: input), "party") + } + + func testImageLabelPosition() async throws { + // given + let input = "![Alice](000003.png)" + + // when + let image = try await paragraphChildren(input).first as? Image + + // then + XCTAssertEqual(slice(image?.labelPosition, in: input), "Alice") + } + + func testLabelPositionSpansNestedMarkup() async throws { + // given + let input = "[**bold** label](u)" + + // when + let link = try await paragraphChildren(input).first as? Link + + // then + XCTAssertEqual(slice(link?.labelPosition, in: input), "**bold** label") + } + + func testEmptyLabelHasNoPosition() async throws { + // given + let input = "[](u)" + + // when + let link = try await paragraphChildren(input).first as? Link + + // then + XCTAssertNotNil(link) + XCTAssertNil(link?.labelPosition) + } + + func testEmptyImageLabelHasNoPosition() async throws { + // given + let input = "![](u)" + + // when + let image = try await paragraphChildren(input).first as? Image + + // then + XCTAssertNotNil(image) + XCTAssertNil(image?.labelPosition) + } + + func testAngleAutoLinkHasNoLabelPosition() async throws { + // given + let input = "" + + // when + let link = try await paragraphChildren(input).first as? Link + + // then + XCTAssertEqual(link?.kind, .autolink) + XCTAssertNil(link?.labelPosition) + } + + func testBareAutoLinkHasNoLabelPosition() async throws { + // given + let input = "bare https://www.example.com autolink" + + // when + let link = try await paragraphChildren(input)[1] as? Link + + // then + XCTAssertEqual(link?.kind, .autolink) + XCTAssertNil(link?.labelPosition) + } + + // MARK: kind + + func testInlineLinkKind() async throws { + // given + let input = "[a](u)" + + // when + let link = try await paragraphChildren(input).first as? Link + + // then + XCTAssertEqual(link?.kind, .inline) + } + + func testFullReferenceLinkKind() async throws { + // given + let input = "[a][id]\n\n[id]: https://example.com" + + // when + let link = try await paragraphChildren(input).first as? Link + + // then + XCTAssertEqual(link?.kind, .reference) + XCTAssertEqual(slice(link?.position, in: input), "[a][id]") + XCTAssertEqual(slice(link?.labelPosition, in: input), "a") + } + + func testShortcutReferenceLinkKind() async throws { + // given + let input = "[id]\n\n[id]: https://example.com" + + // when + let link = try await paragraphChildren(input).first as? Link + + // then + XCTAssertEqual(link?.kind, .reference) + XCTAssertEqual(slice(link?.position, in: input), "[id]") + XCTAssertEqual(slice(link?.labelPosition, in: input), "id") + } +}