diff --git a/.github/ISSUE_TEMPLATE/package_config.md b/.github/ISSUE_TEMPLATE/package_config.md new file mode 100644 index 000000000..f6322d0fa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package_config.md @@ -0,0 +1,5 @@ +--- +name: "package:package_config" +about: "Create a bug or file a feature request against package:package_config." +labels: "package:package_config" +--- \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index 45c2239b1..31b8b4733 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -76,6 +76,10 @@ - changed-files: - any-glob-to-any-file: 'pkgs/oauth2/**' +'package:package_config': + - changed-files: + - any-glob-to-any-file: 'pkgs/package_config/**' + 'package:source_map_stack_trace': - changed-files: - any-glob-to-any-file: 'pkgs/source_map_stack_trace/**' diff --git a/.github/workflows/package_config.yaml b/.github/workflows/package_config.yaml new file mode 100644 index 000000000..416ea1a11 --- /dev/null +++ b/.github/workflows/package_config.yaml @@ -0,0 +1,71 @@ +name: package:package_config + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/package_config.yml' + - 'pkgs/package_config/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/package_config.yml' + - 'pkgs/package_config/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/package_config/ + +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, windows-latest] + sdk: [3.4, 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 tests + run: dart test -p chrome,vm + if: always() && steps.install.outcome == 'success' diff --git a/README.md b/README.md index ed90416dd..01360e92b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ don't naturally belong to other topic monorepos (like | [json_rpc_2](pkgs/json_rpc_2/) | Utilities to write a client or server using the JSON-RPC 2.0 spec. | [![package issues](https://img.shields.io/badge/package:json_rpc_2-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Ajson_rpc_2) | [![pub package](https://img.shields.io/pub/v/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2) | | [mime](pkgs/mime/) | Utilities for handling media (MIME) types, including determining a type from a file extension and file contents. | [![package issues](https://img.shields.io/badge/package:mime-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Amime) | [![pub package](https://img.shields.io/pub/v/mime.svg)](https://pub.dev/packages/mime) | | [oauth2](pkgs/oauth2/) | A client library for authenticating with a remote service via OAuth2 on behalf of a user, and making authorized HTTP requests with the user's OAuth2 credentials. | [![package issues](https://img.shields.io/badge/package:oauth2-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aoauth2) | [![pub package](https://img.shields.io/pub/v/oauth2.svg)](https://pub.dev/packages/oauth2) | +| [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) | | [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) | | [unified_analytics](pkgs/unified_analytics/) | A package for logging analytics for all Dart and Flutter related tooling to Google Analytics. | [![package issues](https://img.shields.io/badge/package:unified_analytics-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aunified_analytics) | [![pub package](https://img.shields.io/pub/v/unified_analytics.svg)](https://pub.dev/packages/unified_analytics) | diff --git a/pkgs/package_config/.gitignore b/pkgs/package_config/.gitignore new file mode 100644 index 000000000..7b888b84c --- /dev/null +++ b/pkgs/package_config/.gitignore @@ -0,0 +1,7 @@ +.packages +.pub +.dart_tool/ +.vscode/ +packages +pubspec.lock +doc/api/ diff --git a/pkgs/package_config/AUTHORS b/pkgs/package_config/AUTHORS new file mode 100644 index 000000000..e8063a8cd --- /dev/null +++ b/pkgs/package_config/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/pkgs/package_config/CHANGELOG.md b/pkgs/package_config/CHANGELOG.md new file mode 100644 index 000000000..101a0fe76 --- /dev/null +++ b/pkgs/package_config/CHANGELOG.md @@ -0,0 +1,108 @@ +## 2.1.1 + +- Require Dart 3.4 +- Move to `dart-lang/tools` monorepo. + +## 2.1.0 + +- Adds `minVersion` to `findPackageConfig` and `findPackageConfigVersion` + which allows ignoring earlier versions (which currently only means + ignoring version 1, aka. `.packages` files.) + +- Changes the version number of `SimplePackageConfig.empty` to the + current maximum version. + +- Improve file read performance; improve lookup performance. +- Emit an error when a package is inside the package root of another package. +- Fix a link in the readme. + +## 2.0.2 + +- Update package description and README. +- Change to package:lints for style checking. +- Add an example. + +## 2.0.1 + +- Use unique library names to correct docs issue. + +## 2.0.0 + +- Migrate to null safety. +- Remove legacy APIs. +- Adds `relativeRoot` property to `Package` which controls whether to + make the root URI relative when writing a configuration file. + +## 1.9.3 + +- Fix `Package` constructor not accepting relative `packageUriRoot`. + +## 1.9.2 + +- Updated to support new rules for picking `package_config.json` over + a specified `.packages`. +- Deduce package root from `.packages` derived package configuration, + and default all such packages to language version 2.7. + +## 1.9.1 + +- Remove accidental transitive import of `dart:io` from entrypoints that are + supposed to be cross-platform compatible. + +## 1.9.0 + +- Based on new JSON file format with more content. +- This version includes all the new functionality intended for a 2.0.0 + version, as well as the, now deprecated, version 1 functionality. + When we release 2.0.0, the deprecated functionality will be removed. + +## 1.1.0 + +- Allow parsing files with default-package entries and metadata. + A default-package entry has an empty key and a valid package name + as value. + Metadata is attached as fragments to base URIs. + +## 1.0.5 + +- Fix usage of SDK constants. + +## 1.0.4 + +- Set max SDK version to <3.0.0. + +## 1.0.3 + +- Removed unneeded dependency constraint on SDK. + +## 1.0.2 + +- Update SDK constraint to be 2.0.0 dev friendly. + +## 1.0.1 + +- Fix test to not write to sink after it's closed. + +## 1.0.0 + +- Public API marked stable. + +## 0.1.5 + +- `FilePackagesDirectoryPackages.getBase(..)` performance improvements. + +## 0.1.4 + +- Strong mode fixes. + +## 0.1.3 + +- Invalid test cleanup (to keep up with changes in `Uri`). + +## 0.1.1 + +- Syntax updates. + +## 0.1.0 + +- Initial implementation. diff --git a/pkgs/package_config/LICENSE b/pkgs/package_config/LICENSE new file mode 100644 index 000000000..767000764 --- /dev/null +++ b/pkgs/package_config/LICENSE @@ -0,0 +1,27 @@ +Copyright 2019, 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/package_config/README.md b/pkgs/package_config/README.md new file mode 100644 index 000000000..76fd3cbed --- /dev/null +++ b/pkgs/package_config/README.md @@ -0,0 +1,26 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/package_config.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/package_config.yaml) +[![pub package](https://img.shields.io/pub/v/package_config.svg)](https://pub.dev/packages/package_config) +[![package publisher](https://img.shields.io/pub/publisher/package_config.svg)](https://pub.dev/packages/package_config/publisher) + +Support for working with **Package Configuration** files as described +in the Package Configuration v2 [design document](https://github.com/dart-lang/language/blob/master/accepted/2.8/language-versioning/package-config-file-v2.md). + +A Dart package configuration file is used to resolve Dart package names (e.g. +`foobar`) to Dart files containing the source code for that package (e.g. +`file:///Users/myuser/.pub-cache/hosted/pub.dartlang.org/foobar-1.1.0`). The +standard package configuration file is `.dart_tool/package_config.json`, and is +written by the Dart tool when the command `dart pub get` is run. + +The primary libraries of this package are +* `package_config.dart`: + Defines the `PackageConfig` class and other types needed to use + package configurations, and provides functions to find, read and + write package configuration files. + +* `package_config_types.dart`: + Just the `PackageConfig` class and other types needed to use + package configurations. This library does not depend on `dart:io`. + +The package includes deprecated backwards compatible functionality to +work with the `.packages` file. This functionality will not be maintained, +and will be removed in a future version of this package. diff --git a/pkgs/package_config/analysis_options.yaml b/pkgs/package_config/analysis_options.yaml new file mode 100644 index 000000000..c0249e5e1 --- /dev/null +++ b/pkgs/package_config/analysis_options.yaml @@ -0,0 +1,5 @@ +# Copyright (c) 2020, 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. + +include: package:dart_flutter_team_lints/analysis_options.yaml diff --git a/pkgs/package_config/example/main.dart b/pkgs/package_config/example/main.dart new file mode 100644 index 000000000..db137caf4 --- /dev/null +++ b/pkgs/package_config/example/main.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2020, 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' show Directory; + +import 'package:package_config/package_config.dart'; + +void main() async { + var packageConfig = await findPackageConfig(Directory.current); + if (packageConfig == null) { + print('Failed to locate or read package config.'); + } else { + print('This package depends on ${packageConfig.packages.length} packages:'); + for (var package in packageConfig.packages) { + print('- ${package.name}'); + } + } +} diff --git a/pkgs/package_config/lib/package_config.dart b/pkgs/package_config/lib/package_config.dart new file mode 100644 index 000000000..074c97707 --- /dev/null +++ b/pkgs/package_config/lib/package_config.dart @@ -0,0 +1,199 @@ +// Copyright (c) 2019, 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. + +/// A package configuration is a way to assign file paths to package URIs, +/// and vice-versa. +/// +/// This package provides functionality to find, read and write package +/// configurations in the [specified format](https://github.com/dart-lang/language/blob/master/accepted/future-releases/language-versioning/package-config-file-v2.md). +library; + +import 'dart:io' show Directory, File; +import 'dart:typed_data' show Uint8List; + +import 'src/discovery.dart' as discover; +import 'src/errors.dart' show throwError; +import 'src/package_config.dart'; +import 'src/package_config_io.dart'; + +export 'package_config_types.dart'; + +/// Reads a specific package configuration file. +/// +/// The file must exist and be readable. +/// It must be either a valid `package_config.json` file +/// or a valid `.packages` file. +/// It is considered a `package_config.json` file if its first character +/// is a `{`. +/// +/// If the file is a `.packages` file (the file name is `.packages`) +/// and [preferNewest] is true, the default, also checks if there is +/// a `.dart_tool/package_config.json` file next +/// to the original file, and if so, loads that instead. +/// If [preferNewest] is set to false, a directly specified `.packages` file +/// is loaded even if there is an available `package_config.json` file. +/// The caller can determine this from the [PackageConfig.version] +/// being 1 and look for a `package_config.json` file themselves. +/// +/// If [onError] is provided, the configuration file parsing will report errors +/// by calling that function, and then try to recover. +/// The returned package configuration is a *best effort* attempt to create +/// a valid configuration from the invalid configuration file. +/// If no [onError] is provided, errors are thrown immediately. +Future loadPackageConfig(File file, + {bool preferNewest = true, void Function(Object error)? onError}) => + readAnyConfigFile(file, preferNewest, onError ?? throwError); + +/// Reads a specific package configuration URI. +/// +/// The file of the URI must exist and be readable. +/// It must be either a valid `package_config.json` file +/// or a valid `.packages` file. +/// It is considered a `package_config.json` file if its first +/// non-whitespace character is a `{`. +/// +/// If [preferNewest] is true, the default, and the file is a `.packages` file, +/// as determined by its file name being `.packages`, +/// first checks if there is a `.dart_tool/package_config.json` file +/// next to the original file, and if so, loads that instead. +/// The [file] *must not* be a `package:` URI. +/// If [preferNewest] is set to false, a directly specified `.packages` file +/// is loaded even if there is an available `package_config.json` file. +/// The caller can determine this from the [PackageConfig.version] +/// being 1 and look for a `package_config.json` file themselves. +/// +/// If [loader] is provided, URIs are loaded using that function. +/// The future returned by the loader must complete with a [Uint8List] +/// containing the entire file content encoded as UTF-8, +/// or with `null` if the file does not exist. +/// The loader may throw at its own discretion, for situations where +/// it determines that an error might be need user attention, +/// but it is always allowed to return `null`. +/// This function makes no attempt to catch such errors. +/// As such, it may throw any error that [loader] throws. +/// +/// If no [loader] is supplied, a default loader is used which +/// only accepts `file:`, `http:` and `https:` URIs, +/// and which uses the platform file system and HTTP requests to +/// fetch file content. The default loader never throws because +/// of an I/O issue, as long as the location URIs are valid. +/// As such, it does not distinguish between a file not existing, +/// and it being temporarily locked or unreachable. +/// +/// If [onError] is provided, the configuration file parsing will report errors +/// by calling that function, and then try to recover. +/// The returned package configuration is a *best effort* attempt to create +/// a valid configuration from the invalid configuration file. +/// If no [onError] is provided, errors are thrown immediately. +Future loadPackageConfigUri(Uri file, + {Future Function(Uri uri)? loader, + bool preferNewest = true, + void Function(Object error)? onError}) => + readAnyConfigFileUri(file, loader, onError ?? throwError, preferNewest); + +/// Finds a package configuration relative to [directory]. +/// +/// If [directory] contains a package configuration, +/// either a `.dart_tool/package_config.json` file or, +/// if not, a `.packages`, then that file is loaded. +/// +/// If no file is found in the current directory, +/// then the parent directories are checked recursively, +/// all the way to the root directory, to check if those contains +/// a package configuration. +/// If [recurse] is set to `false`, this parent directory check is not +/// performed. +/// +/// If [onError] is provided, the configuration file parsing will report errors +/// by calling that function, and then try to recover. +/// The returned package configuration is a *best effort* attempt to create +/// a valid configuration from the invalid configuration file. +/// If no [onError] is provided, errors are thrown immediately. +/// +/// If [minVersion] is set to something greater than its default, +/// any lower-version configuration files are ignored in the search. +/// +/// Returns `null` if no configuration file is found. +Future findPackageConfig(Directory directory, + {bool recurse = true, + void Function(Object error)? onError, + int minVersion = 1}) { + if (minVersion > PackageConfig.maxVersion) { + throw ArgumentError.value(minVersion, 'minVersion', + 'Maximum known version is ${PackageConfig.maxVersion}'); + } + return discover.findPackageConfig( + directory, minVersion, recurse, onError ?? throwError); +} + +/// Finds a package configuration relative to [location]. +/// +/// If [location] contains a package configuration, +/// either a `.dart_tool/package_config.json` file or, +/// if not, a `.packages`, then that file is loaded. +/// The [location] URI *must not* be a `package:` URI. +/// It should be a hierarchical URI which is supported +/// by [loader]. +/// +/// If no file is found in the current directory, +/// then the parent directories are checked recursively, +/// all the way to the root directory, to check if those contains +/// a package configuration. +/// If [recurse] is set to `false`, this parent directory check is not +/// performed. +/// +/// If [loader] is provided, URIs are loaded using that function. +/// The future returned by the loader must complete with a [Uint8List] +/// containing the entire file content, +/// or with `null` if the file does not exist. +/// The loader may throw at its own discretion, for situations where +/// it determines that an error might be need user attention, +/// but it is always allowed to return `null`. +/// This function makes no attempt to catch such errors. +/// +/// If no [loader] is supplied, a default loader is used which +/// only accepts `file:`, `http:` and `https:` URIs, +/// and which uses the platform file system and HTTP requests to +/// fetch file content. The default loader never throws because +/// of an I/O issue, as long as the location URIs are valid. +/// As such, it does not distinguish between a file not existing, +/// and it being temporarily locked or unreachable. +/// +/// If [onError] is provided, the configuration file parsing will report errors +/// by calling that function, and then try to recover. +/// The returned package configuration is a *best effort* attempt to create +/// a valid configuration from the invalid configuration file. +/// If no [onError] is provided, errors are thrown immediately. +/// +/// If [minVersion] is set to something greater than its default, +/// any lower-version configuration files are ignored in the search. +/// +/// Returns `null` if no configuration file is found. +Future findPackageConfigUri(Uri location, + {bool recurse = true, + int minVersion = 1, + Future Function(Uri uri)? loader, + void Function(Object error)? onError}) { + if (minVersion > PackageConfig.maxVersion) { + throw ArgumentError.value(minVersion, 'minVersion', + 'Maximum known version is ${PackageConfig.maxVersion}'); + } + return discover.findPackageConfigUri( + location, minVersion, loader, onError ?? throwError, recurse); +} + +/// Writes a package configuration to the provided directory. +/// +/// Writes `.dart_tool/package_config.json` relative to [directory]. +/// If the `.dart_tool/` directory does not exist, it is created. +/// If it cannot be created, this operation fails. +/// +/// Also writes a `.packages` file in [directory]. +/// This will stop happening eventually as the `.packages` file becomes +/// discontinued. +/// A comment is generated if `[PackageConfig.extraData]` contains a +/// `"generator"` entry. +Future savePackageConfig( + PackageConfig configuration, Directory directory) => + writePackageConfigJsonFile(configuration, directory); diff --git a/pkgs/package_config/lib/package_config_types.dart b/pkgs/package_config/lib/package_config_types.dart new file mode 100644 index 000000000..825f7acec --- /dev/null +++ b/pkgs/package_config/lib/package_config_types.dart @@ -0,0 +1,17 @@ +// Copyright (c) 2019, 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. + +/// A package configuration is a way to assign file paths to package URIs, +/// and vice-versa. +/// +/// {@canonicalFor package_config.InvalidLanguageVersion} +/// {@canonicalFor package_config.LanguageVersion} +/// {@canonicalFor package_config.Package} +/// {@canonicalFor package_config.PackageConfig} +/// {@canonicalFor errors.PackageConfigError} +library; + +export 'src/errors.dart' show PackageConfigError; +export 'src/package_config.dart' + show InvalidLanguageVersion, LanguageVersion, Package, PackageConfig; diff --git a/pkgs/package_config/lib/src/discovery.dart b/pkgs/package_config/lib/src/discovery.dart new file mode 100644 index 000000000..b67841099 --- /dev/null +++ b/pkgs/package_config/lib/src/discovery.dart @@ -0,0 +1,148 @@ +// Copyright (c) 2019, 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 'dart:typed_data'; + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'package_config_io.dart'; +import 'package_config_json.dart'; +import 'packages_file.dart' as packages_file; +import 'util_io.dart' show defaultLoader, pathJoin; + +final Uri packageConfigJsonPath = Uri(path: '.dart_tool/package_config.json'); +final Uri dotPackagesPath = Uri(path: '.packages'); +final Uri currentPath = Uri(path: '.'); +final Uri parentPath = Uri(path: '..'); + +/// Discover the package configuration for a Dart script. +/// +/// The [baseDirectory] points to the directory of the Dart script. +/// A package resolution strategy is found by going through the following steps, +/// and stopping when something is found. +/// +/// * Check if a `.dart_tool/package_config.json` file exists in the directory. +/// * Check if a `.packages` file exists in the directory +/// (if `minVersion <= 1`). +/// * Repeat these checks for the parent directories until reaching the +/// root directory if [recursive] is true. +/// +/// If any of these tests succeed, a `PackageConfig` class is returned. +/// Returns `null` if no configuration was found. If a configuration +/// is needed, then the caller can supply [PackageConfig.empty]. +/// +/// If [minVersion] is greater than 1, `.packages` files are ignored. +/// If [minVersion] is greater than the version read from the +/// `package_config.json` file, it too is ignored. +Future findPackageConfig(Directory baseDirectory, + int minVersion, bool recursive, void Function(Object error) onError) async { + var directory = baseDirectory; + if (!directory.isAbsolute) directory = directory.absolute; + if (!await directory.exists()) { + return null; + } + do { + // Check for $cwd/.packages + var packageConfig = + await findPackageConfigInDirectory(directory, minVersion, onError); + if (packageConfig != null) return packageConfig; + if (!recursive) break; + // Check in parent directories. + var parentDirectory = directory.parent; + if (parentDirectory.path == directory.path) break; + directory = parentDirectory; + } while (true); + return null; +} + +/// Similar to [findPackageConfig] but based on a URI. +Future findPackageConfigUri( + Uri location, + int minVersion, + Future Function(Uri uri)? loader, + void Function(Object error) onError, + bool recursive) async { + if (location.isScheme('package')) { + onError(PackageConfigArgumentError( + location, 'location', 'Must not be a package: URI')); + return null; + } + if (loader == null) { + if (location.isScheme('file')) { + return findPackageConfig( + Directory.fromUri(location.resolveUri(currentPath)), + minVersion, + recursive, + onError); + } + loader = defaultLoader; + } + if (!location.path.endsWith('/')) location = location.resolveUri(currentPath); + while (true) { + var file = location.resolveUri(packageConfigJsonPath); + var bytes = await loader(file); + if (bytes != null) { + var config = parsePackageConfigBytes(bytes, file, onError); + if (config.version >= minVersion) return config; + } + if (minVersion <= 1) { + file = location.resolveUri(dotPackagesPath); + bytes = await loader(file); + if (bytes != null) { + return packages_file.parse(bytes, file, onError); + } + } + if (!recursive) break; + var parent = location.resolveUri(parentPath); + if (parent == location) break; + location = parent; + } + return null; +} + +/// Finds a `.packages` or `.dart_tool/package_config.json` file in [directory]. +/// +/// Loads the file, if it is there, and returns the resulting [PackageConfig]. +/// Returns `null` if the file isn't there. +/// Reports a [FormatException] if a file is there but the content is not valid. +/// If the file exists, but fails to be read, the file system error is reported. +/// +/// If [onError] is supplied, parsing errors are reported using that, and +/// a best-effort attempt is made to return a package configuration. +/// This may be the empty package configuration. +/// +/// If [minVersion] is greater than 1, `.packages` files are ignored. +/// If [minVersion] is greater than the version read from the +/// `package_config.json` file, it too is ignored. +Future findPackageConfigInDirectory(Directory directory, + int minVersion, void Function(Object error) onError) async { + var packageConfigFile = await checkForPackageConfigJsonFile(directory); + if (packageConfigFile != null) { + var config = await readPackageConfigJsonFile(packageConfigFile, onError); + if (config.version < minVersion) return null; + return config; + } + if (minVersion <= 1) { + packageConfigFile = await checkForDotPackagesFile(directory); + if (packageConfigFile != null) { + return await readDotPackagesFile(packageConfigFile, onError); + } + } + return null; +} + +Future checkForPackageConfigJsonFile(Directory directory) async { + assert(directory.isAbsolute); + var file = + File(pathJoin(directory.path, '.dart_tool', 'package_config.json')); + if (await file.exists()) return file; + return null; +} + +Future checkForDotPackagesFile(Directory directory) async { + var file = File(pathJoin(directory.path, '.packages')); + if (await file.exists()) return file; + return null; +} diff --git a/pkgs/package_config/lib/src/errors.dart b/pkgs/package_config/lib/src/errors.dart new file mode 100644 index 000000000..a66fef7f3 --- /dev/null +++ b/pkgs/package_config/lib/src/errors.dart @@ -0,0 +1,34 @@ +// Copyright (c) 2019, 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. + +/// General superclass of most errors and exceptions thrown by this package. +/// +/// Only covers errors thrown while parsing package configuration files. +/// Programming errors and I/O exceptions are not covered. +abstract class PackageConfigError { + PackageConfigError._(); +} + +class PackageConfigArgumentError extends ArgumentError + implements PackageConfigError { + PackageConfigArgumentError( + Object? super.value, String super.name, String super.message) + : super.value(); + + PackageConfigArgumentError.from(ArgumentError error) + : super.value(error.invalidValue, error.name, error.message); +} + +class PackageConfigFormatException extends FormatException + implements PackageConfigError { + PackageConfigFormatException(super.message, Object? super.source, + [super.offset]); + + PackageConfigFormatException.from(FormatException exception) + : super(exception.message, exception.source, exception.offset); +} + +/// The default `onError` handler. +// ignore: only_throw_errors +Never throwError(Object error) => throw error; diff --git a/pkgs/package_config/lib/src/package_config.dart b/pkgs/package_config/lib/src/package_config.dart new file mode 100644 index 000000000..155dfc539 --- /dev/null +++ b/pkgs/package_config/lib/src/package_config.dart @@ -0,0 +1,402 @@ +// Copyright (c) 2019, 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:typed_data'; + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'package_config_json.dart'; + +/// A package configuration. +/// +/// Associates configuration data to packages and files in packages. +/// +/// More members may be added to this class in the future, +/// so classes outside of this package must not implement [PackageConfig] +/// or any subclass of it. +abstract class PackageConfig { + /// The largest configuration version currently recognized. + static const int maxVersion = 2; + + /// An empty package configuration. + /// + /// A package configuration with no available packages. + /// Is used as a default value where a package configuration + /// is expected, but none have been specified or found. + static const PackageConfig empty = SimplePackageConfig.empty(); + + /// Creates a package configuration with the provided available [packages]. + /// + /// The packages must be valid packages (valid package name, valid + /// absolute directory URIs, valid language version, if any), + /// and there must not be two packages with the same name. + /// + /// The package's root ([Package.root]) and package-root + /// ([Package.packageUriRoot]) paths must satisfy a number of constraints + /// We say that one path (which we know ends with a `/` character) + /// is inside another path, if the latter path is a prefix of the former path, + /// including the two paths being the same. + /// + /// * No package's root must be the same as another package's root. + /// * The package-root of a package must be inside the package's root. + /// * If one package's package-root is inside another package's root, + /// then the latter package's package root must not be inside the former + /// package's root. (No getting between a package and its package root!) + /// This also disallows a package's root being the same as another + /// package's package root. + /// + /// If supplied, the [extraData] will be available as the + /// [PackageConfig.extraData] of the created configuration. + /// + /// The version of the resulting configuration is always [maxVersion]. + factory PackageConfig(Iterable packages, {Object? extraData}) => + SimplePackageConfig(maxVersion, packages, extraData); + + /// Parses a package configuration file. + /// + /// The [bytes] must be an UTF-8 encoded JSON object + /// containing a valid package configuration. + /// + /// The [baseUri] is used as the base for resolving relative + /// URI references in the configuration file. If the configuration + /// has been read from a file, the [baseUri] can be the URI of that + /// file, or of the directory it occurs in. + /// + /// If [onError] is provided, errors found during parsing or building + /// the configuration are reported by calling [onError] instead of + /// throwing, and parser makes a *best effort* attempt to continue + /// despite the error. The input must still be valid JSON. + /// The result may be [PackageConfig.empty] if there is no way to + /// extract useful information from the bytes. + static PackageConfig parseBytes(Uint8List bytes, Uri baseUri, + {void Function(Object error)? onError}) => + parsePackageConfigBytes(bytes, baseUri, onError ?? throwError); + + /// Parses a package configuration file. + /// + /// The [configuration] must be a JSON object + /// containing a valid package configuration. + /// + /// The [baseUri] is used as the base for resolving relative + /// URI references in the configuration file. If the configuration + /// has been read from a file, the [baseUri] can be the URI of that + /// file, or of the directory it occurs in. + /// + /// If [onError] is provided, errors found during parsing or building + /// the configuration are reported by calling [onError] instead of + /// throwing, and parser makes a *best effort* attempt to continue + /// despite the error. The input must still be valid JSON. + /// The result may be [PackageConfig.empty] if there is no way to + /// extract useful information from the bytes. + static PackageConfig parseString(String configuration, Uri baseUri, + {void Function(Object error)? onError}) => + parsePackageConfigString(configuration, baseUri, onError ?? throwError); + + /// Parses the JSON data of a package configuration file. + /// + /// The [jsonData] must be a JSON-like Dart data structure, + /// like the one provided by parsing JSON text using `dart:convert`, + /// containing a valid package configuration. + /// + /// The [baseUri] is used as the base for resolving relative + /// URI references in the configuration file. If the configuration + /// has been read from a file, the [baseUri] can be the URI of that + /// file, or of the directory it occurs in. + /// + /// If [onError] is provided, errors found during parsing or building + /// the configuration are reported by calling [onError] instead of + /// throwing, and parser makes a *best effort* attempt to continue + /// despite the error. The input must still be valid JSON. + /// The result may be [PackageConfig.empty] if there is no way to + /// extract useful information from the bytes. + static PackageConfig parseJson(Object? jsonData, Uri baseUri, + {void Function(Object error)? onError}) => + parsePackageConfigJson(jsonData, baseUri, onError ?? throwError); + + /// Writes a configuration file for this configuration on [output]. + /// + /// If [baseUri] is provided, URI references in the generated file + /// will be made relative to [baseUri] where possible. + static void writeBytes(PackageConfig configuration, Sink output, + [Uri? baseUri]) { + writePackageConfigJsonUtf8(configuration, baseUri, output); + } + + /// Writes a configuration JSON text for this configuration on [output]. + /// + /// If [baseUri] is provided, URI references in the generated file + /// will be made relative to [baseUri] where possible. + static void writeString(PackageConfig configuration, StringSink output, + [Uri? baseUri]) { + writePackageConfigJsonString(configuration, baseUri, output); + } + + /// Converts a configuration to a JSON-like data structure. + /// + /// If [baseUri] is provided, URI references in the generated data + /// will be made relative to [baseUri] where possible. + static Map toJson(PackageConfig configuration, + [Uri? baseUri]) => + packageConfigToJson(configuration, baseUri); + + /// The configuration version number. + /// + /// Currently this is 1 or 2, where + /// * Version one is the `.packages` file format and + /// * Version two is the first `package_config.json` format. + /// + /// Instances of this class supports both, and the version + /// is only useful for detecting which kind of file the configuration + /// was read from. + int get version; + + /// All the available packages of this configuration. + /// + /// No two of these packages have the same name, + /// and no two [Package.root] directories overlap. + Iterable get packages; + + /// Look up a package by name. + /// + /// Returns the [Package] from [packages] with [packageName] as + /// [Package.name]. Returns `null` if the package is not available in the + /// current configuration. + Package? operator [](String packageName); + + /// Provides the associated package for a specific [file] (or directory). + /// + /// Returns a [Package] which contains the [file]'s path, if any. + /// That is, the [Package.root] directory is a parent directory + /// of the [file]'s location. + /// + /// Returns `null` if the file does not belong to any package. + Package? packageOf(Uri file); + + /// Resolves a `package:` URI to a non-package URI + /// + /// The [packageUri] must be a valid package URI. That means: + /// * A URI with `package` as scheme, + /// * with no authority part (`package://...`), + /// * with a path starting with a valid package name followed by a slash, and + /// * with no query or fragment part. + /// + /// Throws an [ArgumentError] (which also implements [PackageConfigError]) + /// if the package URI is not valid. + /// + /// Returns `null` if the package name of [packageUri] is not available + /// in this package configuration. + /// Returns the remaining path of the package URI resolved relative to the + /// [Package.packageUriRoot] of the corresponding package. + Uri? resolve(Uri packageUri); + + /// The package URI which resolves to [nonPackageUri]. + /// + /// The [nonPackageUri] must not have any query or fragment part, + /// and it must not have `package` as scheme. + /// Throws an [ArgumentError] (which also implements [PackageConfigError]) + /// if the non-package URI is not valid. + /// + /// Returns a package URI which [resolve] will convert to [nonPackageUri], + /// if any such URI exists. Returns `null` if no such package URI exists. + Uri? toPackageUri(Uri nonPackageUri); + + /// Extra data associated with the package configuration. + /// + /// The data may be in any format, depending on who introduced it. + /// The standard `package_config.json` file storage will only store + /// JSON-like list/map data structures. + Object? get extraData; +} + +/// Configuration data for a single package. +abstract class Package { + /// Creates a package with the provided properties. + /// + /// The [name] must be a valid package name. + /// The [root] must be an absolute directory URI, meaning an absolute URI + /// with no query or fragment path and a path starting and ending with `/`. + /// The [packageUriRoot], if provided, must be either an absolute + /// directory URI or a relative URI reference which is then resolved + /// relative to [root]. It must then also be a subdirectory of [root], + /// or the same directory, and must end with `/`. + /// If [languageVersion] is supplied, it must be a valid Dart language + /// version, which means two decimal integer literals separated by a `.`, + /// where the integer literals have no leading zeros unless they are + /// a single zero digit. + /// + /// The [relativeRoot] controls whether the [root] is written as + /// relative to the `package_config.json` file when the package + /// configuration is written to a file. It defaults to being relative. + /// + /// If [extraData] is supplied, it will be available as the + /// [Package.extraData] of the created package. + factory Package(String name, Uri root, + {Uri? packageUriRoot, + LanguageVersion? languageVersion, + Object? extraData, + bool relativeRoot = true}) => + SimplePackage.validate(name, root, packageUriRoot, languageVersion, + extraData, relativeRoot, throwError)!; + + /// The package-name of the package. + String get name; + + /// The location of the root of the package. + /// + /// Is always an absolute URI with no query or fragment parts, + /// and with a path ending in `/`. + /// + /// All files in the [root] directory are considered + /// part of the package for purposes where that that matters. + Uri get root; + + /// The root of the files available through `package:` URIs. + /// + /// A `package:` URI with [name] as the package name is + /// resolved relative to this location. + /// + /// Is always an absolute URI with no query or fragment part + /// with a path ending in `/`, + /// and with a location which is a subdirectory + /// of the [root], or the same as the [root]. + Uri get packageUriRoot; + + /// The default language version associated with this package. + /// + /// Each package may have a default language version associated, + /// which is the language version used to parse and compile + /// Dart files in the package. + /// A package version is defined by two non-negative numbers, + /// the *major* and *minor* version numbers. + /// + /// A package may have no language version associated with it + /// in the package configuration, in which case tools should + /// use a default behavior for the package. + LanguageVersion? get languageVersion; + + /// Extra data associated with the specific package. + /// + /// The data may be in any format, depending on who introduced it. + /// The standard `package_config.json` file storage will only store + /// JSON-like list/map data structures. + Object? get extraData; + + /// Whether the [root] URI should be written as relative. + /// + /// When the configuration is written to a `package_config.json` + /// file, the [root] URI can be either relative to the file + /// location or absolute, controller by this value. + bool get relativeRoot; +} + +/// A language version. +/// +/// A language version is represented by two non-negative integers, +/// the [major] and [minor] version numbers. +/// +/// If errors during parsing are handled using an `onError` handler, +/// then an *invalid* language version may be represented by an +/// [InvalidLanguageVersion] object. +abstract class LanguageVersion implements Comparable { + /// The maximal value allowed by [major] and [minor] values; + static const int maxValue = 0x7FFFFFFF; + factory LanguageVersion(int major, int minor) { + RangeError.checkValueInInterval(major, 0, maxValue, 'major'); + RangeError.checkValueInInterval(minor, 0, maxValue, 'major'); + return SimpleLanguageVersion(major, minor, null); + } + + /// Parses a language version string. + /// + /// A valid language version string has the form + /// + /// > *decimalNumber* `.` *decimalNumber* + /// + /// where a *decimalNumber* is a non-empty sequence of decimal digits + /// with no unnecessary leading zeros (the decimal number only starts + /// with a zero digit if that digit is the entire number). + /// No spaces are allowed in the string. + /// + /// If the [source] is valid then it is parsed into a valid + /// [LanguageVersion] object. + /// If not, then the [onError] is called with a [FormatException]. + /// If [onError] is not supplied, it defaults to throwing the exception. + /// If the call does not throw, then an [InvalidLanguageVersion] is returned + /// containing the original [source]. + static LanguageVersion parse(String source, + {void Function(Object error)? onError}) => + parseLanguageVersion(source, onError ?? throwError); + + /// The major language version. + /// + /// A non-negative integer less than 231. + /// + /// The value is negative for objects representing *invalid* language + /// versions ([InvalidLanguageVersion]). + int get major; + + /// The minor language version. + /// + /// A non-negative integer less than 231. + /// + /// The value is negative for objects representing *invalid* language + /// versions ([InvalidLanguageVersion]). + int get minor; + + /// Compares language versions. + /// + /// Two language versions are considered equal if they have the + /// same major and minor version numbers. + /// + /// A language version is greater then another if the former's major version + /// is greater than the latter's major version, or if they have + /// the same major version and the former's minor version is greater than + /// the latter's. + @override + int compareTo(LanguageVersion other); + + /// Valid language versions with the same [major] and [minor] values are + /// equal. + /// + /// Invalid language versions ([InvalidLanguageVersion]) are not equal to + /// any other object. + @override + bool operator ==(Object other); + + @override + int get hashCode; + + /// A string representation of the language version. + /// + /// A valid language version is represented as + /// `"${version.major}.${version.minor}"`. + @override + String toString(); +} + +/// An *invalid* language version. +/// +/// Stored in a [Package] when the original language version string +/// was invalid and a `onError` handler was passed to the parser +/// which did not throw on an error. +abstract class InvalidLanguageVersion implements LanguageVersion { + /// The value -1 for an invalid language version. + @override + int get major; + + /// The value -1 for an invalid language version. + @override + int get minor; + + /// An invalid language version is only equal to itself. + @override + bool operator ==(Object other); + + @override + int get hashCode; + + /// The original invalid version string. + @override + String toString(); +} diff --git a/pkgs/package_config/lib/src/package_config_impl.dart b/pkgs/package_config/lib/src/package_config_impl.dart new file mode 100644 index 000000000..865e99a8e --- /dev/null +++ b/pkgs/package_config/lib/src/package_config_impl.dart @@ -0,0 +1,568 @@ +// Copyright (c) 2019, 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 'errors.dart'; +import 'package_config.dart'; +import 'util.dart'; + +export 'package_config.dart'; + +const bool _disallowPackagesInsidePackageUriRoot = false; + +// Implementations of the main data types exposed by the API of this package. + +class SimplePackageConfig implements PackageConfig { + @override + final int version; + final Map _packages; + final PackageTree _packageTree; + @override + final Object? extraData; + + factory SimplePackageConfig(int version, Iterable packages, + [Object? extraData, void Function(Object error)? onError]) { + onError ??= throwError; + var validVersion = _validateVersion(version, onError); + var sortedPackages = [...packages]..sort(_compareRoot); + var packageTree = _validatePackages(packages, sortedPackages, onError); + return SimplePackageConfig._(validVersion, packageTree, + {for (var p in packageTree.allPackages) p.name: p}, extraData); + } + + SimplePackageConfig._( + this.version, this._packageTree, this._packages, this.extraData); + + /// Creates empty configuration. + /// + /// The empty configuration can be used in cases where no configuration is + /// found, but code expects a non-null configuration. + /// + /// The version number is [PackageConfig.maxVersion] to avoid + /// minimum-version filters discarding the configuration. + const SimplePackageConfig.empty() + : version = PackageConfig.maxVersion, + _packageTree = const EmptyPackageTree(), + _packages = const {}, + extraData = null; + + static int _validateVersion( + int version, void Function(Object error) onError) { + if (version < 0 || version > PackageConfig.maxVersion) { + onError(PackageConfigArgumentError(version, 'version', + 'Must be in the range 1 to ${PackageConfig.maxVersion}')); + return 2; // The minimal version supporting a SimplePackageConfig. + } + return version; + } + + static PackageTree _validatePackages(Iterable originalPackages, + List packages, void Function(Object error) onError) { + var packageNames = {}; + var tree = TriePackageTree(); + for (var originalPackage in packages) { + SimplePackage? newPackage; + if (originalPackage is! SimplePackage) { + // SimplePackage validates these properties. + newPackage = SimplePackage.validate( + originalPackage.name, + originalPackage.root, + originalPackage.packageUriRoot, + originalPackage.languageVersion, + originalPackage.extraData, + originalPackage.relativeRoot, (error) { + if (error is PackageConfigArgumentError) { + onError(PackageConfigArgumentError(packages, 'packages', + 'Package ${newPackage!.name}: ${error.message}')); + } else { + onError(error); + } + }); + if (newPackage == null) continue; + } else { + newPackage = originalPackage; + } + var name = newPackage.name; + if (packageNames.contains(name)) { + onError(PackageConfigArgumentError( + name, 'packages', "Duplicate package name '$name'")); + continue; + } + packageNames.add(name); + tree.add(newPackage, (error) { + if (error is ConflictException) { + // There is a conflict with an existing package. + var existingPackage = error.existingPackage; + switch (error.conflictType) { + case ConflictType.sameRoots: + onError(PackageConfigArgumentError( + originalPackages, + 'packages', + 'Packages ${newPackage!.name} and ${existingPackage.name} ' + 'have the same root directory: ${newPackage.root}.\n')); + break; + case ConflictType.interleaving: + // The new package is inside the package URI root of the existing + // package. + onError(PackageConfigArgumentError( + originalPackages, + 'packages', + 'Package ${newPackage!.name} is inside the root of ' + 'package ${existingPackage.name}, and the package root ' + 'of ${existingPackage.name} is inside the root of ' + '${newPackage.name}.\n' + '${existingPackage.name} package root: ' + '${existingPackage.packageUriRoot}\n' + '${newPackage.name} root: ${newPackage.root}\n')); + break; + case ConflictType.insidePackageRoot: + onError(PackageConfigArgumentError( + originalPackages, + 'packages', + 'Package ${newPackage!.name} is inside the package root of ' + 'package ${existingPackage.name}.\n' + '${existingPackage.name} package root: ' + '${existingPackage.packageUriRoot}\n' + '${newPackage.name} root: ${newPackage.root}\n')); + break; + } + } else { + // Any other error. + onError(error); + } + }); + } + return tree; + } + + @override + Iterable get packages => _packages.values; + + @override + Package? operator [](String packageName) => _packages[packageName]; + + @override + Package? packageOf(Uri file) => _packageTree.packageOf(file); + + @override + Uri? resolve(Uri packageUri) { + var packageName = checkValidPackageUri(packageUri, 'packageUri'); + return _packages[packageName]?.packageUriRoot.resolveUri( + Uri(path: packageUri.path.substring(packageName.length + 1))); + } + + @override + Uri? toPackageUri(Uri nonPackageUri) { + if (nonPackageUri.isScheme('package')) { + throw PackageConfigArgumentError( + nonPackageUri, 'nonPackageUri', 'Must not be a package URI'); + } + if (nonPackageUri.hasQuery || nonPackageUri.hasFragment) { + throw PackageConfigArgumentError(nonPackageUri, 'nonPackageUri', + 'Must not have query or fragment part'); + } + // Find package that file belongs to. + var package = _packageTree.packageOf(nonPackageUri); + if (package == null) return null; + // Check if it is inside the package URI root. + var path = nonPackageUri.toString(); + var root = package.packageUriRoot.toString(); + if (_beginsWith(package.root.toString().length, root, path)) { + var rest = path.substring(root.length); + return Uri(scheme: 'package', path: '${package.name}/$rest'); + } + return null; + } +} + +/// Configuration data for a single package. +class SimplePackage implements Package { + @override + final String name; + @override + final Uri root; + @override + final Uri packageUriRoot; + @override + final LanguageVersion? languageVersion; + @override + final Object? extraData; + @override + final bool relativeRoot; + + SimplePackage._(this.name, this.root, this.packageUriRoot, + this.languageVersion, this.extraData, this.relativeRoot); + + /// Creates a [SimplePackage] with the provided content. + /// + /// The provided arguments must be valid. + /// + /// If the arguments are invalid then the error is reported by + /// calling [onError], then the erroneous entry is ignored. + /// + /// If [onError] is provided, the user is expected to be able to handle + /// errors themselves. An invalid [languageVersion] string + /// will be replaced with the string `"invalid"`. This allows + /// users to detect the difference between an absent version and + /// an invalid one. + /// + /// Returns `null` if the input is invalid and an approximately valid package + /// cannot be salvaged from the input. + static SimplePackage? validate( + String name, + Uri root, + Uri? packageUriRoot, + LanguageVersion? languageVersion, + Object? extraData, + bool relativeRoot, + void Function(Object error) onError) { + var fatalError = false; + var invalidIndex = checkPackageName(name); + if (invalidIndex >= 0) { + onError(PackageConfigFormatException( + 'Not a valid package name', name, invalidIndex)); + fatalError = true; + } + if (root.isScheme('package')) { + onError(PackageConfigArgumentError( + '$root', 'root', 'Must not be a package URI')); + fatalError = true; + } else if (!isAbsoluteDirectoryUri(root)) { + onError(PackageConfigArgumentError( + '$root', + 'root', + 'In package $name: Not an absolute URI with no query or fragment ' + 'with a path ending in /')); + // Try to recover. If the URI has a scheme, + // then ensure that the path ends with `/`. + if (!root.hasScheme) { + fatalError = true; + } else if (!root.path.endsWith('/')) { + root = root.replace(path: '${root.path}/'); + } + } + if (packageUriRoot == null) { + packageUriRoot = root; + } else if (!fatalError) { + packageUriRoot = root.resolveUri(packageUriRoot); + if (!isAbsoluteDirectoryUri(packageUriRoot)) { + onError(PackageConfigArgumentError( + packageUriRoot, + 'packageUriRoot', + 'In package $name: Not an absolute URI with no query or fragment ' + 'with a path ending in /')); + packageUriRoot = root; + } else if (!isUriPrefix(root, packageUriRoot)) { + onError(PackageConfigArgumentError(packageUriRoot, 'packageUriRoot', + 'The package URI root is not below the package root')); + packageUriRoot = root; + } + } + if (fatalError) return null; + return SimplePackage._( + name, root, packageUriRoot, languageVersion, extraData, relativeRoot); + } +} + +/// Checks whether [source] is a valid Dart language version string. +/// +/// The format is (as RegExp) `^(0|[1-9]\d+)\.(0|[1-9]\d+)$`. +/// +/// Reports a format exception on [onError] if not, or if the numbers +/// are too large (at most 32-bit signed integers). +LanguageVersion parseLanguageVersion( + String? source, void Function(Object error) onError) { + var index = 0; + // Reads a positive decimal numeral. Returns the value of the numeral, + // or a negative number in case of an error. + // Starts at [index] and increments the index to the position after + // the numeral. + // It is an error if the numeral value is greater than 0x7FFFFFFFF. + // It is a recoverable error if the numeral starts with leading zeros. + int readNumeral() { + const maxValue = 0x7FFFFFFF; + if (index == source!.length) { + onError(PackageConfigFormatException('Missing number', source, index)); + return -1; + } + var start = index; + + var char = source.codeUnitAt(index); + var digit = char ^ 0x30; + if (digit > 9) { + onError(PackageConfigFormatException('Missing number', source, index)); + return -1; + } + var firstDigit = digit; + var value = 0; + do { + value = value * 10 + digit; + if (value > maxValue) { + onError( + PackageConfigFormatException('Number too large', source, start)); + return -1; + } + index++; + if (index == source.length) break; + char = source.codeUnitAt(index); + digit = char ^ 0x30; + } while (digit <= 9); + if (firstDigit == 0 && index > start + 1) { + onError(PackageConfigFormatException( + 'Leading zero not allowed', source, start)); + } + return value; + } + + var major = readNumeral(); + if (major < 0) { + return SimpleInvalidLanguageVersion(source); + } + if (index == source!.length || source.codeUnitAt(index) != $dot) { + onError(PackageConfigFormatException("Missing '.'", source, index)); + return SimpleInvalidLanguageVersion(source); + } + index++; + var minor = readNumeral(); + if (minor < 0) { + return SimpleInvalidLanguageVersion(source); + } + if (index != source.length) { + onError(PackageConfigFormatException( + 'Unexpected trailing character', source, index)); + return SimpleInvalidLanguageVersion(source); + } + return SimpleLanguageVersion(major, minor, source); +} + +abstract class _SimpleLanguageVersionBase implements LanguageVersion { + @override + int compareTo(LanguageVersion other) { + var result = major.compareTo(other.major); + if (result != 0) return result; + return minor.compareTo(other.minor); + } +} + +class SimpleLanguageVersion extends _SimpleLanguageVersionBase { + @override + final int major; + @override + final int minor; + String? _source; + SimpleLanguageVersion(this.major, this.minor, this._source); + + @override + bool operator ==(Object other) => + other is LanguageVersion && major == other.major && minor == other.minor; + + @override + int get hashCode => (major * 17 ^ minor * 37) & 0x3FFFFFFF; + + @override + String toString() => _source ??= '$major.$minor'; +} + +class SimpleInvalidLanguageVersion extends _SimpleLanguageVersionBase + implements InvalidLanguageVersion { + final String? _source; + SimpleInvalidLanguageVersion(this._source); + @override + int get major => -1; + @override + int get minor => -1; + + @override + String toString() => _source!; +} + +abstract class PackageTree { + Iterable get allPackages; + SimplePackage? packageOf(Uri file); +} + +class _PackageTrieNode { + SimplePackage? package; + + /// Indexed by path segment. + Map map = {}; +} + +/// Packages of a package configuration ordered by root path. +/// +/// A package has a root path and a package root path, where the latter +/// contains the files exposed by `package:` URIs. +/// +/// A package is said to be inside another package if the root path URI of +/// the latter is a prefix of the root path URI of the former. +/// +/// No two packages of a package may have the same root path. +/// The package root path of a package must not be inside another package's +/// root path. +/// Entire other packages are allowed inside a package's root. +class TriePackageTree implements PackageTree { + /// Indexed by URI scheme. + final Map _map = {}; + + /// A list of all packages. + final List _packages = []; + + @override + Iterable get allPackages sync* { + for (var package in _packages) { + yield package; + } + } + + bool _checkConflict(_PackageTrieNode node, SimplePackage newPackage, + void Function(Object error) onError) { + var existingPackage = node.package; + if (existingPackage != null) { + // Trying to add package that is inside the existing package. + // 1) If it's an exact match it's not allowed (i.e. the roots can't be + // the same). + if (newPackage.root.path.length == existingPackage.root.path.length) { + onError(ConflictException( + newPackage, existingPackage, ConflictType.sameRoots)); + return true; + } + // 2) The existing package has a packageUriRoot thats inside the + // root of the new package. + if (_beginsWith(0, newPackage.root.toString(), + existingPackage.packageUriRoot.toString())) { + onError(ConflictException( + newPackage, existingPackage, ConflictType.interleaving)); + return true; + } + + // For internal reasons we allow this (for now). One should still never do + // it though. + // 3) The new package is inside the packageUriRoot of existing package. + if (_disallowPackagesInsidePackageUriRoot) { + if (_beginsWith(0, existingPackage.packageUriRoot.toString(), + newPackage.root.toString())) { + onError(ConflictException( + newPackage, existingPackage, ConflictType.insidePackageRoot)); + return true; + } + } + } + return false; + } + + /// Tries to add `newPackage` to the tree. + /// + /// Reports a [ConflictException] if the added package conflicts with an + /// existing package. + /// It conflicts if its root or package root is the same as an existing + /// package's root or package root, is between the two, or if it's inside the + /// package root of an existing package. + /// + /// If a conflict is detected between [newPackage] and a previous package, + /// then [onError] is called with a [ConflictException] object + /// and the [newPackage] is not added to the tree. + /// + /// The packages are added in order of their root path. + void add(SimplePackage newPackage, void Function(Object error) onError) { + var root = newPackage.root; + var node = _map[root.scheme] ??= _PackageTrieNode(); + if (_checkConflict(node, newPackage, onError)) return; + var segments = root.pathSegments; + // Notice that we're skipping the last segment as it's always the empty + // string because roots are directories. + for (var i = 0; i < segments.length - 1; i++) { + var path = segments[i]; + node = node.map[path] ??= _PackageTrieNode(); + if (_checkConflict(node, newPackage, onError)) return; + } + node.package = newPackage; + _packages.add(newPackage); + } + + bool _isMatch( + String path, _PackageTrieNode node, List potential) { + var currentPackage = node.package; + if (currentPackage != null) { + var currentPackageRootLength = currentPackage.root.toString().length; + if (path.length == currentPackageRootLength) return true; + var currentPackageUriRoot = currentPackage.packageUriRoot.toString(); + // Is [file] inside the package root of [currentPackage]? + if (currentPackageUriRoot.length == currentPackageRootLength || + _beginsWith(currentPackageRootLength, currentPackageUriRoot, path)) { + return true; + } + potential.add(currentPackage); + } + return false; + } + + @override + SimplePackage? packageOf(Uri file) { + var currentTrieNode = _map[file.scheme]; + if (currentTrieNode == null) return null; + var path = file.toString(); + var potential = []; + if (_isMatch(path, currentTrieNode, potential)) { + return currentTrieNode.package; + } + var segments = file.pathSegments; + + for (var i = 0; i < segments.length - 1; i++) { + var segment = segments[i]; + currentTrieNode = currentTrieNode!.map[segment]; + if (currentTrieNode == null) break; + if (_isMatch(path, currentTrieNode, potential)) { + return currentTrieNode.package; + } + } + if (potential.isEmpty) return null; + return potential.last; + } +} + +class EmptyPackageTree implements PackageTree { + const EmptyPackageTree(); + + @override + Iterable get allPackages => const Iterable.empty(); + + @override + SimplePackage? packageOf(Uri file) => null; +} + +/// Checks whether [longerPath] begins with [parentPath]. +/// +/// Skips checking the [start] first characters which are assumed to +/// already have been matched. +bool _beginsWith(int start, String parentPath, String longerPath) { + if (longerPath.length < parentPath.length) return false; + for (var i = start; i < parentPath.length; i++) { + if (longerPath.codeUnitAt(i) != parentPath.codeUnitAt(i)) return false; + } + return true; +} + +enum ConflictType { sameRoots, interleaving, insidePackageRoot } + +/// Conflict between packages added to the same configuration. +/// +/// The [package] conflicts with [existingPackage] if it has +/// the same root path or the package URI root path +/// of [existingPackage] is inside the root path of [package]. +class ConflictException { + /// The existing package that [package] conflicts with. + final SimplePackage existingPackage; + + /// The package that could not be added without a conflict. + final SimplePackage package; + + /// Whether the conflict is with the package URI root of [existingPackage]. + final ConflictType conflictType; + + /// Creates a root conflict between [package] and [existingPackage]. + ConflictException(this.package, this.existingPackage, this.conflictType); +} + +/// Used for sorting packages by root path. +int _compareRoot(Package p1, Package p2) => + p1.root.toString().compareTo(p2.root.toString()); diff --git a/pkgs/package_config/lib/src/package_config_io.dart b/pkgs/package_config/lib/src/package_config_io.dart new file mode 100644 index 000000000..8c5773b2b --- /dev/null +++ b/pkgs/package_config/lib/src/package_config_io.dart @@ -0,0 +1,166 @@ +// Copyright (c) 2019, 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. + +// dart:io dependent functionality for reading and writing configuration files. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'package_config_json.dart'; +import 'packages_file.dart' as packages_file; +import 'util.dart'; +import 'util_io.dart'; + +/// Name of directory where Dart tools store their configuration. +/// +/// Directory is created in the package root directory. +const dartToolDirName = '.dart_tool'; + +/// Name of file containing new package configuration data. +/// +/// File is stored in the dart tool directory. +const packageConfigFileName = 'package_config.json'; + +/// Name of file containing legacy package configuration data. +/// +/// File is stored in the package root directory. +const packagesFileName = '.packages'; + +/// Reads a package configuration file. +/// +/// Detects whether the [file] is a version one `.packages` file or +/// a version two `package_config.json` file. +/// +/// If the [file] is a `.packages` file and [preferNewest] is true, +/// first checks whether there is an adjacent `.dart_tool/package_config.json` +/// file, and if so, reads that instead. +/// If [preferNewest] is false, the specified file is loaded even if it is +/// a `.packages` file and there is an available `package_config.json` file. +/// +/// The file must exist and be a normal file. +Future readAnyConfigFile( + File file, bool preferNewest, void Function(Object error) onError) async { + if (preferNewest && fileName(file.path) == packagesFileName) { + var alternateFile = File( + pathJoin(dirName(file.path), dartToolDirName, packageConfigFileName)); + if (alternateFile.existsSync()) { + return await readPackageConfigJsonFile(alternateFile, onError); + } + } + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (e) { + onError(e); + return const SimplePackageConfig.empty(); + } + return parseAnyConfigFile(bytes, file.uri, onError); +} + +/// Like [readAnyConfigFile] but uses a URI and an optional loader. +Future readAnyConfigFileUri( + Uri file, + Future Function(Uri uri)? loader, + void Function(Object error) onError, + bool preferNewest) async { + if (file.isScheme('package')) { + throw PackageConfigArgumentError( + file, 'file', 'Must not be a package: URI'); + } + if (loader == null) { + if (file.isScheme('file')) { + return await readAnyConfigFile(File.fromUri(file), preferNewest, onError); + } + loader = defaultLoader; + } + if (preferNewest && file.pathSegments.last == packagesFileName) { + var alternateFile = file.resolve('$dartToolDirName/$packageConfigFileName'); + Uint8List? bytes; + try { + bytes = await loader(alternateFile); + } catch (e) { + onError(e); + return const SimplePackageConfig.empty(); + } + if (bytes != null) { + return parsePackageConfigBytes(bytes, alternateFile, onError); + } + } + Uint8List? bytes; + try { + bytes = await loader(file); + } catch (e) { + onError(e); + return const SimplePackageConfig.empty(); + } + if (bytes == null) { + onError(PackageConfigArgumentError( + file.toString(), 'file', 'File cannot be read')); + return const SimplePackageConfig.empty(); + } + return parseAnyConfigFile(bytes, file, onError); +} + +/// Parses a `.packages` or `package_config.json` file's contents. +/// +/// Assumes it's a JSON file if the first non-whitespace character +/// is `{`, otherwise assumes it's a `.packages` file. +PackageConfig parseAnyConfigFile( + Uint8List bytes, Uri file, void Function(Object error) onError) { + var firstChar = firstNonWhitespaceChar(bytes); + if (firstChar != $lbrace) { + // Definitely not a JSON object, probably a .packages. + return packages_file.parse(bytes, file, onError); + } + return parsePackageConfigBytes(bytes, file, onError); +} + +Future readPackageConfigJsonFile( + File file, void Function(Object error) onError) async { + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (error) { + onError(error); + return const SimplePackageConfig.empty(); + } + return parsePackageConfigBytes(bytes, file.uri, onError); +} + +Future readDotPackagesFile( + File file, void Function(Object error) onError) async { + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (error) { + onError(error); + return const SimplePackageConfig.empty(); + } + return packages_file.parse(bytes, file.uri, onError); +} + +Future writePackageConfigJsonFile( + PackageConfig config, Directory targetDirectory) async { + // Write .dart_tool/package_config.json first. + var dartToolDir = Directory(pathJoin(targetDirectory.path, dartToolDirName)); + await dartToolDir.create(recursive: true); + var file = File(pathJoin(dartToolDir.path, packageConfigFileName)); + var baseUri = file.uri; + + var sink = file.openWrite(encoding: utf8); + writePackageConfigJsonUtf8(config, baseUri, sink); + var doneJson = sink.close(); + + // Write .packages too. + file = File(pathJoin(targetDirectory.path, packagesFileName)); + baseUri = file.uri; + sink = file.openWrite(encoding: utf8); + writeDotPackages(config, baseUri, sink); + var donePackages = sink.close(); + + await Future.wait([doneJson, donePackages]); +} diff --git a/pkgs/package_config/lib/src/package_config_json.dart b/pkgs/package_config/lib/src/package_config_json.dart new file mode 100644 index 000000000..65560a0f0 --- /dev/null +++ b/pkgs/package_config/lib/src/package_config_json.dart @@ -0,0 +1,321 @@ +// Copyright (c) 2019, 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. + +// Parsing and serialization of package configurations. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'packages_file.dart' as packages_file; +import 'util.dart'; + +const String _configVersionKey = 'configVersion'; +const String _packagesKey = 'packages'; +const List _topNames = [_configVersionKey, _packagesKey]; +const String _nameKey = 'name'; +const String _rootUriKey = 'rootUri'; +const String _packageUriKey = 'packageUri'; +const String _languageVersionKey = 'languageVersion'; +const List _packageNames = [ + _nameKey, + _rootUriKey, + _packageUriKey, + _languageVersionKey +]; + +const String _generatedKey = 'generated'; +const String _generatorKey = 'generator'; +const String _generatorVersionKey = 'generatorVersion'; + +final _jsonUtf8Decoder = json.fuse(utf8).decoder; + +PackageConfig parsePackageConfigBytes( + Uint8List bytes, Uri file, void Function(Object error) onError) { + // TODO(lrn): Make this simpler. Maybe parse directly from bytes. + Object? jsonObject; + try { + jsonObject = _jsonUtf8Decoder.convert(bytes); + } on FormatException catch (e) { + onError(PackageConfigFormatException.from(e)); + return const SimplePackageConfig.empty(); + } + return parsePackageConfigJson(jsonObject, file, onError); +} + +PackageConfig parsePackageConfigString( + String source, Uri file, void Function(Object error) onError) { + Object? jsonObject; + try { + jsonObject = jsonDecode(source); + } on FormatException catch (e) { + onError(PackageConfigFormatException.from(e)); + return const SimplePackageConfig.empty(); + } + return parsePackageConfigJson(jsonObject, file, onError); +} + +/// Creates a [PackageConfig] from a parsed JSON-like object structure. +/// +/// The [json] argument must be a JSON object (`Map`) +/// containing a `"configVersion"` entry with an integer value in the range +/// 1 to [PackageConfig.maxVersion], +/// and with a `"packages"` entry which is a JSON array (`List`) +/// containing JSON objects which each has the following properties: +/// +/// * `"name"`: The package name as a string. +/// * `"rootUri"`: The root of the package as a URI stored as a string. +/// * `"packageUri"`: Optionally the root of for `package:` URI resolution +/// for the package, as a relative URI below the root URI +/// stored as a string. +/// * `"languageVersion"`: Optionally a language version string which is a +/// an integer numeral, a decimal point (`.`) and another integer numeral, +/// where the integer numeral cannot have a sign, and can only have a +/// leading zero if the entire numeral is a single zero. +/// +/// The [baseLocation] is used as base URI to resolve the "rootUri" +/// URI reference string. +PackageConfig parsePackageConfigJson( + Object? json, Uri baseLocation, void Function(Object error) onError) { + if (!baseLocation.hasScheme || baseLocation.isScheme('package')) { + throw PackageConfigArgumentError(baseLocation.toString(), 'baseLocation', + 'Must be an absolute non-package: URI'); + } + + if (!baseLocation.path.endsWith('/')) { + baseLocation = baseLocation.resolveUri(Uri(path: '.')); + } + + String typeName() { + if (0 is T) return 'int'; + if ('' is T) return 'string'; + if (const [] is T) return 'array'; + return 'object'; + } + + T? checkType(Object? value, String name, [String? packageName]) { + if (value is T) return value; + // The only types we are called with are [int], [String], [List] + // and Map. Recognize which to give a better error message. + var message = + "$name${packageName != null ? " of package $packageName" : ""}" + ' is not a JSON ${typeName()}'; + onError(PackageConfigFormatException(message, value)); + return null; + } + + Package? parsePackage(Map entry) { + String? name; + String? rootUri; + String? packageUri; + String? languageVersion; + Map? extraData; + var hasName = false; + var hasRoot = false; + var hasVersion = false; + entry.forEach((key, value) { + switch (key) { + case _nameKey: + hasName = true; + name = checkType(value, _nameKey); + break; + case _rootUriKey: + hasRoot = true; + rootUri = checkType(value, _rootUriKey, name); + break; + case _packageUriKey: + packageUri = checkType(value, _packageUriKey, name); + break; + case _languageVersionKey: + hasVersion = true; + languageVersion = checkType(value, _languageVersionKey, name); + break; + default: + (extraData ??= {})[key] = value; + break; + } + }); + if (!hasName) { + onError(PackageConfigFormatException('Missing name entry', entry)); + } + if (!hasRoot) { + onError(PackageConfigFormatException('Missing rootUri entry', entry)); + } + if (name == null || rootUri == null) return null; + var parsedRootUri = Uri.parse(rootUri!); + var relativeRoot = !hasAbsolutePath(parsedRootUri); + var root = baseLocation.resolveUri(parsedRootUri); + if (!root.path.endsWith('/')) root = root.replace(path: '${root.path}/'); + var packageRoot = root; + if (packageUri != null) packageRoot = root.resolve(packageUri!); + if (!packageRoot.path.endsWith('/')) { + packageRoot = packageRoot.replace(path: '${packageRoot.path}/'); + } + + LanguageVersion? version; + if (languageVersion != null) { + version = parseLanguageVersion(languageVersion, onError); + } else if (hasVersion) { + version = SimpleInvalidLanguageVersion('invalid'); + } + + return SimplePackage.validate( + name!, root, packageRoot, version, extraData, relativeRoot, (error) { + if (error is ArgumentError) { + onError( + PackageConfigFormatException( + error.message.toString(), error.invalidValue), + ); + } else { + onError(error); + } + }); + } + + var map = checkType>(json, 'value'); + if (map == null) return const SimplePackageConfig.empty(); + Map? extraData; + List? packageList; + int? configVersion; + map.forEach((key, value) { + switch (key) { + case _configVersionKey: + configVersion = checkType(value, _configVersionKey) ?? 2; + break; + case _packagesKey: + var packageArray = checkType>(value, _packagesKey) ?? []; + var packages = []; + for (var package in packageArray) { + var packageMap = + checkType>(package, 'package entry'); + if (packageMap != null) { + var entry = parsePackage(packageMap); + if (entry != null) { + packages.add(entry); + } + } + } + packageList = packages; + break; + default: + (extraData ??= {})[key] = value; + break; + } + }); + if (configVersion == null) { + onError(PackageConfigFormatException('Missing configVersion entry', json)); + configVersion = 2; + } + if (packageList == null) { + onError(PackageConfigFormatException('Missing packages list', json)); + packageList = []; + } + return SimplePackageConfig(configVersion!, packageList!, extraData, (error) { + if (error is ArgumentError) { + onError( + PackageConfigFormatException( + error.message.toString(), error.invalidValue), + ); + } else { + onError(error); + } + }); +} + +final _jsonUtf8Encoder = JsonUtf8Encoder(' '); + +void writePackageConfigJsonUtf8( + PackageConfig config, Uri? baseUri, Sink> output) { + // Can be optimized. + var data = packageConfigToJson(config, baseUri); + output.add(_jsonUtf8Encoder.convert(data) as Uint8List); +} + +void writePackageConfigJsonString( + PackageConfig config, Uri? baseUri, StringSink output) { + // Can be optimized. + var data = packageConfigToJson(config, baseUri); + output.write(const JsonEncoder.withIndent(' ').convert(data)); +} + +Map packageConfigToJson(PackageConfig config, Uri? baseUri) => + { + ...?_extractExtraData(config.extraData, _topNames), + _configVersionKey: PackageConfig.maxVersion, + _packagesKey: [ + for (var package in config.packages) + { + _nameKey: package.name, + _rootUriKey: trailingSlash((package.relativeRoot + ? relativizeUri(package.root, baseUri) + : package.root) + .toString()), + if (package.root != package.packageUriRoot) + _packageUriKey: trailingSlash( + relativizeUri(package.packageUriRoot, package.root) + .toString()), + if (package.languageVersion != null && + package.languageVersion is! InvalidLanguageVersion) + _languageVersionKey: package.languageVersion.toString(), + ...?_extractExtraData(package.extraData, _packageNames), + } + ], + }; + +void writeDotPackages(PackageConfig config, Uri baseUri, StringSink output) { + var extraData = config.extraData; + // Write .packages too. + String? comment; + if (extraData is Map) { + var generator = extraData[_generatorKey]; + if (generator is String) { + var generated = extraData[_generatedKey]; + var generatorVersion = extraData[_generatorVersionKey]; + comment = 'Generated by $generator' + "${generatorVersion is String ? " $generatorVersion" : ""}" + "${generated is String ? " on $generated" : ""}."; + } + } + packages_file.write(output, config, baseUri: baseUri, comment: comment); +} + +/// If "extraData" is a JSON map, then return it, otherwise return null. +/// +/// If the value contains any of the [reservedNames] for the current context, +/// entries with that name in the extra data are dropped. +Map? _extractExtraData( + Object? data, Iterable reservedNames) { + if (data is Map) { + if (data.isEmpty) return null; + for (var name in reservedNames) { + if (data.containsKey(name)) { + var filteredData = { + for (var key in data.keys) + if (!reservedNames.contains(key)) key: data[key] + }; + if (filteredData.isEmpty) return null; + for (var value in filteredData.values) { + if (!_validateJson(value)) return null; + } + return filteredData; + } + } + return data; + } + return null; +} + +/// Checks that the object is a valid JSON-like data structure. +bool _validateJson(Object? object) { + if (object == null || true == object || false == object) return true; + if (object is num || object is String) return true; + if (object is List) { + return object.every(_validateJson); + } + if (object is Map) { + return object.values.every(_validateJson); + } + return false; +} diff --git a/pkgs/package_config/lib/src/packages_file.dart b/pkgs/package_config/lib/src/packages_file.dart new file mode 100644 index 000000000..bf68f2c88 --- /dev/null +++ b/pkgs/package_config/lib/src/packages_file.dart @@ -0,0 +1,193 @@ +// Copyright (c) 2019, 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 'errors.dart'; +import 'package_config_impl.dart'; +import 'util.dart'; + +/// The language version prior to the release of language versioning. +/// +/// This is the default language version used by all packages from a +/// `.packages` file. +final LanguageVersion _languageVersion = LanguageVersion(2, 7); + +/// Parses a `.packages` file into a [PackageConfig]. +/// +/// The [source] is the byte content of a `.packages` file, assumed to be +/// UTF-8 encoded. In practice, all significant parts of the file must be ASCII, +/// so Latin-1 or Windows-1252 encoding will also work fine. +/// +/// If the file content is available as a string, its [String.codeUnits] can +/// be used as the `source` argument of this function. +/// +/// The [baseLocation] is used as a base URI to resolve all relative +/// URI references against. +/// If the content was read from a file, `baseLocation` should be the +/// location of that file. +/// +/// Returns a simple package configuration where each package's +/// [Package.packageUriRoot] is the same as its [Package.root] +/// and it has no [Package.languageVersion]. +PackageConfig parse( + List source, Uri baseLocation, void Function(Object error) onError) { + if (baseLocation.isScheme('package')) { + onError(PackageConfigArgumentError( + baseLocation, 'baseLocation', 'Must not be a package: URI')); + return PackageConfig.empty; + } + var index = 0; + var packages = []; + var packageNames = {}; + while (index < source.length) { + var ignoreLine = false; + var start = index; + var separatorIndex = -1; + var end = source.length; + var char = source[index++]; + if (char == $cr || char == $lf) { + continue; + } + if (char == $colon) { + onError(PackageConfigFormatException( + 'Missing package name', source, index - 1)); + ignoreLine = true; // Ignore if package name is invalid. + } else { + ignoreLine = char == $hash; // Ignore if comment. + } + var queryStart = -1; + var fragmentStart = -1; + while (index < source.length) { + char = source[index++]; + if (char == $colon && separatorIndex < 0) { + separatorIndex = index - 1; + } else if (char == $cr || char == $lf) { + end = index - 1; + break; + } else if (char == $question && queryStart < 0 && fragmentStart < 0) { + queryStart = index - 1; + } else if (char == $hash && fragmentStart < 0) { + fragmentStart = index - 1; + } + } + if (ignoreLine) continue; + if (separatorIndex < 0) { + onError( + PackageConfigFormatException("No ':' on line", source, index - 1)); + continue; + } + var packageName = String.fromCharCodes(source, start, separatorIndex); + var invalidIndex = checkPackageName(packageName); + if (invalidIndex >= 0) { + onError(PackageConfigFormatException( + 'Not a valid package name', source, start + invalidIndex)); + continue; + } + if (queryStart >= 0) { + onError(PackageConfigFormatException( + 'Location URI must not have query', source, queryStart)); + end = queryStart; + } else if (fragmentStart >= 0) { + onError(PackageConfigFormatException( + 'Location URI must not have fragment', source, fragmentStart)); + end = fragmentStart; + } + var packageValue = String.fromCharCodes(source, separatorIndex + 1, end); + Uri packageLocation; + try { + packageLocation = Uri.parse(packageValue); + } on FormatException catch (e) { + onError(PackageConfigFormatException.from(e)); + continue; + } + var relativeRoot = !hasAbsolutePath(packageLocation); + packageLocation = baseLocation.resolveUri(packageLocation); + if (packageLocation.isScheme('package')) { + onError(PackageConfigFormatException( + 'Package URI as location for package', source, separatorIndex + 1)); + continue; + } + var path = packageLocation.path; + if (!path.endsWith('/')) { + path += '/'; + packageLocation = packageLocation.replace(path: path); + } + if (packageNames.contains(packageName)) { + onError(PackageConfigFormatException( + 'Same package name occurred more than once', source, start)); + continue; + } + var rootUri = packageLocation; + if (path.endsWith('/lib/')) { + // Assume default Pub package layout. Include package itself in root. + rootUri = + packageLocation.replace(path: path.substring(0, path.length - 4)); + } + var package = SimplePackage.validate(packageName, rootUri, packageLocation, + _languageVersion, null, relativeRoot, (error) { + if (error is ArgumentError) { + onError(PackageConfigFormatException(error.message.toString(), source)); + } else { + onError(error); + } + }); + if (package != null) { + packages.add(package); + packageNames.add(packageName); + } + } + return SimplePackageConfig(1, packages, null, onError); +} + +/// Writes the configuration to a [StringSink]. +/// +/// If [comment] is provided, the output will contain this comment +/// with `# ` in front of each line. +/// Lines are defined as ending in line feed (`'\n'`). If the final +/// line of the comment doesn't end in a line feed, one will be added. +/// +/// If [baseUri] is provided, package locations will be made relative +/// to the base URI, if possible, before writing. +void write(StringSink output, PackageConfig config, + {Uri? baseUri, String? comment}) { + if (baseUri != null && !baseUri.isAbsolute) { + throw PackageConfigArgumentError(baseUri, 'baseUri', 'Must be absolute'); + } + + if (comment != null) { + var lines = comment.split('\n'); + if (lines.last.isEmpty) lines.removeLast(); + for (var commentLine in lines) { + output.write('# '); + output.writeln(commentLine); + } + } else { + output.write('# generated by package:package_config at '); + output.write(DateTime.now()); + output.writeln(); + } + for (var package in config.packages) { + var packageName = package.name; + var uri = package.packageUriRoot; + // Validate packageName. + if (!isValidPackageName(packageName)) { + throw PackageConfigArgumentError( + config, 'config', '"$packageName" is not a valid package name'); + } + if (uri.scheme == 'package') { + throw PackageConfigArgumentError( + config, 'config', 'Package location must not be a package URI: $uri'); + } + output.write(packageName); + output.write(':'); + // If baseUri is provided, make the URI relative to baseUri. + if (baseUri != null) { + uri = relativizeUri(uri, baseUri)!; + } + if (!uri.path.endsWith('/')) { + uri = uri.replace(path: '${uri.path}/'); + } + output.write(uri); + output.writeln(); + } +} diff --git a/pkgs/package_config/lib/src/util.dart b/pkgs/package_config/lib/src/util.dart new file mode 100644 index 000000000..4f0210cda --- /dev/null +++ b/pkgs/package_config/lib/src/util.dart @@ -0,0 +1,253 @@ +// Copyright (c) 2019, 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. + +/// Utility methods used by more than one library in the package. +library; + +import 'errors.dart'; + +// All ASCII characters that are valid in a package name, with space +// for all the invalid ones (including space). +const String _validPackageNameCharacters = + r" ! $ &'()*+,-. 0123456789 ; = " + r'@ABCDEFGHIJKLMNOPQRSTUVWXYZ _ abcdefghijklmnopqrstuvwxyz ~ '; + +/// Tests whether something is a valid Dart package name. +bool isValidPackageName(String string) { + return checkPackageName(string) < 0; +} + +/// Check if a string is a valid package name. +/// +/// Valid package names contain only characters in [_validPackageNameCharacters] +/// and must contain at least one non-'.' character. +/// +/// Returns `-1` if the string is valid. +/// Otherwise returns the index of the first invalid character, +/// or `string.length` if the string contains no non-'.' character. +int checkPackageName(String string) { + // Becomes non-zero if any non-'.' character is encountered. + var nonDot = 0; + for (var i = 0; i < string.length; i++) { + var c = string.codeUnitAt(i); + if (c > 0x7f || _validPackageNameCharacters.codeUnitAt(c) <= $space) { + return i; + } + nonDot += c ^ $dot; + } + if (nonDot == 0) return string.length; + return -1; +} + +/// Validate that a [Uri] is a valid `package:` URI. +/// +/// Used to validate user input. +/// +/// Returns the package name extracted from the package URI, +/// which is the path segment between `package:` and the first `/`. +String checkValidPackageUri(Uri packageUri, String name) { + if (packageUri.scheme != 'package') { + throw PackageConfigArgumentError(packageUri, name, 'Not a package: URI'); + } + if (packageUri.hasAuthority) { + throw PackageConfigArgumentError( + packageUri, name, 'Package URIs must not have a host part'); + } + if (packageUri.hasQuery) { + // A query makes no sense if resolved to a file: URI. + throw PackageConfigArgumentError( + packageUri, name, 'Package URIs must not have a query part'); + } + if (packageUri.hasFragment) { + // We could leave the fragment after the URL when resolving, + // but it would be odd if "package:foo/foo.dart#1" and + // "package:foo/foo.dart#2" were considered different libraries. + // Keep the syntax open in case we ever get multiple libraries in one file. + throw PackageConfigArgumentError( + packageUri, name, 'Package URIs must not have a fragment part'); + } + if (packageUri.path.startsWith('/')) { + throw PackageConfigArgumentError( + packageUri, name, "Package URIs must not start with a '/'"); + } + var firstSlash = packageUri.path.indexOf('/'); + if (firstSlash == -1) { + throw PackageConfigArgumentError(packageUri, name, + "Package URIs must start with the package name followed by a '/'"); + } + var packageName = packageUri.path.substring(0, firstSlash); + var badIndex = checkPackageName(packageName); + if (badIndex >= 0) { + if (packageName.isEmpty) { + throw PackageConfigArgumentError( + packageUri, name, 'Package names mus be non-empty'); + } + if (badIndex == packageName.length) { + throw PackageConfigArgumentError(packageUri, name, + "Package names must contain at least one non-'.' character"); + } + assert(badIndex < packageName.length); + var badCharCode = packageName.codeUnitAt(badIndex); + var badChar = 'U+${badCharCode.toRadixString(16).padLeft(4, '0')}'; + if (badCharCode >= 0x20 && badCharCode <= 0x7e) { + // Printable character. + badChar = "'${packageName[badIndex]}' ($badChar)"; + } + throw PackageConfigArgumentError( + packageUri, name, 'Package names must not contain $badChar'); + } + return packageName; +} + +/// Checks whether URI is just an absolute directory. +/// +/// * It must have a scheme. +/// * It must not have a query or fragment. +/// * The path must end with `/`. +bool isAbsoluteDirectoryUri(Uri uri) { + if (uri.hasQuery) return false; + if (uri.hasFragment) return false; + if (!uri.hasScheme) return false; + var path = uri.path; + if (!path.endsWith('/')) return false; + return true; +} + +/// Whether the former URI is a prefix of the latter. +bool isUriPrefix(Uri prefix, Uri path) { + assert(!prefix.hasFragment); + assert(!prefix.hasQuery); + assert(!path.hasQuery); + assert(!path.hasFragment); + assert(prefix.path.endsWith('/')); + return path.toString().startsWith(prefix.toString()); +} + +/// Finds the first non-JSON-whitespace character in a file. +/// +/// Used to heuristically detect whether a file is a JSON file or an .ini file. +int firstNonWhitespaceChar(List bytes) { + for (var i = 0; i < bytes.length; i++) { + var char = bytes[i]; + if (char != 0x20 && char != 0x09 && char != 0x0a && char != 0x0d) { + return char; + } + } + return -1; +} + +/// Appends a trailing `/` if the path doesn't end with one. +String trailingSlash(String path) { + if (path.isEmpty || path.endsWith('/')) return path; + return '$path/'; +} + +/// Whether a URI should not be considered relative to the base URI. +/// +/// Used to determine whether a parsed root URI is relative +/// to the configuration file or not. +/// If it is relative, then it's rewritten as relative when +/// output again later. If not, it's output as absolute. +bool hasAbsolutePath(Uri uri) => + uri.hasScheme || uri.hasAuthority || uri.hasAbsolutePath; + +/// Attempts to return a relative path-only URI for [uri]. +/// +/// First removes any query or fragment part from [uri]. +/// +/// If [uri] is already relative (has no scheme), it's returned as-is. +/// If that is not desired, the caller can pass `baseUri.resolveUri(uri)` +/// as the [uri] instead. +/// +/// If the [uri] has a scheme or authority part which differs from +/// the [baseUri], or if there is no overlap in the paths of the +/// two URIs at all, the [uri] is returned as-is. +/// +/// Otherwise the result is a path-only URI which satisfies +/// `baseUri.resolveUri(result) == uri`, +/// +/// The `baseUri` must be absolute. +Uri? relativizeUri(Uri? uri, Uri? baseUri) { + if (baseUri == null) return uri; + assert(baseUri.isAbsolute); + if (uri!.hasQuery || uri.hasFragment) { + uri = Uri( + scheme: uri.scheme, + userInfo: uri.hasAuthority ? uri.userInfo : null, + host: uri.hasAuthority ? uri.host : null, + port: uri.hasAuthority ? uri.port : null, + path: uri.path); + } + + // Already relative. We assume the caller knows what they are doing. + if (!uri.isAbsolute) return uri; + + if (baseUri.scheme != uri.scheme) { + return uri; + } + + // If authority differs, we could remove the scheme, but it's not worth it. + if (uri.hasAuthority != baseUri.hasAuthority) return uri; + if (uri.hasAuthority) { + if (uri.userInfo != baseUri.userInfo || + uri.host.toLowerCase() != baseUri.host.toLowerCase() || + uri.port != baseUri.port) { + return uri; + } + } + + baseUri = baseUri.normalizePath(); + var base = [...baseUri.pathSegments]; + if (base.isNotEmpty) base.removeLast(); + uri = uri.normalizePath(); + var target = [...uri.pathSegments]; + if (target.isNotEmpty && target.last.isEmpty) target.removeLast(); + var index = 0; + while (index < base.length && index < target.length) { + if (base[index] != target[index]) { + break; + } + index++; + } + if (index == base.length) { + if (index == target.length) { + return Uri(path: './'); + } + return Uri(path: target.skip(index).join('/')); + } else if (index > 0) { + var buffer = StringBuffer(); + for (var n = base.length - index; n > 0; --n) { + buffer.write('../'); + } + buffer.writeAll(target.skip(index), '/'); + return Uri(path: buffer.toString()); + } else { + return uri; + } +} + +// Character constants used by this package. +/// "Line feed" control character. +const int $lf = 0x0a; + +/// "Carriage return" control character. +const int $cr = 0x0d; + +/// Space character. +const int $space = 0x20; + +/// Character `#`. +const int $hash = 0x23; + +/// Character `.`. +const int $dot = 0x2e; + +/// Character `:`. +const int $colon = 0x3a; + +/// Character `?`. +const int $question = 0x3f; + +/// Character `{`. +const int $lbrace = 0x7b; diff --git a/pkgs/package_config/lib/src/util_io.dart b/pkgs/package_config/lib/src/util_io.dart new file mode 100644 index 000000000..4680eefd4 --- /dev/null +++ b/pkgs/package_config/lib/src/util_io.dart @@ -0,0 +1,108 @@ +// Copyright (c) 2020, 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. + +/// Utility methods requiring dart:io and used by more than one library in the +/// package. +library; + +import 'dart:io'; +import 'dart:typed_data'; + +Future defaultLoader(Uri uri) async { + if (uri.isScheme('file')) { + var file = File.fromUri(uri); + try { + return await file.readAsBytes(); + } catch (_) { + return null; + } + } + if (uri.isScheme('http') || uri.isScheme('https')) { + return _httpGet(uri); + } + throw UnsupportedError('Default URI unsupported scheme: $uri'); +} + +Future _httpGet(Uri uri) async { + assert(uri.isScheme('http') || uri.isScheme('https')); + var client = HttpClient(); + var request = await client.getUrl(uri); + var response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + return null; + } + var splitContent = await response.toList(); + var totalLength = 0; + if (splitContent.length == 1) { + var part = splitContent[0]; + if (part is Uint8List) { + return part; + } + } + for (var list in splitContent) { + totalLength += list.length; + } + var result = Uint8List(totalLength); + var offset = 0; + for (var contentPart in splitContent as Iterable) { + result.setRange(offset, offset + contentPart.length, contentPart); + offset += contentPart.length; + } + return result; +} + +/// The file name of a path. +/// +/// The file name is everything after the last occurrence of +/// [Platform.pathSeparator], or the entire string if no +/// path separator occurs in the string. +String fileName(String path) { + var separator = Platform.pathSeparator; + var lastSeparator = path.lastIndexOf(separator); + if (lastSeparator < 0) return path; + return path.substring(lastSeparator + separator.length); +} + +/// The directory name of a path. +/// +/// The directory name is everything before the last occurrence of +/// [Platform.pathSeparator], or the empty string if no +/// path separator occurs in the string. +String dirName(String path) { + var separator = Platform.pathSeparator; + var lastSeparator = path.lastIndexOf(separator); + if (lastSeparator < 0) return ''; + return path.substring(0, lastSeparator); +} + +/// Join path parts with the [Platform.pathSeparator]. +/// +/// If a part ends with a path separator, then no extra separator is +/// inserted. +String pathJoin(String part1, String part2, [String? part3]) { + var separator = Platform.pathSeparator; + var separator1 = part1.endsWith(separator) ? '' : separator; + if (part3 == null) { + return '$part1$separator1$part2'; + } + var separator2 = part2.endsWith(separator) ? '' : separator; + return '$part1$separator1$part2$separator2$part3'; +} + +/// Join an unknown number of path parts with [Platform.pathSeparator]. +/// +/// If a part ends with a path separator, then no extra separator is +/// inserted. +String pathJoinAll(Iterable parts) { + var buffer = StringBuffer(); + var separator = ''; + for (var part in parts) { + buffer + ..write(separator) + ..write(part); + separator = + part.endsWith(Platform.pathSeparator) ? '' : Platform.pathSeparator; + } + return buffer.toString(); +} diff --git a/pkgs/package_config/pubspec.yaml b/pkgs/package_config/pubspec.yaml new file mode 100644 index 000000000..28f3e1364 --- /dev/null +++ b/pkgs/package_config/pubspec.yaml @@ -0,0 +1,14 @@ +name: package_config +version: 2.1.1 +description: Support for reading and writing Dart Package Configuration files. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/package_config + +environment: + sdk: ^3.4.0 + +dependencies: + path: ^1.8.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.0 diff --git a/pkgs/package_config/test/bench.dart b/pkgs/package_config/test/bench.dart new file mode 100644 index 000000000..8428481f7 --- /dev/null +++ b/pkgs/package_config/test/bench.dart @@ -0,0 +1,71 @@ +// 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 'dart:convert'; +import 'dart:typed_data'; + +import 'package:package_config/src/errors.dart'; +import 'package:package_config/src/package_config_json.dart'; + +void bench(final int size, final bool doPrint) { + var sb = StringBuffer(); + sb.writeln('{'); + sb.writeln('"configVersion": 2,'); + sb.writeln('"packages": ['); + for (var i = 0; i < size; i++) { + if (i != 0) { + sb.writeln(','); + } + sb.writeln('{'); + sb.writeln(' "name": "p_$i",'); + sb.writeln(' "rootUri": "file:///p_$i/",'); + sb.writeln(' "packageUri": "lib/",'); + sb.writeln(' "languageVersion": "2.5",'); + sb.writeln(' "nonstandard": true'); + sb.writeln('}'); + } + sb.writeln('],'); + sb.writeln('"generator": "pub",'); + sb.writeln('"other": [42]'); + sb.writeln('}'); + var stopwatch = Stopwatch()..start(); + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode(sb.toString()) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError, + ); + final read = stopwatch.elapsedMilliseconds; + + stopwatch.reset(); + for (var i = 0; i < size; i++) { + if (config.packageOf(Uri.parse('file:///p_$i/lib/src/foo.dart'))!.name != + 'p_$i') { + throw StateError('Unexpected result!'); + } + } + final lookup = stopwatch.elapsedMilliseconds; + + if (doPrint) { + print('Read file with $size packages in $read ms, ' + 'looked up all packages in $lookup ms'); + } +} + +void main(List args) { + if (args.length != 1 && args.length != 2) { + throw ArgumentError('Expects arguments: ?'); + } + final size = int.parse(args[0]); + if (args.length > 1) { + final warmups = int.parse(args[1]); + print('Performing $warmups warmup iterations.'); + for (var i = 0; i < warmups; i++) { + bench(10, false); + } + } + + // Benchmark. + bench(size, true); +} diff --git a/pkgs/package_config/test/discovery_test.dart b/pkgs/package_config/test/discovery_test.dart new file mode 100644 index 000000000..6d1b65529 --- /dev/null +++ b/pkgs/package_config/test/discovery_test.dart @@ -0,0 +1,346 @@ +// Copyright (c) 2019, 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. + +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:package_config/package_config.dart'; +import 'package:test/test.dart'; + +import 'src/util.dart'; +import 'src/util_io.dart'; + +const packagesFile = ''' +# A comment +foo:file:///dart/packages/foo/ +bar:/dart/packages/bar/ +baz:packages/baz/ +'''; + +const packageConfigFile = ''' +{ + "configVersion": 2, + "packages": [ + { + "name": "foo", + "rootUri": "file:///dart/packages/foo/" + }, + { + "name": "bar", + "rootUri": "/dart/packages/bar/" + }, + { + "name": "baz", + "rootUri": "../packages/baz/" + } + ], + "extra": [42] +} +'''; + +void validatePackagesFile(PackageConfig resolver, Directory directory) { + expect(resolver, isNotNull); + expect(resolver.resolve(pkg('foo', 'bar/baz')), + equals(Uri.parse('file:///dart/packages/foo/bar/baz'))); + expect(resolver.resolve(pkg('bar', 'baz/qux')), + equals(Uri.parse('file:///dart/packages/bar/baz/qux'))); + expect(resolver.resolve(pkg('baz', 'qux/foo')), + equals(Uri.directory(directory.path).resolve('packages/baz/qux/foo'))); + expect([for (var p in resolver.packages) p.name], + unorderedEquals(['foo', 'bar', 'baz'])); +} + +void main() { + group('findPackages', () { + // Finds package_config.json if there. + fileTest('package_config.json', { + '.packages': 'invalid .packages file', + 'script.dart': 'main(){}', + 'packages': {'shouldNotBeFound': {}}, + '.dart_tool': { + 'package_config.json': packageConfigFile, + } + }, (Directory directory) async { + var config = (await findPackageConfig(directory))!; + expect(config.version, 2); // Found package_config.json file. + validatePackagesFile(config, directory); + }); + + // Finds .packages if no package_config.json. + fileTest('.packages', { + '.packages': packagesFile, + 'script.dart': 'main(){}', + 'packages': {'shouldNotBeFound': {}} + }, (Directory directory) async { + var config = (await findPackageConfig(directory))!; + expect(config.version, 1); // Found .packages file. + validatePackagesFile(config, directory); + }); + + // Finds package_config.json in super-directory. + fileTest('package_config.json recursive', { + '.packages': packagesFile, + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + 'subdir': { + 'script.dart': 'main(){}', + } + }, (Directory directory) async { + var config = (await findPackageConfig(subdir(directory, 'subdir/')))!; + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + // Finds .packages in super-directory. + fileTest('.packages recursive', { + '.packages': packagesFile, + 'subdir': {'script.dart': 'main(){}'} + }, (Directory directory) async { + var config = (await findPackageConfig(subdir(directory, 'subdir/')))!; + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + // Does not find a packages/ directory, and returns null if nothing found. + fileTest('package directory packages not supported', { + 'packages': { + 'foo': {}, + } + }, (Directory directory) async { + var config = await findPackageConfig(directory); + expect(config, null); + }); + + group('throws', () { + fileTest('invalid .packages', { + '.packages': 'not a .packages file', + }, (Directory directory) { + expect(findPackageConfig(directory), throwsA(isA())); + }); + + fileTest('invalid .packages as JSON', { + '.packages': packageConfigFile, + }, (Directory directory) { + expect(findPackageConfig(directory), throwsA(isA())); + }); + + fileTest('invalid .packages', { + '.dart_tool': { + 'package_config.json': 'not a JSON file', + } + }, (Directory directory) { + expect(findPackageConfig(directory), throwsA(isA())); + }); + + fileTest('invalid .packages as INI', { + '.dart_tool': { + 'package_config.json': packagesFile, + } + }, (Directory directory) { + expect(findPackageConfig(directory), throwsA(isA())); + }); + }); + + group('handles error', () { + fileTest('invalid .packages', { + '.packages': 'not a .packages file', + }, (Directory directory) async { + var hadError = false; + await findPackageConfig(directory, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + fileTest('invalid .packages as JSON', { + '.packages': packageConfigFile, + }, (Directory directory) async { + var hadError = false; + await findPackageConfig(directory, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + fileTest('invalid package_config not JSON', { + '.dart_tool': { + 'package_config.json': 'not a JSON file', + } + }, (Directory directory) async { + var hadError = false; + await findPackageConfig(directory, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + fileTest('invalid package config as INI', { + '.dart_tool': { + 'package_config.json': packagesFile, + } + }, (Directory directory) async { + var hadError = false; + await findPackageConfig(directory, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + }); + + // Does not find .packages if no package_config.json and minVersion > 1. + fileTest('.packages ignored', { + '.packages': packagesFile, + 'script.dart': 'main(){}' + }, (Directory directory) async { + var config = await findPackageConfig(directory, minVersion: 2); + expect(config, null); + }); + + // Finds package_config.json in super-directory, with .packages in + // subdir and minVersion > 1. + fileTest('package_config.json recursive .packages ignored', { + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + 'subdir': { + '.packages': packagesFile, + 'script.dart': 'main(){}', + } + }, (Directory directory) async { + var config = (await findPackageConfig(subdir(directory, 'subdir/'), + minVersion: 2))!; + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + }); + + group('loadPackageConfig', () { + // Load a specific files + group('package_config.json', () { + var files = { + '.packages': packagesFile, + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + }; + fileTest('directly', files, (Directory directory) async { + var file = + dirFile(subdir(directory, '.dart_tool'), 'package_config.json'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + fileTest('indirectly through .packages', files, + (Directory directory) async { + var file = dirFile(directory, '.packages'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + fileTest('prefer .packages', files, (Directory directory) async { + var file = dirFile(directory, '.packages'); + var config = await loadPackageConfig(file, preferNewest: false); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + }); + + fileTest('package_config.json non-default name', { + '.packages': packagesFile, + 'subdir': { + 'pheldagriff': packageConfigFile, + }, + }, (Directory directory) async { + var file = dirFile(directory, 'subdir/pheldagriff'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + fileTest('package_config.json named .packages', { + 'subdir': { + '.packages': packageConfigFile, + }, + }, (Directory directory) async { + var file = dirFile(directory, 'subdir/.packages'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + fileTest('.packages', { + '.packages': packagesFile, + }, (Directory directory) async { + var file = dirFile(directory, '.packages'); + var config = await loadPackageConfig(file); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + fileTest('.packages non-default name', { + 'pheldagriff': packagesFile, + }, (Directory directory) async { + var file = dirFile(directory, 'pheldagriff'); + var config = await loadPackageConfig(file); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + fileTest('no config found', {}, (Directory directory) { + var file = dirFile(directory, 'anyname'); + expect( + () => loadPackageConfig(file), throwsA(isA())); + }); + + fileTest('no config found, handled', {}, (Directory directory) async { + var file = dirFile(directory, 'anyname'); + var hadError = false; + await loadPackageConfig(file, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + fileTest('specified file syntax error', { + 'anyname': 'syntax error', + }, (Directory directory) { + var file = dirFile(directory, 'anyname'); + expect(() => loadPackageConfig(file), throwsFormatException); + }); + + // Find package_config.json in subdir even if initial file syntax error. + fileTest('specified file syntax onError', { + '.packages': 'syntax error', + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + }, (Directory directory) async { + var file = dirFile(directory, '.packages'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + // A file starting with `{` is a package_config.json file. + fileTest('file syntax error with {', { + '.packages': '{syntax error', + }, (Directory directory) { + var file = dirFile(directory, '.packages'); + expect(() => loadPackageConfig(file), throwsFormatException); + }); + }); +} diff --git a/pkgs/package_config/test/discovery_uri_test.dart b/pkgs/package_config/test/discovery_uri_test.dart new file mode 100644 index 000000000..542bf0a65 --- /dev/null +++ b/pkgs/package_config/test/discovery_uri_test.dart @@ -0,0 +1,310 @@ +// Copyright (c) 2019, 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. + +@TestOn('vm') +library; + +import 'package:package_config/package_config.dart'; +import 'package:test/test.dart'; + +import 'src/util.dart'; + +const packagesFile = ''' +# A comment +foo:file:///dart/packages/foo/ +bar:/dart/packages/bar/ +baz:packages/baz/ +'''; + +const packageConfigFile = ''' +{ + "configVersion": 2, + "packages": [ + { + "name": "foo", + "rootUri": "file:///dart/packages/foo/" + }, + { + "name": "bar", + "rootUri": "/dart/packages/bar/" + }, + { + "name": "baz", + "rootUri": "../packages/baz/" + } + ], + "extra": [42] +} +'''; + +void validatePackagesFile(PackageConfig resolver, Uri directory) { + expect(resolver, isNotNull); + expect(resolver.resolve(pkg('foo', 'bar/baz')), + equals(Uri.parse('file:///dart/packages/foo/bar/baz'))); + expect(resolver.resolve(pkg('bar', 'baz/qux')), + equals(directory.resolve('/dart/packages/bar/baz/qux'))); + expect(resolver.resolve(pkg('baz', 'qux/foo')), + equals(directory.resolve('packages/baz/qux/foo'))); + expect([for (var p in resolver.packages) p.name], + unorderedEquals(['foo', 'bar', 'baz'])); +} + +void main() { + group('findPackages', () { + // Finds package_config.json if there. + loaderTest('package_config.json', { + '.packages': 'invalid .packages file', + 'script.dart': 'main(){}', + 'packages': {'shouldNotBeFound': {}}, + '.dart_tool': { + 'package_config.json': packageConfigFile, + } + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory, loader: loader))!; + expect(config.version, 2); // Found package_config.json file. + validatePackagesFile(config, directory); + }); + + // Finds .packages if no package_config.json. + loaderTest('.packages', { + '.packages': packagesFile, + 'script.dart': 'main(){}', + 'packages': {'shouldNotBeFound': {}} + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory, loader: loader))!; + expect(config.version, 1); // Found .packages file. + validatePackagesFile(config, directory); + }); + + // Finds package_config.json in super-directory. + loaderTest('package_config.json recursive', { + '.packages': packagesFile, + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + 'subdir': { + 'script.dart': 'main(){}', + } + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory.resolve('subdir/'), + loader: loader))!; + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + // Finds .packages in super-directory. + loaderTest('.packages recursive', { + '.packages': packagesFile, + 'subdir': {'script.dart': 'main(){}'} + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory.resolve('subdir/'), + loader: loader))!; + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + // Does not find a packages/ directory, and returns null if nothing found. + loaderTest('package directory packages not supported', { + 'packages': { + 'foo': {}, + } + }, (Uri directory, loader) async { + var config = await findPackageConfigUri(directory, loader: loader); + expect(config, null); + }); + + loaderTest('invalid .packages', { + '.packages': 'not a .packages file', + }, (Uri directory, loader) { + expect(() => findPackageConfigUri(directory, loader: loader), + throwsA(isA())); + }); + + loaderTest('invalid .packages as JSON', { + '.packages': packageConfigFile, + }, (Uri directory, loader) { + expect(() => findPackageConfigUri(directory, loader: loader), + throwsA(isA())); + }); + + loaderTest('invalid .packages', { + '.dart_tool': { + 'package_config.json': 'not a JSON file', + } + }, (Uri directory, loader) { + expect(() => findPackageConfigUri(directory, loader: loader), + throwsA(isA())); + }); + + loaderTest('invalid .packages as INI', { + '.dart_tool': { + 'package_config.json': packagesFile, + } + }, (Uri directory, loader) { + expect(() => findPackageConfigUri(directory, loader: loader), + throwsA(isA())); + }); + + // Does not find .packages if no package_config.json and minVersion > 1. + loaderTest('.packages ignored', { + '.packages': packagesFile, + 'script.dart': 'main(){}' + }, (directory, loader) async { + var config = + await findPackageConfigUri(directory, minVersion: 2, loader: loader); + expect(config, null); + }); + + // Finds package_config.json in super-directory, with .packages in + // subdir and minVersion > 1. + loaderTest('package_config.json recursive ignores .packages', { + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + 'subdir': { + '.packages': packagesFile, + 'script.dart': 'main(){}', + } + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory.resolve('subdir/'), + minVersion: 2, loader: loader))!; + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + }); + + group('loadPackageConfig', () { + // Load a specific files + group('package_config.json', () { + var files = { + '.packages': packagesFile, + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + }; + loaderTest('directly', files, (Uri directory, loader) async { + var file = directory.resolve('.dart_tool/package_config.json'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + loaderTest('indirectly through .packages', files, + (Uri directory, loader) async { + var file = directory.resolve('.packages'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + }); + + loaderTest('package_config.json non-default name', { + '.packages': packagesFile, + 'subdir': { + 'pheldagriff': packageConfigFile, + }, + }, (Uri directory, loader) async { + var file = directory.resolve('subdir/pheldagriff'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + loaderTest('package_config.json named .packages', { + 'subdir': { + '.packages': packageConfigFile, + }, + }, (Uri directory, loader) async { + var file = directory.resolve('subdir/.packages'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + loaderTest('.packages', { + '.packages': packagesFile, + }, (Uri directory, loader) async { + var file = directory.resolve('.packages'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + loaderTest('.packages non-default name', { + 'pheldagriff': packagesFile, + }, (Uri directory, loader) async { + var file = directory.resolve('pheldagriff'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + loaderTest('no config found', {}, (Uri directory, loader) { + var file = directory.resolve('anyname'); + expect(() => loadPackageConfigUri(file, loader: loader), + throwsA(isA())); + }); + + loaderTest('no config found, handle error', {}, + (Uri directory, loader) async { + var file = directory.resolve('anyname'); + var hadError = false; + await loadPackageConfigUri(file, + loader: loader, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + loaderTest('specified file syntax error', { + 'anyname': 'syntax error', + }, (Uri directory, loader) { + var file = directory.resolve('anyname'); + expect(() => loadPackageConfigUri(file, loader: loader), + throwsFormatException); + }); + + loaderTest('specified file syntax onError', { + 'anyname': 'syntax error', + }, (directory, loader) async { + var file = directory.resolve('anyname'); + var hadError = false; + await loadPackageConfigUri(file, + loader: loader, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + // Don't look for package_config.json if original file not named .packages. + loaderTest('specified file syntax error with alternative', { + 'anyname': 'syntax error', + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + }, (directory, loader) async { + var file = directory.resolve('anyname'); + expect(() => loadPackageConfigUri(file, loader: loader), + throwsFormatException); + }); + + // A file starting with `{` is a package_config.json file. + loaderTest('file syntax error with {', { + '.packages': '{syntax error', + }, (directory, loader) async { + var file = directory.resolve('.packages'); + var hadError = false; + await loadPackageConfigUri(file, + loader: loader, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + }); +} diff --git a/pkgs/package_config/test/package_config_impl_test.dart b/pkgs/package_config/test/package_config_impl_test.dart new file mode 100644 index 000000000..0f399636f --- /dev/null +++ b/pkgs/package_config/test/package_config_impl_test.dart @@ -0,0 +1,188 @@ +// Copyright (c) 2020, 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' show jsonDecode; + +import 'package:package_config/package_config_types.dart'; +import 'package:test/test.dart'; +import 'src/util.dart'; + +void main() { + var unique = Object(); + var root = Uri.file('/tmp/root/'); + + group('LanguageVersion', () { + test('minimal', () { + var version = LanguageVersion(3, 5); + expect(version.major, 3); + expect(version.minor, 5); + }); + + test('negative major', () { + expect(() => LanguageVersion(-1, 1), throwsArgumentError); + }); + + test('negative minor', () { + expect(() => LanguageVersion(1, -1), throwsArgumentError); + }); + + test('minimal parse', () { + var version = LanguageVersion.parse('3.5'); + expect(version.major, 3); + expect(version.minor, 5); + }); + + void failParse(String name, String input) { + test('$name - error', () { + expect(() => LanguageVersion.parse(input), + throwsA(isA())); + expect(() => LanguageVersion.parse(input), throwsFormatException); + var failed = false; + var actual = LanguageVersion.parse(input, onError: (_) { + failed = true; + }); + expect(failed, true); + expect(actual, isA()); + }); + } + + failParse('Leading zero major', '01.1'); + failParse('Leading zero minor', '1.01'); + failParse('Sign+ major', '+1.1'); + failParse('Sign- major', '-1.1'); + failParse('Sign+ minor', '1.+1'); + failParse('Sign- minor', '1.-1'); + failParse('WhiteSpace 1', ' 1.1'); + failParse('WhiteSpace 2', '1 .1'); + failParse('WhiteSpace 3', '1. 1'); + failParse('WhiteSpace 4', '1.1 '); + }); + + group('Package', () { + test('minimal', () { + var package = Package('name', root, extraData: unique); + expect(package.name, 'name'); + expect(package.root, root); + expect(package.packageUriRoot, root); + expect(package.languageVersion, null); + expect(package.extraData, same(unique)); + }); + + test('absolute package root', () { + var version = LanguageVersion(1, 1); + var absolute = root.resolve('foo/bar/'); + var package = Package('name', root, + packageUriRoot: absolute, + relativeRoot: false, + languageVersion: version, + extraData: unique); + expect(package.name, 'name'); + expect(package.root, root); + expect(package.packageUriRoot, absolute); + expect(package.languageVersion, version); + expect(package.extraData, same(unique)); + expect(package.relativeRoot, false); + }); + + test('relative package root', () { + var relative = Uri.parse('foo/bar/'); + var absolute = root.resolveUri(relative); + var package = Package('name', root, + packageUriRoot: relative, relativeRoot: true, extraData: unique); + expect(package.name, 'name'); + expect(package.root, root); + expect(package.packageUriRoot, absolute); + expect(package.relativeRoot, true); + expect(package.languageVersion, null); + expect(package.extraData, same(unique)); + }); + + for (var badName in ['a/z', 'a:z', '', '...']) { + test("Invalid name '$badName'", () { + expect(() => Package(badName, root), throwsPackageConfigError); + }); + } + + test('Invalid root, not absolute', () { + expect( + () => Package('name', Uri.parse('/foo/')), throwsPackageConfigError); + }); + + test('Invalid root, not ending in slash', () { + expect(() => Package('name', Uri.parse('file:///foo')), + throwsPackageConfigError); + }); + + test('invalid package root, not ending in slash', () { + expect(() => Package('name', root, packageUriRoot: Uri.parse('foo')), + throwsPackageConfigError); + }); + + test('invalid package root, not inside root', () { + expect(() => Package('name', root, packageUriRoot: Uri.parse('../baz/')), + throwsPackageConfigError); + }); + }); + + group('package config', () { + test('empty', () { + var empty = PackageConfig([], extraData: unique); + expect(empty.version, 2); + expect(empty.packages, isEmpty); + expect(empty.extraData, same(unique)); + expect(empty.resolve(pkg('a', 'b')), isNull); + }); + + test('single', () { + var package = Package('name', root); + var single = PackageConfig([package], extraData: unique); + expect(single.version, 2); + expect(single.packages, hasLength(1)); + expect(single.extraData, same(unique)); + expect(single.resolve(pkg('a', 'b')), isNull); + var resolved = single.resolve(pkg('name', 'a/b')); + expect(resolved, root.resolve('a/b')); + }); + }); + test('writeString', () { + var config = PackageConfig([ + Package('foo', Uri.parse('file:///pkg/foo/'), + packageUriRoot: Uri.parse('file:///pkg/foo/lib/'), + relativeRoot: false, + languageVersion: LanguageVersion(2, 4), + extraData: {'foo': 'foo!'}), + Package('bar', Uri.parse('file:///pkg/bar/'), + packageUriRoot: Uri.parse('file:///pkg/bar/lib/'), + relativeRoot: true, + extraData: {'bar': 'bar!'}), + ], extraData: { + 'extra': 'data' + }); + var buffer = StringBuffer(); + PackageConfig.writeString(config, buffer, Uri.parse('file:///pkg/')); + var text = buffer.toString(); + var json = jsonDecode(text); // Is valid JSON. + expect(json, { + 'configVersion': 2, + 'packages': unorderedEquals([ + { + 'name': 'foo', + 'rootUri': 'file:///pkg/foo/', + 'packageUri': 'lib/', + 'languageVersion': '2.4', + 'foo': 'foo!', + }, + { + 'name': 'bar', + 'rootUri': 'bar/', + 'packageUri': 'lib/', + 'bar': 'bar!', + }, + ]), + 'extra': 'data', + }); + }); +} + +final Matcher throwsPackageConfigError = throwsA(isA()); diff --git a/pkgs/package_config/test/parse_test.dart b/pkgs/package_config/test/parse_test.dart new file mode 100644 index 000000000..a92b9bfcc --- /dev/null +++ b/pkgs/package_config/test/parse_test.dart @@ -0,0 +1,552 @@ +// Copyright (c) 2019, 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:typed_data'; + +import 'package:package_config/package_config_types.dart'; +import 'package:package_config/src/errors.dart'; +import 'package:package_config/src/package_config_json.dart'; +import 'package:package_config/src/packages_file.dart' as packages; +import 'package:test/test.dart'; + +import 'src/util.dart'; + +void main() { + group('.packages', () { + test('valid', () { + var packagesFile = '# Generated by pub yadda yadda\n' + 'foo:file:///foo/lib/\n' + 'bar:/bar/lib/\n' + 'baz:lib/\n'; + var result = packages.parse(utf8.encode(packagesFile), + Uri.parse('file:///tmp/file.dart'), throwError); + expect(result.version, 1); + expect({for (var p in result.packages) p.name}, {'foo', 'bar', 'baz'}); + expect(result.resolve(pkg('foo', 'foo.dart')), + Uri.parse('file:///foo/lib/foo.dart')); + expect(result.resolve(pkg('bar', 'bar.dart')), + Uri.parse('file:///bar/lib/bar.dart')); + expect(result.resolve(pkg('baz', 'baz.dart')), + Uri.parse('file:///tmp/lib/baz.dart')); + + var foo = result['foo']!; + expect(foo, isNotNull); + expect(foo.root, Uri.parse('file:///foo/')); + expect(foo.packageUriRoot, Uri.parse('file:///foo/lib/')); + expect(foo.languageVersion, LanguageVersion(2, 7)); + expect(foo.relativeRoot, false); + }); + + test('valid empty', () { + var packagesFile = '# Generated by pub yadda yadda\n'; + var result = packages.parse( + utf8.encode(packagesFile), Uri.file('/tmp/file.dart'), throwError); + expect(result.version, 1); + expect({for (var p in result.packages) p.name}, {}); + }); + + group('invalid', () { + var baseFile = Uri.file('/tmp/file.dart'); + void testThrows(String name, String content) { + test(name, () { + expect( + () => packages.parse(utf8.encode(content), baseFile, throwError), + throwsA(isA())); + }); + test('$name, handle error', () { + var hadError = false; + packages.parse(utf8.encode(content), baseFile, (error) { + hadError = true; + expect(error, isA()); + }); + expect(hadError, true); + }); + } + + testThrows('repeated package name', 'foo:lib/\nfoo:lib\n'); + testThrows('no colon', 'foo\n'); + testThrows('empty package name', ':lib/\n'); + testThrows('dot only package name', '.:lib/\n'); + testThrows('dot only package name', '..:lib/\n'); + testThrows('invalid package name character', 'f\\o:lib/\n'); + testThrows('package URI', 'foo:package:bar/lib/'); + testThrows('location with query', 'f\\o:lib/?\n'); + testThrows('location with fragment', 'f\\o:lib/#\n'); + }); + }); + + group('package_config.json', () { + test('valid', () { + var packageConfigFile = ''' + { + "configVersion": 2, + "packages": [ + { + "name": "foo", + "rootUri": "file:///foo/", + "packageUri": "lib/", + "languageVersion": "2.5", + "nonstandard": true + }, + { + "name": "bar", + "rootUri": "/bar/", + "packageUri": "lib/", + "languageVersion": "9999.9999" + }, + { + "name": "baz", + "rootUri": "../", + "packageUri": "lib/" + }, + { + "name": "noslash", + "rootUri": "../noslash", + "packageUri": "lib" + } + ], + "generator": "pub", + "other": [42] + } + '''; + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode(packageConfigFile) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect({for (var p in config.packages) p.name}, + {'foo', 'bar', 'baz', 'noslash'}); + + expect(config.resolve(pkg('foo', 'foo.dart')), + Uri.parse('file:///foo/lib/foo.dart')); + expect(config.resolve(pkg('bar', 'bar.dart')), + Uri.parse('file:///bar/lib/bar.dart')); + expect(config.resolve(pkg('baz', 'baz.dart')), + Uri.parse('file:///tmp/lib/baz.dart')); + + var foo = config['foo']!; + expect(foo, isNotNull); + expect(foo.root, Uri.parse('file:///foo/')); + expect(foo.packageUriRoot, Uri.parse('file:///foo/lib/')); + expect(foo.languageVersion, LanguageVersion(2, 5)); + expect(foo.extraData, {'nonstandard': true}); + expect(foo.relativeRoot, false); + + var bar = config['bar']!; + expect(bar, isNotNull); + expect(bar.root, Uri.parse('file:///bar/')); + expect(bar.packageUriRoot, Uri.parse('file:///bar/lib/')); + expect(bar.languageVersion, LanguageVersion(9999, 9999)); + expect(bar.extraData, null); + expect(bar.relativeRoot, false); + + var baz = config['baz']!; + expect(baz, isNotNull); + expect(baz.root, Uri.parse('file:///tmp/')); + expect(baz.packageUriRoot, Uri.parse('file:///tmp/lib/')); + expect(baz.languageVersion, null); + expect(baz.relativeRoot, true); + + // No slash after root or package root. One is inserted. + var noslash = config['noslash']!; + expect(noslash, isNotNull); + expect(noslash.root, Uri.parse('file:///tmp/noslash/')); + expect(noslash.packageUriRoot, Uri.parse('file:///tmp/noslash/lib/')); + expect(noslash.languageVersion, null); + expect(noslash.relativeRoot, true); + + expect(config.extraData, { + 'generator': 'pub', + 'other': [42] + }); + }); + + test('valid other order', () { + // The ordering in the file is not important. + var packageConfigFile = ''' + { + "generator": "pub", + "other": [42], + "packages": [ + { + "languageVersion": "2.5", + "packageUri": "lib/", + "rootUri": "file:///foo/", + "name": "foo" + }, + { + "packageUri": "lib/", + "languageVersion": "9999.9999", + "rootUri": "/bar/", + "name": "bar" + }, + { + "packageUri": "lib/", + "name": "baz", + "rootUri": "../" + } + ], + "configVersion": 2 + } + '''; + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode(packageConfigFile) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect({for (var p in config.packages) p.name}, {'foo', 'bar', 'baz'}); + + expect(config.resolve(pkg('foo', 'foo.dart')), + Uri.parse('file:///foo/lib/foo.dart')); + expect(config.resolve(pkg('bar', 'bar.dart')), + Uri.parse('file:///bar/lib/bar.dart')); + expect(config.resolve(pkg('baz', 'baz.dart')), + Uri.parse('file:///tmp/lib/baz.dart')); + expect(config.extraData, { + 'generator': 'pub', + 'other': [42] + }); + }); + + // Check that a few minimal configurations are valid. + // These form the basis of invalid tests below. + var cfg = '"configVersion":2'; + var pkgs = '"packages":[]'; + var name = '"name":"foo"'; + var root = '"rootUri":"/foo/"'; + test('minimal', () { + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode('{$cfg,$pkgs}') as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect(config.packages, isEmpty); + }); + test('minimal package', () { + // A package must have a name and a rootUri, the remaining properties + // are optional. + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode('{$cfg,"packages":[{$name,$root}]}') as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect(config.packages.first.name, 'foo'); + }); + + test('nested packages', () { + var configBytes = utf8.encode(json.encode({ + 'configVersion': 2, + 'packages': [ + {'name': 'foo', 'rootUri': '/foo/', 'packageUri': 'lib/'}, + {'name': 'bar', 'rootUri': '/foo/bar/', 'packageUri': 'lib/'}, + {'name': 'baz', 'rootUri': '/foo/bar/baz/', 'packageUri': 'lib/'}, + {'name': 'qux', 'rootUri': '/foo/qux/', 'packageUri': 'lib/'}, + ] + })); + // ignore: unnecessary_cast + var config = parsePackageConfigBytes(configBytes as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError); + expect(config.version, 2); + expect(config.packageOf(Uri.parse('file:///foo/lala/lala.dart'))!.name, + 'foo'); + expect(config.packageOf(Uri.parse('file:///foo/bar/lala.dart'))!.name, + 'bar'); + expect(config.packageOf(Uri.parse('file:///foo/bar/baz/lala.dart'))!.name, + 'baz'); + expect(config.packageOf(Uri.parse('file:///foo/qux/lala.dart'))!.name, + 'qux'); + expect(config.toPackageUri(Uri.parse('file:///foo/lib/diz')), + Uri.parse('package:foo/diz')); + expect(config.toPackageUri(Uri.parse('file:///foo/bar/lib/diz')), + Uri.parse('package:bar/diz')); + expect(config.toPackageUri(Uri.parse('file:///foo/bar/baz/lib/diz')), + Uri.parse('package:baz/diz')); + expect(config.toPackageUri(Uri.parse('file:///foo/qux/lib/diz')), + Uri.parse('package:qux/diz')); + }); + + test('nested packages 2', () { + var configBytes = utf8.encode(json.encode({ + 'configVersion': 2, + 'packages': [ + {'name': 'foo', 'rootUri': '/', 'packageUri': 'lib/'}, + {'name': 'bar', 'rootUri': '/bar/', 'packageUri': 'lib/'}, + {'name': 'baz', 'rootUri': '/bar/baz/', 'packageUri': 'lib/'}, + {'name': 'qux', 'rootUri': '/qux/', 'packageUri': 'lib/'}, + ] + })); + // ignore: unnecessary_cast + var config = parsePackageConfigBytes(configBytes as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError); + expect(config.version, 2); + expect( + config.packageOf(Uri.parse('file:///lala/lala.dart'))!.name, 'foo'); + expect(config.packageOf(Uri.parse('file:///bar/lala.dart'))!.name, 'bar'); + expect(config.packageOf(Uri.parse('file:///bar/baz/lala.dart'))!.name, + 'baz'); + expect(config.packageOf(Uri.parse('file:///qux/lala.dart'))!.name, 'qux'); + expect(config.toPackageUri(Uri.parse('file:///lib/diz')), + Uri.parse('package:foo/diz')); + expect(config.toPackageUri(Uri.parse('file:///bar/lib/diz')), + Uri.parse('package:bar/diz')); + expect(config.toPackageUri(Uri.parse('file:///bar/baz/lib/diz')), + Uri.parse('package:baz/diz')); + expect(config.toPackageUri(Uri.parse('file:///qux/lib/diz')), + Uri.parse('package:qux/diz')); + }); + + test('packageOf is case sensitive on windows', () { + var configBytes = utf8.encode(json.encode({ + 'configVersion': 2, + 'packages': [ + {'name': 'foo', 'rootUri': 'file:///C:/Foo/', 'packageUri': 'lib/'}, + ] + })); + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + configBytes as Uint8List, + Uri.parse('file:///C:/tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect( + config.packageOf(Uri.parse('file:///C:/foo/lala/lala.dart')), null); + expect(config.packageOf(Uri.parse('file:///C:/Foo/lala/lala.dart'))!.name, + 'foo'); + }); + + group('invalid', () { + void testThrows(String name, String source) { + test(name, () { + expect( + // ignore: unnecessary_cast + () => parsePackageConfigBytes(utf8.encode(source) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError), + throwsA(isA())); + }); + } + + void testThrowsContains( + String name, String source, String containsString) { + test(name, () { + dynamic exception; + try { + parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode(source) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError, + ); + } catch (e) { + exception = e; + } + if (exception == null) fail("Didn't get exception"); + expect('$exception', contains(containsString)); + }); + } + + testThrows('comment', '# comment\n {$cfg,$pkgs}'); + testThrows('.packages file', 'foo:/foo\n'); + testThrows('no configVersion', '{$pkgs}'); + testThrows('no packages', '{$cfg}'); + group('config version:', () { + testThrows('null', '{"configVersion":null,$pkgs}'); + testThrows('string', '{"configVersion":"2",$pkgs}'); + testThrows('array', '{"configVersion":[2],$pkgs}'); + }); + group('packages:', () { + testThrows('null', '{$cfg,"packages":null}'); + testThrows('string', '{$cfg,"packages":"foo"}'); + testThrows('object', '{$cfg,"packages":{}}'); + }); + group('packages entry:', () { + testThrows('null', '{$cfg,"packages":[null]}'); + testThrows('string', '{$cfg,"packages":["foo"]}'); + testThrows('array', '{$cfg,"packages":[[]]}'); + }); + group('package', () { + testThrows('no name', '{$cfg,"packages":[{$root}]}'); + group('name:', () { + testThrows('null', '{$cfg,"packages":[{"name":null,$root}]}'); + testThrows('num', '{$cfg,"packages":[{"name":1,$root}]}'); + testThrows('object', '{$cfg,"packages":[{"name":{},$root}]}'); + testThrows('empty', '{$cfg,"packages":[{"name":"",$root}]}'); + testThrows('one-dot', '{$cfg,"packages":[{"name":".",$root}]}'); + testThrows('two-dot', '{$cfg,"packages":[{"name":"..",$root}]}'); + testThrows( + "invalid char '\\'", '{$cfg,"packages":[{"name":"\\",$root}]}'); + testThrows( + "invalid char ':'", '{$cfg,"packages":[{"name":":",$root}]}'); + testThrows( + "invalid char ' '", '{$cfg,"packages":[{"name":" ",$root}]}'); + }); + + testThrows('no root', '{$cfg,"packages":[{$name}]}'); + group('root:', () { + testThrows('null', '{$cfg,"packages":[{$name,"rootUri":null}]}'); + testThrows('num', '{$cfg,"packages":[{$name,"rootUri":1}]}'); + testThrows('object', '{$cfg,"packages":[{$name,"rootUri":{}}]}'); + testThrows('fragment', '{$cfg,"packages":[{$name,"rootUri":"x/#"}]}'); + testThrows('query', '{$cfg,"packages":[{$name,"rootUri":"x/?"}]}'); + testThrows('package-URI', + '{$cfg,"packages":[{$name,"rootUri":"package:x/x/"}]}'); + }); + group('package-URI root:', () { + testThrows( + 'null', '{$cfg,"packages":[{$name,$root,"packageUri":null}]}'); + testThrows('num', '{$cfg,"packages":[{$name,$root,"packageUri":1}]}'); + testThrows( + 'object', '{$cfg,"packages":[{$name,$root,"packageUri":{}}]}'); + testThrows('fragment', + '{$cfg,"packages":[{$name,$root,"packageUri":"x/#"}]}'); + testThrows( + 'query', '{$cfg,"packages":[{$name,$root,"packageUri":"x/?"}]}'); + testThrows('package: URI', + '{$cfg,"packages":[{$name,$root,"packageUri":"package:x/x/"}]}'); + testThrows('not inside root', + '{$cfg,"packages":[{$name,$root,"packageUri":"../other/"}]}'); + }); + group('language version', () { + testThrows('null', + '{$cfg,"packages":[{$name,$root,"languageVersion":null}]}'); + testThrows( + 'num', '{$cfg,"packages":[{$name,$root,"languageVersion":1}]}'); + testThrows('object', + '{$cfg,"packages":[{$name,$root,"languageVersion":{}}]}'); + testThrows('empty', + '{$cfg,"packages":[{$name,$root,"languageVersion":""}]}'); + testThrows('non number.number', + '{$cfg,"packages":[{$name,$root,"languageVersion":"x.1"}]}'); + testThrows('number.non number', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.x"}]}'); + testThrows('non number', + '{$cfg,"packages":[{$name,$root,"languageVersion":"x"}]}'); + testThrows('one number', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1"}]}'); + testThrows('three numbers', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.2.3"}]}'); + testThrows('leading zero first', + '{$cfg,"packages":[{$name,$root,"languageVersion":"01.1"}]}'); + testThrows('leading zero second', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.01"}]}'); + testThrows('trailing-', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.1-1"}]}'); + testThrows('trailing+', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.1+1"}]}'); + }); + }); + testThrows('duplicate package name', + '{$cfg,"packages":[{$name,$root},{$name,"rootUri":"/other/"}]}'); + testThrowsContains( + // The roots of foo and bar are the same. + 'same roots', + '{$cfg,"packages":[{$name,$root},{"name":"bar",$root}]}', + 'the same root directory'); + testThrowsContains( + // The roots of foo and bar are the same. + 'same roots 2', + '{$cfg,"packages":[{$name,"rootUri":"/"},{"name":"bar","rootUri":"/"}]}', + 'the same root directory'); + testThrowsContains( + // The root of bar is inside the root of foo, + // but the package root of foo is inside the root of bar. + 'between root and lib', + '{$cfg,"packages":[' + '{"name":"foo","rootUri":"/foo/","packageUri":"bar/lib/"},' + '{"name":"bar","rootUri":"/foo/bar/","packageUri":"baz/lib"}]}', + 'package root of foo is inside the root of bar'); + + // This shouldn't be allowed, but for internal reasons it is. + test('package inside package root', () { + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode( + '{$cfg,"packages":[' + '{"name":"foo","rootUri":"/foo/","packageUri":"lib/"},' + '{"name":"bar","rootUri":"/foo/lib/bar/","packageUri":"lib"}]}', + ) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect( + config + .packageOf(Uri.parse('file:///foo/lib/bar/lib/lala.dart'))! + .name, + 'foo'); // why not bar? + expect(config.toPackageUri(Uri.parse('file:///foo/lib/bar/lib/diz')), + Uri.parse('package:foo/bar/lib/diz')); // why not package:bar/diz? + }); + }); + }); + + group('factories', () { + void testConfig(String name, PackageConfig config, PackageConfig expected) { + group(name, () { + test('structure', () { + expect(config.version, expected.version); + var expectedPackages = {for (var p in expected.packages) p.name}; + var actualPackages = {for (var p in config.packages) p.name}; + expect(actualPackages, expectedPackages); + }); + for (var package in config.packages) { + var name = package.name; + test('package $name', () { + var expectedPackage = expected[name]!; + expect(expectedPackage, isNotNull); + expect(package.root, expectedPackage.root, reason: 'root'); + expect(package.packageUriRoot, expectedPackage.packageUriRoot, + reason: 'package root'); + expect(package.languageVersion, expectedPackage.languageVersion, + reason: 'languageVersion'); + }); + } + }); + } + + var configText = ''' + {"configVersion": 2, "packages": [ + { + "name": "foo", + "rootUri": "foo/", + "packageUri": "bar/", + "languageVersion": "1.2" + } + ]} + '''; + var baseUri = Uri.parse('file:///start/'); + var config = PackageConfig([ + Package('foo', Uri.parse('file:///start/foo/'), + packageUriRoot: Uri.parse('file:///start/foo/bar/'), + languageVersion: LanguageVersion(1, 2)) + ]); + testConfig( + 'string', PackageConfig.parseString(configText, baseUri), config); + testConfig( + 'bytes', + PackageConfig.parseBytes( + Uint8List.fromList(configText.codeUnits), baseUri), + config); + testConfig('json', PackageConfig.parseJson(jsonDecode(configText), baseUri), + config); + + baseUri = Uri.parse('file:///start2/'); + config = PackageConfig([ + Package('foo', Uri.parse('file:///start2/foo/'), + packageUriRoot: Uri.parse('file:///start2/foo/bar/'), + languageVersion: LanguageVersion(1, 2)) + ]); + testConfig( + 'string2', PackageConfig.parseString(configText, baseUri), config); + testConfig( + 'bytes2', + PackageConfig.parseBytes( + Uint8List.fromList(configText.codeUnits), baseUri), + config); + testConfig('json2', + PackageConfig.parseJson(jsonDecode(configText), baseUri), config); + }); +} diff --git a/pkgs/package_config/test/src/util.dart b/pkgs/package_config/test/src/util.dart new file mode 100644 index 000000000..780ee80dc --- /dev/null +++ b/pkgs/package_config/test/src/util.dart @@ -0,0 +1,57 @@ +// Copyright (c) 2019, 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:typed_data'; + +import 'package:test/test.dart'; + +/// Creates a package: URI. +Uri pkg(String packageName, String packagePath) { + var path = + "$packageName${packagePath.startsWith('/') ? "" : "/"}$packagePath"; + return Uri(scheme: 'package', path: path); +} + +// Remove if not used. +String configFromPackages(List> packages) => """ +{ + "configVersion": 2, + "packages": [ +${packages.map((nu) => """ + { + "name": "${nu[0]}", + "rootUri": "${nu[1]}" + }""").join(",\n")} + ] +} +"""; + +/// Mimics a directory structure of [description] and runs [loaderTest]. +/// +/// Description is a map, each key is a file entry. If the value is a map, +/// it's a subdirectory, otherwise it's a file and the value is the content +/// as a string. +void loaderTest( + String name, + Map description, + void Function(Uri root, Future Function(Uri) loader) loaderTest, +) { + var root = Uri(scheme: 'test', path: '/'); + Future loader(Uri uri) async { + var path = uri.path; + if (!uri.isScheme('test') || !path.startsWith('/')) return null; + var parts = path.split('/'); + Object? value = description; + for (var i = 1; i < parts.length; i++) { + if (value is! Map) return null; + value = value[parts[i]]; + } + // ignore: unnecessary_cast + if (value is String) return utf8.encode(value) as Uint8List; + return null; + } + + test(name, () => loaderTest(root, loader)); +} diff --git a/pkgs/package_config/test/src/util_io.dart b/pkgs/package_config/test/src/util_io.dart new file mode 100644 index 000000000..e032556f4 --- /dev/null +++ b/pkgs/package_config/test/src/util_io.dart @@ -0,0 +1,62 @@ +// Copyright (c) 2020, 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:package_config/src/util_io.dart'; +import 'package:test/test.dart'; + +/// Creates a directory structure from [description] and runs [fileTest]. +/// +/// Description is a map, each key is a file entry. If the value is a map, +/// it's a subdirectory, otherwise it's a file and the value is the content +/// as a string. +/// Introduces a group to hold the [setUp]/[tearDown] logic. +void fileTest(String name, Map description, + void Function(Directory directory) fileTest) { + group('file-test', () { + var tempDir = Directory.systemTemp.createTempSync('pkgcfgtest'); + setUp(() { + _createFiles(tempDir, description); + }); + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + test(name, () => fileTest(tempDir)); + }); +} + +/// Creates a set of files under a new temporary directory. +/// Returns the temporary directory. +/// +/// The [description] is a map from file names to content. +/// If the content is again a map, it represents a subdirectory +/// with the content as description. +/// Otherwise the content should be a string, +/// which is written to the file as UTF-8. +// Directory createTestFiles(Map description) { +// var target = Directory.systemTemp.createTempSync("pkgcfgtest"); +// _createFiles(target, description); +// return target; +// } + +// Creates temporary files in the target directory. +void _createFiles(Directory target, Map description) { + description.forEach((name, content) { + var entryName = pathJoin(target.path, '$name'); + if (content is Map) { + _createFiles(Directory(entryName)..createSync(), content); + } else { + File(entryName).writeAsStringSync(content as String, flush: true); + } + }); +} + +/// Creates a [Directory] for a subdirectory of [parent]. +Directory subdir(Directory parent, String dirName) => + Directory(pathJoinAll([parent.path, ...dirName.split('/')])); + +/// Creates a [File] for an entry in the [directory] directory. +File dirFile(Directory directory, String fileName) => + File(pathJoin(directory.path, fileName));