diff --git a/.github/ISSUE_TEMPLATE/pubspec_parse.md b/.github/ISSUE_TEMPLATE/pubspec_parse.md new file mode 100644 index 000000000..2d6588102 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pubspec_parse.md @@ -0,0 +1,5 @@ +--- +name: "package:pubspec_parse" +about: "Create a bug or file a feature request against package:pubspec_parse." +labels: "package:pubspec_parse" +--- \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index bfef3164e..3ab79c051 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -92,6 +92,10 @@ - changed-files: - any-glob-to-any-file: 'pkgs/pub_semver/**' +'package:pubspec_parse': + - changed-files: + - any-glob-to-any-file: 'pkgs/pubspec_parse/**' + 'package:source_map_stack_trace': - changed-files: - any-glob-to-any-file: 'pkgs/source_map_stack_trace/**' diff --git a/.github/workflows/pubspec_parse.yaml b/.github/workflows/pubspec_parse.yaml new file mode 100644 index 000000000..ebe705912 --- /dev/null +++ b/.github/workflows/pubspec_parse.yaml @@ -0,0 +1,71 @@ +name: package:pubspec_parse + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/pubspec_parse.yaml' + - 'pkgs/pubspec_parse/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/pubspec_parse.yaml' + - 'pkgs/pubspec_parse/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/pubspec_parse/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + sdk: [3.2, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm --run-skipped + if: always() && steps.install.outcome == 'success' diff --git a/README.md b/README.md index d1a1d0416..0201aa284 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ don't naturally belong to other topic monorepos (like | [package_config](pkgs/package_config/) | Support for reading and writing Dart Package Configuration files. | [![package issues](https://img.shields.io/badge/package:package_config-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Apackage_config) | [![pub package](https://img.shields.io/pub/v/package_config.svg)](https://pub.dev/packages/package_config) | | [pool](pkgs/pool/) | Manage a finite pool of resources. Useful for controlling concurrent file system or network requests. | [![package issues](https://img.shields.io/badge/package:pool-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Apool) | [![pub package](https://img.shields.io/pub/v/pool.svg)](https://pub.dev/packages/pool) | | [pub_semver](pkgs/pub_semver/) | Versions and version constraints implementing pub's versioning policy. This is very similar to vanilla semver, with a few corner cases. | [![package issues](https://img.shields.io/badge/package:pub_semver-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Apub_semver) | [![pub package](https://img.shields.io/pub/v/pub_semver.svg)](https://pub.dev/packages/pub_semver) | +| [pubspec_parse](pkgs/pubspec_parse/) | Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. | [![package issues](https://img.shields.io/badge/package:pubspec_parse-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Apubspec_parse) | [![pub package](https://img.shields.io/pub/v/pubspec_parse.svg)](https://pub.dev/packages/pubspec_parse) | | [source_map_stack_trace](pkgs/source_map_stack_trace/) | A package for applying source maps to stack traces. | [![package issues](https://img.shields.io/badge/package:source_map_stack_trace-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asource_map_stack_trace) | [![pub package](https://img.shields.io/pub/v/source_map_stack_trace.svg)](https://pub.dev/packages/source_map_stack_trace) | | [source_maps](pkgs/source_maps/) | A library to programmatically manipulate source map files. | [![package issues](https://img.shields.io/badge/package:source_maps-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asource_maps) | [![pub package](https://img.shields.io/pub/v/source_maps.svg)](https://pub.dev/packages/source_maps) | | [source_span](pkgs/source_span/) | Provides a standard representation for source code locations and spans. | [![package issues](https://img.shields.io/badge/package:source_span-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asource_span) | [![pub package](https://img.shields.io/pub/v/source_span.svg)](https://pub.dev/packages/source_span) | diff --git a/pkgs/pubspec_parse/.gitignore b/pkgs/pubspec_parse/.gitignore new file mode 100644 index 000000000..ec8eae3f1 --- /dev/null +++ b/pkgs/pubspec_parse/.gitignore @@ -0,0 +1,4 @@ +# Don’t commit the following directories created by pub. +.dart_tool/ +.packages +pubspec.lock diff --git a/pkgs/pubspec_parse/CHANGELOG.md b/pkgs/pubspec_parse/CHANGELOG.md new file mode 100644 index 000000000..a5f0f1a30 --- /dev/null +++ b/pkgs/pubspec_parse/CHANGELOG.md @@ -0,0 +1,104 @@ +## 1.4.0 + +- Require Dart 3.2 +- Seal the `Dependency` class. +- Set `Pubspec.environment` to non-nullable. +- Remove deprecated package_api_docs rule +- Move to `dart-lang/tools` monorepo. + +## 1.3.0 + +- Require Dart 3.0 +- Added support for `ignored_advisories` field. +- Added structural equality for `Dependency` subclasses and `HostedDetails`. + +## 1.2.3 + +- Added topics to `pubspec.yaml`. + +## 1.2.2 + +- Require Dart SDK >= 2.18.0 +- Required `json_annotation: ^4.8.0` +- Added support for `topics` field. + +## 1.2.1 + +- Added support for `funding` field. + +## 1.2.0 + +- Added support for `screenshots` field. +- Update `HostedDetails` to reflect how `hosted` dependencies are parsed in + Dart 2.15: + - Add `HostedDetails.declaredName` as the (optional) `name` property in a + `hosted` block. + - `HostedDetails.name` now falls back to the name of the dependency if no + name is declared in the block. +- Require Dart SDK >= 2.14.0 + +## 1.1.0 + +- Export `HostedDetails` publicly. + +## 1.0.0 + +- Migrate to null-safety. +- Pubspec: `author` and `authors` are both now deprecated. + See https://dart.dev/tools/pub/pubspec#authorauthors + +## 0.1.8 + +- Allow the latest `package:pub_semver`. + +## 0.1.7 + +- Allow `package:yaml` `v3.x`. + +## 0.1.6 + +- Update SDK requirement to `>=2.7.0 <3.0.0`. +- Allow `package:json_annotation` `v4.x`. + +## 0.1.5 + +- Update SDK requirement to `>=2.2.0 <3.0.0`. +- Support the latest `package:json_annotation`. + +## 0.1.4 + +- Added `lenient` named argument to `Pubspec.fromJson` to ignore format and type errors. + +## 0.1.3 + +- Added support for `flutter`, `issue_tracker`, `publish_to`, and `repository` + fields. + +## 0.1.2+3 + +- Support the latest version of `package:json_annotation`. + +## 0.1.2+2 + +- Support `package:json_annotation` v1. + +## 0.1.2+1 + +- Support the Dart 2 stable release. + +## 0.1.2 + +- Allow superfluous `version` keys with `git` and `path` dependencies. +- Improve errors when unsupported keys are provided in dependencies. +- Provide better errors with invalid `sdk` dependency values. +- Support "scp-like syntax" for Git SSH URIs in the form + `[user@]host.xz:path/to/repo.git/`. + +## 0.1.1 + +- Fixed name collision with error type in latest `package:json_annotation`. +- Improved parsing of hosted dependencies and environment constraints. + +## 0.1.0 + +- Initial release. diff --git a/pkgs/pubspec_parse/LICENSE b/pkgs/pubspec_parse/LICENSE new file mode 100644 index 000000000..4d1ad40a1 --- /dev/null +++ b/pkgs/pubspec_parse/LICENSE @@ -0,0 +1,27 @@ +Copyright 2018, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/pubspec_parse/README.md b/pkgs/pubspec_parse/README.md new file mode 100644 index 000000000..1d04aa486 --- /dev/null +++ b/pkgs/pubspec_parse/README.md @@ -0,0 +1,12 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/pubspec_parse.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/pubspec_parse.yaml) +[![pub package](https://img.shields.io/pub/v/pubspec_parse.svg)](https://pub.dev/packages/pubspec_parse) +[![package publisher](https://img.shields.io/pub/publisher/pubspec_parse.svg)](https://pub.dev/packages/pubspec_parse/publisher) + +## What's this? + +Supports parsing `pubspec.yaml` files with robust error reporting and support +for most of the documented features. + +## More information + +Read more about the [pubspec format](https://dart.dev/tools/pub/pubspec). diff --git a/pkgs/pubspec_parse/analysis_options.yaml b/pkgs/pubspec_parse/analysis_options.yaml new file mode 100644 index 000000000..93eeebff2 --- /dev/null +++ b/pkgs/pubspec_parse/analysis_options.yaml @@ -0,0 +1,30 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - cascade_invocations + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - require_trailing_commas + - unnecessary_await_in_return + - use_string_buffers diff --git a/pkgs/pubspec_parse/build.yaml b/pkgs/pubspec_parse/build.yaml new file mode 100644 index 000000000..2003bc29e --- /dev/null +++ b/pkgs/pubspec_parse/build.yaml @@ -0,0 +1,25 @@ +# Read about `build.yaml` at https://pub.dev/packages/build_config +# To update generated code, run `pub run build_runner build` +targets: + $default: + builders: + json_serializable: + generate_for: + - lib/src/pubspec.dart + - lib/src/dependency.dart + options: + any_map: true + checked: true + create_to_json: false + field_rename: snake + + # The end-user of a builder which applies "source_gen|combining_builder" + # may configure the builder to ignore specific lints for their project + source_gen|combining_builder: + options: + ignore_for_file: + - deprecated_member_use_from_same_package + - lines_longer_than_80_chars + - require_trailing_commas + # https://github.com/google/json_serializable.dart/issues/945 + - unnecessary_cast diff --git a/pkgs/pubspec_parse/dart_test.yaml b/pkgs/pubspec_parse/dart_test.yaml new file mode 100644 index 000000000..1d7ac69cc --- /dev/null +++ b/pkgs/pubspec_parse/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + presubmit-only: + skip: "Should only be run during presubmit" diff --git a/pkgs/pubspec_parse/lib/pubspec_parse.dart b/pkgs/pubspec_parse/lib/pubspec_parse.dart new file mode 100644 index 000000000..b5c12e414 --- /dev/null +++ b/pkgs/pubspec_parse/lib/pubspec_parse.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2018, 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. + +export 'src/dependency.dart' + show + Dependency, + GitDependency, + HostedDependency, + HostedDetails, + PathDependency, + SdkDependency; +export 'src/pubspec.dart' show Pubspec; +export 'src/screenshot.dart' show Screenshot; diff --git a/pkgs/pubspec_parse/lib/src/dependency.dart b/pkgs/pubspec_parse/lib/src/dependency.dart new file mode 100644 index 000000000..24c65eac1 --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/dependency.dart @@ -0,0 +1,277 @@ +// Copyright (c) 2018, 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 'package:collection/collection.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:yaml/yaml.dart'; + +part 'dependency.g.dart'; + +Map parseDeps(Map? source) => + source?.map((k, v) { + final key = k as String; + Dependency? value; + try { + value = _fromJson(v, k); + } on CheckedFromJsonException catch (e) { + if (e.map is! YamlMap) { + // This is likely a "synthetic" map created from a String value + // Use `source` to throw this exception with an actual YamlMap and + // extract the associated error information. + throw CheckedFromJsonException(source, key, e.className!, e.message); + } + rethrow; + } + + if (value == null) { + throw CheckedFromJsonException( + source, + key, + 'Pubspec', + 'Not a valid dependency value.', + ); + } + return MapEntry(key, value); + }) ?? + {}; + +const _sourceKeys = ['sdk', 'git', 'path', 'hosted']; + +/// Returns `null` if the data could not be parsed. +Dependency? _fromJson(Object? data, String name) { + if (data is String || data == null) { + return _$HostedDependencyFromJson({'version': data}); + } + + if (data is Map) { + final matchedKeys = + data.keys.cast().where((key) => key != 'version').toList(); + + if (data.isEmpty || (matchedKeys.isEmpty && data.containsKey('version'))) { + return _$HostedDependencyFromJson(data); + } else { + final firstUnrecognizedKey = + matchedKeys.firstWhereOrNull((k) => !_sourceKeys.contains(k)); + + return $checkedNew('Dependency', data, () { + if (firstUnrecognizedKey != null) { + throw UnrecognizedKeysException( + [firstUnrecognizedKey], + data, + _sourceKeys, + ); + } + if (matchedKeys.length > 1) { + throw CheckedFromJsonException( + data, + matchedKeys[1], + 'Dependency', + 'A dependency may only have one source.', + ); + } + + final key = matchedKeys.single; + + return switch (key) { + 'git' => GitDependency.fromData(data[key]), + 'path' => PathDependency.fromData(data[key]), + 'sdk' => _$SdkDependencyFromJson(data), + 'hosted' => _$HostedDependencyFromJson(data) + ..hosted?._nameOfPackage = name, + _ => throw StateError('There is a bug in pubspec_parse.'), + }; + }); + } + } + + // Not a String or a Map – return null so parent logic can throw proper error + return null; +} + +sealed class Dependency {} + +@JsonSerializable() +class SdkDependency extends Dependency { + final String sdk; + @JsonKey(fromJson: _constraintFromString) + final VersionConstraint version; + + SdkDependency(this.sdk, {VersionConstraint? version}) + : version = version ?? VersionConstraint.any; + + @override + bool operator ==(Object other) => + other is SdkDependency && other.sdk == sdk && other.version == version; + + @override + int get hashCode => Object.hash(sdk, version); + + @override + String toString() => 'SdkDependency: $sdk'; +} + +@JsonSerializable() +class GitDependency extends Dependency { + @JsonKey(fromJson: parseGitUri) + final Uri url; + final String? ref; + final String? path; + + GitDependency(this.url, {this.ref, this.path}); + + factory GitDependency.fromData(Object? data) { + if (data is String) { + data = {'url': data}; + } + + if (data is Map) { + return _$GitDependencyFromJson(data); + } + + throw ArgumentError.value(data, 'git', 'Must be a String or a Map.'); + } + + @override + bool operator ==(Object other) => + other is GitDependency && + other.url == url && + other.ref == ref && + other.path == path; + + @override + int get hashCode => Object.hash(url, ref, path); + + @override + String toString() => 'GitDependency: url@$url'; +} + +Uri? parseGitUriOrNull(String? value) => + value == null ? null : parseGitUri(value); + +Uri parseGitUri(String value) => _tryParseScpUri(value) ?? Uri.parse(value); + +/// Supports URIs like `[user@]host.xz:path/to/repo.git/` +/// See https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a +Uri? _tryParseScpUri(String value) { + final colonIndex = value.indexOf(':'); + + if (colonIndex < 0) { + return null; + } else if (colonIndex == value.indexOf('://')) { + // If the first colon is part of a scheme, it's not an scp-like URI + return null; + } + final slashIndex = value.indexOf('/'); + + if (slashIndex >= 0 && slashIndex < colonIndex) { + // Per docs: This syntax is only recognized if there are no slashes before + // the first colon. This helps differentiate a local path that contains a + // colon. For example the local path foo:bar could be specified as an + // absolute path or ./foo:bar to avoid being misinterpreted as an ssh url. + return null; + } + + final atIndex = value.indexOf('@'); + if (colonIndex > atIndex) { + final user = atIndex >= 0 ? value.substring(0, atIndex) : null; + final host = value.substring(atIndex + 1, colonIndex); + final path = value.substring(colonIndex + 1); + return Uri(scheme: 'ssh', userInfo: user, host: host, path: path); + } + return null; +} + +class PathDependency extends Dependency { + final String path; + + PathDependency(this.path); + + factory PathDependency.fromData(Object? data) { + if (data is String) { + return PathDependency(data); + } + throw ArgumentError.value(data, 'path', 'Must be a String.'); + } + + @override + bool operator ==(Object other) => + other is PathDependency && other.path == path; + + @override + int get hashCode => path.hashCode; + + @override + String toString() => 'PathDependency: path@$path'; +} + +@JsonSerializable(disallowUnrecognizedKeys: true) +class HostedDependency extends Dependency { + @JsonKey(fromJson: _constraintFromString) + final VersionConstraint version; + + @JsonKey(disallowNullValue: true) + final HostedDetails? hosted; + + HostedDependency({VersionConstraint? version, this.hosted}) + : version = version ?? VersionConstraint.any; + + @override + bool operator ==(Object other) => + other is HostedDependency && + other.version == version && + other.hosted == hosted; + + @override + int get hashCode => Object.hash(version, hosted); + + @override + String toString() => 'HostedDependency: $version'; +} + +@JsonSerializable(disallowUnrecognizedKeys: true) +class HostedDetails { + /// The name of the target dependency as declared in a `hosted` block. + /// + /// This may be null if no explicit name is present, for instance because the + /// hosted dependency was declared as a string (`hosted: pub.example.org`). + @JsonKey(name: 'name') + final String? declaredName; + + @JsonKey(fromJson: parseGitUriOrNull, disallowNullValue: true) + final Uri? url; + + @JsonKey(includeFromJson: false, includeToJson: false) + String? _nameOfPackage; + + /// The name of this package on the package repository. + /// + /// If this hosted block has a [declaredName], that one will be used. + /// Otherwise, the name will be inferred from the surrounding package name. + String get name => declaredName ?? _nameOfPackage!; + + HostedDetails(this.declaredName, this.url); + + factory HostedDetails.fromJson(Object data) { + if (data is String) { + data = {'url': data}; + } + + if (data is Map) { + return _$HostedDetailsFromJson(data); + } + + throw ArgumentError.value(data, 'hosted', 'Must be a Map or String.'); + } + + @override + bool operator ==(Object other) => + other is HostedDetails && other.name == name && other.url == url; + + @override + int get hashCode => Object.hash(name, url); +} + +VersionConstraint _constraintFromString(String? input) => + input == null ? VersionConstraint.any : VersionConstraint.parse(input); diff --git a/pkgs/pubspec_parse/lib/src/dependency.g.dart b/pkgs/pubspec_parse/lib/src/dependency.g.dart new file mode 100644 index 000000000..1a504f1fd --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/dependency.g.dart @@ -0,0 +1,72 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: deprecated_member_use_from_same_package, lines_longer_than_80_chars, require_trailing_commas, unnecessary_cast + +part of 'dependency.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SdkDependency _$SdkDependencyFromJson(Map json) => $checkedCreate( + 'SdkDependency', + json, + ($checkedConvert) { + final val = SdkDependency( + $checkedConvert('sdk', (v) => v as String), + version: $checkedConvert( + 'version', (v) => _constraintFromString(v as String?)), + ); + return val; + }, + ); + +GitDependency _$GitDependencyFromJson(Map json) => $checkedCreate( + 'GitDependency', + json, + ($checkedConvert) { + final val = GitDependency( + $checkedConvert('url', (v) => parseGitUri(v as String)), + ref: $checkedConvert('ref', (v) => v as String?), + path: $checkedConvert('path', (v) => v as String?), + ); + return val; + }, + ); + +HostedDependency _$HostedDependencyFromJson(Map json) => $checkedCreate( + 'HostedDependency', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['version', 'hosted'], + disallowNullValues: const ['hosted'], + ); + final val = HostedDependency( + version: $checkedConvert( + 'version', (v) => _constraintFromString(v as String?)), + hosted: $checkedConvert('hosted', + (v) => v == null ? null : HostedDetails.fromJson(v as Object)), + ); + return val; + }, + ); + +HostedDetails _$HostedDetailsFromJson(Map json) => $checkedCreate( + 'HostedDetails', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['name', 'url'], + disallowNullValues: const ['url'], + ); + final val = HostedDetails( + $checkedConvert('name', (v) => v as String?), + $checkedConvert('url', (v) => parseGitUriOrNull(v as String?)), + ); + return val; + }, + fieldKeyMap: const {'declaredName': 'name'}, + ); diff --git a/pkgs/pubspec_parse/lib/src/pubspec.dart b/pkgs/pubspec_parse/lib/src/pubspec.dart new file mode 100644 index 000000000..1317a2309 --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/pubspec.dart @@ -0,0 +1,226 @@ +// Copyright (c) 2018, 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 'package:checked_yaml/checked_yaml.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import 'dependency.dart'; +import 'screenshot.dart'; + +part 'pubspec.g.dart'; + +@JsonSerializable() +class Pubspec { + // TODO: executables + + final String name; + + @JsonKey(fromJson: _versionFromString) + final Version? version; + + final String? description; + + /// This should be a URL pointing to the website for the package. + final String? homepage; + + /// Specifies where to publish this package. + /// + /// Accepted values: `null`, `'none'` or an `http` or `https` URL. + /// + /// [More information](https://dart.dev/tools/pub/pubspec#publish_to). + final String? publishTo; + + /// Optional field to specify the source code repository of the package. + /// Useful when a package has both a home page and a repository. + final Uri? repository; + + /// Optional field to a web page where developers can report new issues or + /// view existing ones. + final Uri? issueTracker; + + /// Optional field to list the URLs where the package authors accept + /// support or funding. + final List? funding; + + /// Optional field to list the topics that this packages belongs to. + final List? topics; + + /// Optional field to list advisories to be ignored by the client. + final List? ignoredAdvisories; + + /// Optional field for specifying included screenshot files. + @JsonKey(fromJson: parseScreenshots) + final List? screenshots; + + /// If there is exactly 1 value in [authors], returns it. + /// + /// If there are 0 or more than 1, returns `null`. + @Deprecated( + 'See https://dart.dev/tools/pub/pubspec#authorauthors', + ) + String? get author { + if (authors.length == 1) { + return authors.single; + } + return null; + } + + @Deprecated( + 'See https://dart.dev/tools/pub/pubspec#authorauthors', + ) + final List authors; + final String? documentation; + + @JsonKey(fromJson: _environmentMap) + final Map environment; + + @JsonKey(fromJson: parseDeps) + final Map dependencies; + + @JsonKey(fromJson: parseDeps) + final Map devDependencies; + + @JsonKey(fromJson: parseDeps) + final Map dependencyOverrides; + + /// Optional configuration specific to [Flutter](https://flutter.io/) + /// packages. + /// + /// May include + /// [assets](https://flutter.io/docs/development/ui/assets-and-images) + /// and other settings. + final Map? flutter; + + /// If [author] and [authors] are both provided, their values are combined + /// with duplicates eliminated. + Pubspec( + this.name, { + this.version, + this.publishTo, + @Deprecated( + 'See https://dart.dev/tools/pub/pubspec#authorauthors', + ) + String? author, + @Deprecated( + 'See https://dart.dev/tools/pub/pubspec#authorauthors', + ) + List? authors, + Map? environment, + this.homepage, + this.repository, + this.issueTracker, + this.funding, + this.topics, + this.ignoredAdvisories, + this.screenshots, + this.documentation, + this.description, + Map? dependencies, + Map? devDependencies, + Map? dependencyOverrides, + this.flutter, + }) : + // ignore: deprecated_member_use_from_same_package + authors = _normalizeAuthors(author, authors), + environment = environment ?? const {}, + dependencies = dependencies ?? const {}, + devDependencies = devDependencies ?? const {}, + dependencyOverrides = dependencyOverrides ?? const {} { + if (name.isEmpty) { + throw ArgumentError.value(name, 'name', '"name" cannot be empty.'); + } + + if (publishTo != null && publishTo != 'none') { + try { + final targetUri = Uri.parse(publishTo!); + if (!(targetUri.isScheme('http') || targetUri.isScheme('https'))) { + throw const FormatException('Must be an http or https URL.'); + } + } on FormatException catch (e) { + throw ArgumentError.value(publishTo, 'publishTo', e.message); + } + } + } + + factory Pubspec.fromJson(Map json, {bool lenient = false}) { + if (lenient) { + while (json.isNotEmpty) { + // Attempting to remove top-level properties that cause parsing errors. + try { + return _$PubspecFromJson(json); + } on CheckedFromJsonException catch (e) { + if (e.map == json && json.containsKey(e.key)) { + json = Map.from(json)..remove(e.key); + continue; + } + rethrow; + } + } + } + + return _$PubspecFromJson(json); + } + + /// Parses source [yaml] into [Pubspec]. + /// + /// When [lenient] is set, top-level property-parsing or type cast errors are + /// ignored and `null` values are returned. + factory Pubspec.parse(String yaml, {Uri? sourceUrl, bool lenient = false}) => + checkedYamlDecode( + yaml, + (map) => Pubspec.fromJson(map!, lenient: lenient), + sourceUrl: sourceUrl, + ); + + static List _normalizeAuthors(String? author, List? authors) { + final value = { + if (author != null) author, + ...?authors, + }; + return value.toList(); + } +} + +Version? _versionFromString(String? input) => + input == null ? null : Version.parse(input); + +Map _environmentMap(Map? source) => + source?.map((k, value) { + final key = k as String; + if (key == 'dart') { + // github.com/dart-lang/pub/blob/d84173eeb03c3/lib/src/pubspec.dart#L342 + // 'dart' is not allowed as a key! + throw CheckedFromJsonException( + source, + 'dart', + 'VersionConstraint', + 'Use "sdk" to for Dart SDK constraints.', + badKey: true, + ); + } + + VersionConstraint? constraint; + if (value == null) { + constraint = null; + } else if (value is String) { + try { + constraint = VersionConstraint.parse(value); + } on FormatException catch (e) { + throw CheckedFromJsonException(source, key, 'Pubspec', e.message); + } + + return MapEntry(key, constraint); + } else { + throw CheckedFromJsonException( + source, + key, + 'VersionConstraint', + '`$value` is not a String.', + ); + } + + return MapEntry(key, constraint); + }) ?? + {}; diff --git a/pkgs/pubspec_parse/lib/src/pubspec.g.dart b/pkgs/pubspec_parse/lib/src/pubspec.g.dart new file mode 100644 index 000000000..fc285718d --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/pubspec.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: deprecated_member_use_from_same_package, lines_longer_than_80_chars, require_trailing_commas, unnecessary_cast + +part of 'pubspec.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Pubspec _$PubspecFromJson(Map json) => $checkedCreate( + 'Pubspec', + json, + ($checkedConvert) { + final val = Pubspec( + $checkedConvert('name', (v) => v as String), + version: $checkedConvert( + 'version', (v) => _versionFromString(v as String?)), + publishTo: $checkedConvert('publish_to', (v) => v as String?), + author: $checkedConvert('author', (v) => v as String?), + authors: $checkedConvert('authors', + (v) => (v as List?)?.map((e) => e as String).toList()), + environment: + $checkedConvert('environment', (v) => _environmentMap(v as Map?)), + homepage: $checkedConvert('homepage', (v) => v as String?), + repository: $checkedConvert( + 'repository', (v) => v == null ? null : Uri.parse(v as String)), + issueTracker: $checkedConvert('issue_tracker', + (v) => v == null ? null : Uri.parse(v as String)), + funding: $checkedConvert( + 'funding', + (v) => (v as List?) + ?.map((e) => Uri.parse(e as String)) + .toList()), + topics: $checkedConvert('topics', + (v) => (v as List?)?.map((e) => e as String).toList()), + ignoredAdvisories: $checkedConvert('ignored_advisories', + (v) => (v as List?)?.map((e) => e as String).toList()), + screenshots: $checkedConvert( + 'screenshots', (v) => parseScreenshots(v as List?)), + documentation: $checkedConvert('documentation', (v) => v as String?), + description: $checkedConvert('description', (v) => v as String?), + dependencies: + $checkedConvert('dependencies', (v) => parseDeps(v as Map?)), + devDependencies: + $checkedConvert('dev_dependencies', (v) => parseDeps(v as Map?)), + dependencyOverrides: $checkedConvert( + 'dependency_overrides', (v) => parseDeps(v as Map?)), + flutter: $checkedConvert( + 'flutter', + (v) => (v as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + ); + return val; + }, + fieldKeyMap: const { + 'publishTo': 'publish_to', + 'issueTracker': 'issue_tracker', + 'ignoredAdvisories': 'ignored_advisories', + 'devDependencies': 'dev_dependencies', + 'dependencyOverrides': 'dependency_overrides' + }, + ); diff --git a/pkgs/pubspec_parse/lib/src/screenshot.dart b/pkgs/pubspec_parse/lib/src/screenshot.dart new file mode 100644 index 000000000..f5f0be2ea --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/screenshot.dart @@ -0,0 +1,65 @@ +// Copyright (c) 2021, 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 'package:json_annotation/json_annotation.dart'; + +@JsonSerializable() +class Screenshot { + final String description; + final String path; + + Screenshot(this.description, this.path); +} + +List parseScreenshots(List? input) { + final res = []; + if (input == null) { + return res; + } + + for (final e in input) { + if (e is! Map) continue; + + final description = e['description']; + if (description == null) { + throw CheckedFromJsonException( + e, + 'description', + 'Screenshot', + 'Missing required key `description`', + ); + } + + if (description is! String) { + throw CheckedFromJsonException( + e, + 'description', + 'Screenshot', + '`$description` is not a String', + ); + } + + final path = e['path']; + if (path == null) { + throw CheckedFromJsonException( + e, + 'path', + 'Screenshot', + 'Missing required key `path`', + ); + } + + if (path is! String) { + throw CheckedFromJsonException( + e, + 'path', + 'Screenshot', + '`$path` is not a String', + ); + } + + res.add(Screenshot(description, path)); + } + return res; +} diff --git a/pkgs/pubspec_parse/pubspec.yaml b/pkgs/pubspec_parse/pubspec.yaml new file mode 100644 index 000000000..ad0c55e67 --- /dev/null +++ b/pkgs/pubspec_parse/pubspec.yaml @@ -0,0 +1,32 @@ +name: pubspec_parse +version: 1.4.0 +description: >- + Simple package for parsing pubspec.yaml files with a type-safe API and rich + error reporting. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/pubspec_parse + +topics: +- dart-pub + +environment: + sdk: ^3.2.0 + +dependencies: + checked_yaml: ^2.0.1 + collection: ^1.15.0 + json_annotation: ^4.8.0 + pub_semver: ^2.0.0 + yaml: ^3.0.0 + +dev_dependencies: + build_runner: ^2.2.1 + build_verify: ^3.0.0 + dart_flutter_team_lints: ^3.0.0 + json_serializable: ^6.6.0 + path: ^1.8.0 + # Needed because we are configuring `combining_builder` + source_gen: ^1.2.3 + stack_trace: ^1.10.0 + test: ^1.21.6 + test_descriptor: ^2.0.0 + test_process: ^2.0.0 diff --git a/pkgs/pubspec_parse/test/dependency_test.dart b/pkgs/pubspec_parse/test/dependency_test.dart new file mode 100644 index 000000000..f1e4f5776 --- /dev/null +++ b/pkgs/pubspec_parse/test/dependency_test.dart @@ -0,0 +1,446 @@ +// Copyright (c) 2018, 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:io'; + +import 'package:pub_semver/pub_semver.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('hosted', _hostedDependency); + group('git', _gitDependency); + group('sdk', _sdkDependency); + group('path', _pathDependency); + + group('errors', () { + test('List', () { + _expectThrows( + [], + r''' +line 4, column 10: Unsupported value for "dep". Not a valid dependency value. + ╷ +4 │ "dep": [] + │ ^^ + ╵''', + ); + }); + + test('int', () { + _expectThrows( + 42, + r''' +line 4, column 10: Unsupported value for "dep". Not a valid dependency value. + ╷ +4 │ "dep": 42 + │ ┌──────────^ +5 │ │ } + │ └─^ + ╵''', + ); + }); + + test('map with too many keys', () { + _expectThrows( + {'path': 'a', 'git': 'b'}, + r''' +line 6, column 11: Unsupported value for "git". A dependency may only have one source. + ╷ +6 │ "git": "b" + │ ^^^ + ╵''', + ); + }); + + test('map with unsupported keys', () { + _expectThrows( + {'bob': 'a', 'jones': 'b'}, + r''' +line 5, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted] + ╷ +5 │ "bob": "a", + │ ^^^^^ + ╵''', + ); + }); + }); +} + +void _hostedDependency() { + test('null', () async { + final dep = await _dependency(null); + expect(dep.version.toString(), 'any'); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: any'); + }); + + test('empty map', () async { + final dep = await _dependency({}); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: any'); + }); + + test('string version', () async { + final dep = await _dependency('^1.0.0'); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('bad string version', () { + _expectThrows( + 'not a version', + r''' +line 4, column 10: Unsupported value for "dep". Could not parse version "not a version". Unknown text at "not a version". + ╷ +4 │ "dep": "not a version" + │ ^^^^^^^^^^^^^^^ + ╵''', + ); + }); + + test('map w/ just version', () async { + final dep = await _dependency({'version': '^1.0.0'}); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('map w/ version and hosted as Map', () async { + final dep = await _dependency({ + 'version': '^1.0.0', + 'hosted': {'name': 'hosted_name', 'url': 'https://hosted_url'}, + }); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted!.name, 'hosted_name'); + expect(dep.hosted!.url.toString(), 'https://hosted_url'); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('map /w hosted as a map without name', () async { + final dep = await _dependency( + { + 'version': '^1.0.0', + 'hosted': {'url': 'https://hosted_url'}, + }, + skipTryPub: true, // todo: Unskip once pub supports this syntax + ); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted!.declaredName, isNull); + expect(dep.hosted!.name, 'dep'); + expect(dep.hosted!.url.toString(), 'https://hosted_url'); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('map w/ bad version value', () { + _expectThrows( + { + 'version': 'not a version', + 'hosted': {'name': 'hosted_name', 'url': 'hosted_url'}, + }, + r''' +line 5, column 15: Unsupported value for "version". Could not parse version "not a version". Unknown text at "not a version". + ╷ +5 │ "version": "not a version", + │ ^^^^^^^^^^^^^^^ + ╵''', + ); + }); + + test('map w/ extra keys should fail', () { + _expectThrows( + { + 'version': '^1.0.0', + 'hosted': {'name': 'hosted_name', 'url': 'hosted_url'}, + 'not_supported': null, + }, + r''' +line 10, column 4: Unrecognized keys: [not_supported]; supported keys: [sdk, git, path, hosted] + ╷ +10 │ "not_supported": null + │ ^^^^^^^^^^^^^^^ + ╵''', + ); + }); + + test('map w/ version and hosted as String', () async { + final dep = await _dependency( + {'version': '^1.0.0', 'hosted': 'hosted_url'}, + skipTryPub: true, // todo: Unskip once put supports this + ); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted!.declaredName, isNull); + expect(dep.hosted!.name, 'dep'); + expect(dep.hosted!.url, Uri.parse('hosted_url')); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('map w/ hosted as String', () async { + final dep = await _dependency({'hosted': 'hosted_url'}); + expect(dep.version, VersionConstraint.any); + expect(dep.hosted!.declaredName, isNull); + expect(dep.hosted!.name, 'dep'); + expect(dep.hosted!.url, Uri.parse('hosted_url')); + expect(dep.toString(), 'HostedDependency: any'); + }); + + test('map w/ null hosted should error', () { + _expectThrows( + {'hosted': null}, + r''' +line 5, column 4: These keys had `null` values, which is not allowed: [hosted] + ╷ +5 │ "hosted": null + │ ^^^^^^^^ + ╵''', + ); + }); + + test('map w/ null version is fine', () async { + final dep = await _dependency({'version': null}); + expect(dep.version, VersionConstraint.any); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: any'); + }); +} + +void _sdkDependency() { + test('without version', () async { + final dep = await _dependency({'sdk': 'flutter'}); + expect(dep.sdk, 'flutter'); + expect(dep.version, VersionConstraint.any); + expect(dep.toString(), 'SdkDependency: flutter'); + }); + + test('with version', () async { + final dep = await _dependency( + {'sdk': 'flutter', 'version': '>=1.2.3 <2.0.0'}, + ); + expect(dep.sdk, 'flutter'); + expect(dep.version.toString(), '>=1.2.3 <2.0.0'); + expect(dep.toString(), 'SdkDependency: flutter'); + }); + + test('null content', () { + _expectThrowsContaining( + {'sdk': null}, + r"type 'Null' is not a subtype of type 'String'", + ); + }); + + test('number content', () { + _expectThrowsContaining( + {'sdk': 42}, + r"type 'int' is not a subtype of type 'String'", + ); + }); +} + +void _gitDependency() { + test('string', () async { + final dep = await _dependency({'git': 'url'}); + expect(dep.url.toString(), 'url'); + expect(dep.path, isNull); + expect(dep.ref, isNull); + expect(dep.toString(), 'GitDependency: url@url'); + }); + + test('string with version key is ignored', () async { + // Regression test for https://github.com/dart-lang/pubspec_parse/issues/13 + final dep = + await _dependency({'git': 'url', 'version': '^1.2.3'}); + expect(dep.url.toString(), 'url'); + expect(dep.path, isNull); + expect(dep.ref, isNull); + expect(dep.toString(), 'GitDependency: url@url'); + }); + + test('string with user@ URL', () async { + final skipTryParse = Platform.environment.containsKey('TRAVIS'); + if (skipTryParse) { + print('FYI: not validating git@ URI on travis due to failure'); + } + final dep = await _dependency( + {'git': 'git@localhost:dep.git'}, + skipTryPub: skipTryParse, + ); + expect(dep.url.toString(), 'ssh://git@localhost/dep.git'); + expect(dep.path, isNull); + expect(dep.ref, isNull); + expect(dep.toString(), 'GitDependency: url@ssh://git@localhost/dep.git'); + }); + + test('string with random extra key fails', () { + _expectThrows( + {'git': 'url', 'bob': '^1.2.3'}, + r''' +line 6, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted] + ╷ +6 │ "bob": "^1.2.3" + │ ^^^^^ + ╵''', + ); + }); + + test('map', () async { + final dep = await _dependency({ + 'git': {'url': 'url', 'path': 'path', 'ref': 'ref'}, + }); + expect(dep.url.toString(), 'url'); + expect(dep.path, 'path'); + expect(dep.ref, 'ref'); + expect(dep.toString(), 'GitDependency: url@url'); + }); + + test('git - null content', () { + _expectThrows( + {'git': null}, + r''' +line 5, column 11: Unsupported value for "git". Must be a String or a Map. + ╷ +5 │ "git": null + │ ┌───────────^ +6 │ │ } + │ └──^ + ╵''', + ); + }); + + test('git - int content', () { + _expectThrows( + {'git': 42}, + r''' +line 5, column 11: Unsupported value for "git". Must be a String or a Map. + ╷ +5 │ "git": 42 + │ ┌───────────^ +6 │ │ } + │ └──^ + ╵''', + ); + }); + + test('git - empty map', () { + _expectThrowsContaining( + {'git': {}}, + r"type 'Null' is not a subtype of type 'String'", + ); + }); + + test('git - null url', () { + _expectThrowsContaining( + { + 'git': {'url': null}, + }, + r"type 'Null' is not a subtype of type 'String'", + ); + }); + + test('git - int url', () { + _expectThrowsContaining( + { + 'git': {'url': 42}, + }, + r"type 'int' is not a subtype of type 'String'", + ); + }); +} + +void _pathDependency() { + test('valid', () async { + final dep = await _dependency({'path': '../path'}); + expect(dep.path, '../path'); + expect(dep.toString(), 'PathDependency: path@../path'); + }); + + test('valid with version key is ignored', () async { + final dep = await _dependency( + {'path': '../path', 'version': '^1.2.3'}, + ); + expect(dep.path, '../path'); + expect(dep.toString(), 'PathDependency: path@../path'); + }); + + test('valid with random extra key fails', () { + _expectThrows( + {'path': '../path', 'bob': '^1.2.3'}, + r''' +line 6, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted] + ╷ +6 │ "bob": "^1.2.3" + │ ^^^^^ + ╵''', + ); + }); + + test('null content', () { + _expectThrows( + {'path': null}, + r''' +line 5, column 12: Unsupported value for "path". Must be a String. + ╷ +5 │ "path": null + │ ┌────────────^ +6 │ │ } + │ └──^ + ╵''', + ); + }); + + test('int content', () { + _expectThrows( + {'path': 42}, + r''' +line 5, column 12: Unsupported value for "path". Must be a String. + ╷ +5 │ "path": 42 + │ ┌────────────^ +6 │ │ } + │ └──^ + ╵''', + ); + }); +} + +void _expectThrows(Object content, String expectedError) { + expectParseThrows( + { + 'name': 'sample', + 'dependencies': {'dep': content}, + }, + expectedError, + ); +} + +void _expectThrowsContaining(Object content, String errorText) { + expectParseThrowsContaining( + { + 'name': 'sample', + 'dependencies': {'dep': content}, + }, + errorText, + ); +} + +Future _dependency( + Object? content, { + bool skipTryPub = false, +}) async { + final value = await parse( + { + ...defaultPubspec, + 'dependencies': {'dep': content}, + }, + skipTryPub: skipTryPub, + ); + expect(value.name, 'sample'); + expect(value.dependencies, hasLength(1)); + + final entry = value.dependencies.entries.single; + expect(entry.key, 'dep'); + + return entry.value as T; +} diff --git a/pkgs/pubspec_parse/test/ensure_build_test.dart b/pkgs/pubspec_parse/test/ensure_build_test.dart new file mode 100644 index 000000000..0e4371c13 --- /dev/null +++ b/pkgs/pubspec_parse/test/ensure_build_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2018, 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. + +@Timeout.factor(2) +@TestOn('vm') +@Tags(['presubmit-only']) +library; + +import 'package:build_verify/build_verify.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'ensure_build', + () => expectBuildClean(packageRelativeDirectory: 'pkgs/pubspec_parse/'), + ); +} diff --git a/pkgs/pubspec_parse/test/git_uri_test.dart b/pkgs/pubspec_parse/test/git_uri_test.dart new file mode 100644 index 000000000..be89ba8a0 --- /dev/null +++ b/pkgs/pubspec_parse/test/git_uri_test.dart @@ -0,0 +1,25 @@ +import 'package:pubspec_parse/src/dependency.dart'; +import 'package:test/test.dart'; + +void main() { + for (var item in { + 'git@github.com:google/grinder.dart.git': + 'ssh://git@github.com/google/grinder.dart.git', + 'host.xz:path/to/repo.git/': 'ssh://host.xz/path/to/repo.git/', + 'http:path/to/repo.git/': 'ssh://http/path/to/repo.git/', + 'file:path/to/repo.git/': 'ssh://file/path/to/repo.git/', + './foo:bar': 'foo%3Abar', + '/path/to/repo.git/': '/path/to/repo.git/', + 'file:///path/to/repo.git/': 'file:///path/to/repo.git/', + }.entries) { + test(item.key, () { + final uri = parseGitUri(item.key); + + printOnFailure( + [uri.scheme, uri.userInfo, uri.host, uri.port, uri.path].join('\n'), + ); + + expect(uri, Uri.parse(item.value)); + }); + } +} diff --git a/pkgs/pubspec_parse/test/parse_test.dart b/pkgs/pubspec_parse/test/parse_test.dart new file mode 100644 index 000000000..6251f41fb --- /dev/null +++ b/pkgs/pubspec_parse/test/parse_test.dart @@ -0,0 +1,715 @@ +// Copyright (c) 2018, 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. + +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: lines_longer_than_80_chars + +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + test('minimal set values', () async { + final value = await parse(defaultPubspec); + expect(value.name, 'sample'); + expect(value.version, isNull); + expect(value.publishTo, isNull); + expect(value.description, isNull); + expect(value.homepage, isNull); + expect(value.author, isNull); + expect(value.authors, isEmpty); + expect( + value.environment, + {'sdk': VersionConstraint.parse('>=2.12.0 <3.0.0')}, + ); + expect(value.documentation, isNull); + expect(value.dependencies, isEmpty); + expect(value.devDependencies, isEmpty); + expect(value.dependencyOverrides, isEmpty); + expect(value.flutter, isNull); + expect(value.repository, isNull); + expect(value.issueTracker, isNull); + expect(value.screenshots, isEmpty); + }); + + test('all fields set', () async { + final version = Version.parse('1.2.3'); + final sdkConstraint = VersionConstraint.parse('>=2.12.0 <3.0.0'); + final value = await parse({ + 'name': 'sample', + 'version': version.toString(), + 'publish_to': 'none', + 'author': 'name@example.com', + 'environment': {'sdk': sdkConstraint.toString()}, + 'description': 'description', + 'homepage': 'homepage', + 'documentation': 'documentation', + 'repository': 'https://github.com/example/repo', + 'issue_tracker': 'https://github.com/example/repo/issues', + 'funding': [ + 'https://patreon.com/example', + ], + 'topics': ['widget', 'button'], + 'ignored_advisories': ['111', '222'], + 'screenshots': [ + {'description': 'my screenshot', 'path': 'path/to/screenshot'}, + ], + }); + expect(value.name, 'sample'); + expect(value.version, version); + expect(value.publishTo, 'none'); + expect(value.description, 'description'); + expect(value.homepage, 'homepage'); + expect(value.author, 'name@example.com'); + expect(value.authors, ['name@example.com']); + expect(value.environment, hasLength(1)); + expect(value.environment, containsPair('sdk', sdkConstraint)); + expect(value.documentation, 'documentation'); + expect(value.dependencies, isEmpty); + expect(value.devDependencies, isEmpty); + expect(value.dependencyOverrides, isEmpty); + expect(value.repository, Uri.parse('https://github.com/example/repo')); + expect( + value.issueTracker, + Uri.parse('https://github.com/example/repo/issues'), + ); + expect(value.funding, hasLength(1)); + expect(value.funding!.single.toString(), 'https://patreon.com/example'); + expect(value.topics, hasLength(2)); + expect(value.topics!.first, 'widget'); + expect(value.topics!.last, 'button'); + expect(value.ignoredAdvisories, hasLength(2)); + expect(value.ignoredAdvisories!.first, '111'); + expect(value.ignoredAdvisories!.last, '222'); + expect(value.screenshots, hasLength(1)); + expect(value.screenshots!.first.description, 'my screenshot'); + expect(value.screenshots!.first.path, 'path/to/screenshot'); + }); + + test('environment values can be null', () async { + final value = await parse( + { + 'name': 'sample', + 'environment': { + 'sdk': '>=2.12.0 <3.0.0', + 'bob': null, + }, + }, + skipTryPub: true, + ); + expect(value.name, 'sample'); + expect(value.environment, hasLength(2)); + expect(value.environment, containsPair('bob', isNull)); + }); + + group('publish_to', () { + for (var entry in { + 42: "Unsupported value for \"publish_to\". type 'int' is not a subtype of type 'String?'", + '##not a uri!': r''' +line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL. + ╷ +3 │ "publish_to": "##not a uri!" + │ ^^^^^^^^^^^^^^ + ╵''', + '/cool/beans': r''' +line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL. + ╷ +3 │ "publish_to": "/cool/beans" + │ ^^^^^^^^^^^^^ + ╵''', + 'file:///Users/kevmoo/': r''' +line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL. + ╷ +3 │ "publish_to": "file:///Users/kevmoo/" + │ ^^^^^^^^^^^^^^^^^^^^^^^ + ╵''', + }.entries) { + test('cannot be `${entry.key}`', () { + expectParseThrowsContaining( + {'name': 'sample', 'publish_to': entry.key}, + entry.value, + skipTryPub: true, + ); + }); + } + + for (var entry in { + null: null, + 'http': 'http://example.com', + 'https': 'https://example.com', + 'none': 'none', + }.entries) { + test('can be ${entry.key}', () async { + final value = await parse({ + ...defaultPubspec, + 'publish_to': entry.value, + }); + expect(value.publishTo, entry.value); + }); + } + }); + + group('author, authors', () { + test('one author', () async { + final value = await parse({ + ...defaultPubspec, + 'author': 'name@example.com', + }); + expect(value.author, 'name@example.com'); + expect(value.authors, ['name@example.com']); + }); + + test('one author, via authors', () async { + final value = await parse({ + ...defaultPubspec, + 'authors': ['name@example.com'], + }); + expect(value.author, 'name@example.com'); + expect(value.authors, ['name@example.com']); + }); + + test('many authors', () async { + final value = await parse({ + ...defaultPubspec, + 'authors': ['name@example.com', 'name2@example.com'], + }); + expect(value.author, isNull); + expect(value.authors, ['name@example.com', 'name2@example.com']); + }); + + test('author and authors', () async { + final value = await parse({ + ...defaultPubspec, + 'author': 'name@example.com', + 'authors': ['name2@example.com'], + }); + expect(value.author, isNull); + expect(value.authors, ['name@example.com', 'name2@example.com']); + }); + + test('duplicate author values', () async { + final value = await parse({ + ...defaultPubspec, + 'author': 'name@example.com', + 'authors': ['name@example.com', 'name@example.com'], + }); + expect(value.author, 'name@example.com'); + expect(value.authors, ['name@example.com']); + }); + + test('flutter', () async { + final value = await parse({ + ...defaultPubspec, + 'flutter': {'key': 'value'}, + }); + expect(value.flutter, {'key': 'value'}); + }); + }); + + group('invalid', () { + test('null', () { + expectParseThrows( + null, + r''' +line 1, column 1: Not a map + ╷ +1 │ null + │ ^^^^ + ╵''', + ); + }); + test('empty string', () { + expectParseThrows( + '', + r''' +line 1, column 1: Not a map + ╷ +1 │ "" + │ ^^ + ╵''', + ); + }); + test('array', () { + expectParseThrows( + [], + r''' +line 1, column 1: Not a map + ╷ +1 │ [] + │ ^^ + ╵''', + ); + }); + + test('missing name', () { + expectParseThrowsContaining( + {}, + "Missing key \"name\". type 'Null' is not a subtype of type 'String'", + ); + }); + + test('null name value', () { + expectParseThrowsContaining( + {'name': null}, + "Unsupported value for \"name\". type 'Null' is not a subtype of type 'String'", + ); + }); + + test('empty name value', () { + expectParseThrows( + {'name': ''}, + r''' +line 2, column 10: Unsupported value for "name". "name" cannot be empty. + ╷ +2 │ "name": "" + │ ^^ + ╵''', + ); + }); + + test('"dart" is an invalid environment key', () { + expectParseThrows( + { + 'name': 'sample', + 'environment': {'dart': 'cool'}, + }, + r''' +line 4, column 3: Use "sdk" to for Dart SDK constraints. + ╷ +4 │ "dart": "cool" + │ ^^^^^^ + ╵''', + ); + }); + + test('environment values cannot be int', () { + expectParseThrows( + { + 'name': 'sample', + 'environment': {'sdk': 42}, + }, + r''' +line 4, column 10: Unsupported value for "sdk". `42` is not a String. + ╷ +4 │ "sdk": 42 + │ ┌──────────^ +5 │ │ } + │ └─^ + ╵''', + ); + }); + + test('version', () { + expectParseThrows( + {'name': 'sample', 'version': 'invalid'}, + r''' +line 3, column 13: Unsupported value for "version". Could not parse "invalid". + ╷ +3 │ "version": "invalid" + │ ^^^^^^^^^ + ╵''', + ); + }); + + test('invalid environment value', () { + expectParseThrows( + { + 'name': 'sample', + 'environment': {'sdk': 'silly'}, + }, + r''' +line 4, column 10: Unsupported value for "sdk". Could not parse version "silly". Unknown text at "silly". + ╷ +4 │ "sdk": "silly" + │ ^^^^^^^ + ╵''', + ); + }); + + test('bad repository url', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'repository': {'x': 'y'}, + }, + "Unsupported value for \"repository\". type 'YamlMap' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + + test('bad issue_tracker url', () { + expectParseThrowsContaining( + { + 'name': 'sample', + 'issue_tracker': {'x': 'y'}, + }, + "Unsupported value for \"issue_tracker\". type 'YamlMap' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + }); + + group('funding', () { + test('not a list', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'funding': 1, + }, + "Unsupported value for \"funding\". type 'int' is not a subtype of type 'List?'", + skipTryPub: true, + ); + }); + + test('not an uri', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'funding': [1], + }, + "Unsupported value for \"funding\". type 'int' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + + test('not an uri', () { + expectParseThrows( + { + ...defaultPubspec, + 'funding': ['ht tps://example.com/'], + }, + r''' +line 6, column 13: Unsupported value for "funding". Illegal scheme character at offset 2. + ╷ +6 │ "funding": [ + │ ┌─────────────^ +7 │ │ "ht tps://example.com/" +8 │ └ ] + ╵''', + skipTryPub: true, + ); + }); + }); + group('topics', () { + test('not a list', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'topics': 1, + }, + "Unsupported value for \"topics\". type 'int' is not a subtype of type 'List?'", + skipTryPub: true, + ); + }); + + test('not a string', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'topics': [1], + }, + "Unsupported value for \"topics\". type 'int' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + + test('invalid data - lenient', () async { + final value = await parse( + { + ...defaultPubspec, + 'topics': [1], + }, + skipTryPub: true, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.topics, isNull); + }); + }); + + group('ignored_advisories', () { + test('not a list', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'ignored_advisories': 1, + }, + "Unsupported value for \"ignored_advisories\". type 'int' is not a subtype of type 'List?'", + skipTryPub: true, + ); + }); + + test('not a string', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'ignored_advisories': [1], + }, + "Unsupported value for \"ignored_advisories\". type 'int' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + + test('invalid data - lenient', () async { + final value = await parse( + { + ...defaultPubspec, + 'ignored_advisories': [1], + }, + skipTryPub: true, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.ignoredAdvisories, isNull); + }); + }); + + group('screenshots', () { + test('one screenshot', () async { + final value = await parse({ + ...defaultPubspec, + 'screenshots': [ + {'description': 'my screenshot', 'path': 'path/to/screenshot'}, + ], + }); + expect(value.screenshots, hasLength(1)); + expect(value.screenshots!.first.description, 'my screenshot'); + expect(value.screenshots!.first.path, 'path/to/screenshot'); + }); + + test('many screenshots', () async { + final value = await parse({ + ...defaultPubspec, + 'screenshots': [ + {'description': 'my screenshot', 'path': 'path/to/screenshot'}, + { + 'description': 'my second screenshot', + 'path': 'path/to/screenshot2', + }, + ], + }); + expect(value.screenshots, hasLength(2)); + expect(value.screenshots!.first.description, 'my screenshot'); + expect(value.screenshots!.first.path, 'path/to/screenshot'); + expect(value.screenshots!.last.description, 'my second screenshot'); + expect(value.screenshots!.last.path, 'path/to/screenshot2'); + }); + + test('one screenshot plus invalid entries', () async { + final value = await parse({ + ...defaultPubspec, + 'screenshots': [ + 42, + { + 'description': 'my screenshot', + 'path': 'path/to/screenshot', + 'extraKey': 'not important', + }, + 'not a screenshot', + ], + }); + expect(value.screenshots, hasLength(1)); + expect(value.screenshots!.first.description, 'my screenshot'); + expect(value.screenshots!.first.path, 'path/to/screenshot'); + }); + + test('invalid entries', () async { + final value = await parse({ + ...defaultPubspec, + 'screenshots': [ + 42, + 'not a screenshot', + ], + }); + expect(value.screenshots, isEmpty); + }); + + test('missing key `dessription', () { + expectParseThrows( + { + ...defaultPubspec, + 'screenshots': [ + {'path': 'my/path'}, + ], + }, + r''' +line 7, column 3: Missing key "description". Missing required key `description` + ╷ +7 │ ┌ { +8 │ │ "path": "my/path" +9 │ └ } + ╵''', + skipTryPub: true, + ); + }); + + test('missing key `path`', () { + expectParseThrows( + { + ...defaultPubspec, + 'screenshots': [ + {'description': 'my screenshot'}, + ], + }, + r''' +line 7, column 3: Missing key "path". Missing required key `path` + ╷ +7 │ ┌ { +8 │ │ "description": "my screenshot" +9 │ └ } + ╵''', + skipTryPub: true, + ); + }); + + test('Value of description not a String`', () { + expectParseThrows( + { + ...defaultPubspec, + 'screenshots': [ + {'description': 42}, + ], + }, + r''' +line 8, column 19: Unsupported value for "description". `42` is not a String + ╷ +8 │ "description": 42 + │ ┌───────────────────^ +9 │ │ } + │ └──^ + ╵''', + skipTryPub: true, + ); + }); + + test('Value of path not a String`', () { + expectParseThrows( + { + ...defaultPubspec, + 'screenshots': [ + { + 'description': '', + 'path': 42, + }, + ], + }, + r''' +line 9, column 12: Unsupported value for "path". `42` is not a String + ╷ +9 │ "path": 42 + │ ┌────────────^ +10 │ │ } + │ └──^ + ╵''', + skipTryPub: true, + ); + }); + + test('invalid screenshot - lenient', () async { + final value = await parse( + { + ...defaultPubspec, + 'screenshots': 'Invalid value', + }, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.screenshots, isEmpty); + }); + }); + + group('lenient', () { + test('null', () { + expectParseThrows( + null, + r''' +line 1, column 1: Not a map + ╷ +1 │ null + │ ^^^^ + ╵''', + lenient: true, + ); + }); + + test('empty string', () { + expectParseThrows( + '', + r''' +line 1, column 1: Not a map + ╷ +1 │ "" + │ ^^ + ╵''', + lenient: true, + ); + }); + + test('name cannot be empty', () { + expectParseThrowsContaining( + {}, + "Missing key \"name\". type 'Null' is not a subtype of type 'String'", + lenient: true, + ); + }); + + test('bad repository url', () async { + final value = await parse( + { + ...defaultPubspec, + 'repository': {'x': 'y'}, + }, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.repository, isNull); + }); + + test('bad issue_tracker url', () async { + final value = await parse( + { + ...defaultPubspec, + 'issue_tracker': {'x': 'y'}, + }, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.issueTracker, isNull); + }); + + test('multiple bad values', () async { + final value = await parse( + { + ...defaultPubspec, + 'repository': {'x': 'y'}, + 'issue_tracker': {'x': 'y'}, + }, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.repository, isNull); + expect(value.issueTracker, isNull); + }); + + test('deep error throws with lenient', () { + expect( + () => parse( + { + 'name': 'sample', + 'dependencies': { + 'foo': { + 'git': {'url': 1}, + }, + }, + 'issue_tracker': {'x': 'y'}, + }, + skipTryPub: true, + lenient: true, + ), + throwsException, + ); + }); + }); +} diff --git a/pkgs/pubspec_parse/test/pub_utils.dart b/pkgs/pubspec_parse/test/pub_utils.dart new file mode 100644 index 000000000..a60aa2a99 --- /dev/null +++ b/pkgs/pubspec_parse/test/pub_utils.dart @@ -0,0 +1,88 @@ +// Copyright (c) 2018, 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:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; +import 'package:test_process/test_process.dart'; + +Future tryPub(String content) async { + await d.file('pubspec.yaml', content).create(); + + final proc = await TestProcess.start( + Platform.resolvedExecutable, + ['pub', 'get', '--offline'], + workingDirectory: d.sandbox, + // Don't pass current process VM options to child + environment: Map.from(Platform.environment)..remove('DART_VM_OPTIONS'), + ); + + final result = await ProcResult.fromTestProcess(proc); + + printOnFailure( + [ + '-----BEGIN pub output-----', + result.toString().trim(), + '-----END pub output-----', + ].join('\n'), + ); + + if (result.exitCode == 0) { + final lockContent = + File(p.join(d.sandbox, 'pubspec.lock')).readAsStringSync(); + + printOnFailure( + [ + '-----BEGIN pubspec.lock-----', + lockContent.trim(), + '-----END pubspec.lock-----', + ].join('\n'), + ); + } + + return result; +} + +class ProcResult { + final int exitCode; + final List lines; + + bool get cleanParse => exitCode == 0 || exitCode == 66 || exitCode == 69; + + ProcResult(this.exitCode, this.lines); + + static Future fromTestProcess(TestProcess proc) async { + final items = []; + + final values = await Future.wait([ + proc.exitCode, + proc.stdoutStream().forEach((line) => items.add(ProcLine(false, line))), + proc.stderrStream().forEach((line) => items.add(ProcLine(true, line))), + ]); + + return ProcResult(values[0] as int, items); + } + + @override + String toString() { + final buffer = StringBuffer('Exit code: $exitCode'); + for (var line in lines) { + buffer.write('\n$line'); + } + return buffer.toString(); + } +} + +class ProcLine { + final bool isError; + final String line; + + ProcLine(this.isError, this.line); + + @override + String toString() => '${isError ? 'err' : 'out'} $line'; +} diff --git a/pkgs/pubspec_parse/test/test_utils.dart b/pkgs/pubspec_parse/test/test_utils.dart new file mode 100644 index 000000000..cc46522b7 --- /dev/null +++ b/pkgs/pubspec_parse/test/test_utils.dart @@ -0,0 +1,157 @@ +// Copyright (c) 2018, 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 'package:checked_yaml/checked_yaml.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +import 'pub_utils.dart'; + +const defaultPubspec = { + 'name': 'sample', + 'environment': {'sdk': '>=2.12.0 <3.0.0'}, +}; + +String _encodeJson(Object? input) => + const JsonEncoder.withIndent(' ').convert(input); + +Matcher _throwsParsedYamlException(String prettyValue) => throwsA( + const TypeMatcher().having( + (e) { + final message = e.formattedMessage; + printOnFailure("Actual error format:\nr'''\n$message'''"); + _printDebugParsedYamlException(e); + return message; + }, + 'formattedMessage', + prettyValue, + ), + ); + +void _printDebugParsedYamlException(ParsedYamlException e) { + var innerError = e.innerError; + StackTrace? innerStack; + + if (innerError is CheckedFromJsonException) { + final cfje = innerError; + + if (cfje.innerError != null) { + innerError = cfje.innerError; + innerStack = cfje.innerStack; + } + } + + if (innerError != null) { + final items = [innerError]; + if (innerStack != null) { + items.add(Trace.format(innerStack)); + } + + final content = + LineSplitter.split(items.join('\n')).map((e) => ' $e').join('\n'); + + printOnFailure('Inner error details:\n$content'); + } +} + +Future parse( + Object? content, { + bool quietOnError = false, + bool skipTryPub = false, + bool lenient = false, +}) async { + final encoded = _encodeJson(content); + + ProcResult? pubResult; + if (!skipTryPub) { + // ignore: deprecated_member_use + pubResult = await tryPub(encoded); + expect(pubResult, isNotNull); + } + + try { + final value = Pubspec.parse(encoded, lenient: lenient); + + if (pubResult != null) { + addTearDown(() { + expect( + pubResult!.cleanParse, + isTrue, + reason: + 'On success, parsing from the pub client should also succeed.', + ); + }); + } + return value; + } catch (e) { + if (pubResult != null) { + addTearDown(() { + expect( + pubResult!.cleanParse, + isFalse, + reason: 'On failure, parsing from the pub client should also fail.', + ); + }); + } + if (e is ParsedYamlException) { + if (!quietOnError) { + _printDebugParsedYamlException(e); + } + } + rethrow; + } +} + +void expectParseThrows( + Object? content, + String expectedError, { + bool skipTryPub = false, + bool lenient = false, +}) => + expect( + () => parse( + content, + lenient: lenient, + quietOnError: true, + skipTryPub: skipTryPub, + ), + _throwsParsedYamlException(expectedError), + ); + +void expectParseThrowsContaining( + Object? content, + String errorFragment, { + bool skipTryPub = false, + bool lenient = false, +}) { + expect( + () => parse( + content, + lenient: lenient, + quietOnError: true, + skipTryPub: skipTryPub, + ), + _throwsParsedYamlExceptionContaining(errorFragment), + ); +} + +// ignore: prefer_expression_function_bodies +Matcher _throwsParsedYamlExceptionContaining(String errorFragment) { + return throwsA( + const TypeMatcher().having( + (e) { + final message = e.formattedMessage; + printOnFailure("Actual error format:\nr'''\n$message'''"); + _printDebugParsedYamlException(e); + return message; + }, + 'formattedMessage', + contains(errorFragment), + ), + ); +}