diff --git a/pkgs/package_config/CHANGELOG.md b/pkgs/package_config/CHANGELOG.md index 83953a9d1b..51d9b45dfc 100644 --- a/pkgs/package_config/CHANGELOG.md +++ b/pkgs/package_config/CHANGELOG.md @@ -1,24 +1,5 @@ ## 2.3.0-wip -- Removes support for the `.packages` file. - The Dart SDK no longer supports that file, and no new `.packages` files - will be generated. - Since the SDK requirement for this package is above 3.0.0, - no supporting SDK can use or generate `.packages`. - -- Simplifies API that no longer needs to support two separate files. - - Renamed `readAnyConfigFile` to `readConfigFile`, and removed - the `preferNewest` parameter. - - Same for `readAnyConfigFileUri` which becomes `readConfigFileUri`. - - Old functions still exists as deprecated, forwarding to the new - functions without the `preferNewest` argument. - - Also makes `PackageConfig`, `Package` and `LanguageVersion` `@sealed` classes, - in preparation for making them `final` in a future update. - -- Adds `PackageConfig.minVersion` to complement `.maxVersion`. - Currently both are `2`. - ## 2.2.0 - Add relational operators to `LanguageVersion` with extension methods diff --git a/pkgs/package_config/README.md b/pkgs/package_config/README.md index 4e3f0b0c7e..76fd3cbed0 100644 --- a/pkgs/package_config/README.md +++ b/pkgs/package_config/README.md @@ -21,5 +21,6 @@ The primary libraries of this package are Just the `PackageConfig` class and other types needed to use package configurations. This library does not depend on `dart:io`. -The package no longer contains backwards compatible functionality to -work with `.packages` files. +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/bin/package_config_of.dart b/pkgs/package_config/bin/package_config_of.dart deleted file mode 100644 index 6e8f95b7f3..0000000000 --- a/pkgs/package_config/bin/package_config_of.dart +++ /dev/null @@ -1,443 +0,0 @@ -#! /bin/env dart -// Copyright (c) 2025, 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 for checking which package configuration applies to a specific file -/// or path. -library; - -import 'dart:convert'; -import 'dart:io'; - -import 'package:package_config/package_config.dart'; -import 'package:path/path.dart' as p; - -/// Output modes. -const _printText = 0; -const _printJsonLines = 1; -const _printJsonList = 2; - -void main(List args) async { - // Basic command line parser. No fancy. - var files = []; - var stopAtPubspec = false; - var noParent = false; - var hasPrintedUsage = false; - var parseFlags = true; - var printFormat = _printText; - for (var arg in args) { - if (parseFlags && arg.startsWith('-')) { - switch (arg) { - case '-b': - noParent = true; - case '-g': - stopAtPubspec = false; - case '-h': - if (!hasPrintedUsage) { - hasPrintedUsage = true; - stdout.writeln(usage); - } - case '-j': - printFormat = _printJsonLines; - case '-jl': - printFormat = _printJsonList; - case '-p': - stopAtPubspec = true; - case '--': - parseFlags = false; - default: - stderr.writeln('Unexpected flag: $arg'); - if (!hasPrintedUsage) { - hasPrintedUsage = true; - stderr.writeln(usage); - } - } - } else { - files.add(arg); - } - } - if (hasPrintedUsage) return; - - /// Check current directory if no PATHs on command line. - if (files.isEmpty) { - files.add(p.current); - } - - var loader = PackageConfigLoader( - stopAtPubspec: stopAtPubspec, - noParent: noParent, - ); - - // Collects infos if printing as single JSON value. - // Otherwise prints output for each file as soon as it's available. - var jsonList = >[]; - - for (var arg in files) { - var fileInfo = await _resolveInfo(arg, loader); - if (fileInfo == null) continue; // File does not exist, already reported. - if (printFormat == _printText) { - // Display as "readable" text. - print(fileInfo); - } else if (printFormat == _printJsonLines) { - // Write JSON on a single line. - stdout.writeln( - const JsonEncoder.withIndent(null).convert(fileInfo.toJson()), - ); - } else { - // Store JSON in list, print entire list later. - assert(printFormat == _printJsonList); - jsonList.add(fileInfo.toJson()); - } - } - if (printFormat == _printJsonList) { - stdout.writeln(const JsonEncoder.withIndent(' ').convert(jsonList)); - } -} - -/// Finds package information for command line provided path. -Future _resolveInfo(String arg, PackageConfigLoader loader) async { - var path = p.normalize(arg); - var file = File(path); - if (file.existsSync()) { - file = file.absolute; - var directory = Directory(p.dirname(file.path)); - return await _resolvePackageConfig(directory, file, loader); - } - var directory = Directory(path); - if (directory.existsSync()) { - return await _resolvePackageConfig(directory.absolute, null, loader); - } - stderr.writeln('Cannot find file or directory: $arg'); - return null; -} - -// -------------------------------------------------------------------- -// Convert package configuration information to a simple model object. - -/// Extract package configuration information for a file from a configuration. -Future _resolvePackageConfig( - Directory path, - File? file, - PackageConfigLoader loader, -) async { - var originPath = path.path; - var targetPath = file?.path ?? originPath; - var (configPath, config) = await loader.findPackageConfig(originPath); - Package? package; - Uri? packageUri; - LanguageVersion? overrideVersion; - if (config != null) { - var uri = file?.uri ?? path.uri; - package = config.packageOf(uri); - if (package != null) { - packageUri = config.toPackageUri(uri); - } - } - if (file != null) { - overrideVersion = _readOverrideVersion(file); - } - return ConfigInfo( - targetPath, - configPath, - package, - packageUri, - overrideVersion, - ); -} - -/// Gathered package configuration information for [path]. -final class ConfigInfo { - /// Original path being resolved. - final String path; - - /// Path to package configuration file, if any. - final String? configPath; - - /// Package that path belongs to, if any. - final Package? package; - - /// Package URI for [path], if it has one. - /// Always `null` if [package] is `null`. - final Uri? packageUri; - - /// Language version override in file, if any. - final LanguageVersion? languageVersionOverride; - - ConfigInfo( - this.path, - this.configPath, - this.package, - this.packageUri, - this.languageVersionOverride, - ); - - Map toJson() { - return { - JsonKey.path: path, - if (configPath != null) JsonKey.configPath: configPath, - if (package case var package?) - JsonKey.package: { - JsonKey.name: package.name, - JsonKey.root: _fileUriPath(package.root), - if (package.languageVersion case var languageVersion?) - JsonKey.languageVersion: languageVersion.toString(), - if (packageUri case var packageUri?) ...{ - JsonKey.packageUri: packageUri.toString(), - if (package.root != package.packageUriRoot) - JsonKey.lib: _fileUriPath(package.packageUriRoot), - }, - }, - if (languageVersionOverride case var override?) - JsonKey.languageVersionOverride: override.toString(), - }; - } - - /// Package configuration information for a path in a readable format. - @override - String toString() { - var buffer = StringBuffer(); - var sep = Platform.pathSeparator; - var kind = path.endsWith(Platform.pathSeparator) ? 'directory' : 'file'; - buffer.writeln('Package configuration for $kind: ${p.relative(path)}'); - if (configPath case var configPath?) { - buffer.writeln('- Package configuration: ${p.relative(configPath)}'); - if (package case var package?) { - buffer.writeln('- In package: ${package.name}'); - if (package.languageVersion case var version?) { - buffer.writeln(' - default language version: $version'); - } - var rootUri = _fileUriPath(package.root); - var rootPath = p.relative(Directory.fromUri(Uri.parse(rootUri)).path); - buffer.writeln(' - with root: $rootPath$sep'); - if (packageUri case var packageUri?) { - buffer.writeln(' - Has package URI: $packageUri'); - if (package.root != package.packageUriRoot) { - var libPath = p.relative( - Directory.fromUri(package.packageUriRoot).path, - ); - buffer.writeln(' - relative to: $libPath$sep'); - } else { - buffer.writeln(' - relative to root'); - } - } - } else { - buffer.writeln('- Is not part of any package'); - } - } else { - buffer.writeln('- No package configuration found'); - assert(package == null); - } - if (languageVersionOverride case var override?) { - buffer.writeln('- Language version override: // @dart=$override'); - } - return buffer.toString(); - } - - static String _fileUriPath(Uri uri) { - assert(uri.isScheme('file')); - return File.fromUri(uri).path; - } -} - -// Constants for all used JSON keys to prevent mis-typing. -extension type const JsonKey(String value) implements String { - static const JsonKey path = JsonKey('path'); - static const JsonKey configPath = JsonKey('configPath'); - static const JsonKey package = JsonKey('package'); - static const JsonKey name = JsonKey('name'); - static const JsonKey root = JsonKey('root'); - static const JsonKey packageUri = JsonKey('packageUri'); - static const JsonKey lib = JsonKey('lib'); - static const JsonKey languageVersion = JsonKey('languageVersion'); - static const JsonKey languageVersionOverride = JsonKey( - 'languageVersionOverride', - ); -} - -// -------------------------------------------------------------------- -// Find language version override marker in file. - -/// Tries to find a language override marker in a Dart file. -/// -/// Uses a best-effort approach to scan for a line -/// of the form `// @dart=X.Y` before any Dart directive. -/// Any consistently and idiomatically formatted file will be recognized -/// correctly. A file with a multiline `/*...*/` comment where -/// internal lines do not start with `*`, or where a Dart directive -/// follows a `*/` on the same line, may be misinterpreted. -LanguageVersion? _readOverrideVersion(File file) { - String fileContent; - try { - fileContent = file.readAsStringSync(); - } catch (e) { - stderr - ..writeln('Error reading ${file.path} as UTF-8 text:') - ..writeln(e); - return null; - } - // Skip BOM only at start. - const bom = '\uFEFF'; - var contentStart = 0; - // Skip BOM only at start. - if (fileContent.startsWith(bom)) contentStart = 1; - // Skip `#! ...` line only at start. - if (fileContent.startsWith('#!', contentStart)) { - // Skip until end-of-line, whether ended by `\n` or `\r\n`. - var endOfHashBang = fileContent.indexOf('\n', contentStart + 2); - if (endOfHashBang < 0) return null; // No EOL after `#!`. - contentStart = endOfHashBang + 1; - } - // Match lines until one that looks like a version override or not a comment. - for (var match in leadRegExp.allMatches(fileContent, contentStart)) { - if (match.namedGroup('major') case var major?) { - // Found `// @dart=.` line. - var minor = match.namedGroup('minor')!; - return LanguageVersion(int.parse(major), int.parse(minor)); - } else if (match.namedGroup('other') != null) { - // Found non-comment, so too late for language version markers. - break; - } - } - return null; -} - -/// Heuristic scanner for finding leading comment lines. -/// -/// Finds leading comments and any language version override marker -/// within them. -/// -/// Accepts empty lines or lines starting with `/*`, `*` or `//` as -/// initial comment lines, and any other non-space/tab first character -/// as a non-comment, which ends the section -/// that can contain language overrides. -/// -/// It's possible to construct files where that's not correct, fx. -/// ``` -/// /* something */ import "banana.dart"; -/// // @dart=2.14 -/// ``` -/// To be absolutely certain, the code would need to properly tokenize -/// *nested* comments, which is not a job for a RegExp. -/// This RegExp should work for well-behaved and -formatted files. -final leadRegExp = RegExp( - r'^[ \t]*' - r'(?:' - r'$' // Empty line - r'|' - r'/?\*' // Line starting with `/*` or `*`, assumed a comment continuation. - r'|' - // Line starting with `//`, and possibly followed by language override. - r'//(?: *@ *dart *= *(?\d+) *\. *(?\d+) *$)?' - r'|' - r'(?[^ \t/*])' // Any other line, assumed to end initial comments. - r')', - multiLine: true, -); - -// -------------------------------------------------------------------- -// Find and load (and cache) package configurations - -class PackageConfigLoader { - /// Stop searching at the current working directory. - final bool noParent; - - /// Stop searching if finding a `pubspec.yaml` with no package configuration. - final bool stopAtPubspec; - - /// Cache lookup results in case someone does more lookups on the same path. - final Map< - (String path, bool stopAtPubspec), - (String? configPath, PackageConfig? config) - > - _packageConfigCache = {}; - - PackageConfigLoader({this.stopAtPubspec = false, this.noParent = false}); - - /// Finds a package configuration relative to [path]. - /// - /// Caches result for each directory looked at. - /// If someone does multiple lookups in the same directory, there is no need - /// to find and parse the same configuration more than once. - Future<(String? path, PackageConfig? config)> findPackageConfig( - String path, - ) async => - _packageConfigCache[( - path, - stopAtPubspec, - )] ??= await _findPackageConfigNoCache(path); - - Future<(String? path, PackageConfig? config)> _findPackageConfigNoCache( - String path, - ) async { - var configPath = p.join(path, '.dart_tool', 'package_config.json'); - var configFile = File(configPath); - if (configFile.existsSync()) { - var hasError = false; - var config = await loadPackageConfig( - configFile.absolute, - onError: (error) { - stderr.writeln( - 'Error parsing package configuration config ($configPath):\n' - ' $error', - ); - hasError = true; - }, - ); - return (configPath, hasError ? null : config); - } - if (stopAtPubspec) { - var pubspecPath = p.join(path, 'pubspec.yaml'); - var pubspecFile = File(pubspecPath); - if (pubspecFile.existsSync()) { - stderr - ..writeln('Found pubspec.yaml with no .dart_tool/package_config.json') - ..writeln(' at $path'); - return (null, null); - } - } - if (noParent && path == p.current) return (null, null); - var parentPath = p.dirname(path); - if (parentPath == path) return (null, null); - // Recurse on parent path. - return findPackageConfig(parentPath); - } -} - -const String usage = ''' -Usage: dart package_config_of.dart [-p|-j|-jl|-h] PATH* - -Searches from (each) PATH for a `.dart_tool/package_config.json` file, -loads that, and prints information about that package configuration -and the configuration for PATH in it. -If no PATH is given, the current directory is used. - -Flags: - -p : Stop when finding a 'pubspec.yaml' file without a package config. - The default is to ignore `pubspec.yaml` files and only look for - `.dart_tool/package_config.json` files, which will work correctly - for Pub workspaces. - -b : Stop if reaching the current working directory. - When looking for a package configuration, don't try further out - than the current directory. - Only works for starting PATHs inside the current directory. - -j : Output as JSON. Emits a single JSON object for each file, on - a line of its own (JSON-lines format). See format below. - Default is to output human readable text. - -jl : Emits as a single JSON list containing the JSON outputs. - See format of individual elements below. - Default is to output human readable text. - -h : Print this help text. Ignore any PATHs. - -A JSON object written using -j or -jl has following entries: - "path": "PATH", normalized and ends in a `/` if a directory. - "configPath": path to config file. Omitted if no or invalid config. - "package": The package that PATH belongs to, if any. A map with: - "name": Package name. - "root": Path to package root. - "languageVersion": "A.B", default language version for package. - "packageUri": The package: URI of PATH, if one exists. - "lib": If package URI exists, the package URI root, - unless it's the same as the package root. - "languageVersionOverride": "A.B", if file has '//@dart=' version override. -'''; diff --git a/pkgs/package_config/lib/package_config.dart b/pkgs/package_config/lib/package_config.dart index fc618539d3..2f2a612ea4 100644 --- a/pkgs/package_config/lib/package_config.dart +++ b/pkgs/package_config/lib/package_config.dart @@ -21,7 +21,20 @@ export 'package_config_types.dart'; /// Reads a specific package configuration file. /// -/// The file must exist, be readable and be a valid `package_config.json` 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. @@ -29,22 +42,28 @@ export 'package_config_types.dart'; /// a valid configuration from the invalid configuration file. /// If no [onError] is provided, errors are thrown immediately. Future loadPackageConfig( - File file, { - void Function(Object error)? onError, -}) => readConfigFile(file, onError ?? throwError); - -/// @nodoc -@Deprecated('use loadPackageConfig instead') -Future loadAnyPackageConfig( File file, { bool preferNewest = true, void Function(Object error)? onError, -}) => loadPackageConfig(file, onError: onError); +}) => readAnyConfigFile(file, preferNewest, onError ?? throwError); /// Reads a specific package configuration URI. /// -/// The file of the URI must exist, be readable, -/// and be a valid `package_config.json` file. +/// 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] @@ -57,7 +76,7 @@ Future loadAnyPackageConfig( /// 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, +/// 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. @@ -72,21 +91,15 @@ Future loadAnyPackageConfig( Future loadPackageConfigUri( Uri file, { Future Function(Uri uri)? loader, - void Function(Object error)? onError, -}) => readConfigFileUri(file, loader, onError ?? throwError); - -/// @nodoc -@Deprecated('use loadPackageConfigUri instead') -Future loadAnyPackageConfigUri( - Uri uri, { bool preferNewest = true, void Function(Object error)? onError, -}) => loadPackageConfigUri(uri, onError: onError); +}) => readAnyConfigFileUri(file, loader, onError ?? throwError, preferNewest); /// Finds a package configuration relative to [directory]. /// -/// If [directory] contains a `.dart_tool/package_config.json` file, -/// then that file is loaded. +/// 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, @@ -128,8 +141,9 @@ Future findPackageConfig( /// Finds a package configuration relative to [location]. /// -/// If [location] contains a `.dart_tool/package_config.json` -/// package configuration file or, then that file is loaded. +/// 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]. @@ -197,6 +211,9 @@ Future findPackageConfigUri( /// 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( diff --git a/pkgs/package_config/lib/src/discovery.dart b/pkgs/package_config/lib/src/discovery.dart index 7f9c23ba45..a125166d0f 100644 --- a/pkgs/package_config/lib/src/discovery.dart +++ b/pkgs/package_config/lib/src/discovery.dart @@ -6,12 +6,14 @@ import 'dart:io'; import 'dart:typed_data'; import 'errors.dart'; -import 'package_config.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: '..'); @@ -22,13 +24,16 @@ final Uri parentPath = Uri(path: '..'); /// and stopping when something is found. /// /// * Check if a `.dart_tool/package_config.json` file exists in the directory. -/// * Repeat this check for the parent directories until reaching the +/// * 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 such a test succeeds, a `PackageConfig` class is returned. +/// 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( @@ -43,6 +48,7 @@ Future findPackageConfig( return null; } do { + // Check for $cwd/.packages var packageConfig = await findPackageConfigInDirectory( directory, minVersion, @@ -95,6 +101,13 @@ Future findPackageConfigUri( 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; @@ -103,7 +116,7 @@ Future findPackageConfigUri( return null; } -/// Finds a `.dart_tool/package_config.json` file in [directory]. +/// 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. @@ -114,6 +127,7 @@ Future findPackageConfigUri( /// 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( @@ -123,10 +137,16 @@ Future findPackageConfigInDirectory( ) async { var packageConfigFile = await checkForPackageConfigJsonFile(directory); if (packageConfigFile != null) { - var config = await readConfigFile(packageConfigFile, onError); + 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; } @@ -138,3 +158,9 @@ Future checkForPackageConfigJsonFile(Directory directory) async { 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/package_config.dart b/pkgs/package_config/lib/src/package_config.dart index ce626e2128..0db7ac5b69 100644 --- a/pkgs/package_config/lib/src/package_config.dart +++ b/pkgs/package_config/lib/src/package_config.dart @@ -4,11 +4,9 @@ import 'dart:typed_data'; -import 'package:meta/meta.dart' show sealed; - import 'errors.dart'; +import 'package_config_impl.dart'; import 'package_config_json.dart'; -import 'util.dart'; /// A package configuration. /// @@ -17,12 +15,8 @@ import 'util.dart'; /// 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. -@sealed abstract class PackageConfig { - /// The lowest configuration version currently supported. - static const int minVersion = 2; - - /// The highest configuration version currently supported. + /// The largest configuration version currently recognized. static const int maxVersion = 2; /// An empty package configuration. @@ -161,8 +155,8 @@ abstract class PackageConfig { /// The configuration version number. /// - /// So far these have been 1 or 2, where - /// * Version one is the `.packages` file format, and is no longer supported. + /// 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 @@ -229,7 +223,6 @@ abstract class PackageConfig { } /// Configuration data for a single package. -@sealed abstract class Package { /// Creates a package with the provided properties. /// @@ -328,7 +321,6 @@ abstract class Package { /// If errors during parsing are handled using an `onError` handler, /// then an *invalid* language version may be represented by an /// [InvalidLanguageVersion] object. -@sealed abstract class LanguageVersion implements Comparable { /// The maximal value allowed by [major] and [minor] values; static const int maxValue = 0x7FFFFFFF; @@ -338,11 +330,11 @@ abstract class LanguageVersion implements Comparable { /// /// Both [major] and [minor] must be greater than or equal to 0 /// and less than or equal to [maxValue]. - factory LanguageVersion(int major, int minor) => SimpleLanguageVersion( - RangeError.checkValueInInterval(major, 0, maxValue, 'major'), - RangeError.checkValueInInterval(minor, 0, maxValue, 'minor'), - null, - ); + factory LanguageVersion(int major, int minor) { + RangeError.checkValueInInterval(major, 0, maxValue, 'major'); + RangeError.checkValueInInterval(minor, 0, maxValue, 'minor'); + return SimpleLanguageVersion(major, minor, null); + } /// Parses a language version string. /// @@ -384,16 +376,13 @@ abstract class LanguageVersion implements Comparable { /// Compares language versions. /// - /// Two language versions are equal if they have the same - /// major and minor version numbers. + /// Two language versions are considered equal if they have the + /// same major and minor version numbers. /// /// A language version is greater than 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. - /// - /// Invalid language versions are ordered before all valid versions, - /// and are all ordered together. @override int compareTo(LanguageVersion other); @@ -420,10 +409,7 @@ abstract class LanguageVersion implements Comparable { /// /// 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 for that error. -/// The caller which provided the non-throwing `onError` handler -/// should be prepared to encounter invalid values. -@sealed +/// which did not throw on an error. abstract class InvalidLanguageVersion implements LanguageVersion { /// The value -1 for an invalid language version. @override @@ -445,49 +431,63 @@ abstract class InvalidLanguageVersion implements LanguageVersion { String toString(); } -/// Relational operators for [LanguageVersion]. -/// -/// Compares valid versions with [LanguageVersion.compareTo], -/// and rejects invalid versions. +/// Relational operators for [LanguageVersion] that +/// compare valid versions with [LanguageVersion.compareTo]. /// -/// Versions should be verified as valid before using them. +/// If either operand is an [InvalidLanguageVersion], a [StateError] is thrown. +/// Versions should be verified as valid after parsing and before using them. extension LanguageVersionRelationalOperators on LanguageVersion { /// Whether this language version is less than [other]. /// - /// Neither version being compared must be an [InvalidLanguageVersion]. + /// If either version being compared is an [InvalidLanguageVersion], + /// a [StateError] is thrown. Verify versions are valid before comparing them. /// /// For details on how valid language versions are compared, /// check out [LanguageVersion.compareTo]. bool operator <(LanguageVersion other) { - // Throw an error if comparing an invalid language version. - if (major < 0) _throwThisInvalid(); - if (other.major < 0) _throwOtherInvalid(); + // Throw an error if comparing as or with an invalid language version. + if (this is InvalidLanguageVersion) { + _throwThisInvalid(); + } else if (other is InvalidLanguageVersion) { + _throwOtherInvalid(); + } + return compareTo(other) < 0; } /// Whether this language version is less than or equal to [other]. /// - /// Neither version being compared must be an [InvalidLanguageVersion]. + /// If either version being compared is an [InvalidLanguageVersion], + /// a [StateError] is thrown. Verify versions are valid before comparing them. /// /// For details on how valid language versions are compared, /// check out [LanguageVersion.compareTo]. bool operator <=(LanguageVersion other) { - // Throw an error if comparing an invalid language version. - if (major < 0) _throwThisInvalid(); - if (other.major < 0) _throwOtherInvalid(); + // Throw an error if comparing as or with an invalid language version. + if (this is InvalidLanguageVersion) { + _throwThisInvalid(); + } else if (other is InvalidLanguageVersion) { + _throwOtherInvalid(); + } + return compareTo(other) <= 0; } /// Whether this language version is greater than [other]. /// - /// Neither version being compared must be an [InvalidLanguageVersion]. + /// If either version being compared is an [InvalidLanguageVersion], + /// a [StateError] is thrown. Verify versions are valid before comparing them. /// /// For details on how valid language versions are compared, /// check out [LanguageVersion.compareTo]. bool operator >(LanguageVersion other) { - // Throw an error if comparing an invalid language version. - if (major < 0) _throwThisInvalid(); - if (other.major < 0) _throwOtherInvalid(); + // Throw an error if comparing as or with an invalid language version. + if (this is InvalidLanguageVersion) { + _throwThisInvalid(); + } else if (other is InvalidLanguageVersion) { + _throwOtherInvalid(); + } + return compareTo(other) > 0; } @@ -499,705 +499,25 @@ extension LanguageVersionRelationalOperators on LanguageVersion { /// For details on how valid language versions are compared, /// check out [LanguageVersion.compareTo]. bool operator >=(LanguageVersion other) { - // Throw an error if comparing an invalid language version. - if (major < 0) _throwThisInvalid(); - if (other.major < 0) _throwOtherInvalid(); - return compareTo(other) >= 0; - } - - static Never _throwThisInvalid() => - throw UnsupportedError( - 'Can\'t compare an invalid language version to another ' - 'language version. ' - 'Verify language versions are valid before use.', - ); - static Never _throwOtherInvalid() => - throw UnsupportedError( - 'Can\'t compare a language version to an invalid language version. ' - 'Verify language versions are valid before use.', - ); -} - -// -------------------------------------------------------------------- -// Implementation of interfaces. Not exported by top-level libraries. - -const bool _disallowPackagesInsidePackageUriRoot = false; - -// Implementations of the main data types exposed by the API of this package. - -@sealed -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. + // Throw an error if comparing as or with an invalid language version. + if (this is InvalidLanguageVersion) { + _throwThisInvalid(); + } else if (other is InvalidLanguageVersion) { + _throwOtherInvalid(); } - 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; + return compareTo(other) >= 0; } -} -/// Configuration data for a single package. -@sealed -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 /', - ), + static Never _throwThisInvalid() => + throw StateError( + "Can't compare an invalid language version to another language version." + ' Verify language versions are valid after parsing.', ); - // 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 by calling [onError] if the format isn't correct, -/// 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), + static Never _throwOtherInvalid() => + throw StateError( + "Can't compare a language version to an invalid language version." + ' Verify language versions are valid after parsing.', ); - } - 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); -} - -@sealed -class SimpleLanguageVersion implements LanguageVersion { - @override - final int major; - @override - final int minor; - - /// A cache for `toString`, pre-filled with source if created by parsing. - /// - /// Also used by [SimpleInvalidLanguageVersion] for its invalid source - /// or a suitably invalid `toString` value. - 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'; - - @override - int compareTo(LanguageVersion other) { - var result = major - other.major; - if (result != 0) return result; - return minor - other.minor; - } -} - -@sealed -class SimpleInvalidLanguageVersion extends SimpleLanguageVersion - implements InvalidLanguageVersion { - SimpleInvalidLanguageVersion(String source) : super(-1, -1, source); - - @override - int get hashCode => identityHashCode(this); - @override - bool operator ==(Object other) => identical(this, other); -} - -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_impl.dart b/pkgs/package_config/lib/src/package_config_impl.dart new file mode 100644 index 0000000000..a33ccf8a2e --- /dev/null +++ b/pkgs/package_config/lib/src/package_config_impl.dart @@ -0,0 +1,685 @@ +// 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 index 8b91a1f31d..bcbe31bf1d 100644 --- a/pkgs/package_config/lib/src/package_config_io.dart +++ b/pkgs/package_config/lib/src/package_config_io.dart @@ -9,8 +9,10 @@ import 'dart:io'; import 'dart:typed_data'; import 'errors.dart'; -import 'package_config.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. @@ -23,28 +25,52 @@ const dartToolDirName = '.dart_tool'; /// 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 readConfigFile( +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 (error) { - onError(error); + } catch (e) { + onError(e); return const SimplePackageConfig.empty(); } - return parsePackageConfigBytes(bytes, file.uri, onError); + return parseAnyConfigFile(bytes, file.uri, onError); } -/// Like [readConfigFile] but uses a URI and an optional loader. -Future readConfigFileUri( +/// 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( @@ -55,11 +81,23 @@ Future readConfigFileUri( } if (loader == null) { if (file.isScheme('file')) { - return await readConfigFile(File.fromUri(file), onError); + 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); @@ -77,9 +115,54 @@ Future readConfigFileUri( ); 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, @@ -92,5 +175,14 @@ Future writePackageConfigJsonFile( var sink = file.openWrite(encoding: utf8); writePackageConfigJsonUtf8(config, baseUri, sink); - await sink.close(); + 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 index ee818ea137..6198abf706 100644 --- a/pkgs/package_config/lib/src/package_config_json.dart +++ b/pkgs/package_config/lib/src/package_config_json.dart @@ -8,7 +8,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'errors.dart'; -import 'package_config.dart'; +import 'package_config_impl.dart'; +import 'packages_file.dart' as packages_file; import 'util.dart'; const String _configVersionKey = 'configVersion'; @@ -25,6 +26,10 @@ const List _packageNames = [ _languageVersionKey, ]; +const String _generatedKey = 'generated'; +const String _generatorKey = 'generator'; +const String _generatorVersionKey = 'generatorVersion'; + final _jsonUtf8Decoder = json.fuse(utf8).decoder; PackageConfig parsePackageConfigBytes( @@ -162,7 +167,7 @@ PackageConfig parsePackageConfigJson( } LanguageVersion? version; - if (languageVersion case var languageVersion?) { + if (languageVersion != null) { version = parseLanguageVersion(languageVersion, onError); } else if (hasVersion) { version = SimpleInvalidLanguageVersion('invalid'); @@ -292,6 +297,24 @@ Map packageConfigToJson(PackageConfig config, Uri? baseUri) => ], }; +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, 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 0000000000..6a977a0ad9 --- /dev/null +++ b/pkgs/package_config/lib/src/packages_file.dart @@ -0,0 +1,248 @@ +// 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 index ce112b571c..cebd3c8c0e 100644 --- a/pkgs/package_config/lib/src/util.dart +++ b/pkgs/package_config/lib/src/util.dart @@ -13,6 +13,11 @@ 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] @@ -143,7 +148,20 @@ bool isUriPrefix(Uri prefix, Uri path) { return path.toString().startsWith(prefix.toString()); } -/// Appends a trailing `/` if the [path] ends in a non-`/` character. +/// 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/'; @@ -235,8 +253,26 @@ Uri? relativizeUri(Uri? uri, Uri? baseUri) { } // 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/pubspec.yaml b/pkgs/package_config/pubspec.yaml index f81f5dbccc..5ff9c53c18 100644 --- a/pkgs/package_config/pubspec.yaml +++ b/pkgs/package_config/pubspec.yaml @@ -8,9 +8,8 @@ environment: sdk: ^3.7.0 dependencies: - meta: ^1.15.0 path: ^1.8.0 dev_dependencies: - dart_flutter_team_lints: ^3.4.0 + 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 index ba7408edd3..1d0ab1da35 100644 --- a/pkgs/package_config/test/bench.dart +++ b/pkgs/package_config/test/bench.dart @@ -3,6 +3,7 @@ // 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'; @@ -30,7 +31,8 @@ void bench(final int size, final bool doPrint) { sb.writeln('}'); var stopwatch = Stopwatch()..start(); var config = parsePackageConfigBytes( - utf8.encode(sb.toString()), + // ignore: unnecessary_cast + utf8.encode(sb.toString()) as Uint8List, Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError, ); diff --git a/pkgs/package_config/test/discovery_test.dart b/pkgs/package_config/test/discovery_test.dart index 529afcdeb6..1e76e1e0b0 100644 --- a/pkgs/package_config/test/discovery_test.dart +++ b/pkgs/package_config/test/discovery_test.dart @@ -78,7 +78,7 @@ void main() { }, ); - // Does not find .packages if no package_config.json. + // Finds .packages if no package_config.json. fileTest( '.packages', { @@ -87,8 +87,9 @@ void main() { 'packages': {'shouldNotBeFound': {}}, }, (Directory directory) async { - var config = await findPackageConfig(directory); - expect(config, null); + var config = (await findPackageConfig(directory))!; + expect(config.version, 1); // Found .packages file. + validatePackagesFile(config, directory); }, ); @@ -98,7 +99,7 @@ void main() { { '.packages': packagesFile, '.dart_tool': {'package_config.json': packageConfigFile}, - 'subdir': {'.packages': packagesFile, 'script.dart': 'main(){}'}, + 'subdir': {'script.dart': 'main(){}'}, }, (Directory directory) async { var config = (await findPackageConfig(subdir(directory, 'subdir/')))!; @@ -107,11 +108,25 @@ void main() { }, ); + // 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': {}}, + 'packages': {'foo': {}}, }, (Directory directory) async { var config = await findPackageConfig(directory); @@ -120,8 +135,20 @@ void main() { ); 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 package config not JSON', + 'invalid .packages', { '.dart_tool': {'package_config.json': 'not a JSON file'}, }, @@ -131,7 +158,7 @@ void main() { ); fileTest( - 'invalid package config as INI', + 'invalid .packages as INI', { '.dart_tool': {'package_config.json': packagesFile}, }, @@ -139,25 +166,39 @@ void main() { expect(findPackageConfig(directory), throwsA(isA())); }, ); - - fileTest( - 'indirectly through .packages', - { - '.packages': packagesFile, - '.dart_tool': {'package_config.json': packageConfigFile}, - }, - (Directory directory) async { - // A .packages file in the directory of a .dart_tool/package_config.json - // used to automatically redirect to the package_config.json. - var file = dirFile(directory, '.packages'); - expect(loadPackageConfig(file), 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', + 'invalid package_config not JSON', { '.dart_tool': {'package_config.json': 'not a JSON file'}, }, @@ -203,8 +244,8 @@ void main() { }, ); - // Finds package_config.json in super-directory. - // (Even with `.packages` in search directory.) + // Finds package_config.json in super-directory, with .packages in + // subdir and minVersion > 1. fileTest( 'package_config.json recursive .packages ignored', { @@ -239,6 +280,20 @@ void main() { 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( @@ -268,15 +323,26 @@ void main() { }, ); - fileTest('.packages cannot be loaded', {'.packages': packagesFile}, ( + fileTest('.packages', {'.packages': packagesFile}, ( Directory directory, ) async { var file = dirFile(directory, '.packages'); - expect(loadPackageConfig(file), throwsFormatException); + var config = await loadPackageConfig(file); + expect(config.version, 1); + validatePackagesFile(config, directory); }); - fileTest('no config file found', {}, (Directory directory) { - var file = dirFile(directory, 'any_name'); + 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()), @@ -284,7 +350,7 @@ void main() { }); fileTest('no config found, handled', {}, (Directory directory) async { - var file = dirFile(directory, 'any_name'); + var file = dirFile(directory, 'anyname'); var hadError = false; await loadPackageConfig( file, @@ -296,10 +362,33 @@ void main() { expect(hadError, true); }); - fileTest('specified file syntax error', {'any_name': 'syntax error'}, ( + 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, 'any_name'); + 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 index 2d056a762f..e81d02314d 100644 --- a/pkgs/package_config/test/discovery_uri_test.dart +++ b/pkgs/package_config/test/discovery_uri_test.dart @@ -65,7 +65,7 @@ void main() { { '.packages': 'invalid .packages file', 'script.dart': 'main(){}', - 'packages': {'shouldNotBeFound': {}}, + 'packages': {'shouldNotBeFound': {}}, '.dart_tool': {'package_config.json': packageConfigFile}, }, (directory, loader) async { @@ -75,6 +75,21 @@ void main() { }, ); + // 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', @@ -94,21 +109,21 @@ void main() { }, ); - // Does not find a .packages file. + // Finds .packages in super-directory. loaderTest( - 'Not .packages', + '.packages recursive', { '.packages': packagesFile, - 'script.dart': 'main(){}', - 'packages': {'shouldNotBeFound': {}}, + 'subdir': {'script.dart': 'main(){}'}, }, (directory, loader) async { - var config = await findPackageConfigUri( - recurse: false, - directory, - loader: loader, - ); - expect(config, null); + var config = + (await findPackageConfigUri( + directory.resolve('subdir/'), + loader: loader, + ))!; + expect(config.version, 1); + validatePackagesFile(config, directory); }, ); @@ -116,13 +131,93 @@ void main() { loaderTest( 'package directory packages not supported', { - 'packages': {'foo': {}}, + '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', () { @@ -142,17 +237,10 @@ void main() { Uri directory, loader, ) async { - // Is no longer supported. var file = directory.resolve('.packages'); - var hadError = false; - await loadPackageConfigUri( - file, - loader: loader, - onError: (_) { - hadError = true; - }, - ); - expect(hadError, true); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); }); }); @@ -183,6 +271,26 @@ void main() { }, ); + 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( @@ -236,7 +344,7 @@ void main() { expect(hadError, true); }); - // Don't look for package_config.json if original name or file are bad. + // Don't look for package_config.json if original file not named .packages. loaderTest( 'specified file syntax error with alternative', { diff --git a/pkgs/package_config/test/package_config_impl_test.dart b/pkgs/package_config/test/package_config_impl_test.dart index e68c31df39..0ad399e2b5 100644 --- a/pkgs/package_config/test/package_config_impl_test.dart +++ b/pkgs/package_config/test/package_config_impl_test.dart @@ -138,11 +138,11 @@ void main() { ) { expect(version == otherVersion, identical(version, otherVersion)); - expect(() => version < otherVersion, throwsA(isA())); - expect(() => version <= otherVersion, throwsA(isA())); + expect(() => version < otherVersion, throwsA(isA())); + expect(() => version <= otherVersion, throwsA(isA())); - expect(() => version > otherVersion, throwsA(isA())); - expect(() => version >= otherVersion, throwsA(isA())); + expect(() => version > otherVersion, throwsA(isA())); + expect(() => version >= otherVersion, throwsA(isA())); } var validVersion = LanguageVersion(3, 5); diff --git a/pkgs/package_config/test/parse_test.dart b/pkgs/package_config/test/parse_test.dart index adeeaa8cde..d5c73258fd 100644 --- a/pkgs/package_config/test/parse_test.dart +++ b/pkgs/package_config/test/parse_test.dart @@ -8,11 +8,89 @@ 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 = ''' @@ -332,7 +410,7 @@ void main() { String containsString, ) { test(name, () { - Object? exception; + dynamic exception; try { parsePackageConfigBytes( utf8.encode(source), diff --git a/pkgs/package_config/test/src/util.dart b/pkgs/package_config/test/src/util.dart index 0253e914ec..780ee80dcf 100644 --- a/pkgs/package_config/test/src/util.dart +++ b/pkgs/package_config/test/src/util.dart @@ -48,7 +48,8 @@ void loaderTest( if (value is! Map) return null; value = value[parts[i]]; } - if (value is String) return utf8.encode(value); + // ignore: unnecessary_cast + if (value is String) return utf8.encode(value) as Uint8List; return null; }