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
32 changes: 11 additions & 21 deletions Sources/Markdown/Base/DirectiveArgument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ public struct DirectiveArgumentText: Equatable {

/// A segment of a line of argument text.
public struct LineSegment: Equatable {
/// The segment's untrimmed text from which arguments can be parsed.
/// The original untrimmed text of the line, from which arguments can be parsed.
public var untrimmedText: String

/// The index in ``untrimmedText`` where the line started.
public var lineStartIndex: String.Index
@available(*, deprecated, renamed: "untrimmedText.startIndex")
public var lineStartIndex: String.Index {
get { untrimmedText.startIndex }
set { }
}

/// The index from which parsing should start.
public var parseIndex: String.Index
Expand All @@ -64,28 +67,15 @@ public struct DirectiveArgumentText: Equatable {
/// Create an argument line segment.
/// - Parameters:
/// - untrimmedText: the segment's untrimmed text from which arguments can be parsed.
/// - lineStartIndex: the index in ``text`` where the line started.
/// - parseIndex: index from which parsing should start.
/// - parseIndex: The index from which parsing should start.
/// - range: The range from which a segment was extracted from a line
/// of source, or `nil` if the argument text was provided by other means.
init(untrimmedText: String, lineStartIndex: String.Index, parseIndex: String.Index? = nil, range: SourceRange? = nil) {
init(untrimmedText: String, parseIndex: String.Index? = nil, range: SourceRange? = nil) {
self.untrimmedText = untrimmedText
self.lineStartIndex = lineStartIndex
self.parseIndex = parseIndex ?? untrimmedText.startIndex
self.range = range
}

/// Returns a Boolean value indicating whether two line segments are equal.
/// - Parameter lhs: a line segment to compare
/// - Parameter rhs: another line segment to compare
/// - Returns: `true` if the two segments are equal.
public static func ==(lhs: LineSegment, rhs: LineSegment) -> Bool {
return lhs.untrimmedText == rhs.untrimmedText &&
lhs.lineStartIndex == rhs.lineStartIndex &&
lhs.parseIndex == rhs.parseIndex &&
lhs.range == rhs.range
}

/// Parse a quoted literal.
///
/// ```
Expand Down Expand Up @@ -201,8 +191,8 @@ public struct DirectiveArgumentText: Equatable {
var line = TrimmedLine(untrimmedText[...],
source: range?.lowerBound.source,
lineNumber: range?.lowerBound.line,
parseIndex: parseIndex,
startParseIndex: untrimmedText.index(parseIndex, offsetBy: 1 - (range?.lowerBound.column ?? 1)))
parseIndex: parseIndex
)
line.lexWhitespace()
while !line.isEmptyOrAllWhitespace {
let name: TrimmedLine.Lex?
Expand Down Expand Up @@ -284,7 +274,7 @@ public struct DirectiveArgumentText: Equatable {
/// from a string.
public init<S: StringProtocol>(_ string: S) {
let text = String(string)
self.segments = [LineSegment(untrimmedText: text, lineStartIndex: text.startIndex, range: nil)]
self.segments = [LineSegment(untrimmedText: text, range: nil)]
}

/// Create a body of argument text from a sequence of ``LineSegment`` elements.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ public extension BlockDirective {
omittingEmptySubsequences: false).map { lineText -> DirectiveArgumentText.LineSegment in
let untrimmedText = String(lineText)
return DirectiveArgumentText.LineSegment(untrimmedText: untrimmedText,
lineStartIndex: untrimmedText.startIndex,
range: nil)
} ?? []
try! self.init(.blockDirective(name: name,
Expand Down
46 changes: 28 additions & 18 deletions Sources/Markdown/Parser/BlockDirectiveParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,6 @@ struct TrimmedLine {
/// The original untrimmed text of the line.
let untrimmedText: Substring

/// The starting parse index.
let startParseIndex: Substring.Index

/// The current index a parser is looking at on a line.
var parseIndex: Substring.Index

Expand All @@ -303,13 +300,16 @@ struct TrimmedLine {
}
}

/// - parameter untrimmedText: ``untrimmedText``
init(_ untrimmedText: Substring, source: URL?, lineNumber: Int?, parseIndex: Substring.Index? = nil, startParseIndex: Substring.Index? = nil) {
/// - Parameters:
/// - untrimmedText: The original untrimmed text of the line.
/// - source: The source file or resource from which the line came, or `nil` if no such file or resource can be identified.
/// - lineNumber: The line number of this line in the source if known, starting with `0`.
/// - parseIndex: The current index a parser is looking at on a line, or `nil` if a parser is looking at the start of the untrimmed text.
init(_ untrimmedText: Substring, source: URL?, lineNumber: Int?, parseIndex: Substring.Index? = nil) {
self.untrimmedText = untrimmedText
self.source = source
self.parseIndex = parseIndex ?? untrimmedText.startIndex
self.lineNumber = lineNumber
self.startParseIndex = startParseIndex ?? self.parseIndex
}

/// Return the UTF-8 source location of the parse index if the line
Expand All @@ -318,10 +318,7 @@ struct TrimmedLine {
guard let lineNumber = lineNumber else {
return nil
}
let startIndex = lineNumber == 1
? untrimmedText.startIndex
: startParseIndex
let alreadyParsedPrefix = untrimmedText[startIndex..<parseIndex]
let alreadyParsedPrefix = untrimmedText[..<parseIndex]
return .init(line: lineNumber, column: alreadyParsedPrefix.utf8.count + 1, source: source)
}

Expand Down Expand Up @@ -718,14 +715,27 @@ private enum ParseContainer: CustomStringConvertible {
let children = children.flatMap {
$0.convertToRawMarkup(ranges: &ranges, parent: self, options: options)
}
return [.blockDirective(name: String(pendingBlockDirective.name),
nameLocation: pendingBlockDirective.atLocation,
argumentText: DirectiveArgumentText(segments: pendingBlockDirective.argumentsText.map {
let untrimmedText = String($0.text.base[$0.text.base.startIndex..<$0.text.endIndex])
return DirectiveArgumentText.LineSegment(untrimmedText: untrimmedText, lineStartIndex: untrimmedText.startIndex, parseIndex: $0.text.startIndex, range: $0.range)
}),
parsedRange: pendingBlockDirective.atLocation..<pendingBlockDirective.endLocation,
children)]
return [.blockDirective(
name: String(pendingBlockDirective.name),
nameLocation: pendingBlockDirective.atLocation,
argumentText: DirectiveArgumentText(segments: pendingBlockDirective.argumentsText.map {
let base = $0.text.base
let lineStartIndex: String.Index
if let argumentRange = $0.range {
// If the argument has a known source range, offset the column (number of UTF8 bytes) to find the start of the line.
lineStartIndex = base.utf8.index($0.text.startIndex, offsetBy: 1 - argumentRange.lowerBound.column)
} else if let newLineIndex = base[..<$0.text.startIndex].lastIndex(where: \.isNewline) {
// Iterate backwards from the argument start index to find the the start of the line.
lineStartIndex = base.utf8.index(after: newLineIndex)
} else {
lineStartIndex = base.startIndex
}
let parseIndex = base.utf8.index($0.text.startIndex, offsetBy: -base.utf8.distance(from: base.startIndex, to: lineStartIndex))
let untrimmedLine = String(base[lineStartIndex..<$0.text.endIndex])
return DirectiveArgumentText.LineSegment(untrimmedText: untrimmedLine, parseIndex: parseIndex, range: $0.range)
}),
parsedRange: pendingBlockDirective.atLocation..<pendingBlockDirective.endLocation,
children)]
case let .doxygenCommand(pendingDoxygenCommand, lines):
let range = pendingDoxygenCommand.atLocation..<pendingDoxygenCommand.endLocation
ranges.add(range)
Expand Down
186 changes: 167 additions & 19 deletions Tests/MarkdownTests/Parsing/BlockDirectiveParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1042,29 +1042,177 @@ class BlockDirectiveArgumentParserTests: XCTestCase {
└─ Text "Line c This is a single-line comment"
"""
XCTAssertEqual(expected, documentation.debugDescription())
}

func testDirectiveArgumentOnNonfirstLineParsing() throws {
let source = """

@Options(scope: page)
"""
func testParsingDirectiveArgumentsWithWhitespaceBeforeDirective() throws {
struct ExpectedArgumentInfo {
var line: Int
let name: String
var nameRange: Range<Int>
let value: String
var valueRange: Range<Int>
}

func assertDirectiveArguments(
_ expectedArguments: ExpectedArgumentInfo...,
parsing content: String,
file: StaticString = #file,
line: UInt = #line
) throws {
func substring(with range: SourceRange) -> String {
let line = content.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)[range.lowerBound.line - 1]
let startIndex = line.utf8.index(line.utf8.startIndex, offsetBy: range.lowerBound.column - 1)
let endIndex = line.utf8.index(line.utf8.startIndex, offsetBy: range.upperBound.column - 1)
return String(line[startIndex ..< endIndex])
}

let source = URL(fileURLWithPath: "/test-file-location")
let document = Document(parsing: content, source: source, options: .parseBlockDirectives)
let directive = try XCTUnwrap(document.children.compactMap({ $0 as? BlockDirective }).first, file: file, line: line)
let arguments = directive.argumentText.parseNameValueArguments()
XCTAssertEqual(arguments.count, expectedArguments.count, file: file, line: line)
for expectedArgument in expectedArguments {
let argument = try XCTUnwrap(arguments[expectedArgument.name], file: file, line: line)

XCTAssertEqual(expectedArgument.name, argument.name, file: file, line: line)
XCTAssertEqual(
argument.nameRange,
SourceLocation(line: expectedArgument.line, column: expectedArgument.nameRange.lowerBound, source: source) ..< SourceLocation(line: expectedArgument.line, column: expectedArgument.nameRange.upperBound, source: source),
file: file,
line: line
)
XCTAssertEqual(expectedArgument.name, argument.nameRange.map(substring(with:)), file: file, line: line)

XCTAssertEqual(expectedArgument.value, argument.value, file: file, line: line)
XCTAssertEqual(
argument.valueRange,
SourceLocation(line: expectedArgument.line, column: expectedArgument.valueRange.lowerBound, source: source) ..< SourceLocation(line: expectedArgument.line, column: expectedArgument.valueRange.upperBound, source: source),
file: file,
line: line
)
XCTAssertEqual(expectedArgument.value, argument.valueRange.map(substring(with:)), file: file, line: line)
}
}

// One argument

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 1, name: "firstArgument", nameRange: 16 ..< 29, value: "firstValue", valueRange: 31 ..< 41),
parsing: "@DirectiveName(firstArgument: firstValue)"
)

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 2, name: "firstArgument", nameRange: 16 ..< 29, value: "firstValue", valueRange: 31 ..< 41),
parsing: """

@DirectiveName(firstArgument: firstValue)
"""
)

// Argument on single line

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 1, name: "firstArgument", nameRange: 17 ..< 30, value: "firstValue", valueRange: 31 ..< 41),
ExpectedArgumentInfo(line: 1, name: "secondArgument", nameRange: 44 ..< 58, value: "secondValue", valueRange: 62 ..< 73),
parsing: "@DirectiveName( firstArgument:firstValue ,\tsecondArgument: \t secondValue)"
)

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 2, name: "firstArgument", nameRange: 17 ..< 30, value: "firstValue", valueRange: 31 ..< 41),
ExpectedArgumentInfo(line: 2, name: "secondArgument", nameRange: 44 ..< 58, value: "secondValue", valueRange: 62 ..< 73),
parsing: """

let line = 2
let document = Document(parsing: source, options: .parseBlockDirectives)
let directive = try XCTUnwrap(document.child(at: 0) as? BlockDirective)
let arguments = directive.argumentText.parseNameValueArguments()
let scopeArg = try XCTUnwrap(arguments["scope"])

XCTAssertEqual("scope", scopeArg.name)
XCTAssertEqual(
scopeArg.nameRange,
SourceLocation(line: line, column: 10, source: nil) ..< SourceLocation(line: line, column: 15, source: nil)
@DirectiveName( firstArgument:firstValue ,\tsecondArgument: \t secondValue)
"""
)

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 2, name: "firstArgument", nameRange: 19 ..< 32, value: "firstValue", valueRange: 33 ..< 43),
ExpectedArgumentInfo(line: 2, name: "secondArgument", nameRange: 46 ..< 60, value: "secondValue", valueRange: 64 ..< 75),
parsing: """

XCTAssertEqual("page", scopeArg.value)
XCTAssertEqual(
scopeArg.valueRange,
SourceLocation(line: line, column: 17, source: nil) ..< SourceLocation(line: line, column: 21, source: nil)
@DirectiveName( firstArgument:firstValue ,\tsecondArgument: \t secondValue)
"""
)

// Second argument on new line

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 1, name: "firstArgument", nameRange: 17 ..< 30, value: "firstValue", valueRange: 31 ..< 41),
ExpectedArgumentInfo(line: 2, name: "secondArgument", nameRange: 16 ..< 30, value: "secondValue", valueRange: 34 ..< 45),
parsing: """
@DirectiveName( firstArgument:firstValue ,
secondArgument: \t secondValue)
"""
)

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 2, name: "firstArgument", nameRange: 17 ..< 30, value: "firstValue", valueRange: 31 ..< 41),
ExpectedArgumentInfo(line: 3, name: "secondArgument", nameRange: 16 ..< 30, value: "secondValue", valueRange: 34 ..< 45),
parsing: """

@DirectiveName( firstArgument:firstValue ,
secondArgument: \t secondValue)
"""
)

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 2, name: "firstArgument", nameRange: 19 ..< 32, value: "firstValue", valueRange: 33 ..< 43),
ExpectedArgumentInfo(line: 3, name: "secondArgument", nameRange: 18 ..< 32, value: "secondValue", valueRange: 36 ..< 47),
parsing: """

@DirectiveName( firstArgument:firstValue ,
secondArgument: \t secondValue)
"""
)

// Arguments on separate lines

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 2, name: "firstArgument", nameRange: 3 ..< 16, value: "firstValue", valueRange: 17 ..< 27),
ExpectedArgumentInfo(line: 3, name: "secondArgument", nameRange: 2 ..< 16, value: "secondValue", valueRange: 20 ..< 31),
parsing: """
@DirectiveName(
firstArgument:firstValue ,
\tsecondArgument: \t secondValue
)
"""
)

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 3, name: "firstArgument", nameRange: 3 ..< 16, value: "firstValue", valueRange: 17 ..< 27),
ExpectedArgumentInfo(line: 4, name: "secondArgument", nameRange: 2 ..< 16, value: "secondValue", valueRange: 20 ..< 31),
parsing: """

@DirectiveName(
firstArgument:firstValue ,
\tsecondArgument: \t secondValue
)
"""
)

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 3, name: "firstArgument", nameRange: 5 ..< 18, value: "firstValue", valueRange: 19 ..< 29),
ExpectedArgumentInfo(line: 4, name: "secondArgument", nameRange: 4 ..< 18, value: "secondValue", valueRange: 22 ..< 33),
parsing: """

@DirectiveName(
firstArgument:firstValue ,
\tsecondArgument: \t secondValue
)
"""
)

// Content and directives with emoji

try assertDirectiveArguments(
ExpectedArgumentInfo(line: 3, name: "firstArgument", nameRange: 20 ..< 33, value: "first💻Value", valueRange: 35 ..< 49),
ExpectedArgumentInfo(line: 3, name: "secondArgument", nameRange: 51 ..< 65, value: "secondValue", valueRange: 67 ..< 78),
parsing: """
Paragraph before with emoji: 💻

@Directive💻Name(firstArgument: first💻Value, secondArgument: secondValue)
"""
)
}

Expand Down