Skip to content

Commit fdc1b5c

Browse files
authored
Adapt xcresult parser for Xcode 16 changes (flutter#172596)
This PR updates the iOS build process to support breaking changes in Xcode 16's xcresulttool. The xcresulttool get command, which Flutter uses to parse native build issues, is deprecated and replaced by the new xcresulttool get build-results command. This new command also introduces a completely new, flatter JSON output format. To address this while maintaining backwards compatibility, this change introduces a two-part solution: - The XCResultGenerator now detects the Xcode version and conditionally calls the appropriate command (get build-results for Xcode 16+ and get for older versions). - The XCResult parser has been refactored to be "bilingual," meaning it can intelligently detect the JSON structure and parse both the old (nested) and new (flat) formats correctly. - This ensures that native iOS build errors and warnings continue to be correctly parsed and displayed to the user on all supported Xcode versions. Fixes flutter#151502 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent 8aeaf72 commit fdc1b5c

3 files changed

Lines changed: 253 additions & 78 deletions

File tree

packages/flutter_tools/lib/src/ios/xcresult.dart

Lines changed: 143 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,25 @@ class XCResultGenerator {
4040
List<XCResultIssueDiscarder> issueDiscarders = const <XCResultIssueDiscarder>[],
4141
}) async {
4242
final Version? xcodeVersion = xcode.currentVersion;
43-
final RunResult result = await processUtils.run(<String>[
44-
...xcode.xcrunCommand(),
45-
'xcresulttool',
46-
'get',
47-
// See https://github.com/flutter/flutter/issues/151502
48-
if (xcodeVersion != null && xcodeVersion >= Version(16, 0, 0)) '--legacy',
49-
'--path',
50-
resultPath,
51-
'--format',
52-
'json',
53-
]);
43+
final bool useNewCommand = xcodeVersion != null && xcodeVersion >= Version(16, 0, 0);
44+
45+
final baseCommand = <String>[...xcode.xcrunCommand(), 'xcresulttool'];
46+
47+
if (useNewCommand) {
48+
baseCommand.addAll(<String>[
49+
'get',
50+
'build-results',
51+
'--path',
52+
resultPath,
53+
'--format',
54+
'json',
55+
]);
56+
} else {
57+
baseCommand.addAll(<String>['get', '--path', resultPath, '--format', 'json']);
58+
}
59+
60+
final RunResult result = await processUtils.run(baseCommand);
61+
5462
if (result.exitCode != 0) {
5563
return XCResult.failed(errorMessage: result.stderr);
5664
}
@@ -59,9 +67,6 @@ class XCResultGenerator {
5967
}
6068
final Object? resultJson = json.decode(result.stdout);
6169
if (resultJson == null || resultJson is! Map<String, Object?>) {
62-
// If json parsing failed, indicate such error.
63-
// This also includes the top level json object is an array, which indicates
64-
// the structure of the json is changed and this parser class possibly needs to update for this change.
6570
return XCResult.failed(errorMessage: 'xcresult parser: Unrecognized top level json format.');
6671
}
6772
return XCResult(resultJson: resultJson, issueDiscarders: issueDiscarders);
@@ -80,40 +85,71 @@ class XCResult {
8085
}) {
8186
final issues = <XCResultIssue>[];
8287

83-
final Object? issuesMap = resultJson['issues'];
84-
if (issuesMap == null || issuesMap is! Map<String, Object?>) {
85-
return XCResult.failed(errorMessage: 'xcresult parser: Failed to parse the issues map.');
86-
}
88+
/// Detect which xcresult JSON format is being used to ensure backwards compatibility.
89+
///
90+
/// Xcode 16 introduced a new `get build-results` command with a flatter JSON structure.
91+
/// Older versions use the original `get` command with a deeply nested structure.
92+
/// We differentiate them by checking for the presence of top-level 'errors' or 'warnings'
93+
/// keys, which are unique to the modern format.
8794
88-
final Object? errorSummaries = issuesMap['errorSummaries'];
89-
if (errorSummaries is Map<String, Object?>) {
95+
if (resultJson.containsKey('errors') || resultJson.containsKey('warnings')) {
9096
issues.addAll(
91-
_parseIssuesFromIssueSummariesJson(
97+
_parseIssuesFromNewFormat(
9298
type: XCResultIssueType.error,
93-
issueSummariesJson: errorSummaries,
94-
issueDiscarder: issueDiscarders,
99+
jsonList: resultJson['errors'],
100+
issueDiscarders: issueDiscarders,
95101
),
96102
);
97-
}
98-
99-
final Object? warningSummaries = issuesMap['warningSummaries'];
100-
if (warningSummaries is Map<String, Object?>) {
101103
issues.addAll(
102-
_parseIssuesFromIssueSummariesJson(
104+
_parseIssuesFromNewFormat(
103105
type: XCResultIssueType.warning,
104-
issueSummariesJson: warningSummaries,
105-
issueDiscarder: issueDiscarders,
106+
jsonList: resultJson['warnings'],
107+
issueDiscarders: issueDiscarders,
106108
),
107109
);
110+
} else {
111+
final Object? issuesMap = resultJson['issues'];
112+
113+
if (issuesMap is! Map<String, Object?>) {
114+
return XCResult.failed(errorMessage: 'xcresult parser: Failed to parse the issues map.');
115+
}
116+
117+
final Object? errorSummaries = issuesMap['errorSummaries'];
118+
if (errorSummaries is Map<String, Object?>) {
119+
issues.addAll(
120+
_parseIssuesFromXcode15Format(
121+
type: XCResultIssueType.error,
122+
issueSummariesJson: errorSummaries,
123+
issueDiscarder: issueDiscarders,
124+
),
125+
);
126+
}
127+
final Object? warningSummaries = issuesMap['warningSummaries'];
128+
if (warningSummaries is Map<String, Object?>) {
129+
issues.addAll(
130+
_parseIssuesFromXcode15Format(
131+
type: XCResultIssueType.warning,
132+
issueSummariesJson: warningSummaries,
133+
issueDiscarder: issueDiscarders,
134+
),
135+
);
136+
}
137+
final Object? actionsMap = resultJson['actions'];
138+
if (actionsMap is Map<String, Object?>) {
139+
final List<XCResultIssue> actionIssues = _parseActionIssues(
140+
actionsMap,
141+
issueDiscarders: issueDiscarders,
142+
);
143+
issues.addAll(actionIssues);
144+
}
108145
}
109146

110-
final Object? actionsMap = resultJson['actions'];
111-
if (actionsMap is Map<String, Object?>) {
112-
final List<XCResultIssue> actionIssues = _parseActionIssues(
113-
actionsMap,
114-
issueDiscarders: issueDiscarders,
115-
);
116-
issues.addAll(actionIssues);
147+
if (issues.isEmpty &&
148+
resultJson['issues'] == null &&
149+
resultJson['actions'] == null &&
150+
resultJson['errors'] == null &&
151+
resultJson['warnings'] == null) {
152+
return XCResult.failed(errorMessage: 'xcresult parser: Failed to parse the issues map.');
117153
}
118154

119155
return XCResult._(issues: issues);
@@ -143,7 +179,6 @@ class XCResult {
143179
this.parsingErrorMessage,
144180
});
145181

146-
/// The issues in the xcresult file.
147182
final List<XCResultIssue> issues;
148183

149184
/// Indicate if the xcresult was successfully parsed.
@@ -157,33 +192,24 @@ class XCResult {
157192
final String? parsingErrorMessage;
158193
}
159194

160-
/// An issue object in the XCResult
161195
class XCResultIssue {
162196
/// Construct an `XCResultIssue` object from `issueJson`.
163197
///
164198
/// `issueJson` is the object at xcresultJson[['actions']['_values'][0]['buildResult']['issues']['errorSummaries'/'warningSummaries']['_values'].
165-
factory XCResultIssue({
199+
factory XCResultIssue.fromOldFormat({
166200
required XCResultIssueType type,
167201
required Map<String, Object?> issueJson,
168202
}) {
169-
// Parse type.
170203
final Object? issueSubTypeMap = issueJson['issueType'];
171204
String? subType;
172205
if (issueSubTypeMap is Map<String, Object?>) {
173-
final Object? subTypeValue = issueSubTypeMap['_value'];
174-
if (subTypeValue is String) {
175-
subType = subTypeValue;
176-
}
206+
subType = issueSubTypeMap['_value'] as String?;
177207
}
178208

179-
// Parse message.
180209
String? message;
181210
final Object? messageMap = issueJson['message'];
182211
if (messageMap is Map<String, Object?>) {
183-
final Object? messageValue = messageMap['_value'];
184-
if (messageValue is String) {
185-
message = messageValue;
186-
}
212+
message = messageMap['_value'] as String?;
187213
}
188214

189215
final warnings = <String>[];
@@ -215,7 +241,37 @@ class XCResultIssue {
215241
);
216242
}
217243

218-
/// Create a [XCResultIssue] without JSON parsing for testing.
244+
/// Construct an `XCResultIssue` object from the (Xcode 16+) format `issueJson`.
245+
factory XCResultIssue.fromNewFormat({
246+
required XCResultIssueType type,
247+
required Map<String, Object?> issueJson,
248+
}) {
249+
final message = issueJson['message'] as String?;
250+
251+
final subType = issueJson['issueType'] as String?;
252+
253+
String? location;
254+
final warnings = <String>[];
255+
256+
final sourceUrl = issueJson['sourceURL'] as String?;
257+
if (sourceUrl != null) {
258+
location = _convertUrlToLocationString(sourceUrl);
259+
if (location == null) {
260+
warnings.add(
261+
'(XCResult) The `sourceURL` exists but it failed to be parsed. url: $sourceUrl',
262+
);
263+
}
264+
}
265+
266+
return XCResultIssue._(
267+
type: type,
268+
subType: subType,
269+
message: message,
270+
location: location,
271+
warnings: warnings,
272+
);
273+
}
274+
219275
@visibleForTesting
220276
factory XCResultIssue.test({
221277
XCResultIssueType type = XCResultIssueType.error,
@@ -235,10 +291,10 @@ class XCResultIssue {
235291

236292
XCResultIssue._({
237293
required this.type,
238-
required this.subType,
239-
required this.message,
240-
required this.location,
241-
required this.warnings,
294+
this.subType,
295+
this.message,
296+
this.location,
297+
this.warnings = const <String>[],
242298
});
243299

244300
/// The type of the issue.
@@ -314,11 +370,10 @@ class XCResultIssueDiscarder {
314370
}
315371

316372
// A typical location url string looks like file:///foo.swift#CharacterRangeLen=0&EndingColumnNumber=82&EndingLineNumber=7&StartingColumnNumber=82&StartingLineNumber=7.
317-
//
318-
// This function converts it to something like: /foo.swift:<StartingLineNumber>:<StartingColumnNumber>.
373+
// This function is now used by BOTH the old and new parsers.
319374
String? _convertUrlToLocationString(String url) {
320375
final Uri? fragmentLocation = Uri.tryParse(url);
321-
if (fragmentLocation == null) {
376+
if (fragmentLocation == null || !fragmentLocation.hasFragment) {
322377
return null;
323378
}
324379
// Parse the fragment as a query of key-values:
@@ -334,7 +389,6 @@ String? _convertUrlToLocationString(String url) {
334389
return '${fileLocation.path}$startingLineNumber$startingColumnNumber';
335390
}
336391

337-
// Determine if an `issue` should be discarded based on the `discarder`.
338392
bool _shouldDiscardIssue({
339393
required XCResultIssue issue,
340394
required XCResultIssueDiscarder discarder,
@@ -361,7 +415,30 @@ bool _shouldDiscardIssue({
361415
return false;
362416
}
363417

364-
List<XCResultIssue> _parseIssuesFromIssueSummariesJson({
418+
/// Helper to parse issues from the new (Xcode 16+) flat list format.
419+
List<XCResultIssue> _parseIssuesFromNewFormat({
420+
required XCResultIssueType type,
421+
required Object? jsonList,
422+
required List<XCResultIssueDiscarder> issueDiscarders,
423+
}) {
424+
if (jsonList is! List<Object?>) {
425+
return const <XCResultIssue>[];
426+
}
427+
428+
return jsonList
429+
.whereType<Map<String, Object?>>()
430+
.map((issueJson) => XCResultIssue.fromNewFormat(type: type, issueJson: issueJson))
431+
.where((issue) {
432+
final bool shouldDiscard = issueDiscarders.any(
433+
(discarder) => _shouldDiscardIssue(issue: issue, discarder: discarder),
434+
);
435+
return !shouldDiscard;
436+
})
437+
.toList();
438+
}
439+
440+
/// Helper to parse issues from the old (pre-Xcode 16) format.
441+
List<XCResultIssue> _parseIssuesFromXcode15Format({
365442
required XCResultIssueType type,
366443
required Map<String, Object?> issueSummariesJson,
367444
required List<XCResultIssueDiscarder> issueDiscarder,
@@ -370,26 +447,26 @@ List<XCResultIssue> _parseIssuesFromIssueSummariesJson({
370447
final Object? errorsList = issueSummariesJson['_values'];
371448
if (errorsList is List<Object?>) {
372449
for (final Object? issueJson in errorsList) {
373-
if (issueJson == null || issueJson is! Map<String, Object?>) {
450+
if (issueJson is! Map<String, Object?>) {
374451
continue;
375452
}
376-
final resultIssue = XCResultIssue(type: type, issueJson: issueJson);
453+
final resultIssue = XCResultIssue.fromOldFormat(type: type, issueJson: issueJson);
377454
var discard = false;
378455
for (final discarder in issueDiscarder) {
379456
if (_shouldDiscardIssue(issue: resultIssue, discarder: discarder)) {
380457
discard = true;
381458
break;
382459
}
383460
}
384-
if (discard) {
385-
continue;
461+
if (!discard) {
462+
issues.add(resultIssue);
386463
}
387-
issues.add(resultIssue);
388464
}
389465
}
390466
return issues;
391467
}
392468

469+
/// Helper to parse issues from the `actions` block in the pre-Xcode 16 format.
393470
List<XCResultIssue> _parseActionIssues(
394471
Map<String, Object?> actionsMap, {
395472
required List<XCResultIssueDiscarder> issueDiscarders,
@@ -461,14 +538,13 @@ List<XCResultIssue> _parseActionIssues(
461538
final Object? testFailureSummaries = actionResultIssues['testFailureSummaries'];
462539
if (testFailureSummaries is Map<String, Object?>) {
463540
issues.addAll(
464-
_parseIssuesFromIssueSummariesJson(
541+
_parseIssuesFromXcode15Format(
465542
type: XCResultIssueType.error,
466543
issueSummariesJson: testFailureSummaries,
467544
issueDiscarder: issueDiscarders,
468545
),
469546
);
470547
}
471548
}
472-
473549
return issues;
474550
}

0 commit comments

Comments
 (0)