Skip to content

Commit 9d05ff8

Browse files
authored
Merge pull request #4 from diogot/feature/fix-source-url-parsing
Fix sourceURL parsing for xcresulttool query-string format
2 parents 1526bb6 + 8bd74e0 commit 9d05ff8

File tree

4 files changed

+70
-18
lines changed

4 files changed

+70
-18
lines changed

Sources/XCResultParser/Models/SourceLocation.swift

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,53 @@ public struct SourceLocation: Sendable, Equatable {
3232
}
3333

3434
/// Parse sourceURL from build issues
35-
/// Format: `file:///absolute/path/to/File.swift#LineNumber`
35+
/// Format: `file:///path/File.swift#EndingColumnNumber=X&EndingLineNumber=X&StartingColumnNumber=X&StartingLineNumber=X&Timestamp=X`
36+
/// Note: Line numbers in xcresulttool are 0-based, so we add 1 for 1-based output
3637
public static func fromSourceURL(_ urlString: String) -> SourceLocation? {
3738
guard urlString.hasPrefix("file://") else { return nil }
3839

3940
let withoutScheme = String(urlString.dropFirst(7))
4041
let parts = withoutScheme.split(separator: "#", maxSplits: 1)
4142
let absolutePath = String(parts[0])
42-
let line = parts.count > 1 ? Int(parts[1]) : nil
43+
44+
var line: Int?
45+
var column: Int?
46+
47+
// Parse query-string fragment if present
48+
if parts.count > 1 {
49+
let fragment = String(parts[1])
50+
let params = fragment.split(separator: "&")
51+
52+
for param in params {
53+
let keyValue = param.split(separator: "=", maxSplits: 1)
54+
guard keyValue.count == 2 else { continue }
55+
56+
let key = String(keyValue[0])
57+
let value = String(keyValue[1])
58+
59+
switch key {
60+
case "StartingLineNumber":
61+
// xcresulttool uses 0-based line numbers, convert to 1-based
62+
if let num = Int(value) {
63+
line = num + 1
64+
}
65+
case "StartingColumnNumber":
66+
// xcresulttool uses 0-based column numbers, convert to 1-based
67+
if let num = Int(value) {
68+
column = num + 1
69+
}
70+
default:
71+
break
72+
}
73+
}
74+
}
75+
76+
guard let line else { return nil }
4377

4478
return SourceLocation(
4579
file: absolutePath,
46-
line: line ?? 1,
47-
column: nil
80+
line: line,
81+
column: column
4882
)
4983
}
5084

Tests/XCResultParserTests/Fixtures/build-results-errors.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
"issueType": "Error",
1010
"message": "Cannot find 'undefinedVariable' in scope",
1111
"targetName": "SampleProject",
12-
"sourceURL": "file:///Users/dev/SampleProject/Sources/BrokenFile.swift#25",
12+
"sourceURL": "file:///Users/dev/SampleProject/Sources/BrokenFile.swift#StartingLineNumber=24&StartingColumnNumber=0",
1313
"className": "BrokenFile"
1414
},
1515
{
1616
"issueType": "Error",
1717
"message": "Type 'String' has no member 'nonExistent'",
1818
"targetName": "SampleProject",
19-
"sourceURL": "file:///Users/dev/SampleProject/Sources/BrokenFile.swift#30"
19+
"sourceURL": "file:///Users/dev/SampleProject/Sources/BrokenFile.swift#StartingLineNumber=29&StartingColumnNumber=0"
2020
}
2121
],
2222
"startTime": 1763935325.829,
@@ -27,7 +27,7 @@
2727
"issueType": "Warning",
2828
"message": "Initialization of variable was never used",
2929
"targetName": "SampleProject",
30-
"sourceURL": "file:///Users/dev/SampleProject/Sources/Other.swift#10"
30+
"sourceURL": "file:///Users/dev/SampleProject/Sources/Other.swift#StartingLineNumber=9&StartingColumnNumber=0"
3131
}
3232
]
3333
}

Tests/XCResultParserTests/Fixtures/build-results-warnings.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"issueType": "Analyzer Warning",
77
"message": "Potential memory leak",
88
"targetName": "SampleProject",
9-
"sourceURL": "file:///Users/dev/SampleProject/Sources/Memory.swift#42",
9+
"sourceURL": "file:///Users/dev/SampleProject/Sources/Memory.swift#StartingLineNumber=41&StartingColumnNumber=0",
1010
"className": "Memory"
1111
}
1212
],
@@ -21,14 +21,14 @@
2121
"issueType": "Warning",
2222
"message": "Variable 'unused' was never used",
2323
"targetName": "SampleProject",
24-
"sourceURL": "file:///Users/dev/SampleProject/Sources/ContentView.swift#15",
24+
"sourceURL": "file:///Users/dev/SampleProject/Sources/ContentView.swift#StartingLineNumber=14&StartingColumnNumber=0",
2525
"className": "ContentView"
2626
},
2727
{
2828
"issueType": "Warning",
2929
"message": "Deprecated API usage",
3030
"targetName": "SampleProject",
31-
"sourceURL": "file:///Users/dev/SampleProject/Sources/LegacyCode.swift#100"
31+
"sourceURL": "file:///Users/dev/SampleProject/Sources/LegacyCode.swift#StartingLineNumber=99&StartingColumnNumber=0"
3232
}
3333
]
3434
}

Tests/XCResultParserTests/SourceLocationTests.swift

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,35 @@ import Testing
33

44
@Suite("SourceLocation Tests")
55
struct SourceLocationTests {
6-
@Test("Parse source URL with line number")
7-
func parseSourceURLWithLine() {
8-
let url = "file:///Users/dev/SampleProject/Sources/ContentView.swift#15"
6+
@Test("Parse source URL with xcresulttool format")
7+
func parseSourceURLWithXcresulttoolFormat() {
8+
// Real format from xcresulttool - line numbers are 0-based
9+
let url = "file:///Users/dev/SampleProject/Sources/ContentView.swift#EndingColumnNumber=30&EndingLineNumber=14&StartingColumnNumber=30&StartingLineNumber=14&Timestamp=786206164.289793"
910
let location = SourceLocation.fromSourceURL(url)
1011

1112
#expect(location != nil)
1213
#expect(location?.file == "/Users/dev/SampleProject/Sources/ContentView.swift")
13-
#expect(location?.line == 15)
14-
#expect(location?.column == nil)
14+
#expect(location?.line == 15) // 0-based 14 becomes 1-based 15
15+
#expect(location?.column == 31) // 0-based 30 becomes 1-based 31
1516
}
1617

17-
@Test("Parse source URL without line number defaults to 1")
18-
func parseSourceURLWithoutLine() {
18+
@Test("Parse source URL without fragment returns nil")
19+
func parseSourceURLWithoutFragment() {
1920
let url = "file:///Users/dev/SampleProject/Sources/ContentView.swift"
2021
let location = SourceLocation.fromSourceURL(url)
2122

23+
#expect(location == nil)
24+
}
25+
26+
@Test("Parse source URL with minimal fragment")
27+
func parseSourceURLWithMinimalFragment() {
28+
let url = "file:///Users/dev/SampleProject/Sources/ContentView.swift#StartingLineNumber=9"
29+
let location = SourceLocation.fromSourceURL(url)
30+
2231
#expect(location != nil)
2332
#expect(location?.file == "/Users/dev/SampleProject/Sources/ContentView.swift")
24-
#expect(location?.line == 1)
33+
#expect(location?.line == 10) // 0-based 9 becomes 1-based 10
34+
#expect(location?.column == nil)
2535
}
2636

2737
@Test("Parse invalid URL returns nil")
@@ -30,6 +40,14 @@ struct SourceLocationTests {
3040
#expect(location == nil)
3141
}
3242

43+
@Test("Parse source URL with invalid fragment returns nil")
44+
func parseSourceURLWithInvalidFragment() {
45+
let url = "file:///Users/dev/SampleProject/Sources/ContentView.swift#InvalidFragment"
46+
let location = SourceLocation.fromSourceURL(url)
47+
48+
#expect(location == nil)
49+
}
50+
3351
@Test("Parse failure message with location")
3452
func parseFailureMessageWithLocation() {
3553
let name = "SampleProjectTests.swift:14: Issue recorded: This test will always fail"

0 commit comments

Comments
 (0)