blob: f2601369b032ae04372fa510cf000a9875fb0872 [file]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:yaml_edit/yaml_edit.dart';
import '../api_summary.dart';
/// Supported serialization formats for [ApiSummary].
enum ApiSummaryFormat {
/// Human-readable text summary (`api.txt`).
text('api.txt'),
/// Pretty-printed JSON summary (`api.json`).
json('api.json'),
/// YAML summary (`api.yaml`).
yaml('api.yaml');
const ApiSummaryFormat(this.defaultFileName);
/// The default golden file name for this format in a package root.
final String defaultFileName;
/// Formats [summary] according to this format.
String format(ApiSummary summary) => switch (this) {
.text => summary.toString(),
.json =>
'${const JsonEncoder.withIndent(' ').convert(summary.toJson())}\n',
.yaml => () {
final editor = YamlEditor('');
editor.update([], summary.toJson());
return '$editor\n';
}(),
};
}
/// Exception thrown by [expectApiSummaryClean] when a package's golden API
/// summary file is missing or out of sync with the package's public API.
final class ApiSummaryVerificationException implements Exception {
/// A human-readable description of the verification failure, including a
/// line diff and remediation command.
final String message;
/// The path to the golden file that was checked.
final String goldenFilePath;
/// The expected content read from [goldenFilePath], or `null` if the file
/// did not exist.
final String? expected;
/// The actual summary generated from the package's current source code.
final String actual;
ApiSummaryVerificationException({
required this.message,
required this.goldenFilePath,
required this.actual,
this.expected,
});
@override
String toString() => 'ApiSummaryVerificationException: $message';
}
/// Verifies that the package's generated [ApiSummary] matches the golden file
/// at [goldenFilePath] (defaults to [ApiSummaryFormat.defaultFileName] inside
/// [packagePath]).
///
/// If [packagePath] is omitted, defaults to [Directory.current].
///
/// Throws an [ApiSummaryVerificationException] if the golden file does not
/// exist or if its contents differ from the generated summary.
///
/// Because all parameters are optional and named, [expectApiSummaryClean] can
/// be passed directly as a tear-off to `test()`:
/// ```dart
/// import 'package:api_summary/api_summary.dart';
/// import 'package:test/scaffolding.dart';
///
/// void main() {
/// test('api_summary', expectApiSummaryClean);
/// }
/// ```
Future<void> expectApiSummaryClean({
String? goldenFilePath,
String? packagePath,
String? packageName,
ApiSummaryCustomizer? customizer,
ApiSummaryFormat format = ApiSummaryFormat.text,
}) async {
final resolvedPackagePath = p.normalize(
p.absolute(packagePath ?? Directory.current.path),
);
final effectiveGoldenPath = goldenFilePath == null
? p.join(resolvedPackagePath, format.defaultFileName)
: (p.isAbsolute(goldenFilePath)
? p.normalize(goldenFilePath)
: p.normalize(p.join(resolvedPackagePath, goldenFilePath)));
final summary = await apiSummary(
resolvedPackagePath,
packageName: packageName,
customizer: customizer,
);
final actualOutput = format.format(summary);
final goldenFile = File(effectiveGoldenPath);
final displayGoldenPath = p.isWithin(resolvedPackagePath, effectiveGoldenPath)
? p.relative(effectiveGoldenPath, from: resolvedPackagePath)
: effectiveGoldenPath;
final remediationCommand = _buildRemediationCommand(
format: format,
displayGoldenPath: displayGoldenPath,
);
if (!goldenFile.existsSync()) {
throw ApiSummaryVerificationException(
message:
'Golden file "$displayGoldenPath" does not exist at '
'"$effectiveGoldenPath".\n'
'To generate it, run:\n'
' $remediationCommand',
goldenFilePath: effectiveGoldenPath,
actual: actualOutput,
);
}
final expectedRaw = goldenFile.readAsStringSync();
final expectedLines = LineSplitter.split(expectedRaw).toList();
final actualLines = LineSplitter.split(actualOutput).toList();
if (!_linesEqual(expectedLines, actualLines)) {
final diff = _buildLineDiff(expectedLines, actualLines);
throw ApiSummaryVerificationException(
message:
'"$displayGoldenPath" does not match the current public API of '
'package "${summary.name}".\n\n'
'$diff\n\n'
'To update the golden file, run:\n'
' $remediationCommand',
goldenFilePath: effectiveGoldenPath,
expected: expectedRaw,
actual: actualOutput,
);
}
}
/// Builds the CLI command shown in failure messages to regenerate or update
/// the golden file at [displayGoldenPath].
///
/// Uses `--write` when [displayGoldenPath] matches [format]'s
/// [ApiSummaryFormat.defaultFileName], and `--output=<path>` otherwise.
String _buildRemediationCommand({
required ApiSummaryFormat format,
required String displayGoldenPath,
}) => [
'dart run api_summary',
if (displayGoldenPath == format.defaultFileName) ...[
'--write',
if (format != .text) '--format=${format.name}',
] else ...[
if (format != .text) '--format=${format.name}',
'--output=$displayGoldenPath',
],
].join(' ');
/// Whether [a] and [b] contain identical lines in the same order.
bool _linesEqual(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
/// Formats a compact unified-style diff of the first differing span between
/// [expected] and [actual].
///
/// Strips matching leading and trailing lines, emits a 1-based
/// `@@ line <N> @@` header at the start of the divergence, and lists up to
/// 20 removed (`-`) and 20 added (`+`) lines before summarizing any
/// remaining lines.
String _buildLineDiff(List<String> expected, List<String> actual) {
var prefix = 0;
final minLen = expected.length < actual.length
? expected.length
: actual.length;
while (prefix < minLen && expected[prefix] == actual[prefix]) {
prefix++;
}
var expectedSuffix = expected.length;
var actualSuffix = actual.length;
while (expectedSuffix > prefix &&
actualSuffix > prefix &&
expected[expectedSuffix - 1] == actual[actualSuffix - 1]) {
expectedSuffix--;
actualSuffix--;
}
final buffer = StringBuffer();
buffer.writeln('@@ line ${prefix + 1} @@');
const maxLinesShown = 20;
final removedCount = expectedSuffix - prefix;
for (var i = 0; i < removedCount && i < maxLinesShown; i++) {
buffer.writeln('- ${expected[prefix + i]}');
}
if (removedCount > maxLinesShown) {
buffer.writeln('... (${removedCount - maxLinesShown} more removed lines)');
}
final addedCount = actualSuffix - prefix;
for (var i = 0; i < addedCount && i < maxLinesShown; i++) {
buffer.writeln('+ ${actual[prefix + i]}');
}
if (addedCount > maxLinesShown) {
buffer.writeln('... (${addedCount - maxLinesShown} more added lines)');
}
return buffer.toString().trimRight();
}