Skip to content

Commit 67984d9

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 67984d9

File tree

3 files changed

+321
-6
lines changed

3 files changed

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

160291
// MARK: - BridgeJSCoreError
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)