Skip to content

Commit b8dd2ea

Browse files
committed
Respect #sourceLocation directives in SourceLocationConverter
Add a `presumedFile` and `presumedLine` property to `SourceLocation` that contains the file and line of the location while taking `#sourceLocation` directives into account. The terms “presumed file” and “presumed line” have been taken from LLVM and the Swift compiler. rdar://99187174
1 parent 1076504 commit b8dd2ea

File tree

6 files changed

+328
-18
lines changed

6 files changed

+328
-18
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: 158 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,39 @@
1313
/// Represents a source location in a Swift file.
1414
public struct SourceLocation: Hashable, Codable, CustomDebugStringConvertible {
1515

16-
/// The UTF-8 byte offset into the file where this location resides.
17-
public let offset: Int
18-
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

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
48+
2949
/// Returns the location as `<line>:<column>` for debugging purposes.
3050
/// Do not rely on this output being stable.
3151
public var debugDescription: String {
@@ -47,11 +67,26 @@ public struct SourceLocation: Hashable, Codable, CustomDebugStringConvertible {
4767
/// location in the source file has `offset` 0.
4868
/// - file: A string describing the name of the file in which this location
4969
/// is contained.
50-
public init(line: Int, column: Int, offset: Int, file: String) {
70+
/// - presumedLine: If the location has been adjusted using `#sourceLocation`
71+
/// directives, the adjusted line. If `nil`, this defaults to
72+
/// `line`.
73+
/// - presumedFile: If the location has been adjusted using `#sourceLocation`
74+
/// directives, the adjusted file. If `nil`, this defaults to
75+
/// `file`.
76+
public init(
77+
line: Int,
78+
column: Int,
79+
offset: Int,
80+
file: String,
81+
presumedLine: Int? = nil,
82+
presumedFile: String? = nil
83+
) {
5184
self.line = line
5285
self.offset = offset
5386
self.column = column
5487
self.file = file
88+
self.presumedLine = presumedLine ?? line
89+
self.presumedFile = presumedFile ?? file
5590
}
5691
}
5792

@@ -83,18 +118,85 @@ public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible {
83118
}
84119
}
85120

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

184+
/// The information from all `#sourceLocation` directives in the file
185+
/// necessary to compute presumed locations.
186+
///
187+
/// - `sourceLine` is the line at which the `#sourceLocation` statement occurs
188+
/// within the current file.
189+
/// - `arguments` are the `file` and `line` arguments of the directive or `nil`
190+
/// if spelled as `#sourceLocation()` to reset the source location directive.
191+
private var sourceLocationDirectives: [(sourceLine: Int, arguments: SourceLocationDirectiveArguments?)] = []
192+
193+
/// Create a new ``SourceLocationConverter`` to convert betwen ``AbsolutePosition``
194+
/// and ``SourceLocation`` in a syntax tree.
195+
///
196+
/// This converter ignores any malformed `#sourceLocation` directives, e.g.
197+
/// `#sourceLocation` directives with a non-decimal line number or with a file
198+
/// name that contains string interpolation.
199+
///
98200
/// - Parameters:
99201
/// - file: The file path associated with the syntax tree.
100202
/// - tree: The root of the syntax tree to convert positions to line/columns for.
@@ -104,11 +206,29 @@ public final class SourceLocationConverter {
104206
self.source = tree.syntaxTextBytes
105207
(self.lines, endOfFile) = computeLines(tree: Syntax(tree))
106208
precondition(tree.byteSize == endOfFile.utf8Offset)
209+
210+
for directive in SourceLocationCollector.collectSourceLocations(in: tree) {
211+
let location = self.physicalLocation(for: directive.positionAfterSkippingLeadingTrivia)
212+
if let args = directive.args {
213+
if let parsedArgs = try? SourceLocationDirectiveArguments(args) {
214+
// Ignore any malformed `#sourceLocation` directives.
215+
sourceLocationDirectives.append((sourceLine: location.line, arguments: parsedArgs))
216+
}
217+
} else {
218+
// `#sourceLocation()` without any arguments resets the `#sourceLocation` directive.
219+
sourceLocationDirectives.append((sourceLine: location.line, arguments: nil))
220+
}
221+
}
107222
}
108223

224+
/// - Important: This initializer does not take `#sourceLocation` directives
225+
/// into account and doesn’t produce `presumedFile` and
226+
/// `presumedLine`.
227+
///
109228
/// - Parameters:
110229
/// - file: The file path associated with the syntax tree.
111230
/// - source: The source code to convert positions to line/columns for.
231+
@available(*, deprecated, message: "Use init(file:tree:) instead")
112232
public init(file: String, source: String) {
113233
self.file = file
114234
self.source = Array(source.utf8)
@@ -145,13 +265,40 @@ public final class SourceLocationConverter {
145265
}
146266
}
147267

148-
/// Convert a ``AbsolutePosition`` to a ``SourceLocation``. If the position is
268+
/// Convert a ``AbsolutePosition`` to a ``SourceLocation``.
269+
///
270+
/// If the position is exceeding the file length then the ``SourceLocation``
271+
/// for the end of file is returned. If position is negative the location for
272+
/// start of file is returned.
273+
public func location(for position: AbsolutePosition) -> SourceLocation {
274+
let physicalLocation = physicalLocation(for: position)
275+
if let lastSourceLocationDirective = sourceLocationDirectives.last(where: { $0.sourceLine < physicalLocation.line }),
276+
let arguments = lastSourceLocationDirective.arguments
277+
{
278+
let presumedLine = arguments.line + physicalLocation.line - lastSourceLocationDirective.sourceLine - 1
279+
return SourceLocation(
280+
line: physicalLocation.line,
281+
column: physicalLocation.column,
282+
offset: physicalLocation.offset,
283+
file: physicalLocation.file,
284+
presumedLine: presumedLine,
285+
presumedFile: arguments.file
286+
)
287+
}
288+
289+
return physicalLocation
290+
}
291+
292+
/// Compute the location of `position` without taking `#sourceLocation`
293+
/// directives into account.
294+
///
295+
/// If the position is
149296
/// exceeding the file length then the ``SourceLocation`` for the end of file
150297
/// is returned. If position is negative the location for start of file is
151298
/// returned.
152-
public func location(for origpos: AbsolutePosition) -> SourceLocation {
299+
private func physicalLocation(for position: AbsolutePosition) -> SourceLocation {
153300
// Clamp the given position to the end of file if needed.
154-
let pos = min(origpos, endOfFile)
301+
let pos = min(position, endOfFile)
155302
if pos.utf8Offset < 0 {
156303
return SourceLocation(line: 1, column: 1, offset: 0, file: self.file)
157304
}

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
@@ -331,7 +331,7 @@ func assertLocation<T: SyntaxProtocol>(
331331
line: UInt = #line
332332
) {
333333
if let markerLoc = markerLocations[locationMarker] {
334-
let locationConverter = SourceLocationConverter(file: "", source: tree.description)
334+
let locationConverter = SourceLocationConverter(file: "", tree: tree)
335335
let actualLocation = location
336336
let expectedLocation = locationConverter.location(for: AbsolutePosition(utf8Offset: markerLoc))
337337
if actualLocation.line != expectedLocation.line || actualLocation.column != expectedLocation.column {
@@ -355,7 +355,7 @@ func assertNote<T: SyntaxProtocol>(
355355
expected spec: NoteSpec
356356
) {
357357
XCTAssertEqual(note.message, spec.message, file: spec.file, line: spec.line)
358-
let locationConverter = SourceLocationConverter(file: "", source: tree.description)
358+
let locationConverter = SourceLocationConverter(file: "", tree: tree)
359359
assertLocation(
360360
note.location(converter: locationConverter),
361361
in: tree,
@@ -374,7 +374,7 @@ func assertDiagnostic<T: SyntaxProtocol>(
374374
markerLocations: [String: Int],
375375
expected spec: DiagnosticSpec
376376
) {
377-
let locationConverter = SourceLocationConverter(file: "", source: tree.description)
377+
let locationConverter = SourceLocationConverter(file: "", tree: tree)
378378
assertLocation(
379379
diag.location(converter: locationConverter),
380380
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)