diff --git a/lib/src/command/global_activate.dart b/lib/src/command/global_activate.dart index 06e8cd371..d2bfda255 100644 --- a/lib/src/command/global_activate.dart +++ b/lib/src/command/global_activate.dart @@ -52,6 +52,8 @@ class GlobalActivateCommand extends PubCommand { hide: true, ); + argParser.addMultiOption('experiments', help: 'Experiments(s) to enable.'); + argParser.addFlag( 'no-executables', negatable: false, @@ -131,6 +133,7 @@ class GlobalActivateCommand extends PubCommand { overwriteBinStubs: overwrite, path: argResults.option('git-path'), ref: argResults.option('git-ref'), + allowedExperiments: argResults.multiOption('experiments'), ); case 'hosted': @@ -171,6 +174,7 @@ class GlobalActivateCommand extends PubCommand { ref.withConstraint(constraint), executables, overwriteBinStubs: overwrite, + allowedExperiments: argResults.multiOption('experiments'), ); case 'path': diff --git a/lib/src/entrypoint.dart b/lib/src/entrypoint.dart index 3f2be9695..57f033220 100644 --- a/lib/src/entrypoint.dart +++ b/lib/src/entrypoint.dart @@ -404,7 +404,9 @@ See $workspacesDocUrl for more information.''', /// package dir. /// /// Also marks the package active in `PUB_CACHE/active_roots/`. - Future writePackageConfigFiles() async { + Future writePackageConfigFiles({ + required List experiments, + }) async { ensureDir(p.dirname(packageConfigPath)); writeTextFileIfDifferent( @@ -416,6 +418,7 @@ See $workspacesDocUrl for more information.''', .pubspec .sdkConstraints[sdk.identifier] ?.effectiveConstraint, + experiments: experiments, ), ); writeTextFileIfDifferent(packageGraphPath, await _packageGraphFile(cache)); @@ -471,6 +474,7 @@ See $workspacesDocUrl for more information.''', Future _packageConfigFile( SystemCache cache, { VersionConstraint? entrypointSdkConstraint, + required List experiments, }) async { final entries = []; if (lockFile.packages.isNotEmpty) { @@ -515,6 +519,7 @@ See $workspacesDocUrl for more information.''', packages: entries, generator: 'pub', generatorVersion: sdk.version, + experiments: experiments, additionalProperties: { if (FlutterSdk().isAvailable) ...{ 'flutterRoot': @@ -616,6 +621,7 @@ Try running `$topLevelProgram pub get` to create `$lockFilePath`.'''); lockFile, newLockFile, result.availableVersions, + result.experiments, cache, dryRun: dryRun, enforceLockfile: enforceLockfile, @@ -644,7 +650,7 @@ To update `$lockFilePath` run `$topLevelProgram pub get`$suffix without /// have to reload and reparse all the pubspecs. _packageGraph = Future.value(PackageGraph.fromSolveResult(this, result)); - await writePackageConfigFiles(); + await writePackageConfigFiles(experiments: result.experiments); try { if (precompile) { diff --git a/lib/src/experiment.dart b/lib/src/experiment.dart new file mode 100644 index 000000000..c71b6ec4a --- /dev/null +++ b/lib/src/experiment.dart @@ -0,0 +1,16 @@ +// 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. + +/// An experiment as described by an sdk_experiments file +class Experiment { + final String name; + + /// A description of the experiment + final String description; + + /// Where you can read more about the experiment + final String docUrl; + + Experiment(this.name, this.description, this.docUrl); +} diff --git a/lib/src/global_packages.dart b/lib/src/global_packages.dart index 25169eb03..6ce711e88 100644 --- a/lib/src/global_packages.dart +++ b/lib/src/global_packages.dart @@ -90,6 +90,7 @@ class GlobalPackages { Future activateGit( String repo, List? executables, { + required List allowedExperiments, required bool overwriteBinStubs, String? path, String? ref, @@ -127,10 +128,15 @@ class GlobalPackages { packageRef.withConstraint(VersionConstraint.any), executables, overwriteBinStubs: overwriteBinStubs, + allowedExperiments: allowedExperiments, ); } - Package packageForConstraint(PackageRange dep, String dir) { + Package packageForConstraint( + PackageRange dep, + String dir, + List allowedExperiments, + ) { return Package( Pubspec( 'pub global activate', @@ -142,6 +148,7 @@ class GlobalPackages { defaultUpperBoundConstraint: null, ), }, + experiments: allowedExperiments, ), dir, [], @@ -164,12 +171,14 @@ class GlobalPackages { Future activateHosted( PackageRange range, List? executables, { + required List allowedExperiments, required bool overwriteBinStubs, String? url, }) async { await _installInCache( range, executables, + allowedExperiments: allowedExperiments, overwriteBinStubs: overwriteBinStubs, ); } @@ -231,6 +240,7 @@ class GlobalPackages { Future _installInCache( PackageRange dep, List? executables, { + required List allowedExperiments, required bool overwriteBinStubs, bool silent = false, }) async { @@ -239,7 +249,7 @@ class GlobalPackages { final tempDir = cache.createTempDir(); // Create a dummy package with just [dep] so we can do resolution on it. - final root = packageForConstraint(dep, tempDir); + final root = packageForConstraint(dep, tempDir, allowedExperiments); // Resolve it and download its dependencies. SolveResult result; @@ -284,6 +294,7 @@ To recompile executables, first run `$topLevelProgram pub global deactivate $nam originalLockFile ?? LockFile.empty(), lockFile, result.availableVersions, + result.experiments, cache, dryRun: false, quiet: false, @@ -300,19 +311,19 @@ To recompile executables, first run `$topLevelProgram pub global deactivate $nam // Load the package graph from [result] so we don't need to re-parse all // the pubspecs. final entrypoint = Entrypoint.global( - packageForConstraint(dep, packageDir), + packageForConstraint(dep, packageDir, result.experiments), lockFile, cache, solveResult: result, ); - await entrypoint.writePackageConfigFiles(); + await entrypoint.writePackageConfigFiles(experiments: result.experiments); await entrypoint.precompileExecutables(); } final entrypoint = Entrypoint.global( - packageForConstraint(dep, _packageDir(dep.name)), + packageForConstraint(dep, _packageDir(dep.name), allowedExperiments), lockFile, cache, solveResult: result, @@ -427,7 +438,11 @@ Consider `$topLevelProgram pub global deactivate $name`'''); // For cached sources, the package itself is in the cache and the // lockfile is the one we just loaded. entrypoint = Entrypoint.global( - packageForConstraint(id.toRange(), _packageDir(id.name)), + packageForConstraint( + id.toRange(), + _packageDir(id.name), + [], // XXX load experiments here + ), lockFile, cache, ); @@ -536,6 +551,7 @@ Try reactivating the package. entrypoint.lockFile, newLockFile, result.availableVersions, + result.experiments, cache, dryRun: true, enforceLockfile: true, @@ -678,6 +694,7 @@ Try reactivating the package. id.toRange(), packageExecutables, overwriteBinStubs: true, + allowedExperiments: [], // XXX silent: true, ); } else { diff --git a/lib/src/package.dart b/lib/src/package.dart index a92eaecf7..21487df25 100644 --- a/lib/src/package.dart +++ b/lib/src/package.dart @@ -87,6 +87,11 @@ class Package { ...package.pubspec.dependencyOverrides, }; + /// A collection of the union of all experiments used in the workspace. + late final Set allExperimentsInWorkspace = { + for (final package in transitiveWorkspace) ...package.pubspec.experiments, + }; + /// The immediate dependencies this package specifies in its pubspec. Map get dependencies => pubspec.dependencies; diff --git a/lib/src/package_config.dart b/lib/src/package_config.dart index ffffe5997..95d96d346 100644 --- a/lib/src/package_config.dart +++ b/lib/src/package_config.dart @@ -38,11 +38,14 @@ class PackageConfig { /// `.dart_tool/package_config.json` file. Map additionalProperties; + List experiments; + PackageConfig({ required this.configVersion, required this.packages, this.generator, this.generatorVersion, + required this.experiments, Map? additionalProperties, }) : additionalProperties = additionalProperties ?? {} { final names = {}; @@ -100,6 +103,21 @@ class PackageConfig { ); } + // Read the 'experiments' property + final experiments = root['experiments'] ?? []; + if (experiments is! List) { + throw const FormatException( + '"experiments" in package_config.json must be a list, if given', + ); + } + for (final experiment in experiments) { + if (experiment is! String) { + throw const FormatException( + '"experiments" in package_config.json must all be strings', + ); + } + } + // Read the 'generatorVersion' property Version? generatorVersion; final generatorVersionRaw = root['generatorVersion']; @@ -122,6 +140,7 @@ class PackageConfig { packages: packages, generator: generator, generatorVersion: generatorVersion, + experiments: experiments.cast(), additionalProperties: Map.fromEntries( root.entries.where( (e) => @@ -131,6 +150,7 @@ class PackageConfig { 'generated', 'generator', 'generatorVersion', + 'experiments', }.contains(e.key), ), ), @@ -141,6 +161,7 @@ class PackageConfig { Map toJson() => { 'configVersion': configVersion, 'packages': packages.map((p) => p.toJson()).toList(), + 'experiments': experiments, 'generator': generator, 'generatorVersion': generatorVersion?.toString(), }..addAll(additionalProperties); diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index 62eaeb969..944a9b132 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -143,6 +143,51 @@ environment: _containingDescription, ); + List? _experiments; + List get experiments => _experiments ??= parseExperiments(); + + List parseExperiments() { + final experimentsNode = fields.nodes['experiments']; + if (experimentsNode == null || experimentsNode.value == null) { + return []; + } + if (experimentsNode is! YamlList) { + _error('`experiments` must be a list of strings', experimentsNode.span); + } + final result = []; + for (final e in experimentsNode.nodes) { + final value = e.value; + if (value is! String) { + _error('`experiments` must be a list of strings', e.span); + } + + /// For root packages, validate that all experiments are known by at least + /// one of the current sdks. + /// + /// Dependencies will only be chosen by the solver if their experiments + /// are a subset of those of the root packages, so we don't filter here. + if (_containingDescription is ResolvedRootDescription && + !availableExperiments.containsKey(value)) { + final availableExperimentsDescription = + availableExperiments.isEmpty + ? '''There are no available experiments.''' + : ''' +Available experiments are: +${availableExperiments.values.map((experiment) => '* ${experiment.name}: ${experiment.description}, ${experiment.docUrl}').join('\n')}'''; + _error(''' +$value is not a known experiment. + +$availableExperimentsDescription + +Read more about experiments at https://dart.dev/go/experiments. +''', e.span); + } else { + result.add(value); + } + } + return result; + } + Map? _dependencies; /// The packages this package depends on when it is the root package. @@ -341,6 +386,7 @@ environment: this.workspace = const [], this.dependencyOverridesFromOverridesFile = false, this.resolution = Resolution.none, + List experiments = const [], }) : _dependencies = dependencies == null ? null @@ -364,6 +410,7 @@ environment: // This is a dummy value. Dependencies should already be resolved, so we // never need to do relative resolutions. _containingDescription = ResolvedRootDescription.fromDir('.'), + _experiments = experiments, super( fields == null ? YamlMap() : YamlMap.wrap(fields), name: name, diff --git a/lib/src/sdk.dart b/lib/src/sdk.dart index 203c95e7c..cd51659d1 100644 --- a/lib/src/sdk.dart +++ b/lib/src/sdk.dart @@ -3,12 +3,18 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; import 'package:pub_semver/pub_semver.dart'; +import 'experiment.dart'; +import 'io.dart'; +import 'log.dart'; import 'sdk/dart.dart'; import 'sdk/flutter.dart'; import 'sdk/fuchsia.dart'; +import 'utils.dart'; /// An SDK that can provide packages and on which pubspecs can express version /// constraints. @@ -47,6 +53,41 @@ abstract class Sdk { /// package with the given name. String? packagePath(String name); + String get experimentsPath; + + late final Map experiments = _loadExperiments(); + + Map _loadExperiments() { + if (!isAvailable) return {}; + final Object? json; + try { + json = jsonDecode(readTextFile(experimentsPath)); + } on IOException catch (e) { + fine('Could not load $experimentsPath $e'); + // Most likely the file doesn't exist, return empty map. + return {}; + } on FormatException catch (e) { + fail('Failed to parse $experimentsPath. $e'); + } + final result = {}; + if (json case {'experiments': final List experiments}) { + for (final experiment in experiments) { + if (experiment case { + 'name': final String name, + 'description': final String description, + 'docUrl': final String url, + }) { + result[name] = Experiment(name, description, url); + } else { + fail('Malformed experiments file $experimentsPath'); + } + } + } else { + fail('Malformed experiments file $experimentsPath'); + } + return result; + } + @override String toString() => name; } @@ -59,6 +100,12 @@ final sdks = UnmodifiableMapView({ 'fuchsia': FuchsiaSdk(), }); +/// The experiments available +final Map availableExperiments = { + for (final sdk in sdks.values.where((sdk) => sdk.isAvailable)) + ...sdk.experiments, +}; + /// The core Dart SDK. final sdk = DartSdk(); diff --git a/lib/src/sdk/dart.dart b/lib/src/sdk/dart.dart index 4b3e397ee..aa68be2b4 100644 --- a/lib/src/sdk/dart.dart +++ b/lib/src/sdk/dart.dart @@ -40,6 +40,9 @@ class DartSdk extends Sdk { return aboveExecutable; }(); + @override + String get experimentsPath => p.join(_rootDirectory, '.sdk_experiments.json'); + /// The loaded `sdk_packages.yaml` file if present. static final SdkPackageConfig? _sdkPackages = () { final path = p.join(_rootDirectory, 'sdk_packages.yaml'); diff --git a/lib/src/sdk/flutter.dart b/lib/src/sdk/flutter.dart index 4f17abde9..9a0a00950 100644 --- a/lib/src/sdk/flutter.dart +++ b/lib/src/sdk/flutter.dart @@ -116,4 +116,7 @@ class FlutterSdk extends Sdk { return null; } + + @override + String get experimentsPath => p.join(rootDirectory!, '.sdk_experiments.json'); } diff --git a/lib/src/sdk/fuchsia.dart b/lib/src/sdk/fuchsia.dart index 6a491275a..875d810b7 100644 --- a/lib/src/sdk/fuchsia.dart +++ b/lib/src/sdk/fuchsia.dart @@ -22,6 +22,10 @@ class FuchsiaSdk extends Sdk { static final String? _rootDirectory = Platform.environment['FUCHSIA_DART_SDK_ROOT']; + @override + String get experimentsPath => + p.join(_rootDirectory!, '.sdk_experiments.json'); + @override String get installMessage => 'Please set the FUCHSIA_DART_SDK_ROOT environment variable to point to ' diff --git a/lib/src/solver/incompatibility.dart b/lib/src/solver/incompatibility.dart index bfdfe43be..9a01e93e0 100644 --- a/lib/src/solver/incompatibility.dart +++ b/lib/src/solver/incompatibility.dart @@ -97,6 +97,7 @@ class Incompatibility { /// for packages with the given names. @override String toString([Map? details]) { + final cause = this.cause; if (cause is DependencyIncompatibilityCause) { assert(terms.length == 2); @@ -107,11 +108,15 @@ class Incompatibility { return '${_terse(depender, details, allowEvery: true)} depends on ' '${_terse(dependee, details)}'; + } else if (cause is ExperimentIncompatibilityCause) { + assert(terms.length == 1); + final dependee = terms.first; + return '${_terse(dependee, details, allowEvery: true)} depends on ' + 'the experiment ${cause.experiment}'; } else if (cause is SdkIncompatibilityCause) { assert(terms.length == 1); assert(terms.first.isPositive); - final cause = this.cause as SdkIncompatibilityCause; final buffer = StringBuffer( _terse(terms.first, details, allowEvery: true), ); @@ -440,16 +445,17 @@ class Incompatibility { final latterCause = latter.cause; if (latterCause is SdkIncompatibilityCause) { - final cause = latter.cause as SdkIncompatibilityCause; - if (cause.noNullSafetyCause) { + if (latterCause.noNullSafetyCause) { buffer.write('which doesn\'t support null safety'); } else { buffer.write('which requires '); - if (!cause.sdk.isAvailable) { - buffer.write('the ${cause.sdk.name} SDK'); + if (!latterCause.sdk.isAvailable) { + buffer.write('the ${latterCause.sdk.name} SDK'); } else { - if (cause.sdk.name != 'Dart') buffer.write('${cause.sdk.name} '); - buffer.write('SDK version ${cause.constraint}'); + if (latterCause.sdk.name != 'Dart') { + buffer.write('${latterCause.sdk.name} '); + } + buffer.write('SDK version ${latterCause.constraint}'); } } } else if (latterCause is NoVersionsIncompatibilityCause) { @@ -460,6 +466,10 @@ class Incompatibility { "which doesn't exist " '($exceptionMessage)', ); + } else if (latterCause is ExperimentIncompatibilityCause) { + buffer.write( + 'which requires enabling the experiment `${latterCause.experiment}`', + ); } else { buffer.write('which is forbidden'); } diff --git a/lib/src/solver/incompatibility_cause.dart b/lib/src/solver/incompatibility_cause.dart index 01b9dcf3f..529382184 100644 --- a/lib/src/solver/incompatibility_cause.dart +++ b/lib/src/solver/incompatibility_cause.dart @@ -68,6 +68,52 @@ class NoVersionsIncompatibilityCause extends IncompatibilityCause { const NoVersionsIncompatibilityCause._(); } +/// The incompatibility indicates that the uses an experiment not allowed by the +/// roots. +class ExperimentIncompatibilityCause extends IncompatibilityCause { + final String experiment; + final Iterable allowedExperiments; + + ExperimentIncompatibilityCause(this.experiment, this.allowedExperiments); + + @override + String? get hint { + if (!availableExperiments.containsKey(experiment)) { + final availableExperimentsDescription = + availableExperiments.isEmpty + ? '''There are no available experiments.''' + : ''' +Available experiments are: +${availableExperiments.values.map((experiment) => '* ${experiment.name}: ${experiment.description}, ${experiment.docUrl}').join('\n')}'''; + return ''' +$experiment is not a known experiment. + +$availableExperimentsDescription + +Read more about experiments at https://dart.dev/go/experiments.'''; + } else { + final enabledExperimentsDescription = + allowedExperiments.isEmpty + ? 'Currently no experiments are enabled.' + : 'Currently the following experiments are enabled: ' + '${allowedExperiments.join(', ')}'; + return ''' +The experiment `$experiment` has not been enabled. + +$enabledExperimentsDescription + +To enable it add to your pubspec.yaml: + +``` +experiments: + - $experiment +``` + +Read more about experiments at https://dart.dev/go/experiments.'''; + } + } +} + /// The incompatibility indicates that the package has an unknown source. class UnknownSourceIncompatibilityCause extends IncompatibilityCause { factory UnknownSourceIncompatibilityCause() => diff --git a/lib/src/solver/package_lister.dart b/lib/src/solver/package_lister.dart index 56aa96085..c08f4d962 100644 --- a/lib/src/solver/package_lister.dart +++ b/lib/src/solver/package_lister.dart @@ -60,6 +60,8 @@ class PackageLister { final Map sdkOverrides; + final Set allowedExperiments; + /// A map from dependency names to constraints indicating which versions of /// [_ref] have already had their dependencies on the given versions returned /// by [incompatibilitiesFor]. @@ -123,6 +125,7 @@ class PackageLister { this._allowedRetractedVersion, { bool downgrade = false, this.sdkOverrides = const {}, + required this.allowedExperiments, }) : _isDowngrade = downgrade, _rootPackage = null; @@ -132,6 +135,7 @@ class PackageLister { this._systemCache, { required Set overriddenPackages, required Map? sdkOverrides, + required this.allowedExperiments, }) : _ref = PackageRef.root(package), // Treat the package as locked so we avoid the logic for finding the // boundaries of various constraints, which is useless for the root @@ -247,7 +251,16 @@ class PackageLister { _locked != null && id.version == _locked.version) { if (_listedLockedVersion) return const []; - + // Check if this version uses any disallowed experiments. + for (final experiment in pubspec.experiments) { + if (!allowedExperiments.contains(experiment)) { + return [ + Incompatibility([ + Term(id.toRange(), true), + ], ExperimentIncompatibilityCause(experiment, allowedExperiments)), + ]; + } + } final depender = id.toRange(); _listedLockedVersion = true; for (var sdk in sdks.values) { @@ -300,6 +313,9 @@ class PackageLister { if (sdkIncompatibility != null) return [sdkIncompatibility]; } + final experimentIncompatility = await _checkExperiments(index); + if (experimentIncompatility != null) return [experimentIncompatility]; + // Don't recompute dependencies that have already been emitted. final dependencies = Map.from(pubspec.dependencies); for (var package in dependencies.keys.toList()) { @@ -345,6 +361,40 @@ class PackageLister { ], DependencyIncompatibilityCause(depender, target)); } + /// If the version at [index] in [_versions] isn't compatible with the allowed + /// experiments, returns an [Incompatibility] indicating this fact. + /// + /// Otherwise, returns `null`. + Future _checkExperiments(int index) async { + final versions = await _versions; + + final disAllowedExperiment = (await _describeSafe( + versions[index], + )).experiments.firstWhereOrNull((e) => !allowedExperiments.contains(e)); + + if (disAllowedExperiment == null) return null; + + final (boundsFirstIndex, boundsLastIndex) = await _findBounds( + index, + (pubspec) => pubspec.experiments.contains(disAllowedExperiment), + ); + final incompatibleVersions = VersionRange( + min: boundsFirstIndex == 0 ? null : versions[boundsFirstIndex].version, + includeMin: true, + max: + boundsLastIndex == versions.length - 1 + ? null + : versions[boundsLastIndex + 1].version, + alwaysIncludeMaxPreRelease: true, + ); + _knownInvalidVersions = incompatibleVersions.union(_knownInvalidVersions); + + return Incompatibility( + [Term(_ref.withConstraint(incompatibleVersions), true)], + ExperimentIncompatibilityCause(disAllowedExperiment, allowedExperiments), + ); + } + /// If the version at [index] in [_versions] isn't compatible with the current /// version of [sdk], returns an [Incompatibility] indicating that. /// diff --git a/lib/src/solver/report.dart b/lib/src/solver/report.dart index bfc426da0..7f8342e70 100644 --- a/lib/src/solver/report.dart +++ b/lib/src/solver/report.dart @@ -11,6 +11,7 @@ import '../lock_file.dart'; import '../log.dart' as log; import '../package_name.dart'; import '../pubspec.dart'; +import '../sdk.dart'; import '../source/hosted.dart'; import '../source/root.dart'; import '../system_cache.dart'; @@ -50,6 +51,8 @@ class SolveReport { static const maxAdvisoryFootnotesPerLine = 5; final advisoryDisplayHandles = []; + final List experiments; + SolveReport( this._type, this._location, @@ -58,6 +61,7 @@ class SolveReport { this._previousLockFile, this._newLockFile, this._availableVersions, + this.experiments, this._cache, { required bool dryRun, required bool enforceLockfile, @@ -76,6 +80,7 @@ class SolveReport { final changes = await _reportChanges(); _checkContentHashesMatchOldLockfile(); if (summary) await summarize(changes); + reportExperiments(); } void _checkContentHashesMatchOldLockfile() { @@ -308,6 +313,19 @@ $contentHashesDocumentationUrl } } + void reportExperiments() { + if (experiments.isNotEmpty) { + message('The following experiments have been enabled:'); + + for (final experimentName in experiments) { + final experiment = availableExperiments[experimentName]!; + message('* ${experiment.name} (see ${experiment.docUrl})'); + } + + message('See (https://dart.dev/go/experiments for more information).'); + } + } + static DependencyType dependencyType(LockFile lockFile, String name) => lockFile.mainDependencies.contains(name) ? DependencyType.direct diff --git a/lib/src/solver/result.dart b/lib/src/solver/result.dart index 7c88666f2..dff174f1c 100644 --- a/lib/src/solver/result.dart +++ b/lib/src/solver/result.dart @@ -48,6 +48,9 @@ class SolveResult { /// The wall clock time the resolution took. final Duration resolutionTime; + /// The experiments enabled for this solve. + List get experiments => _root.allExperimentsInWorkspace.toList(); + /// Downloads all the cached packages selected by this version resolution. /// /// If some already cached package differs from what is provided by the server diff --git a/lib/src/solver/version_solver.dart b/lib/src/solver/version_solver.dart index 0d0f119e7..2c602f252 100644 --- a/lib/src/solver/version_solver.dart +++ b/lib/src/solver/version_solver.dart @@ -93,6 +93,8 @@ class VersionSolver { final _stopwatch = Stopwatch(); + final Set _allowedExperiments; + VersionSolver( this._type, this._systemCache, @@ -102,7 +104,8 @@ class VersionSolver { Map sdkOverrides = const {}, }) : _sdkOverrides = sdkOverrides, _dependencyOverrides = _root.allOverridesInWorkspace, - _unlock = {...unlock}; + _unlock = {...unlock}, + _allowedExperiments = _root.allExperimentsInWorkspace; /// Prime the solver with [constraints]. void addConstraints(Iterable constraints) { @@ -541,6 +544,7 @@ class VersionSolver { _systemCache, overriddenPackages: _overriddenPackages, sdkOverrides: _sdkOverrides, + allowedExperiments: _root.allExperimentsInWorkspace, ); } @@ -566,6 +570,7 @@ class VersionSolver { _getAllowedRetracted(ref.name), downgrade: _type == SolveType.downgrade, sdkOverrides: _sdkOverrides, + allowedExperiments: _allowedExperiments, ); }); } diff --git a/lib/src/validator/pubspec_typo.dart b/lib/src/validator/pubspec_typo.dart index e8720714c..18d43a87e 100644 --- a/lib/src/validator/pubspec_typo.dart +++ b/lib/src/validator/pubspec_typo.dart @@ -57,6 +57,7 @@ const _validPubspecKeys = [ 'name', 'version', 'description', + 'experiments', 'homepage', 'repository', 'issue_tracker', diff --git a/pubspec.lock b/pubspec.lock index f24c3e1b5..049bbdf34 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -474,4 +474,4 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.8.0 <4.0.0" diff --git a/test/descriptor/package_config.dart b/test/descriptor/package_config.dart index 8aad15702..2c98f6054 100644 --- a/test/descriptor/package_config.dart +++ b/test/descriptor/package_config.dart @@ -18,6 +18,7 @@ class PackageConfigFileDescriptor extends Descriptor { final String _pubCache; final String? _flutterRoot; final String? _flutterVersion; + final List experiments; /// A map describing the packages in this `package_config.json` file. final List _packages; @@ -28,6 +29,7 @@ class PackageConfigFileDescriptor extends Descriptor { packages: _packages, generatorVersion: Version.parse(_generatorVersion), generator: 'pub', + experiments: experiments, additionalProperties: { 'pubCache': p.toUri(_pubCache).toString(), if (_flutterRoot != null) @@ -45,8 +47,9 @@ class PackageConfigFileDescriptor extends Descriptor { this._generatorVersion, this._pubCache, this._flutterRoot, - this._flutterVersion, - ) : super('.dart_tool/package_config.json'); + this._flutterVersion, { + this.experiments = const [], + }) : super('.dart_tool/package_config.json'); @override Future create([String? parent]) async { diff --git a/test/experiments_test.dart b/test/experiments_test.dart new file mode 100644 index 000000000..d27e4d3a2 --- /dev/null +++ b/test/experiments_test.dart @@ -0,0 +1,188 @@ +// 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. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:pub/src/exit_codes.dart'; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart'; + +import 'descriptor.dart' as d; +import 'test_pub.dart'; + +Future main() async { + test('allows experiments that are enabled in the root', () async { + final server = await servePackages(); + await _setupFlutterRootWithExperiment(); + + server.serve( + 'foo', + '1.0.0', + pubspec: { + 'experiments': ['abc'], + }, + ); + await d + .appDir( + dependencies: {'foo': '^1.0.0'}, + pubspec: { + 'experiments': ['abc'], + }, + ) + .create(); + + await pubGet( + output: contains(''' +The following experiments have been enabled: +* abc (see https://dart.dev/experiments/abc) +'''), + environment: {'FLUTTER_ROOT': p.join(sandbox, 'flutter')}, + ); + + final packageConfig = + json.decode( + File( + p.join(sandbox, appPath, '.dart_tool', 'package_config.json'), + ).readAsStringSync(), + ) + as Map; + expect(packageConfig['experiments'], ['abc']); + }); + + test('Finds the version with the right experiments enabled', () async { + final server = await servePackages(); + await _setupFlutterRootWithExperiment(); + server.serve( + 'foo', + '1.0.0-dev', + pubspec: { + 'experiments': ['abc'], + }, + ); + server.serve( + 'foo', + '1.0.1-dev', // This version is newer, but uses a disabled experiment. + pubspec: { + 'experiments': ['abcd'], + }, + ); + await d + .appDir( + dependencies: {'foo': '^1.0.0-dev'}, + pubspec: { + 'experiments': ['abc'], + }, + ) + .create(); + + await pubGet( + output: contains('+ foo 1.0.0-dev'), + environment: {'FLUTTER_ROOT': p.join(sandbox, 'flutter')}, + ); + }); + + test('disallows experiments that are not enabled in the root', () async { + final server = await servePackages(); + await _setupFlutterRootWithExperiment(); + server.serve( + 'foo', + '1.1.0-dev', + pubspec: { + 'experiments': ['abc'], + }, + ); + await d.appDir(dependencies: {'foo': '^1.0.0-dev'}).create(); + + await pubGet( + error: ''' +Because myapp depends on foo any which requires enabling the experiment `abc`, version solving failed. + +The experiment `abc` has not been enabled. + +Currently no experiments are enabled. + +To enable it add to your pubspec.yaml: + +``` +experiments: + - abc +``` + +Read more about experiments at https://dart.dev/go/experiments.''', + environment: {'FLUTTER_ROOT': p.join(sandbox, 'flutter')}, + ); + }); + + test('disallows experiments that are not enabled in the sdk', () async { + await servePackages(); + await _setupFlutterRootWithExperiment(); + await d + .appDir( + pubspec: { + 'experiments': ['abcd'], + }, + ) + .create(); + + await pubGet( + error: contains(''' +abcd is not a known experiment. + +Available experiments are: +* abc: New alphabetical feature, https://dart.dev/experiments/abc + +Read more about experiments at https://dart.dev/go/experiments.'''), + environment: {'FLUTTER_ROOT': p.join(sandbox, 'flutter')}, + exitCode: DATA, + ); + }); + + test('Can global activate a package using experiments', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + pubspec: { + 'experiments': ['abc'], + }, + ); + await _setupFlutterRootWithExperiment(); + await d + .appDir( + pubspec: { + 'experiments': ['abcd'], + }, + ) + .create(); + + await runPub( + args: ['global', 'activate', 'foo', '--experiments', 'abc'], + output: contains(''' +The following experiments have been enabled: +* abc (see https://dart.dev/experiments/abc) +'''), + environment: {'FLUTTER_ROOT': p.join(sandbox, 'flutter')}, + ); + }); +} + +Future _setupFlutterRootWithExperiment() async { + await d.dir('flutter', [ + d.flutterVersion('1.2.3'), + d.file( + '.sdk_experiments.json', + jsonEncode({ + 'experiments': [ + { + 'name': 'abc', + 'description': 'New alphabetical feature', + 'docUrl': 'https://dart.dev/experiments/abc', + }, + ], + }), + ), + ]).create(); +} diff --git a/test/testdata/goldens/embedding/embedding_test/logfile is written with --verbose and on unexpected exceptions.txt b/test/testdata/goldens/embedding/embedding_test/logfile is written with --verbose and on unexpected exceptions.txt index 21b203d50..6c3ba2c16 100644 --- a/test/testdata/goldens/embedding/embedding_test/logfile is written with --verbose and on unexpected exceptions.txt +++ b/test/testdata/goldens/embedding/embedding_test/logfile is written with --verbose and on unexpected exceptions.txt @@ -122,6 +122,7 @@ MSG : Logs written to $SANDBOX/cache/log/pub_log.txt. [E] | "languageVersion": "3.0" [E] | } [E] | ], +[E] | "experiments": [], [E] | "generator": "pub", [E] | "generatorVersion": "3.1.2+3", [E] | "pubCache": "file://$SANDBOX/cache" @@ -311,6 +312,7 @@ FINE: Contents: | "languageVersion": "3.0" | } | ], + | "experiments": [], | "generator": "pub", | "generatorVersion": "3.1.2+3", | "pubCache": "file://$SANDBOX/cache" diff --git a/test/testdata/goldens/help_test/pub global activate --help.txt b/test/testdata/goldens/help_test/pub global activate --help.txt index 75fbc3da5..858aaaefa 100644 --- a/test/testdata/goldens/help_test/pub global activate --help.txt +++ b/test/testdata/goldens/help_test/pub global activate --help.txt @@ -10,6 +10,7 @@ Usage: pub global activate [version-constraint] [git, hosted (default), path] --git-path Path of git package in repository --git-ref Git branch or commit to be retrieved + --experiments Experiments(s) to enable. --no-executables Do not put executables on PATH. -x, --executable Executable(s) to place on PATH. --overwrite Overwrite executables from other packages with the same name.