Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
154 changes: 135 additions & 19 deletions Sources/MarkdownSyntax/CMark/CMNode+PositionAdjustment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<https://example.com>`) 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
/// `<https://example.com>`
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:
Expand All @@ -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 <http://example.com> 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 `<http://example.com>` 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,
Expand All @@ -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.
Expand Down Expand Up @@ -101,4 +218,3 @@ extension CMNode {
return pos
}
}

18 changes: 15 additions & 3 deletions Sources/MarkdownSyntax/Markdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
}
}
16 changes: 15 additions & 1 deletion Sources/MarkdownSyntax/Nodes/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
21 changes: 20 additions & 1 deletion Sources/MarkdownSyntax/Nodes/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<url>` 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
}
}
25 changes: 25 additions & 0 deletions Sources/MarkdownSyntax/Types/LinkKind.swift
Original file line number Diff line number Diff line change
@@ -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

/// `<https://example.com>` 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
}
Loading
Loading