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
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
</BuildableReference>
<SkippedTests>
<Test
Identifier = "ContentBlockPositionTests/testHTMLCommentPosition()">
Identifier = "ParserInlineTests/testInvalidLink()">
Copy link
Owner Author

@hebertialmeida hebertialmeida Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If swiftlang/swift-cmark#84 gets merged, add this test back into the suite.

</Test>
</SkippedTests>
</TestableReference>
Expand Down
16 changes: 0 additions & 16 deletions Package.resolved

This file was deleted.

10 changes: 8 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ let package = Package(
.library(name: "MarkdownSyntax", targets: ["MarkdownSyntax"]),
],
dependencies: [
.package(url: "https://github.com/hebertialmeida/swift-cmark-gfm", .upToNextMajor(from: "1.1.0"))
.package(url: "https://github.com/swiftlang/swift-cmark", .upToNextMajor(from: "0.7.1"))
],
targets: [
.target(name: "MarkdownSyntax", dependencies: [.product(name: "cmark_gfm", package: "swift-cmark-gfm")]),
.target(
name: "MarkdownSyntax",
dependencies: [
.product(name: "cmark-gfm", package: "swift-cmark"),
.product(name: "cmark-gfm-extensions", package: "swift-cmark"),
]
),
.testTarget(name: "MarkdownSyntaxTests", dependencies: ["MarkdownSyntax"]),
]
)
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,11 @@ Once you have your Swift package set up, adding MarkdownSyntax as a dependency i

```swift
dependencies: [
.package(url: "https://github.com/hebertialmeida/MarkdownSyntax", from: "1.2.1")
.package(url: "https://github.com/hebertialmeida/MarkdownSyntax", from: "1.3.0")
]
```

### Acknowledgements

- [cmark](https://github.com/commonmark/cmark)
- [GitHub cmark fork](https://github.com/github/cmark)
- [libcmark_gfm](https://github.com/KristopherGBaker/libcmark_gfm)
- [swift-cmark](https://github.com/swiftlang/swift-cmark)
- [libcmark_gfm](https://github.com/KristopherGBaker/libcmark_gfm)
1 change: 1 addition & 0 deletions Sources/MarkdownSyntax/CMark/CMDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import struct Foundation.Data
import cmark_gfm
import cmark_gfm_extensions

/// Represents a cmark document error.
public enum CMDocumentError: Error {
Expand Down
1 change: 1 addition & 0 deletions Sources/MarkdownSyntax/CMark/CMNode+ASTManipulation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import struct Foundation.URL
import cmark_gfm
import cmark_gfm_extensions

/// Extension for manipulating ndoe values and the Abstract Syntax Tree
public extension CMNode {
Expand Down
9 changes: 2 additions & 7 deletions Sources/MarkdownSyntax/CMark/CMNode+Position.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,8 @@
extension CMNode {

func position(in text: String, using lineOffsets: [String.Index]) -> Position {
let startLine = Int(self.startLine)
let startColumn = Int(self.startColumn)
let endLine = Int(self.endLine)
let endColumn = Int(self.endColumn)

var startPoint = Point(line: startLine, column: startColumn, offset: nil)
var endPoint = Point(line: endLine, column: endColumn, offset: nil)
var startPoint = Point(line: startLine, column: startColumn - backtickCount, offset: nil)
var endPoint = Point(line: endLine, column: endColumn + backtickCount, offset: nil)

func index(of point: Point) -> String.Index {
let line = point.line > 0 ? point.line-1 : point.line
Expand Down
104 changes: 104 additions & 0 deletions Sources/MarkdownSyntax/CMark/CMNode+PositionAdjustment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//
// CMNode+PositionAdjustment.swift
// MarkdownSyntax
//
// Created for swift-cmark migration compatibility
//
// 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
//
// 2. Footnote definitions: Positions now exclude the [^label]: prefix
//

extension CMNode {

/// Adjusts position to restore syntax delimiters that swift-cmark now excludes.
///
/// - Parameters:
/// - text: The source markdown text
/// - lineOffsets: Line offset indices
/// - Returns: Position adjusted to include syntax delimiters
func adjustedPosition(in text: String, using lineOffsets: [String.Index]) -> Position {
let pos = position(in: text, using: lineOffsets)

guard let startOffset = pos.start.offset,
let endOffset = pos.end.offset,
startOffset >= text.startIndex,
endOffset < text.endIndex else {
return pos
}

switch type {
case .link where isAutolink():
return adjustAutolinkPosition(pos, in: text, startOffset: startOffset, endOffset: endOffset)

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(
_ pos: Position,
in text: String,
startOffset: String.Index,
endOffset: String.Index
) -> Position {
guard startOffset > text.startIndex else {
return pos
}

// Fix off-by-one for GFM bare URLs: position includes character before URL
let adjustedStart = text.utf8.index(startOffset, offsetBy: 1, limitedBy: endOffset) ?? startOffset
return Position(
start: Point(line: pos.start.line, column: pos.start.column + 1, offset: adjustedStart),
end: pos.end,
indent: pos.indent
)
}

/// 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
}

// GFM expands www.example.com to http://www.example.com
return childText == linkURLString || linkURLString.hasSuffix(childText)
}

/// Adjusts footnote definition position to include [^label]: prefix.
/// Searches backwards up to 100 chars to find the footnote marker.
private func adjustFootnotePosition(
_ pos: Position,
in text: String,
startOffset: String.Index
) -> Position {
guard startOffset > text.startIndex else { return pos }

let searchLimit = text.utf8.index(startOffset, offsetBy: -100, limitedBy: text.startIndex) ?? text.startIndex
let searchRange = searchLimit..<startOffset

// Use native backward search for the [^ pattern
if let range = text.range(of: "[^", options: .backwards, range: searchRange) {
let distance = text.utf8.distance(from: range.lowerBound, to: startOffset)
return Position(
start: Point(line: pos.start.line, column: pos.start.column - distance, offset: range.lowerBound),
end: pos.end,
indent: pos.indent
)
}

return pos
}
}

1 change: 1 addition & 0 deletions Sources/MarkdownSyntax/CMark/CMNode+TableAlign.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import cmark_gfm
import cmark_gfm_extensions

extension CMNode {
func getTableAlignments() -> [AlignType] {
Expand Down
1 change: 1 addition & 0 deletions Sources/MarkdownSyntax/CMark/CMNode+Task.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import cmark_gfm
import cmark_gfm_extensions

/// Extension properties for tasklist items
public extension CMNode {
Expand Down
29 changes: 17 additions & 12 deletions Sources/MarkdownSyntax/CMark/CMNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ public extension CMNode {
}

/// The heading level.
var headingLevel: Int32 {
return cmark_node_get_heading_level(cmarkNode)
var headingLevel: Int {
Int(cmark_node_get_heading_level(cmarkNode))
}

/// The fenced code info.
Expand Down Expand Up @@ -190,8 +190,8 @@ public extension CMNode {
}

/// The list starting number.
var listStartingNumber: Int32 {
return cmark_node_get_list_start(cmarkNode)
var listStartingNumber: Int {
Int(cmark_node_get_list_start(cmarkNode))
}

/// The list tight.
Expand Down Expand Up @@ -236,23 +236,28 @@ public extension CMNode {
}

/// The start line.
var startLine: Int32 {
return cmark_node_get_start_line(cmarkNode)
var startLine: Int {
Int(cmark_node_get_start_line(cmarkNode))
}

/// The start column.
var startColumn: Int32 {
return cmark_node_get_start_column(cmarkNode)
var startColumn: Int {
Int(cmark_node_get_start_column(cmarkNode))
}

/// The end line.
var endLine: Int32 {
return cmark_node_get_end_line(cmarkNode)
var endLine: Int {
Int(cmark_node_get_end_line(cmarkNode))
}

/// The end column.
var endColumn: Int32 {
return cmark_node_get_end_column(cmarkNode)
var endColumn: Int {
Int(cmark_node_get_end_column(cmarkNode))
}

/// Backtick count for code.
var backtickCount: Int {
Int(cmark_node_get_backtick_count(cmarkNode))
}

/// Returns an iterator for the node.
Expand Down
10 changes: 5 additions & 5 deletions Sources/MarkdownSyntax/CMark/CMNodeType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public enum CMNodeExtensionType: Equatable, Sendable {
///
/// But Swift 6 strict concurrency complains about that.
private struct CMarkConstants {
static let strikethrough: UInt32 = 49164 // 0xBFFC
static let strikethrough: UInt32 = 49165 // 0xBFFD
static let table: UInt32 = 32780 // 0x800C
static let tableRow: UInt32 = 32781 // 0x800D
static let tableCell: UInt32 = 32782 // 0x800E
Expand All @@ -48,13 +48,13 @@ public enum CMNodeExtensionType: Equatable, Sendable {

init(rawValue: UInt32) {
switch rawValue {
case CMARK_NODE_STRIKETHROUGH.rawValue:
case CMarkConstants.strikethrough:
self = .strikethrough
case CMARK_NODE_TABLE.rawValue:
case CMarkConstants.table:
self = .table
case CMARK_NODE_TABLE_ROW.rawValue:
case CMarkConstants.tableRow:
self = .tableRow
case CMARK_NODE_TABLE_CELL.rawValue:
case CMarkConstants.tableCell:
self = .tableCell
default:
self = .other(rawValue)
Expand Down
7 changes: 6 additions & 1 deletion Sources/MarkdownSyntax/Markdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ public final actor Markdown {
// MARK: Position

func position(for node: CMNode) -> Position {
node.position(in: text, using: lineOffsets)
switch node.type {
case .link, .footnoteDefinition:
return node.adjustedPosition(in: text, using: lineOffsets)
default:
return node.position(in: text, using: lineOffsets)
}
}
}
7 changes: 0 additions & 7 deletions Tests/MarkdownSyntaxTests/ContentBlockPositionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,8 @@ final class ContentBlockPositionTests: XCTestCase {
let range2 = input.range(116...127)

// then
// XCTAssertEqual(node?.position.range, range)
XCTAssertEqual(input[node!.position.range!], "[^1]: Here is the footnote.")
XCTAssertEqual(input[range], "[^1]:")
// XCTAssertEqual(node2?.position.range, range2)
XCTAssertEqual(input[node2!.position.range!], "[^longnote]: Here's one with multiple blocks.")
XCTAssertEqual(input[range2], "[^longnote]:")
}
Expand All @@ -175,11 +173,6 @@ final class ContentBlockPositionTests: XCTestCase {
}

func testHTMLCommentPosition() async throws {
// Because html comment is a inline element,
// something is causing the range to be wrong,
// check this later after cmark upgrade.
try XCTSkipIf(isCI)

// given
let input = "<!-- this -->\n"

Expand Down
3 changes: 3 additions & 0 deletions Tests/MarkdownSyntaxTests/ParserInlineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ final class ParserInlineTests: XCTestCase {
XCTAssertEqual(linkText?.value, "alpha")
}

// TODO: Enable this when https://github.com/swiftlang/swift-cmark/pull/84 is merged
// Fixes https://github.com/commonmark/commonmark.js/issues/177
func testInvalidLink() async throws {
try XCTSkipIf(isCI)

// given
let input = """
[link](/u(ri )
Expand Down