Skip to content

Commit bb57974

Browse files
BridgeJS: Include source-context in diagnostic messages
```console $ echo "@js func foo(_ bar: A<X>) {}" | ./Plugins/BridgeJS/.build/debug/BridgeJSToolInternal emit-skeleton - Error: <stdin>:1:21: error: Unsupported type 'A<X>'. 1 | @js func foo(_ bar: A<X>) {} | `- error: Unsupported type 'A<X>'. 2 | Hint: Only primitive types and types defined in the same module are allowed ```
1 parent 0452169 commit bb57974

File tree

3 files changed

+328
-6
lines changed

3 files changed

+328
-6
lines changed

Plugins/BridgeJS/Package.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ let package = Package(
5858
"BridgeJSCore",
5959
"BridgeJSLink",
6060
"TS2Swift",
61+
.product(name: "SwiftParser", package: "swift-syntax"),
62+
.product(name: "SwiftSyntax", package: "swift-syntax"),
6163
],
6264
exclude: ["__Snapshots__", "Inputs"]
6365
),

Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ private enum JSON {
134134
// MARK: - DiagnosticError
135135

136136
import SwiftSyntax
137+
import class Foundation.ProcessInfo
137138

138139
struct DiagnosticError: Error {
139140
let node: Syntax
@@ -146,17 +147,154 @@ struct DiagnosticError: Error {
146147
self.hint = hint
147148
}
148149

149-
func formattedDescription(fileName: String) -> String {
150-
let locationConverter = SourceLocationConverter(fileName: fileName, tree: node.root)
151-
let location = locationConverter.location(for: node.position)
152-
var description = "\(fileName):\(location.line):\(location.column): error: \(message)"
150+
/// Formats the diagnostic error as a string.
151+
///
152+
/// - Parameters:
153+
/// - fileName: The name of the file to display in the output.
154+
/// - colorize: Whether to colorize the output with ANSI escape sequences.
155+
/// - Returns: The formatted diagnostic error string.
156+
func formattedDescription(fileName: String, colorize: Bool = Self.shouldColorize) -> String {
157+
let displayFileName = fileName == "-" ? "<stdin>" : fileName
158+
let converter = SourceLocationConverter(fileName: displayFileName, tree: node.root)
159+
let startLocation = converter.location(for: node.positionAfterSkippingLeadingTrivia)
160+
let endLocation = converter.location(for: node.endPositionBeforeTrailingTrivia)
161+
162+
let sourceText = node.root.description
163+
let lines = sourceText.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
164+
let startLineIndex = max(0, min(lines.count - 1, startLocation.line - 1))
165+
let mainLine = String(lines[startLineIndex])
166+
167+
let lineNumberWidth = max(3, String(lines.count).count)
168+
169+
let header: String = {
170+
guard colorize else {
171+
return "\(displayFileName):\(startLocation.line):\(startLocation.column): error: \(message)"
172+
}
173+
return
174+
"\(displayFileName):\(startLocation.line):\(startLocation.column): \(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
175+
}()
176+
177+
let highlightStartColumn = min(max(1, startLocation.column), mainLine.utf8.count + 1)
178+
let availableColumns = max(0, mainLine.utf8.count - (highlightStartColumn - 1))
179+
let rawHighlightLength: Int = {
180+
guard availableColumns > 0 else { return 0 }
181+
if startLocation.line == endLocation.line {
182+
return max(1, min(endLocation.column - startLocation.column, availableColumns))
183+
} else {
184+
return min(1, availableColumns)
185+
}
186+
}()
187+
let highlightLength = min(rawHighlightLength, availableColumns)
188+
189+
let formattedMainLine: String = {
190+
guard colorize, highlightLength > 0 else { return mainLine }
191+
192+
let startIndex = Self.index(atUTF8Offset: highlightStartColumn - 1, in: mainLine)
193+
let endIndex = Self.index(atUTF8Offset: highlightStartColumn - 1 + highlightLength, in: mainLine)
194+
195+
let prefix = String(mainLine[..<startIndex])
196+
let highlighted = String(mainLine[startIndex..<endIndex])
197+
let suffix = String(mainLine[endIndex...])
198+
199+
return prefix + ANSI.underline + highlighted + ANSI.reset + suffix
200+
}()
201+
202+
var descriptionParts = [header]
203+
204+
// Include up to the previous three lines for context
205+
for offset in (-3)...(-1) {
206+
let lineIndex = startLineIndex + offset
207+
guard lineIndex >= 0, lineIndex < lines.count else { continue }
208+
descriptionParts.append(
209+
Self.formatSourceLine(
210+
number: lineIndex + 1,
211+
text: String(lines[lineIndex]),
212+
width: lineNumberWidth,
213+
colorize: colorize
214+
)
215+
)
216+
}
217+
218+
descriptionParts.append(
219+
Self.formatSourceLine(
220+
number: startLocation.line,
221+
text: formattedMainLine,
222+
width: lineNumberWidth,
223+
colorize: colorize
224+
)
225+
)
226+
227+
let pointerSpacing = max(0, highlightStartColumn - 1)
228+
let pointerMessage: String = {
229+
let pointer = String(repeating: " ", count: pointerSpacing) + "`- "
230+
guard colorize else { return pointer + "error: \(message)" }
231+
return pointer + "\(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
232+
}()
233+
descriptionParts.append(
234+
Self.formatSourceLine(
235+
number: nil,
236+
text: pointerMessage,
237+
width: lineNumberWidth,
238+
colorize: colorize
239+
)
240+
)
241+
242+
if startLineIndex + 1 < lines.count {
243+
descriptionParts.append(
244+
Self.formatSourceLine(
245+
number: startLocation.line + 1,
246+
text: String(lines[startLineIndex + 1]),
247+
width: lineNumberWidth,
248+
colorize: colorize
249+
)
250+
)
251+
}
252+
153253
if let hint {
154-
description += "\nHint: \(hint)"
254+
descriptionParts.append("Hint: \(hint)")
155255
}
156-
return description
256+
257+
return descriptionParts.joined(separator: "\n")
258+
}
259+
260+
private static func formatSourceLine(
261+
number: Int?,
262+
text: String,
263+
width: Int,
264+
colorize: Bool
265+
) -> String {
266+
let gutter: String
267+
if let number {
268+
let paddedNumber = String(repeating: " ", count: max(0, width - String(number).count)) + String(number)
269+
gutter = colorize ? ANSI.cyan + paddedNumber + ANSI.reset : paddedNumber
270+
} else {
271+
gutter = String(repeating: " ", count: width)
272+
}
273+
return "\(gutter) | \(text)"
274+
}
275+
276+
private static var shouldColorize: Bool {
277+
let env = ProcessInfo.processInfo.environment
278+
let termIsDumb = env["TERM"] == "dumb"
279+
return env["NO_COLOR"] == nil && !termIsDumb
280+
}
281+
282+
private static func index(atUTF8Offset offset: Int, in line: String) -> String.Index {
283+
let clamped = max(0, min(offset, line.utf8.count))
284+
let utf8Index = line.utf8.index(line.utf8.startIndex, offsetBy: clamped)
285+
// String.Index initializer is guaranteed to succeed because the UTF8 index comes from the same string.
286+
return String.Index(utf8Index, within: line)!
157287
}
158288
}
159289

290+
private enum ANSI {
291+
static let reset = "\u{001B}[0;0m"
292+
static let boldRed = "\u{001B}[1;31m"
293+
static let boldDefault = "\u{001B}[1;39m"
294+
static let cyan = "\u{001B}[0;36m"
295+
static let underline = "\u{001B}[4;39m"
296+
}
297+
160298
// MARK: - BridgeJSCoreError
161299

162300
public struct BridgeJSCoreError: Swift.Error, CustomStringConvertible {
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import SwiftParser
2+
import SwiftSyntax
3+
import Testing
4+
5+
@testable import BridgeJSCore
6+
7+
@Suite struct DiagnosticsTests {
8+
/// Returns the first parameter's type node from a function in the source (the first `@JS func`-like decl), for pinpointing diagnostics.
9+
private func firstParameterTypeNode(source: String) -> TypeSyntax? {
10+
let tree = Parser.parse(source: source)
11+
for stmt in tree.statements {
12+
if let funcDecl = stmt.item.as(FunctionDeclSyntax.self),
13+
let firstParam = funcDecl.signature.parameterClause.parameters.first
14+
{
15+
return firstParam.type
16+
}
17+
}
18+
return nil
19+
}
20+
21+
@Test
22+
func diagnosticIncludesLocationSourceAndHint() throws {
23+
let source = "@JS func foo(_ bar: A<X>) {}\n"
24+
let typeNode = try #require(firstParameterTypeNode(source: source))
25+
let diagnostic = DiagnosticError(
26+
node: typeNode,
27+
message: "Unsupported type 'A<X>'.",
28+
hint: "Only primitive types and types defined in the same module are allowed"
29+
)
30+
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
31+
let expectedPrefix = """
32+
<stdin>:1:21: error: Unsupported type 'A<X>'.
33+
1 | @JS func foo(_ bar: A<X>) {}
34+
| `- error: Unsupported type 'A<X>'.
35+
2 |
36+
""".trimmingCharacters(in: .whitespacesAndNewlines)
37+
#expect(description.hasPrefix(expectedPrefix))
38+
#expect(description.contains("Hint: Only primitive types and types defined in the same module are allowed"))
39+
}
40+
41+
@Test
42+
func diagnosticOmitsHintWhenNotProvided() throws {
43+
let source = "@JS static func foo() {}\n"
44+
let tree = Parser.parse(source: source)
45+
let diagnostic = DiagnosticError(
46+
node: tree,
47+
message: "Top-level functions cannot be static",
48+
hint: nil
49+
)
50+
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
51+
let expectedPrefix = """
52+
<stdin>:1:1: error: Top-level functions cannot be static
53+
1 | @JS static func foo() {}
54+
| `- error: Top-level functions cannot be static
55+
2 |
56+
""".trimmingCharacters(in: .whitespacesAndNewlines)
57+
#expect(description.hasPrefix(expectedPrefix))
58+
#expect(!description.contains("Hint:"))
59+
}
60+
61+
@Test
62+
func diagnosticUsesGivenFileNameNotStdin() throws {
63+
let source = "@JS func foo(_ bar: A<X>) {}\n"
64+
let typeNode = try #require(firstParameterTypeNode(source: source))
65+
let diagnostic = DiagnosticError(
66+
node: typeNode,
67+
message: "Unsupported type 'A<X>'.",
68+
hint: nil
69+
)
70+
let description = diagnostic.formattedDescription(fileName: "Sources/Foo.swift", colorize: false)
71+
#expect(description.hasPrefix("Sources/Foo.swift:1:21: error: Unsupported type 'A<X>'."))
72+
}
73+
74+
@Test
75+
func diagnosticWithColorizeTrueIncludesANSISequences() throws {
76+
let source = "@JS func foo(_ bar: A<X>) {}\n"
77+
let typeNode = try #require(firstParameterTypeNode(source: source))
78+
let diagnostic = DiagnosticError(
79+
node: typeNode,
80+
message: "Unsupported type 'A<X>'.",
81+
hint: nil
82+
)
83+
let description = diagnostic.formattedDescription(fileName: "-", colorize: true)
84+
let esc = "\u{001B}"
85+
let boldRed = "\(esc)[1;31m"
86+
let boldDefault = "\(esc)[1;39m"
87+
let reset = "\(esc)[0;0m"
88+
let cyan = "\(esc)[0;36m"
89+
let underline = "\(esc)[4;39m"
90+
let expected =
91+
"<stdin>:1:21: \(boldRed)error: \(boldDefault)Unsupported type 'A<X>'.\(reset)\n"
92+
+ "\(cyan) 1\(reset) | @JS func foo(_ bar: \(underline)A<X>\(reset)) {}\n"
93+
+ " | `- \(boldRed)error: \(boldDefault)Unsupported type 'A<X>'.\(reset)\n"
94+
+ "\(cyan) 2\(reset) | "
95+
#expect(description == expected)
96+
}
97+
98+
// MARK: - Context source lines
99+
100+
@Test
101+
func showsOnePreviousLineWhenErrorNotOnFirstLine() throws {
102+
let source = """
103+
preamble
104+
@JS func foo(_ bar: A<X>) {}
105+
"""
106+
let typeNode = try #require(firstParameterTypeNode(source: source))
107+
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
108+
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
109+
#expect(description.contains(" 1 | preamble"))
110+
#expect(description.contains(" 2 | @JS func foo(_ bar: A<X>) {}"))
111+
#expect(description.contains("<stdin>:2:"))
112+
}
113+
114+
@Test
115+
func showsThreePreviousLinesWhenAvailable() throws {
116+
let source = """
117+
first
118+
second
119+
third
120+
@JS func foo(_ bar: A<X>) {}
121+
"""
122+
let typeNode = try #require(firstParameterTypeNode(source: source))
123+
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
124+
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
125+
#expect(description.contains(" 1 | first"))
126+
#expect(description.contains(" 2 | second"))
127+
#expect(description.contains(" 3 | third"))
128+
#expect(description.contains(" 4 | @JS func foo(_ bar: A<X>) {}"))
129+
#expect(description.contains("<stdin>:4:"))
130+
}
131+
132+
@Test
133+
func capsContextAtThreePreviousLines() throws {
134+
let source = """
135+
line0
136+
line1
137+
line2
138+
line3
139+
@JS func foo(_ bar: A<X>) {}
140+
"""
141+
let typeNode = try #require(firstParameterTypeNode(source: source))
142+
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
143+
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
144+
#expect(!description.contains(" 1 | line0"))
145+
#expect(description.contains(" 2 | line1"))
146+
#expect(description.contains(" 3 | line2"))
147+
#expect(description.contains(" 4 | line3"))
148+
#expect(description.contains(" 5 | @JS func foo(_ bar: A<X>) {}"))
149+
#expect(description.contains("<stdin>:5:"))
150+
}
151+
152+
@Test
153+
func includesNextLineAfterErrorLine() throws {
154+
let source = """
155+
@JS func foo(
156+
_ bar: A<X>
157+
) {}
158+
"""
159+
let typeNode = try #require(firstParameterTypeNode(source: source))
160+
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
161+
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
162+
#expect(description.contains(" 1 | @JS func foo("))
163+
#expect(description.contains(" 2 | _ bar: A<X>"))
164+
#expect(description.contains(" 3 | ) {}"))
165+
#expect(description.contains("<stdin>:2:"))
166+
}
167+
168+
@Test
169+
func omitsNextLineWhenErrorIsOnLastLine() throws {
170+
let source = """
171+
preamble
172+
@JS func foo(_ bar: A<X>)
173+
"""
174+
let typeNode = try #require(firstParameterTypeNode(source: source))
175+
let diagnostic = DiagnosticError(node: typeNode, message: "Unsupported type 'A<X>'.", hint: nil)
176+
let description = diagnostic.formattedDescription(fileName: "-", colorize: false)
177+
#expect(description.contains(" 2 | @JS func foo(_ bar: A<X>)"))
178+
#expect(description.contains("<stdin>:2:"))
179+
// No line 3 in source, so output must not show a " 3 |" context line after the pointer
180+
#expect(!description.contains(" 3 |"))
181+
}
182+
}

0 commit comments

Comments
 (0)