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
2 changes: 2 additions & 0 deletions Plugins/BridgeJS/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ let package = Package(
"BridgeJSCore",
"BridgeJSLink",
"TS2Swift",
.product(name: "SwiftParser", package: "swift-syntax"),
.product(name: "SwiftSyntax", package: "swift-syntax"),
],
exclude: ["__Snapshots__", "Inputs"]
),
Expand Down
150 changes: 144 additions & 6 deletions Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ private enum JSON {
// MARK: - DiagnosticError

import SwiftSyntax
import class Foundation.ProcessInfo

struct DiagnosticError: Error {
let node: Syntax
Expand All @@ -146,17 +147,154 @@ struct DiagnosticError: Error {
self.hint = hint
}

func formattedDescription(fileName: String) -> String {
let locationConverter = SourceLocationConverter(fileName: fileName, tree: node.root)
let location = locationConverter.location(for: node.position)
var description = "\(fileName):\(location.line):\(location.column): error: \(message)"
/// Formats the diagnostic error as a string.
///
/// - Parameters:
/// - fileName: The name of the file to display in the output.
/// - colorize: Whether to colorize the output with ANSI escape sequences.
/// - Returns: The formatted diagnostic error string.
func formattedDescription(fileName: String, colorize: Bool = Self.shouldColorize) -> String {
let displayFileName = fileName == "-" ? "<stdin>" : fileName
let converter = SourceLocationConverter(fileName: displayFileName, tree: node.root)
let startLocation = converter.location(for: node.positionAfterSkippingLeadingTrivia)
let endLocation = converter.location(for: node.endPositionBeforeTrailingTrivia)

let sourceText = node.root.description
let lines = sourceText.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
let startLineIndex = max(0, min(lines.count - 1, startLocation.line - 1))
let mainLine = String(lines[startLineIndex])

let lineNumberWidth = max(3, String(lines.count).count)

let header: String = {
guard colorize else {
return "\(displayFileName):\(startLocation.line):\(startLocation.column): error: \(message)"
}
return
"\(displayFileName):\(startLocation.line):\(startLocation.column): \(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
}()

let highlightStartColumn = min(max(1, startLocation.column), mainLine.utf8.count + 1)
let availableColumns = max(0, mainLine.utf8.count - (highlightStartColumn - 1))
let rawHighlightLength: Int = {
guard availableColumns > 0 else { return 0 }
if startLocation.line == endLocation.line {
return max(1, min(endLocation.column - startLocation.column, availableColumns))
} else {
return min(1, availableColumns)
}
}()
let highlightLength = min(rawHighlightLength, availableColumns)

let formattedMainLine: String = {
guard colorize, highlightLength > 0 else { return mainLine }

let startIndex = Self.index(atUTF8Offset: highlightStartColumn - 1, in: mainLine)
let endIndex = Self.index(atUTF8Offset: highlightStartColumn - 1 + highlightLength, in: mainLine)

let prefix = String(mainLine[..<startIndex])
let highlighted = String(mainLine[startIndex..<endIndex])
let suffix = String(mainLine[endIndex...])

return prefix + ANSI.underline + highlighted + ANSI.reset + suffix
}()

var descriptionParts = [header]

// Include up to the previous three lines for context
for offset in (-3)...(-1) {
let lineIndex = startLineIndex + offset
guard lineIndex >= 0, lineIndex < lines.count else { continue }
descriptionParts.append(
Self.formatSourceLine(
number: lineIndex + 1,
text: String(lines[lineIndex]),
width: lineNumberWidth,
colorize: colorize
)
)
}

descriptionParts.append(
Self.formatSourceLine(
number: startLocation.line,
text: formattedMainLine,
width: lineNumberWidth,
colorize: colorize
)
)

let pointerSpacing = max(0, highlightStartColumn - 1)
let pointerMessage: String = {
let pointer = String(repeating: " ", count: pointerSpacing) + "`- "
guard colorize else { return pointer + "error: \(message)" }
return pointer + "\(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
}()
descriptionParts.append(
Self.formatSourceLine(
number: nil,
text: pointerMessage,
width: lineNumberWidth,
colorize: colorize
)
)

if startLineIndex + 1 < lines.count {
descriptionParts.append(
Self.formatSourceLine(
number: startLocation.line + 1,
text: String(lines[startLineIndex + 1]),
width: lineNumberWidth,
colorize: colorize
)
)
}

if let hint {
description += "\nHint: \(hint)"
descriptionParts.append("Hint: \(hint)")
}
return description

return descriptionParts.joined(separator: "\n")
}

private static func formatSourceLine(
number: Int?,
text: String,
width: Int,
colorize: Bool
) -> String {
let gutter: String
if let number {
let paddedNumber = String(repeating: " ", count: max(0, width - String(number).count)) + String(number)
gutter = colorize ? ANSI.cyan + paddedNumber + ANSI.reset : paddedNumber
} else {
gutter = String(repeating: " ", count: width)
}
return "\(gutter) | \(text)"
}

private static var shouldColorize: Bool {
let env = ProcessInfo.processInfo.environment
let termIsDumb = env["TERM"] == "dumb"
return env["NO_COLOR"] == nil && !termIsDumb
}

private static func index(atUTF8Offset offset: Int, in line: String) -> String.Index {
let clamped = max(0, min(offset, line.utf8.count))
let utf8Index = line.utf8.index(line.utf8.startIndex, offsetBy: clamped)
// String.Index initializer is guaranteed to succeed because the UTF8 index comes from the same string.
return String.Index(utf8Index, within: line)!
}
}

private enum ANSI {
static let reset = "\u{001B}[0;0m"
static let boldRed = "\u{001B}[1;31m"
static let boldDefault = "\u{001B}[1;39m"
static let cyan = "\u{001B}[0;36m"
static let underline = "\u{001B}[4;39m"
}

// MARK: - BridgeJSCoreError

public struct BridgeJSCoreError: Swift.Error, CustomStringConvertible {
Expand Down
182 changes: 182 additions & 0 deletions Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import SwiftParser
import SwiftSyntax
import Testing

@testable import BridgeJSCore

@Suite struct DiagnosticsTests {
/// Returns the first parameter's type node from a function in the source (the first `@JS func`-like decl), for pinpointing diagnostics.
private func firstParameterTypeNode(source: String) -> TypeSyntax? {
let tree = Parser.parse(source: source)
for stmt in tree.statements {
if let funcDecl = stmt.item.as(FunctionDeclSyntax.self),
let firstParam = funcDecl.signature.parameterClause.parameters.first
{
return firstParam.type
}
}
return nil
}

@Test
func diagnosticIncludesLocationSourceAndHint() throws {
let source = "@JS func foo(_ bar: A<X>) {}\n"
let typeNode = try #require(firstParameterTypeNode(source: source))
let diagnostic = DiagnosticError(
node: typeNode,
message: "Unsupported type 'A<X>'.",
hint: "Only primitive types and types defined in the same module are allowed"
)
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
let expectedPrefix = """
<stdin>:1:21: error: Unsupported type 'A<X>'.
1 | @JS func foo(_ bar: A<X>) {}
| `- error: Unsupported type 'A<X>'.
2 |
""".trimmingCharacters(in: .whitespacesAndNewlines)
#expect(description.hasPrefix(expectedPrefix))
#expect(description.contains("Hint: Only primitive types and types defined in the same module are allowed"))
}

@Test
func diagnosticOmitsHintWhenNotProvided() throws {
let source = "@JS static func foo() {}\n"
let tree = Parser.parse(source: source)
let diagnostic = DiagnosticError(
node: tree,
message: "Top-level functions cannot be static",
hint: nil
)
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
let expectedPrefix = """
<stdin>:1:1: error: Top-level functions cannot be static
1 | @JS static func foo() {}
| `- error: Top-level functions cannot be static
2 |
""".trimmingCharacters(in: .whitespacesAndNewlines)
#expect(description.hasPrefix(expectedPrefix))
#expect(!description.contains("Hint:"))
}

@Test
func diagnosticUsesGivenFileNameNotStdin() throws {
let source = "@JS func foo(_ bar: A<X>) {}\n"
let typeNode = try #require(firstParameterTypeNode(source: source))
let diagnostic = DiagnosticError(
node: typeNode,
message: "Unsupported type 'A<X>'.",
hint: nil
)
let description = diagnostic.formattedDescription(fileName: "Sources/Foo.swift", colorize: false)
#expect(description.hasPrefix("Sources/Foo.swift:1:21: error: Unsupported type 'A<X>'."))
}

@Test
func diagnosticWithColorizeTrueIncludesANSISequences() throws {
let source = "@JS func foo(_ bar: A<X>) {}\n"
let typeNode = try #require(firstParameterTypeNode(source: source))
let diagnostic = DiagnosticError(
node: typeNode,
message: "Unsupported type 'A<X>'.",
hint: nil
)
let description = diagnostic.formattedDescription(fileName: "-", colorize: true)
let esc = "\u{001B}"
let boldRed = "\(esc)[1;31m"
let boldDefault = "\(esc)[1;39m"
let reset = "\(esc)[0;0m"
let cyan = "\(esc)[0;36m"
let underline = "\(esc)[4;39m"
let expected =
"<stdin>:1:21: \(boldRed)error: \(boldDefault)Unsupported type 'A<X>'.\(reset)\n"
+ "\(cyan) 1\(reset) | @JS func foo(_ bar: \(underline)A<X>\(reset)) {}\n"
+ " | `- \(boldRed)error: \(boldDefault)Unsupported type 'A<X>'.\(reset)\n"
+ "\(cyan) 2\(reset) | "
#expect(description == expected)
}

// MARK: - Context source lines

@Test
func showsOnePreviousLineWhenErrorNotOnFirstLine() throws {
let source = """
preamble
@JS func foo(_ bar: A<X>) {}
"""
let typeNode = try #require(firstParameterTypeNode(source: source))
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
#expect(description.contains(" 1 | preamble"))
#expect(description.contains(" 2 | @JS func foo(_ bar: A<X>) {}"))
#expect(description.contains("<stdin>:2:"))
}

@Test
func showsThreePreviousLinesWhenAvailable() throws {
let source = """
first
second
third
@JS func foo(_ bar: A<X>) {}
"""
let typeNode = try #require(firstParameterTypeNode(source: source))
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
#expect(description.contains(" 1 | first"))
#expect(description.contains(" 2 | second"))
#expect(description.contains(" 3 | third"))
#expect(description.contains(" 4 | @JS func foo(_ bar: A<X>) {}"))
#expect(description.contains("<stdin>:4:"))
}

@Test
func capsContextAtThreePreviousLines() throws {
let source = """
line0
line1
line2
line3
@JS func foo(_ bar: A<X>) {}
"""
let typeNode = try #require(firstParameterTypeNode(source: source))
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
#expect(!description.contains(" 1 | line0"))
#expect(description.contains(" 2 | line1"))
#expect(description.contains(" 3 | line2"))
#expect(description.contains(" 4 | line3"))
#expect(description.contains(" 5 | @JS func foo(_ bar: A<X>) {}"))
#expect(description.contains("<stdin>:5:"))
}

@Test
func includesNextLineAfterErrorLine() throws {
let source = """
@JS func foo(
_ bar: A<X>
) {}
"""
let typeNode = try #require(firstParameterTypeNode(source: source))
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
#expect(description.contains(" 1 | @JS func foo("))
#expect(description.contains(" 2 | _ bar: A<X>"))
#expect(description.contains(" 3 | ) {}"))
#expect(description.contains("<stdin>:2:"))
}

@Test
func omitsNextLineWhenErrorIsOnLastLine() throws {
let source = """
preamble
@JS func foo(_ bar: A<X>)
"""
let typeNode = try #require(firstParameterTypeNode(source: source))
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
#expect(description.contains(" 2 | @JS func foo(_ bar: A<X>)"))
#expect(description.contains("<stdin>:2:"))
// No line 3 in source, so output must not show a " 3 |" context line after the pointer
#expect(!description.contains(" 3 |"))
}
}