Skip to content

Commit dbd3e78

Browse files
authored
Merge pull request #1827 from ahoppen/ahoppen/respect-source-location
Respect `#sourceLocation` directives in `SourceLocationConverter`
2 parents 0fbe758 + 9c8e011 commit dbd3e78

File tree

6 files changed

+329
-32
lines changed

6 files changed

+329
-32
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ let package = Package(
144144

145145
.testTarget(
146146
name: "SwiftSyntaxTest",
147-
dependencies: ["_SwiftSyntaxTestSupport", "SwiftSyntax"]
147+
dependencies: ["_SwiftSyntaxTestSupport", "SwiftSyntax", "SwiftSyntaxBuilder"]
148148
),
149149

150150
// MARK: SwiftSyntaxBuilder

Sources/SwiftSyntax/SourceLocation.swift

Lines changed: 159 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,40 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
/// Represents a source location in a Swift file.
14-
public struct SourceLocation: Hashable, Codable, CustomDebugStringConvertible {
15-
16-
/// The UTF-8 byte offset into the file where this location resides.
17-
public let offset: Int
14+
public struct SourceLocation: Hashable, Codable {
1815

1916
/// The line in the file where this location resides. 1-based.
17+
///
18+
/// ### See also
19+
/// ``SourceLocation/presumedLine``
2020
public var line: Int
2121

2222
/// The UTF-8 byte offset from the beginning of the line where this location
2323
/// resides. 1-based.
2424
public let column: Int
2525

26+
/// The UTF-8 byte offset into the file where this location resides.
27+
public let offset: Int
28+
2629
/// The file in which this location resides.
30+
///
31+
/// ### See also
32+
/// ``SourceLocation/presumedFile``
2733
public let file: String
2834

29-
/// Returns the location as `<line>:<column>` for debugging purposes.
30-
/// Do not rely on this output being stable.
31-
public var debugDescription: String {
32-
// Print file name?
33-
return "\(line):\(column)"
34-
}
35+
/// The line of this location when respecting `#sourceLocation` directives.
36+
///
37+
/// If the location hasn’t been adjusted using `#sourceLocation` directives,
38+
/// this is the same as `line`.
39+
public let presumedLine: Int
40+
41+
/// The file in which the the location resides when respecting `#sourceLocation`
42+
/// directives.
43+
///
44+
/// If the location has been adjusted using `#sourceLocation` directives, this
45+
/// is the file mentioned in the last `#sourceLocation` directive before this
46+
/// location, otherwise this is the same as `file`.
47+
public let presumedFile: String
3548

3649
/// Create a new source location at the specified `line` and `column` in `file`.
3750
///
@@ -47,16 +60,31 @@ public struct SourceLocation: Hashable, Codable, CustomDebugStringConvertible {
4760
/// location in the source file has `offset` 0.
4861
/// - file: A string describing the name of the file in which this location
4962
/// is contained.
50-
public init(line: Int, column: Int, offset: Int, file: String) {
63+
/// - presumedLine: If the location has been adjusted using `#sourceLocation`
64+
/// directives, the adjusted line. If `nil`, this defaults to
65+
/// `line`.
66+
/// - presumedFile: If the location has been adjusted using `#sourceLocation`
67+
/// directives, the adjusted file. If `nil`, this defaults to
68+
/// `file`.
69+
public init(
70+
line: Int,
71+
column: Int,
72+
offset: Int,
73+
file: String,
74+
presumedLine: Int? = nil,
75+
presumedFile: String? = nil
76+
) {
5177
self.line = line
5278
self.offset = offset
5379
self.column = column
5480
self.file = file
81+
self.presumedLine = presumedLine ?? line
82+
self.presumedFile = presumedFile ?? file
5583
}
5684
}
5785

5886
/// Represents a half-open range in a Swift file.
59-
public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible {
87+
public struct SourceRange: Hashable, Codable {
6088

6189
/// The beginning location of the source range.
6290
///
@@ -69,12 +97,6 @@ public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible {
6997
/// ie. this location is not included in the range.
7098
public let end: SourceLocation
7199

72-
/// A description describing this range for debugging purposes, don't rely on
73-
/// it being stable
74-
public var debugDescription: String {
75-
return "(\(start.debugDescription),\(end.debugDescription))"
76-
}
77-
78100
/// Construct a new source range, starting at `start` (inclusive) and ending
79101
/// at `end` (exclusive).
80102
public init(start: SourceLocation, end: SourceLocation) {
@@ -83,18 +105,85 @@ public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible {
83105
}
84106
}
85107

108+
/// Collects all `PoundSourceLocationSyntax` directives in a file.
109+
fileprivate class SourceLocationCollector: SyntaxVisitor {
110+
private var sourceLocationDirectives: [PoundSourceLocationSyntax] = []
111+
112+
override func visit(_ node: PoundSourceLocationSyntax) -> SyntaxVisitorContinueKind {
113+
sourceLocationDirectives.append(node)
114+
return .skipChildren
115+
}
116+
117+
static func collectSourceLocations(in tree: some SyntaxProtocol) -> [PoundSourceLocationSyntax] {
118+
let collector = SourceLocationCollector(viewMode: .sourceAccurate)
119+
collector.walk(tree)
120+
return collector.sourceLocationDirectives
121+
}
122+
}
123+
124+
fileprivate struct SourceLocationDirectiveArguments {
125+
enum Error: Swift.Error, CustomStringConvertible {
126+
case nonDecimalLineNumber(TokenSyntax)
127+
case stringInterpolationInFileName(StringLiteralExprSyntax)
128+
129+
var description: String {
130+
switch self {
131+
case .nonDecimalLineNumber(let token):
132+
return "'\(token.text)' is not a decimal integer"
133+
case .stringInterpolationInFileName(let stringLiteral):
134+
return "The string literal '\(stringLiteral)' contains string interpolation, which is not allowed"
135+
}
136+
}
137+
}
138+
139+
/// The `file` argument of the `#sourceLocation` directive.
140+
let file: String
141+
142+
/// The `line` argument of the `#sourceLocation` directive.
143+
let line: Int
144+
145+
init(_ args: PoundSourceLocationArgsSyntax) throws {
146+
guard args.fileName.segments.count == 1,
147+
case .stringSegment(let segment) = args.fileName.segments.first!
148+
else {
149+
throw Error.stringInterpolationInFileName(args.fileName)
150+
}
151+
self.file = segment.content.text
152+
guard let line = Int(args.lineNumber.text) else {
153+
throw Error.nonDecimalLineNumber(args.lineNumber)
154+
}
155+
self.line = line
156+
}
157+
}
158+
86159
/// Converts ``AbsolutePosition``s of syntax nodes to ``SourceLocation``s, and
87160
/// vice-versa. The ``AbsolutePosition``s must be originating from nodes that are
88161
/// part of the same tree that was used to initialize this class.
89162
public final class SourceLocationConverter {
90-
let file: String
163+
private let file: String
91164
/// The source of the file, modelled as data so it can contain invalid UTF-8.
92-
let source: [UInt8]
165+
private let source: [UInt8]
93166
/// Array of lines and the position at the start of the line.
94-
let lines: [AbsolutePosition]
167+
private let lines: [AbsolutePosition]
95168
/// Position at end of file.
96-
let endOfFile: AbsolutePosition
169+
private let endOfFile: AbsolutePosition
97170

171+
/// The information from all `#sourceLocation` directives in the file
172+
/// necessary to compute presumed locations.
173+
///
174+
/// - `sourceLine` is the line at which the `#sourceLocation` statement occurs
175+
/// within the current file.
176+
/// - `arguments` are the `file` and `line` arguments of the directive or `nil`
177+
/// if spelled as `#sourceLocation()` to reset the source location directive.
178+
private var sourceLocationDirectives: [(sourceLine: Int, arguments: SourceLocationDirectiveArguments?)] = []
179+
180+
/// Create a new ``SourceLocationConverter`` to convert betwen ``AbsolutePosition``
181+
/// and ``SourceLocation`` in a syntax tree.
182+
///
183+
/// This converter ignores any malformed `#sourceLocation` directives, e.g.
184+
/// `#sourceLocation` directives with a non-decimal line number or with a file
185+
/// name that contains string interpolation.
186+
///
98187
/// - Parameters:
99188
/// - file: The file path associated with the syntax tree.
100189
/// - tree: The root of the syntax tree to convert positions to line/columns for.
@@ -104,11 +193,29 @@ public final class SourceLocationConverter {
104193
self.source = tree.syntaxTextBytes
105194
(self.lines, endOfFile) = computeLines(tree: Syntax(tree))
106195
precondition(tree.byteSize == endOfFile.utf8Offset)
196+
197+
for directive in SourceLocationCollector.collectSourceLocations(in: tree) {
198+
let location = self.physicalLocation(for: directive.positionAfterSkippingLeadingTrivia)
199+
if let args = directive.args {
200+
if let parsedArgs = try? SourceLocationDirectiveArguments(args) {
201+
// Ignore any malformed `#sourceLocation` directives.
202+
sourceLocationDirectives.append((sourceLine: location.line, arguments: parsedArgs))
203+
}
204+
} else {
205+
// `#sourceLocation()` without any arguments resets the `#sourceLocation` directive.
206+
sourceLocationDirectives.append((sourceLine: location.line, arguments: nil))
207+
}
208+
}
107209
}
108210

211+
/// - Important: This initializer does not take `#sourceLocation` directives
212+
/// into account and doesn’t produce `presumedFile` and
213+
/// `presumedLine`.
214+
///
109215
/// - Parameters:
110216
/// - file: The file path associated with the syntax tree.
111217
/// - source: The source code to convert positions to line/columns for.
218+
@available(*, deprecated, message: "Use init(file:tree:) instead")
112219
public init(file: String, source: String) {
113220
self.file = file
114221
self.source = Array(source.utf8)
@@ -145,13 +252,40 @@ public final class SourceLocationConverter {
145252
}
146253
}
147254

148-
/// Convert a ``AbsolutePosition`` to a ``SourceLocation``. If the position is
255+
/// Convert a ``AbsolutePosition`` to a ``SourceLocation``.
256+
///
257+
/// If the position is exceeding the file length then the ``SourceLocation``
258+
/// for the end of file is returned. If position is negative the location for
259+
/// start of file is returned.
260+
public func location(for position: AbsolutePosition) -> SourceLocation {
261+
let physicalLocation = physicalLocation(for: position)
262+
if let lastSourceLocationDirective = sourceLocationDirectives.last(where: { $0.sourceLine < physicalLocation.line }),
263+
let arguments = lastSourceLocationDirective.arguments
264+
{
265+
let presumedLine = arguments.line + physicalLocation.line - lastSourceLocationDirective.sourceLine - 1
266+
return SourceLocation(
267+
line: physicalLocation.line,
268+
column: physicalLocation.column,
269+
offset: physicalLocation.offset,
270+
file: physicalLocation.file,
271+
presumedLine: presumedLine,
272+
presumedFile: arguments.file
273+
)
274+
}
275+
276+
return physicalLocation
277+
}
278+
279+
/// Compute the location of `position` without taking `#sourceLocation`
280+
/// directives into account.
281+
///
282+
/// If the position is
149283
/// exceeding the file length then the ``SourceLocation`` for the end of file
150284
/// is returned. If position is negative the location for start of file is
151285
/// returned.
152-
public func location(for origpos: AbsolutePosition) -> SourceLocation {
286+
private func physicalLocation(for position: AbsolutePosition) -> SourceLocation {
153287
// Clamp the given position to the end of file if needed.
154-
let pos = min(origpos, endOfFile)
288+
let pos = min(position, endOfFile)
155289
if pos.utf8Offset < 0 {
156290
return SourceLocation(line: 1, column: 1, offset: 0, file: self.file)
157291
}

Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func assertNote(
6565
expected spec: NoteSpec
6666
) {
6767
assertStringsEqualWithDiff(note.message, spec.message, "message of note does not match", file: spec.originatorFile, line: spec.originatorLine)
68-
let location = note.location(converter: SourceLocationConverter(file: "", source: tree.description))
68+
let location = note.location(converter: SourceLocationConverter(file: "", tree: tree))
6969
XCTAssertEqual(location.line, spec.line, "line of note does not match", file: spec.originatorFile, line: spec.originatorLine)
7070
XCTAssertEqual(location.column, spec.column, "column of note does not match", file: spec.originatorFile, line: spec.originatorLine)
7171
}
@@ -187,7 +187,7 @@ func assertDiagnostic(
187187
XCTAssertEqual(diag.diagnosticID, id, "diagnostic ID does not match", file: spec.originatorFile, line: spec.originatorLine)
188188
}
189189
assertStringsEqualWithDiff(diag.message, spec.message, "message does not match", file: spec.originatorFile, line: spec.originatorLine)
190-
let location = diag.location(converter: SourceLocationConverter(file: "", source: tree.description))
190+
let location = diag.location(converter: SourceLocationConverter(file: "", tree: tree))
191191
XCTAssertEqual(location.line, spec.line, "line does not match", file: spec.originatorFile, line: spec.originatorLine)
192192
XCTAssertEqual(location.column, spec.column, "column does not match", file: spec.originatorFile, line: spec.originatorLine)
193193

Tests/SwiftParserTest/Assertions.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ func assertLocation<T: SyntaxProtocol>(
333333
line: UInt = #line
334334
) {
335335
if let markerLoc = markerLocations[locationMarker] {
336-
let locationConverter = SourceLocationConverter(file: "", source: tree.description)
336+
let locationConverter = SourceLocationConverter(file: "", tree: tree)
337337
let actualLocation = location
338338
let expectedLocation = locationConverter.location(for: AbsolutePosition(utf8Offset: markerLoc))
339339
if actualLocation.line != expectedLocation.line || actualLocation.column != expectedLocation.column {
@@ -357,7 +357,7 @@ func assertNote<T: SyntaxProtocol>(
357357
expected spec: NoteSpec
358358
) {
359359
XCTAssertEqual(note.message, spec.message, file: spec.file, line: spec.line)
360-
let locationConverter = SourceLocationConverter(file: "", source: tree.description)
360+
let locationConverter = SourceLocationConverter(file: "", tree: tree)
361361
assertLocation(
362362
note.location(converter: locationConverter),
363363
in: tree,
@@ -376,7 +376,7 @@ func assertDiagnostic<T: SyntaxProtocol>(
376376
markerLocations: [String: Int],
377377
expected spec: DiagnosticSpec
378378
) {
379-
let locationConverter = SourceLocationConverter(file: "", source: tree.description)
379+
let locationConverter = SourceLocationConverter(file: "", tree: tree)
380380
assertLocation(
381381
diag.location(converter: locationConverter),
382382
in: tree,

Tests/SwiftParserTest/DirectiveTests.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ final class DirectiveTests: XCTestCase {
111111
}
112112
"""
113113
)
114+
115+
assertParse(
116+
"""
117+
#sourceLocation(file: "f.swift", line: 1️⃣-1)
118+
""",
119+
diagnostics: [
120+
DiagnosticSpec(message: "expected line number in '#sourceLocation' arguments", fixIts: ["insert line number"]),
121+
DiagnosticSpec(message: "unexpected code '-1' in '#sourceLocation' directive"),
122+
],
123+
fixedSource: """
124+
#sourceLocation(file: "f.swift", line: <#integer literal#>-1)
125+
"""
126+
)
114127
}
115128

116129
public func testUnterminatedPoundIf() {

0 commit comments

Comments
 (0)