Skip to content

Fix Directive argument name and value ranges on non-first line #79

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 1, 2024
33 changes: 12 additions & 21 deletions Sources/Markdown/Base/DirectiveArgument.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
Expand Down 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,7 +191,8 @@ public struct DirectiveArgumentText: Equatable {
var line = TrimmedLine(untrimmedText[...],
source: range?.lowerBound.source,
lineNumber: range?.lowerBound.line,
parseIndex: parseIndex)
parseIndex: parseIndex
)
line.lexWhitespace()
while !line.isEmptyOrAllWhitespace {
let name: TrimmedLine.Lex?
Expand Down Expand Up @@ -283,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
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2021 Apple Inc. and the Swift project authors
Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
Expand Down 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
47 changes: 30 additions & 17 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``
/// - 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 = 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 = (self.lineNumber ?? 1) == 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,30 @@ 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
172 changes: 172 additions & 0 deletions Tests/MarkdownTests/Parsing/BlockDirectiveParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,178 @@ class BlockDirectiveArgumentParserTests: XCTestCase {
"""
XCTAssertEqual(expected, documentation.debugDescription())
}

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: """

@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: """

@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)
"""
)
}

// FIXME: swift-testing macro for specifying the relationship between a bug and a test
// Uncomment the following code when we integrate swift-testing
Expand Down