| // Copyright 2014 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'package:meta/meta.dart'; | |
| import '../base/process.dart'; | |
| import '../base/version.dart'; | |
| import '../convert.dart' show json; | |
| import '../convert.dart'; | |
| import '../macos/xcode.dart'; | |
| /// The generator of xcresults. | |
| /// | |
| /// Call [generate] after an iOS/MacOS build will generate a [XCResult]. | |
| /// This only works when the `-resultBundleVersion` is set to 3. | |
| /// * See also: [XCResult]. | |
| class XCResultGenerator { | |
| /// Construct the [XCResultGenerator]. | |
| XCResultGenerator({required this.resultPath, required this.xcode, required this.processUtils}); | |
| /// The file path that used to store the xcrun result. | |
| /// | |
| /// There's usually a `resultPath.xcresult` file in the same folder. | |
| final String resultPath; | |
| /// The [ProcessUtils] to run commands. | |
| final ProcessUtils processUtils; | |
| /// [Xcode] object used to run xcode command. | |
| final Xcode xcode; | |
| /// Generates the XCResult. | |
| /// | |
| /// Calls `xcrun xcresulttool get --legacy --path <resultPath> --format json`, | |
| /// then stores the useful information the json into an [XCResult] object. | |
| /// | |
| /// A`issueDiscarders` can be passed to discard any issues that matches the description of any [XCResultIssueDiscarder] in the list. | |
| Future<XCResult> generate({ | |
| List<XCResultIssueDiscarder> issueDiscarders = const <XCResultIssueDiscarder>[], | |
| }) async { | |
| final Version? xcodeVersion = xcode.currentVersion; | |
| final bool useNewCommand = xcodeVersion != null && xcodeVersion >= Version(16, 0, 0); | |
| final baseCommand = <String>[...xcode.xcrunCommand(), 'xcresulttool']; | |
| if (useNewCommand) { | |
| baseCommand.addAll(<String>[ | |
| 'get', | |
| 'build-results', | |
| '--path', | |
| resultPath, | |
| '--format', | |
| 'json', | |
| ]); | |
| } else { | |
| baseCommand.addAll(<String>['get', '--path', resultPath, '--format', 'json']); | |
| } | |
| final RunResult result = await processUtils.run(baseCommand); | |
| if (result.exitCode != 0) { | |
| return XCResult.failed(errorMessage: result.stderr); | |
| } | |
| if (result.stdout.isEmpty) { | |
| return XCResult.failed(errorMessage: 'xcresult parser: Unrecognized top level json format.'); | |
| } | |
| final Object? resultJson = json.decode(result.stdout); | |
| if (resultJson == null || resultJson is! Map<String, Object?>) { | |
| return XCResult.failed(errorMessage: 'xcresult parser: Unrecognized top level json format.'); | |
| } | |
| return XCResult(resultJson: resultJson, issueDiscarders: issueDiscarders); | |
| } | |
| } | |
| /// The xcresult of an `xcodebuild` command. | |
| /// | |
| /// This is the result from an `xcrun xcresulttool get --legacy --path <resultPath> --format json` run. | |
| /// The result contains useful information such as build errors and warnings. | |
| class XCResult { | |
| /// Parse the `resultJson` and stores useful information in the returned `XCResult`. | |
| factory XCResult({ | |
| required Map<String, Object?> resultJson, | |
| List<XCResultIssueDiscarder> issueDiscarders = const <XCResultIssueDiscarder>[], | |
| }) { | |
| final issues = <XCResultIssue>[]; | |
| // Detect which xcresult JSON format is being used to ensure backwards compatibility. | |
| // | |
| // Xcode 16 introduced a new `get build-results` command with a flatter JSON structure. | |
| // Older versions use the original `get` command with a deeply nested structure. | |
| // We differentiate them by checking for the presence of top-level 'errors' or 'warnings' | |
| // keys, which are unique to the modern format. | |
| if (resultJson.containsKey('errors') || resultJson.containsKey('warnings')) { | |
| issues.addAll( | |
| _parseIssuesFromXcode16Format( | |
| type: XCResultIssueType.error, | |
| jsonList: resultJson['errors'], | |
| issueDiscarders: issueDiscarders, | |
| ), | |
| ); | |
| issues.addAll( | |
| _parseIssuesFromXcode16Format( | |
| type: XCResultIssueType.warning, | |
| jsonList: resultJson['warnings'], | |
| issueDiscarders: issueDiscarders, | |
| ), | |
| ); | |
| } else { | |
| final Object? issuesMap = resultJson['issues']; | |
| if (issuesMap is! Map<String, Object?>) { | |
| return XCResult.failed(errorMessage: 'xcresult parser: Failed to parse the issues map.'); | |
| } | |
| final Object? errorSummaries = issuesMap['errorSummaries']; | |
| if (errorSummaries is Map<String, Object?>) { | |
| issues.addAll( | |
| _parseIssuesFromXcode15Format( | |
| type: XCResultIssueType.error, | |
| issueSummariesJson: errorSummaries, | |
| issueDiscarder: issueDiscarders, | |
| ), | |
| ); | |
| } | |
| final Object? warningSummaries = issuesMap['warningSummaries']; | |
| if (warningSummaries is Map<String, Object?>) { | |
| issues.addAll( | |
| _parseIssuesFromXcode15Format( | |
| type: XCResultIssueType.warning, | |
| issueSummariesJson: warningSummaries, | |
| issueDiscarder: issueDiscarders, | |
| ), | |
| ); | |
| } | |
| final Object? actionsMap = resultJson['actions']; | |
| if (actionsMap is Map<String, Object?>) { | |
| final List<XCResultIssue> actionIssues = _parseActionIssues( | |
| actionsMap, | |
| issueDiscarders: issueDiscarders, | |
| ); | |
| issues.addAll(actionIssues); | |
| } | |
| } | |
| if (issues.isEmpty && | |
| resultJson['issues'] == null && | |
| resultJson['actions'] == null && | |
| resultJson['errors'] == null && | |
| resultJson['warnings'] == null) { | |
| return XCResult.failed(errorMessage: 'xcresult parser: Failed to parse the issues map.'); | |
| } | |
| return XCResult._(issues: issues); | |
| } | |
| factory XCResult.failed({required String errorMessage}) { | |
| return XCResult._(parseSuccess: false, parsingErrorMessage: errorMessage); | |
| } | |
| /// Create a [XCResult] with constructed [XCResultIssue]s for testing. | |
| @visibleForTesting | |
| factory XCResult.test({ | |
| List<XCResultIssue>? issues, | |
| bool? parseSuccess, | |
| String? parsingErrorMessage, | |
| }) { | |
| return XCResult._( | |
| issues: issues ?? const <XCResultIssue>[], | |
| parseSuccess: parseSuccess ?? true, | |
| parsingErrorMessage: parsingErrorMessage, | |
| ); | |
| } | |
| XCResult._({ | |
| this.issues = const <XCResultIssue>[], | |
| this.parseSuccess = true, | |
| this.parsingErrorMessage, | |
| }); | |
| final List<XCResultIssue> issues; | |
| /// Indicate if the xcresult was successfully parsed. | |
| /// | |
| /// See also: [parsingErrorMessage] for the error message if the parsing was unsuccessful. | |
| final bool parseSuccess; | |
| /// The error message describes why the parse if unsuccessful. | |
| /// | |
| /// This is `null` if [parseSuccess] is `true`. | |
| final String? parsingErrorMessage; | |
| } | |
| class XCResultIssue { | |
| /// Construct an `XCResultIssue` object from `issueJson`. | |
| /// | |
| /// `issueJson` is the object at xcresultJson[['actions']['_values'][0]['buildResult']['issues']['errorSummaries'/'warningSummaries']['_values']. | |
| factory XCResultIssue.fromOldFormat({ | |
| required XCResultIssueType type, | |
| required Map<String, Object?> issueJson, | |
| }) { | |
| final Object? issueSubTypeMap = issueJson['issueType']; | |
| String? subType; | |
| if (issueSubTypeMap is Map<String, Object?>) { | |
| subType = issueSubTypeMap['_value'] as String?; | |
| } | |
| String? message; | |
| final Object? messageMap = issueJson['message']; | |
| if (messageMap is Map<String, Object?>) { | |
| message = messageMap['_value'] as String?; | |
| } | |
| final warnings = <String>[]; | |
| // Parse url and convert it to a location String. | |
| String? location; | |
| final Object? documentLocationInCreatingWorkspaceMap = | |
| issueJson['documentLocationInCreatingWorkspace']; | |
| if (documentLocationInCreatingWorkspaceMap is Map<String, Object?>) { | |
| final Object? urlMap = documentLocationInCreatingWorkspaceMap['url']; | |
| if (urlMap is Map<String, Object?>) { | |
| final Object? urlValue = urlMap['_value']; | |
| if (urlValue is String) { | |
| location = _convertUrlToLocationString(urlValue); | |
| if (location == null) { | |
| warnings.add( | |
| '(XCResult) The `url` exists but it was failed to be parsed. url: $urlValue', | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| return XCResultIssue._( | |
| type: type, | |
| subType: subType, | |
| message: message, | |
| location: location, | |
| warnings: warnings, | |
| ); | |
| } | |
| /// Construct an `XCResultIssue` object from the (Xcode 16+) format `issueJson`. | |
| factory XCResultIssue.fromNewFormat({ | |
| required XCResultIssueType type, | |
| required Map<String, Object?> issueJson, | |
| }) { | |
| final message = issueJson['message'] as String?; | |
| final subType = issueJson['issueType'] as String?; | |
| String? location; | |
| final warnings = <String>[]; | |
| final sourceUrl = issueJson['sourceURL'] as String?; | |
| if (sourceUrl != null) { | |
| location = _convertUrlToLocationString(sourceUrl); | |
| if (location == null) { | |
| warnings.add( | |
| '(XCResult) The `sourceURL` exists but it failed to be parsed. url: $sourceUrl', | |
| ); | |
| } | |
| } | |
| return XCResultIssue._( | |
| type: type, | |
| subType: subType, | |
| message: message, | |
| location: location, | |
| warnings: warnings, | |
| ); | |
| } | |
| @visibleForTesting | |
| factory XCResultIssue.test({ | |
| XCResultIssueType type = XCResultIssueType.error, | |
| String? subType, | |
| String? message, | |
| String? location, | |
| List<String> warnings = const <String>[], | |
| }) { | |
| return XCResultIssue._( | |
| type: type, | |
| subType: subType, | |
| message: message, | |
| location: location, | |
| warnings: warnings, | |
| ); | |
| } | |
| XCResultIssue._({ | |
| required this.type, | |
| this.subType, | |
| this.message, | |
| this.location, | |
| this.warnings = const <String>[], | |
| }); | |
| /// The type of the issue. | |
| final XCResultIssueType type; | |
| /// The sub type of the issue. | |
| /// | |
| /// This is a more detailed category about the issue. | |
| /// The possible values are `Warning`, `Semantic Issue'` etc. | |
| final String? subType; | |
| /// Human readable message for the issue. | |
| /// | |
| /// This can be displayed to user for their information. | |
| final String? message; | |
| /// The location where the issue occurs. | |
| /// | |
| /// This is a re-formatted version of the "url" value in the json. | |
| /// The format looks like `<FileLocation>:<StartingLineNumber>:<StartingColumnNumber>`. | |
| final String? location; | |
| /// Warnings when constructing the issue object. | |
| final List<String> warnings; | |
| } | |
| /// The type of an `XCResultIssue`. | |
| enum XCResultIssueType { | |
| /// The issue is an warning. | |
| /// | |
| /// This is for all the issues under the `warningSummaries` key in the xcresult. | |
| warning, | |
| /// The issue is an warning. | |
| /// | |
| /// This is for all the issues under the `errorSummaries` key in the xcresult. | |
| error, | |
| } | |
| /// Discards the [XCResultIssue] that matches any of the matchers. | |
| class XCResultIssueDiscarder { | |
| XCResultIssueDiscarder({ | |
| this.typeMatcher, | |
| this.subTypeMatcher, | |
| this.messageMatcher, | |
| this.locationMatcher, | |
| }) : assert( | |
| typeMatcher != null || | |
| subTypeMatcher != null || | |
| messageMatcher != null || | |
| locationMatcher != null, | |
| ); | |
| /// The type of the discarder. | |
| /// | |
| /// A [XCResultIssue] should be discarded if its `type` equals to this. | |
| final XCResultIssueType? typeMatcher; | |
| /// The subType of the discarder. | |
| /// | |
| /// A [XCResultIssue] should be discarded if its `subType` matches the RegExp. | |
| final RegExp? subTypeMatcher; | |
| /// The message of the discarder. | |
| /// | |
| /// A [XCResultIssue] should be discarded if its `message` matches the RegExp. | |
| final RegExp? messageMatcher; | |
| /// The location of the discarder. | |
| /// | |
| /// A [XCResultIssue] should be discarded if its `location` matches the RegExp. | |
| final RegExp? locationMatcher; | |
| } | |
| // A typical location url string looks like file:///foo.swift#CharacterRangeLen=0&EndingColumnNumber=82&EndingLineNumber=7&StartingColumnNumber=82&StartingLineNumber=7. | |
| // This function is now used by BOTH the old and new parsers. | |
| String? _convertUrlToLocationString(String url) { | |
| final Uri? fragmentLocation = Uri.tryParse(url); | |
| if (fragmentLocation == null || !fragmentLocation.hasFragment) { | |
| return null; | |
| } | |
| // Parse the fragment as a query of key-values: | |
| final fileLocation = Uri(path: fragmentLocation.path, query: fragmentLocation.fragment); | |
| String startingLineNumber = fileLocation.queryParameters['StartingLineNumber'] ?? ''; | |
| if (startingLineNumber.isNotEmpty) { | |
| startingLineNumber = ':$startingLineNumber'; | |
| } | |
| String startingColumnNumber = fileLocation.queryParameters['StartingColumnNumber'] ?? ''; | |
| if (startingColumnNumber.isNotEmpty) { | |
| startingColumnNumber = ':$startingColumnNumber'; | |
| } | |
| return '${fileLocation.path}$startingLineNumber$startingColumnNumber'; | |
| } | |
| bool _shouldDiscardIssue({ | |
| required XCResultIssue issue, | |
| required XCResultIssueDiscarder discarder, | |
| }) { | |
| if (issue.type == discarder.typeMatcher) { | |
| return true; | |
| } | |
| if (issue.subType != null && | |
| discarder.subTypeMatcher != null && | |
| discarder.subTypeMatcher!.hasMatch(issue.subType!)) { | |
| return true; | |
| } | |
| if (issue.message != null && | |
| discarder.messageMatcher != null && | |
| discarder.messageMatcher!.hasMatch(issue.message!)) { | |
| return true; | |
| } | |
| if (issue.location != null && | |
| discarder.locationMatcher != null && | |
| discarder.locationMatcher!.hasMatch(issue.location!)) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| /// Helper to parse issues from the (Xcode 16+) flat list format. | |
| List<XCResultIssue> _parseIssuesFromXcode16Format({ | |
| required XCResultIssueType type, | |
| required Object? jsonList, | |
| required List<XCResultIssueDiscarder> issueDiscarders, | |
| }) { | |
| if (jsonList is! List<Object?>) { | |
| return const <XCResultIssue>[]; | |
| } | |
| return jsonList | |
| .whereType<Map<String, Object?>>() | |
| .map((issueJson) => XCResultIssue.fromNewFormat(type: type, issueJson: issueJson)) | |
| .where((issue) { | |
| final bool shouldDiscard = issueDiscarders.any( | |
| (discarder) => _shouldDiscardIssue(issue: issue, discarder: discarder), | |
| ); | |
| return !shouldDiscard; | |
| }) | |
| .toList(); | |
| } | |
| /// Helper to parse issues from the old (pre-Xcode 16) format. | |
| List<XCResultIssue> _parseIssuesFromXcode15Format({ | |
| required XCResultIssueType type, | |
| required Map<String, Object?> issueSummariesJson, | |
| required List<XCResultIssueDiscarder> issueDiscarder, | |
| }) { | |
| final issues = <XCResultIssue>[]; | |
| final Object? errorsList = issueSummariesJson['_values']; | |
| if (errorsList is List<Object?>) { | |
| for (final Object? issueJson in errorsList) { | |
| if (issueJson is! Map<String, Object?>) { | |
| continue; | |
| } | |
| final resultIssue = XCResultIssue.fromOldFormat(type: type, issueJson: issueJson); | |
| var discard = false; | |
| for (final discarder in issueDiscarder) { | |
| if (_shouldDiscardIssue(issue: resultIssue, discarder: discarder)) { | |
| discard = true; | |
| break; | |
| } | |
| } | |
| if (!discard) { | |
| issues.add(resultIssue); | |
| } | |
| } | |
| } | |
| return issues; | |
| } | |
| /// Helper to parse issues from the `actions` block in the pre-Xcode 16 format. | |
| List<XCResultIssue> _parseActionIssues( | |
| Map<String, Object?> actionsMap, { | |
| required List<XCResultIssueDiscarder> issueDiscarders, | |
| }) { | |
| // Example of json: | |
| // { | |
| // "actions" : { | |
| // "_values" : [ | |
| // { | |
| // "actionResult" : { | |
| // "_type" : { | |
| // "_name" : "ActionResult" | |
| // }, | |
| // "issues" : { | |
| // "_type" : { | |
| // "_name" : "ResultIssueSummaries" | |
| // }, | |
| // "testFailureSummaries" : { | |
| // "_type" : { | |
| // "_name" : "Array" | |
| // }, | |
| // "_values" : [ | |
| // { | |
| // "_type" : { | |
| // "_name" : "TestFailureIssueSummary", | |
| // "_supertype" : { | |
| // "_name" : "IssueSummary" | |
| // } | |
| // }, | |
| // "issueType" : { | |
| // "_type" : { | |
| // "_name" : "String" | |
| // }, | |
| // "_value" : "Uncategorized" | |
| // }, | |
| // "message" : { | |
| // "_type" : { | |
| // "_name" : "String" | |
| // }, | |
| // "_value" : "Unable to find a destination matching the provided destination specifier:\n\t\t{ id:1234D567-890C-1DA2-34E5-F6789A0123C4 }\n\n\tIneligible destinations for the \"Runner\" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device, error:iOS 17.0 is not installed. To use with Xcode, first download and install the platform }" | |
| // } | |
| // } | |
| // ] | |
| // } | |
| // } | |
| // } | |
| // } | |
| // ] | |
| // } | |
| // } | |
| final issues = <XCResultIssue>[]; | |
| final Object? actionsValues = actionsMap['_values']; | |
| if (actionsValues is! List<Object?>) { | |
| return issues; | |
| } | |
| for (final Object? actionValue in actionsValues) { | |
| if (actionValue is! Map<String, Object?>) { | |
| continue; | |
| } | |
| final Object? actionResult = actionValue['actionResult']; | |
| if (actionResult is! Map<String, Object?>) { | |
| continue; | |
| } | |
| final Object? actionResultIssues = actionResult['issues']; | |
| if (actionResultIssues is! Map<String, Object?>) { | |
| continue; | |
| } | |
| final Object? testFailureSummaries = actionResultIssues['testFailureSummaries']; | |
| if (testFailureSummaries is Map<String, Object?>) { | |
| issues.addAll( | |
| _parseIssuesFromXcode15Format( | |
| type: XCResultIssueType.error, | |
| issueSummariesJson: testFailureSummaries, | |
| issueDiscarder: issueDiscarders, | |
| ), | |
| ); | |
| } | |
| } | |
| return issues; | |
| } |