diff --git a/Package.swift b/Package.swift index 6156f22a0e6..86c1ab6fc4e 100644 --- a/Package.swift +++ b/Package.swift @@ -144,7 +144,7 @@ let package = Package( .testTarget( name: "SwiftSyntaxTest", - dependencies: ["_SwiftSyntaxTestSupport", "SwiftSyntax"] + dependencies: ["_SwiftSyntaxTestSupport", "SwiftSyntax", "SwiftSyntaxBuilder"] ), // MARK: SwiftSyntaxBuilder diff --git a/Sources/SwiftSyntax/SourceLocation.swift b/Sources/SwiftSyntax/SourceLocation.swift index 3dd1ace3943..1b9fcf56acb 100644 --- a/Sources/SwiftSyntax/SourceLocation.swift +++ b/Sources/SwiftSyntax/SourceLocation.swift @@ -11,27 +11,40 @@ //===----------------------------------------------------------------------===// /// Represents a source location in a Swift file. -public struct SourceLocation: Hashable, Codable, CustomDebugStringConvertible { - - /// The UTF-8 byte offset into the file where this location resides. - public let offset: Int +public struct SourceLocation: Hashable, Codable { /// The line in the file where this location resides. 1-based. + /// + /// ### See also + /// ``SourceLocation/presumedLine`` public var line: Int /// The UTF-8 byte offset from the beginning of the line where this location /// resides. 1-based. public let column: Int + /// The UTF-8 byte offset into the file where this location resides. + public let offset: Int + /// The file in which this location resides. + /// + /// ### See also + /// ``SourceLocation/presumedFile`` public let file: String - /// Returns the location as `:` for debugging purposes. - /// Do not rely on this output being stable. - public var debugDescription: String { - // Print file name? - return "\(line):\(column)" - } + /// The line of this location when respecting `#sourceLocation` directives. + /// + /// If the location hasn’t been adjusted using `#sourceLocation` directives, + /// this is the same as `line`. + public let presumedLine: Int + + /// The file in which the the location resides when respecting `#sourceLocation` + /// directives. + /// + /// If the location has been adjusted using `#sourceLocation` directives, this + /// is the file mentioned in the last `#sourceLocation` directive before this + /// location, otherwise this is the same as `file`. + public let presumedFile: String /// Create a new source location at the specified `line` and `column` in `file`. /// @@ -47,16 +60,31 @@ public struct SourceLocation: Hashable, Codable, CustomDebugStringConvertible { /// location in the source file has `offset` 0. /// - file: A string describing the name of the file in which this location /// is contained. - public init(line: Int, column: Int, offset: Int, file: String) { + /// - presumedLine: If the location has been adjusted using `#sourceLocation` + /// directives, the adjusted line. If `nil`, this defaults to + /// `line`. + /// - presumedFile: If the location has been adjusted using `#sourceLocation` + /// directives, the adjusted file. If `nil`, this defaults to + /// `file`. + public init( + line: Int, + column: Int, + offset: Int, + file: String, + presumedLine: Int? = nil, + presumedFile: String? = nil + ) { self.line = line self.offset = offset self.column = column self.file = file + self.presumedLine = presumedLine ?? line + self.presumedFile = presumedFile ?? file } } /// Represents a half-open range in a Swift file. -public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible { +public struct SourceRange: Hashable, Codable { /// The beginning location of the source range. /// @@ -69,12 +97,6 @@ public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible { /// ie. this location is not included in the range. public let end: SourceLocation - /// A description describing this range for debugging purposes, don't rely on - /// it being stable - public var debugDescription: String { - return "(\(start.debugDescription),\(end.debugDescription))" - } - /// Construct a new source range, starting at `start` (inclusive) and ending /// at `end` (exclusive). public init(start: SourceLocation, end: SourceLocation) { @@ -83,18 +105,85 @@ public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible { } } +/// Collects all `PoundSourceLocationSyntax` directives in a file. +fileprivate class SourceLocationCollector: SyntaxVisitor { + private var sourceLocationDirectives: [PoundSourceLocationSyntax] = [] + + override func visit(_ node: PoundSourceLocationSyntax) -> SyntaxVisitorContinueKind { + sourceLocationDirectives.append(node) + return .skipChildren + } + + static func collectSourceLocations(in tree: some SyntaxProtocol) -> [PoundSourceLocationSyntax] { + let collector = SourceLocationCollector(viewMode: .sourceAccurate) + collector.walk(tree) + return collector.sourceLocationDirectives + } +} + +fileprivate struct SourceLocationDirectiveArguments { + enum Error: Swift.Error, CustomStringConvertible { + case nonDecimalLineNumber(TokenSyntax) + case stringInterpolationInFileName(StringLiteralExprSyntax) + + var description: String { + switch self { + case .nonDecimalLineNumber(let token): + return "'\(token.text)' is not a decimal integer" + case .stringInterpolationInFileName(let stringLiteral): + return "The string literal '\(stringLiteral)' contains string interpolation, which is not allowed" + } + } + } + + /// The `file` argument of the `#sourceLocation` directive. + let file: String + + /// The `line` argument of the `#sourceLocation` directive. + let line: Int + + init(_ args: PoundSourceLocationArgsSyntax) throws { + guard args.fileName.segments.count == 1, + case .stringSegment(let segment) = args.fileName.segments.first! + else { + throw Error.stringInterpolationInFileName(args.fileName) + } + self.file = segment.content.text + guard let line = Int(args.lineNumber.text) else { + throw Error.nonDecimalLineNumber(args.lineNumber) + } + self.line = line + } +} + /// Converts ``AbsolutePosition``s of syntax nodes to ``SourceLocation``s, and /// vice-versa. The ``AbsolutePosition``s must be originating from nodes that are /// part of the same tree that was used to initialize this class. public final class SourceLocationConverter { - let file: String + private let file: String /// The source of the file, modelled as data so it can contain invalid UTF-8. - let source: [UInt8] + private let source: [UInt8] /// Array of lines and the position at the start of the line. - let lines: [AbsolutePosition] + private let lines: [AbsolutePosition] /// Position at end of file. - let endOfFile: AbsolutePosition + private let endOfFile: AbsolutePosition + /// The information from all `#sourceLocation` directives in the file + /// necessary to compute presumed locations. + /// + /// - `sourceLine` is the line at which the `#sourceLocation` statement occurs + /// within the current file. + /// - `arguments` are the `file` and `line` arguments of the directive or `nil` + /// if spelled as `#sourceLocation()` to reset the source location directive. + private var sourceLocationDirectives: [(sourceLine: Int, arguments: SourceLocationDirectiveArguments?)] = [] + + /// Create a new ``SourceLocationConverter`` to convert betwen ``AbsolutePosition`` + /// and ``SourceLocation`` in a syntax tree. + /// + /// This converter ignores any malformed `#sourceLocation` directives, e.g. + /// `#sourceLocation` directives with a non-decimal line number or with a file + /// name that contains string interpolation. + /// /// - Parameters: /// - file: The file path associated with the syntax tree. /// - tree: The root of the syntax tree to convert positions to line/columns for. @@ -104,11 +193,29 @@ public final class SourceLocationConverter { self.source = tree.syntaxTextBytes (self.lines, endOfFile) = computeLines(tree: Syntax(tree)) precondition(tree.byteSize == endOfFile.utf8Offset) + + for directive in SourceLocationCollector.collectSourceLocations(in: tree) { + let location = self.physicalLocation(for: directive.positionAfterSkippingLeadingTrivia) + if let args = directive.args { + if let parsedArgs = try? SourceLocationDirectiveArguments(args) { + // Ignore any malformed `#sourceLocation` directives. + sourceLocationDirectives.append((sourceLine: location.line, arguments: parsedArgs)) + } + } else { + // `#sourceLocation()` without any arguments resets the `#sourceLocation` directive. + sourceLocationDirectives.append((sourceLine: location.line, arguments: nil)) + } + } } + /// - Important: This initializer does not take `#sourceLocation` directives + /// into account and doesn’t produce `presumedFile` and + /// `presumedLine`. + /// /// - Parameters: /// - file: The file path associated with the syntax tree. /// - source: The source code to convert positions to line/columns for. + @available(*, deprecated, message: "Use init(file:tree:) instead") public init(file: String, source: String) { self.file = file self.source = Array(source.utf8) @@ -145,13 +252,40 @@ public final class SourceLocationConverter { } } - /// Convert a ``AbsolutePosition`` to a ``SourceLocation``. If the position is + /// Convert a ``AbsolutePosition`` to a ``SourceLocation``. + /// + /// If the position is exceeding the file length then the ``SourceLocation`` + /// for the end of file is returned. If position is negative the location for + /// start of file is returned. + public func location(for position: AbsolutePosition) -> SourceLocation { + let physicalLocation = physicalLocation(for: position) + if let lastSourceLocationDirective = sourceLocationDirectives.last(where: { $0.sourceLine < physicalLocation.line }), + let arguments = lastSourceLocationDirective.arguments + { + let presumedLine = arguments.line + physicalLocation.line - lastSourceLocationDirective.sourceLine - 1 + return SourceLocation( + line: physicalLocation.line, + column: physicalLocation.column, + offset: physicalLocation.offset, + file: physicalLocation.file, + presumedLine: presumedLine, + presumedFile: arguments.file + ) + } + + return physicalLocation + } + + /// Compute the location of `position` without taking `#sourceLocation` + /// directives into account. + /// + /// If the position is /// exceeding the file length then the ``SourceLocation`` for the end of file /// is returned. If position is negative the location for start of file is /// returned. - public func location(for origpos: AbsolutePosition) -> SourceLocation { + private func physicalLocation(for position: AbsolutePosition) -> SourceLocation { // Clamp the given position to the end of file if needed. - let pos = min(origpos, endOfFile) + let pos = min(position, endOfFile) if pos.utf8Offset < 0 { return SourceLocation(line: 1, column: 1, offset: 0, file: self.file) } diff --git a/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift b/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift index 2be2fbb1bed..1d57d5fadbf 100644 --- a/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift +++ b/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift @@ -65,7 +65,7 @@ func assertNote( expected spec: NoteSpec ) { assertStringsEqualWithDiff(note.message, spec.message, "message of note does not match", file: spec.originatorFile, line: spec.originatorLine) - let location = note.location(converter: SourceLocationConverter(file: "", source: tree.description)) + let location = note.location(converter: SourceLocationConverter(file: "", tree: tree)) XCTAssertEqual(location.line, spec.line, "line of note does not match", file: spec.originatorFile, line: spec.originatorLine) XCTAssertEqual(location.column, spec.column, "column of note does not match", file: spec.originatorFile, line: spec.originatorLine) } @@ -187,7 +187,7 @@ func assertDiagnostic( XCTAssertEqual(diag.diagnosticID, id, "diagnostic ID does not match", file: spec.originatorFile, line: spec.originatorLine) } assertStringsEqualWithDiff(diag.message, spec.message, "message does not match", file: spec.originatorFile, line: spec.originatorLine) - let location = diag.location(converter: SourceLocationConverter(file: "", source: tree.description)) + let location = diag.location(converter: SourceLocationConverter(file: "", tree: tree)) XCTAssertEqual(location.line, spec.line, "line does not match", file: spec.originatorFile, line: spec.originatorLine) XCTAssertEqual(location.column, spec.column, "column does not match", file: spec.originatorFile, line: spec.originatorLine) diff --git a/Tests/SwiftParserTest/Assertions.swift b/Tests/SwiftParserTest/Assertions.swift index dcef165bbca..315240c698d 100644 --- a/Tests/SwiftParserTest/Assertions.swift +++ b/Tests/SwiftParserTest/Assertions.swift @@ -331,7 +331,7 @@ func assertLocation( line: UInt = #line ) { if let markerLoc = markerLocations[locationMarker] { - let locationConverter = SourceLocationConverter(file: "", source: tree.description) + let locationConverter = SourceLocationConverter(file: "", tree: tree) let actualLocation = location let expectedLocation = locationConverter.location(for: AbsolutePosition(utf8Offset: markerLoc)) if actualLocation.line != expectedLocation.line || actualLocation.column != expectedLocation.column { @@ -355,7 +355,7 @@ func assertNote( expected spec: NoteSpec ) { XCTAssertEqual(note.message, spec.message, file: spec.file, line: spec.line) - let locationConverter = SourceLocationConverter(file: "", source: tree.description) + let locationConverter = SourceLocationConverter(file: "", tree: tree) assertLocation( note.location(converter: locationConverter), in: tree, @@ -374,7 +374,7 @@ func assertDiagnostic( markerLocations: [String: Int], expected spec: DiagnosticSpec ) { - let locationConverter = SourceLocationConverter(file: "", source: tree.description) + let locationConverter = SourceLocationConverter(file: "", tree: tree) assertLocation( diag.location(converter: locationConverter), in: tree, diff --git a/Tests/SwiftParserTest/DirectiveTests.swift b/Tests/SwiftParserTest/DirectiveTests.swift index 0953305254b..6f76a1d02de 100644 --- a/Tests/SwiftParserTest/DirectiveTests.swift +++ b/Tests/SwiftParserTest/DirectiveTests.swift @@ -111,6 +111,19 @@ final class DirectiveTests: XCTestCase { } """ ) + + assertParse( + """ + #sourceLocation(file: "f.swift", line: 1️⃣-1) + """, + diagnostics: [ + DiagnosticSpec(message: "expected line number in '#sourceLocation' arguments", fixIts: ["insert line number"]), + DiagnosticSpec(message: "unexpected code '-1' in '#sourceLocation' directive"), + ], + fixedSource: """ + #sourceLocation(file: "f.swift", line: <#integer literal#>-1) + """ + ) } public func testUnterminatedPoundIf() { diff --git a/Tests/SwiftSyntaxTest/SourceLocationConverterTests.swift b/Tests/SwiftSyntaxTest/SourceLocationConverterTests.swift index f1aad3eadb2..15d8aa451ff 100644 --- a/Tests/SwiftSyntaxTest/SourceLocationConverterTests.swift +++ b/Tests/SwiftSyntaxTest/SourceLocationConverterTests.swift @@ -10,8 +10,29 @@ // //===----------------------------------------------------------------------===// -import XCTest +import _SwiftSyntaxTestSupport @_spi(RawSyntax) import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest + +fileprivate func assertPresumedSourceLocation( + _ source: SourceFileSyntax, + inspectionItemFilter: (CodeBlockItemSyntax.Item) -> (some SyntaxProtocol)? = { $0.as(VariableDeclSyntax.self) }, + presumedFile: String, + presumedLine: Int, + file: StaticString = #file, + line: UInt = #line +) { + let converter = SourceLocationConverter(file: "input.swift", tree: source) + + guard let variableDecl = source.statements.compactMap({ inspectionItemFilter($0.item) }).first else { + XCTFail("Could not find a node that matches the `inspectionItemFilter` in `source`", file: file, line: line) + return + } + let location = converter.location(for: variableDecl.positionAfterSkippingLeadingTrivia) + XCTAssertEqual(presumedFile, location.presumedFile, "presumed file did not match", file: file, line: line) + XCTAssertEqual(presumedLine, location.presumedLine, "presumed line did not match", file: file, line: line) +} final class SourceLocationConverterTests: XCTestCase { func testInvalidUtf8() { @@ -43,4 +64,133 @@ final class SourceLocationConverterTests: XCTestCase { // ``` _ = SourceLocationConverter(file: "", tree: tree) } + + func testSingleSourceLocationDirective() { + assertPresumedSourceLocation( + """ + #sourceLocation(file: "other.swift", line: 1) + let a = 2 + """, + presumedFile: "other.swift", + presumedLine: 1 + ) + + assertPresumedSourceLocation( + """ + #sourceLocation(file: "other.swift", line: 3) + let a = 2 + """, + presumedFile: "other.swift", + presumedLine: 3 + ) + + assertPresumedSourceLocation( + """ + #sourceLocation(file: "other.swift", line: 4) + func foo() { + } + let a = 2 + """, + presumedFile: "other.swift", + presumedLine: 6 + ) + + assertPresumedSourceLocation( + """ + func foo() { + print(1) + } + #sourceLocation(file: "other.swift", line: 1) + let a = 2 + """, + presumedFile: "other.swift", + presumedLine: 1 + ) + } + + func testMultipleSourceLocationDirectives() { + assertPresumedSourceLocation( + """ + #sourceLocation(file: "other.swift", line: 10) + + let a = 2 + + #sourceLocation(file: "andAnother.swift", line: 20) + """, + presumedFile: "other.swift", + presumedLine: 11 + ) + + assertPresumedSourceLocation( + """ + #sourceLocation(file: "other.swift", line: 10) + + #sourceLocation(file: "andAnother.swift", line: 20) + + let a = 2 + """, + presumedFile: "andAnother.swift", + presumedLine: 21 + ) + } + + func testResetSourceLocationDirective() { + assertPresumedSourceLocation( + """ + #sourceLocation(file: "other.swift", line: 10) + + #sourceLocation() + + let a = 2 + """, + presumedFile: "input.swift", + presumedLine: 5 + ) + } + + func testHexLineNumber() { + // We ignore `#sourceLocation` directives with a non-decimal line number + assertPresumedSourceLocation( + """ + #sourceLocation(file: "other.swift", line: 0x10) + + let a = 2 + """, + presumedFile: "input.swift", + presumedLine: 3 + ) + } + + func testStringInterpolationInFilename() { + // We ignore `#sourceLocation` directives that have string interpolation in their file names + assertPresumedSourceLocation( + #""" + #sourceLocation(file: "other\(1).swift", line: 10) + + let a = 2 + """#, + presumedFile: "input.swift", + presumedLine: 3 + ) + } + + func testMultiLineStringLiteralAsFilename() { + // FIXME: The current parser handles this fine but it’s a really bogus filename. + // We ignore the directive because the multi-line string literal contains multiple segments. + // We should probably justs disallow multi-line string literals for the `file` argument. + // cf https://github.com/apple/swift-syntax/issues/1831 + assertPresumedSourceLocation( + #""" + #sourceLocation(file: """ + test.swift + other.swift + """, line: 10) + + let a = 2 + """#, + presumedFile: "input.swift", + presumedLine: 6 + ) + } + }