From 5ba5a028dcce3264fd2c83330e524bfe2a32d6ea Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 13:47:26 -0600 Subject: [PATCH 1/2] Add pubspec mod CLI (pm) --- .github/dependabot.yml | 11 + .github/workflows/ci.yml | 32 + Makefile | 39 + README.md | 137 +++- analysis_options.yaml | 3 + bin/pm.dart | 664 ++++++++++++++++++ pubspec.yaml | 13 +- test/fixtures/pubmod/basic/pubspec.yaml | 10 + .../recursive/packages/bad/pubspec.yaml | 5 + .../recursive/packages/child/pubspec.yaml | 6 + .../packages/double_quoted/pubspec.yaml | 6 + .../recursive/packages/hosted/pubspec.yaml | 8 + test/fixtures/pubmod/recursive/pubspec.yaml | 6 + test/fixtures/pubmod/sdk_basic/pubspec.yaml | 6 + .../sdk_recursive/packages/bad/pubspec.yaml | 5 + .../sdk_recursive/packages/child/pubspec.yaml | 6 + .../packages/double_quoted/pubspec.yaml | 6 + .../packages/no_env/pubspec.yaml | 4 + .../pubmod/sdk_recursive/pubspec.yaml | 6 + test/pm_test.dart | 278 ++++++++ 20 files changed, 1243 insertions(+), 8 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile create mode 100644 analysis_options.yaml create mode 100644 bin/pm.dart create mode 100644 test/fixtures/pubmod/basic/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/packages/child/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_basic/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/pubspec.yaml create mode 100644 test/pm_test.dart diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9a5430a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..efdef0f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Dart SDK + uses: dart-lang/setup-dart@v1 + + - name: Install dependencies + run: dart pub get + + - name: Analyze + run: dart analyze . + + - name: Check formatting + run: dart format -o none --set-exit-if-changed . + + - name: Run tests + run: dart test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b592e31 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +# This makefile leverages automatic documentation. Running `make` will generate a list +# of the most commonly used targets. `make help` will generate a more complete list. +# +# When adding a target, prefix the doc string with two pound signs to add it to the common +# list or two pounds and VERBOSE to demote it to the `make help` list. Prefix it with two pounds +# and INTERNAL to exclude from all help, or just don't add a doc string. +# +# Based on https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html + +.DEFAULT_GOAL := help +SHELL=/bin/bash -o pipefail + +# Supports: +# make install /usr/local/bin +# make install +INSTALLPATH ?= $(if $(word 2,$(MAKECMDGOALS)),$(word 2,$(MAKECMDGOALS)),$(HOME)/bin) + +ifneq (,$(filter install,$(MAKECMDGOALS))) +ifneq ($(word 2,$(MAKECMDGOALS)),) +.PHONY: $(word 2,$(MAKECMDGOALS)) +$(word 2,$(MAKECMDGOALS)): + @: +endif +endif + +.PHONY: help +help: ##META Display all make targets + @printf "\33[32m" + @echo "All documented make targets. Use 'make' to see only the most commonly used targets." + @printf "\033[0m\n" + + @grep -E '^%?[a-zA-Z0-9/_-]+:.*?##(VERBOSE)? .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##(VERBOSE)? "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +# Compile both executables in bin +.PHONY: install +install: ## Build and install executables (default: ~/bin) + @mkdir -p "$(INSTALLPATH)" + dart compile exe bin/replace.dart -o "$(INSTALLPATH)/replace" + dart compile exe bin/pm.dart -o "$(INSTALLPATH)/pm" \ No newline at end of file diff --git a/README.md b/README.md index e1b8294..eb8bd24 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,18 @@ allow it or work around it by renaming the file, replacing, and renaming it back ### Installing `pub global activate replace` +This installs both command line tools from this package: +- `replace` for regex find/replace in files +- `pm` for editing dependency constraints in pubspec.yaml files + or more advanced install - clone the project - `pub get` - `dart compile exe bin/replace.dart -o replace` -- Place the `replace` executable in your path +- `dart compile exe bin/pm.dart -o pm` +- Place the `replace` and `pm` executable in your path -### Running / Usage +### How to use replace `replace ...` This means you can pass as many globs, directory names, or filenames @@ -62,3 +67,131 @@ Match a word at the beginning of a line Match a word at the end of a line `replace "dessert$" cookies menu.txt` + +## How to use pm + +`pm` updates dependency version constraints in one or more pubspec.yaml files. + +### Usage + +`pm [global options] ` + +Global options: +- `-h, --help` Print usage information +- `-r, --recursive` Recurse through subdirectories and process all pubspec.yaml files +- `--fail-on-parse-error` Exit with non-zero if any pubspec.yaml cannot be parsed + +Commands: +- `set` Set dependency constraint exactly as provided +- `set-sdk` Set `environment.sdk` constraint exactly as provided +- `raise-min` Raise the minimum bound (inclusive) of a version range +- `raise-min-sdk` Raise the minimum `environment.sdk` bound (inclusive) +- `raise-max` Raise the maximum bound (exclusive) of a version range +- `raise-max-sdk` Raise the maximum `environment.sdk` bound (exclusive) +- `lower-max` Lower the maximum bound (exclusive) of a version range + +Notes: +- Updates both `dependencies` and `dev_dependencies` +- SDK commands update `environment.sdk` only +- `set` accepts any valid Dart version constraint, for example `^1.9.0` or `'>=1.9.0 <2.0.0'` +- `set-sdk` accepts any valid Dart SDK constraint, for example `'>=3.3.0 <4.0.0'` +- `raise-min`, `raise-max`, and `lower-max` expect a specific semantic version, for example `1.9.1` +- `raise-min-sdk` and `raise-max-sdk` expect a specific semantic version, for example `3.4.0` + +### Examples + +Set a dependency version: + +```sh +pm set path 1.9.1 +``` + +Set a single version to a range constraint: + +```sh +pm set path '>=1.9.0 <2.0.0' +``` + +Before: + +```yaml +dependencies: + path: 1.9.1 +``` + +After: + +```yaml +dependencies: + path: '>=1.9.0 <2.0.0' +``` + +Raise minimum version recursively across a monorepo: + +```sh +pm raise-min path 1.9.1 -r +``` + +Range to single version when the new minimum meets or exceeds the old maximum: + +```sh +pm raise-min path 1.9.1 +``` + +Before: + +```yaml +dependencies: + path: '>=1.8.0 <1.9.1' +``` + +After: + +```yaml +dependencies: + path: 1.9.1 +``` + +Range to narrower range when the upper bound is still greater than the new minimum: + +```sh +pm raise-min path 1.9.1 +``` + +Before: + +```yaml +dependencies: + path: '>=1.8.0 <2.0.0' +``` + +After: + +```yaml +dependencies: + path: '>=1.9.1 <2.0.0' +``` + +Lower max version and fail if any pubspec is malformed: + +```sh +pm lower-max path 2.5.0 -r --fail-on-parse-error +``` + +Set SDK constraint: + +```sh +pm set-sdk '>=3.3.0 <4.0.0' +``` + +Raise SDK minimum recursively across a monorepo: + +```sh +pm raise-min-sdk 3.4.0 -r +``` + +Raise SDK maximum recursively: + +```sh +pm raise-max-sdk 5.0.0 -r +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..c065a24 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,3 @@ +analyzer: + exclude: + - test/fixtures/** \ No newline at end of file diff --git a/bin/pm.dart b/bin/pm.dart new file mode 100644 index 0000000..059ad54 --- /dev/null +++ b/bin/pm.dart @@ -0,0 +1,664 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:pub_semver/pub_semver.dart' as semver; +import 'package:pubspec_manager/pubspec_manager.dart' + hide Version, VersionConstraint; + +const _commandLowerMax = 'lower-max'; +const _commandRaiseMax = 'raise-max'; +const _commandRaiseMaxSdk = 'raise-max-sdk'; +const _commandRaiseMin = 'raise-min'; +const _commandRaiseMinSdk = 'raise-min-sdk'; +const _commandSet = 'set'; +const _commandSetSdk = 'set-sdk'; + +const _usageHeader = 'Usage: dart run pubmod [arguments]'; + +Future main(List args) async { + final exitCode = await _run(args); + if (exitCode != 0) { + // ignore: avoid_print + stderr.writeln('pubmod failed with exit code $exitCode.'); + } + exit(exitCode); +} + +Future _run(List args) async { + final parser = _buildParser(); + + late ArgResults results; + try { + results = parser.parse(args); + } on FormatException catch (e) { + stderr.writeln(e.message); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + final showHelp = results['help'] as bool; + if (showHelp) { + _printUsage(parser); + return 0; + } + + final command = results.command; + if (command == null) { + _printUsage(parser); + return 64; + } + + final commandName = command.name; + if (commandName == null) { + _printUsage(parser); + return 64; + } + final targetsSdk = _isSdkCommand(commandName); + final rest = command.rest; + late String dependencyName; + late String versionText; + if (targetsSdk) { + if (rest.length != 1) { + stderr.writeln( + 'Command "$commandName" requires exactly 1 argument: ', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + dependencyName = 'sdk'; + versionText = rest[0].trim(); + if (versionText.isEmpty) { + stderr.writeln('Version must not be empty.'); + return 64; + } + } else { + if (rest.length != 2) { + stderr.writeln( + 'Command "$commandName" requires exactly 2 arguments: ', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + dependencyName = rest[0].trim(); + versionText = rest[1].trim(); + if (dependencyName.isEmpty || versionText.isEmpty) { + stderr.writeln('Dependency and version must not be empty.'); + return 64; + } + } + + final failOnParseError = results['fail-on-parse-error'] as bool; + final recursive = results['recursive'] as bool; + + final op = _Operation.fromCommand(commandName); + if (op == null) { + stderr.writeln('Unknown command: $commandName'); + return 64; + } + + if (op == _Operation.set) { + try { + semver.VersionConstraint.parse(versionText); + } on FormatException catch (e) { + stderr.writeln('Invalid version constraint "$versionText": ${e.message}'); + return 64; + } + } else { + try { + semver.Version.parse(versionText); + } on FormatException catch (e) { + stderr.writeln('Invalid version "$versionText": ${e.message}'); + return 64; + } + } + + final pubspecFiles = _findPubspecFiles(recursive: recursive); + if (pubspecFiles.isEmpty) { + return 0; + } + + var hadParseError = false; + for (final path in pubspecFiles) { + PubSpec pubspec; + try { + pubspec = PubSpec.loadFromPath(path); + } catch (e) { + hadParseError = true; + stderr.writeln('Unable to parse $path: $e'); + if (failOnParseError) { + return 1; + } + continue; + } + + final before = File(path).readAsStringSync(); + + final updateMessages = []; + if (targetsSdk) { + updateMessages.addAll(_applyToSdk(pubspec, op, versionText)); + } else { + updateMessages.addAll( + _applyToDependencies(pubspec.dependencies, dependencyName, op, versionText), + ); + updateMessages.addAll( + _applyToDependencies( + pubspec.devDependencies, + dependencyName, + op, + versionText, + ), + ); + } + + final changed = updateMessages.isNotEmpty; + + if (!changed) { + continue; + } + + final after = pubspec.toString(); + if (before == after) { + continue; + } + + pubspec.saveTo(path); + for (final message in updateMessages) { + stdout.writeln(message); + } + } + + if (failOnParseError && hadParseError) { + return 1; + } + + return 0; +} + +List _applyToDependencies( + Dependencies dependencies, + String dependencyName, + _Operation operation, + String inputVersion, +) { + final dependency = dependencies[dependencyName]; + if (dependency == null) { + return const []; + } + + if (dependency is! DependencyVersioned) { + return const []; + } + + final versionedDependency = dependency as DependencyVersioned; + final currentText = versionedDependency.versionConstraint.trim(); + + if (operation == _Operation.set) { + final nextText = _toYamlConstraint(inputVersion.trim(), currentText); + if (nextText == currentText) { + return const []; + } + versionedDependency.versionConstraint = nextText; + return [_buildUpdateMessage(dependency.name, currentText, nextText)]; + } + + final target = semver.Version.parse(inputVersion); + final normalizedCurrent = _stripMatchingQuotes(currentText); + + semver.VersionConstraint currentConstraint; + try { + currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); + } on FormatException catch (e) { + stderr.writeln( + 'Skipping ${dependency.name}: invalid version constraint "$currentText" (${e.message})', + ); + return const []; + } + final bounds = _ConstraintBounds.fromConstraint(currentConstraint); + if (bounds == null) { + stderr.writeln( + 'Skipping ${dependency.name}: unsupported version constraint "$currentText"', + ); + return const []; + } + + final nextBounds = bounds.copy(); + switch (operation) { + case _Operation.lowerMax: + if (_hasLowerOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMax: + if (_hasHigherOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMin: + if (_hasHigherOrEqualInclusiveMin(bounds, target)) { + return const []; + } + nextBounds.min = target; + nextBounds.includeMin = true; + case _Operation.set: + throw StateError('Unexpected operation in bounds transform.'); + } + + if (!nextBounds.isValid()) { + stderr.writeln( + 'Skipping ${dependency.name}: resulting range would be empty (${nextBounds.toConstraintString()})', + ); + return const []; + } + + final nextText = _toYamlConstraint( + nextBounds.toConstraintString(), + currentText, + ); + if (nextText == currentText) { + return const []; + } + + versionedDependency.versionConstraint = nextText; + return [_buildUpdateMessage(dependency.name, currentText, nextText)]; +} + +List _applyToSdk( + PubSpec pubspec, + _Operation operation, + String inputVersion, +) { + final currentText = pubspec.environment.sdk.trim(); + if (currentText.isEmpty) { + return const []; + } + + if (operation == _Operation.set) { + final nextText = _toYamlConstraint(inputVersion.trim(), currentText); + if (nextText == currentText) { + return const []; + } + pubspec.environment.sdk = nextText; + return [_buildUpdateMessage('sdk', currentText, nextText)]; + } + + final target = semver.Version.parse(inputVersion); + final normalizedCurrent = _stripMatchingQuotes(currentText); + + semver.VersionConstraint currentConstraint; + try { + currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); + } on FormatException catch (e) { + stderr.writeln( + 'Skipping sdk: invalid version constraint "$currentText" (${e.message})', + ); + return const []; + } + + final bounds = _ConstraintBounds.fromConstraint(currentConstraint); + if (bounds == null) { + stderr.writeln( + 'Skipping sdk: unsupported version constraint "$currentText"', + ); + return const []; + } + + final nextBounds = bounds.copy(); + switch (operation) { + case _Operation.lowerMax: + if (_hasLowerOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMax: + if (_hasHigherOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMin: + if (_hasHigherOrEqualInclusiveMin(bounds, target)) { + return const []; + } + nextBounds.min = target; + nextBounds.includeMin = true; + case _Operation.set: + throw StateError('Unexpected operation in bounds transform.'); + } + + if (!nextBounds.isValid()) { + stderr.writeln( + 'Skipping sdk: resulting range would be empty (${nextBounds.toConstraintString()})', + ); + return const []; + } + + final nextText = _toYamlConstraint(nextBounds.toConstraintString(), currentText); + if (nextText == currentText) { + return const []; + } + + pubspec.environment.sdk = nextText; + return [_buildUpdateMessage('sdk', currentText, nextText)]; +} + +ArgParser _buildParser() { + final parser = ArgParser() + ..addFlag('help', abbr: 'h', negatable: false, help: 'Print this usage information.') + ..addFlag( + 'fail-on-parse-error', + negatable: false, + help: 'If a pubspec.yaml cannot be parsed, fail immediately and set the exit code', + ) + ..addFlag( + 'recursive', + abbr: 'r', + negatable: false, + help: 'Recurse through subdirectories and run on all pubspec.yaml files', + ); + + for (final command in [ + _commandLowerMax, + _commandRaiseMax, + _commandRaiseMaxSdk, + _commandRaiseMin, + _commandRaiseMinSdk, + _commandSet, + _commandSetSdk, + ]) { + parser.addCommand(command); + } + + return parser; +} + +void _printUsage(ArgParser parser) { + stdout.writeln(_usageHeader); + stdout.writeln(''); + stdout.writeln('Global options:'); + stdout.writeln(parser.usage); + stdout.writeln('Available commands:'); + stdout.writeln('(dependencies)'); + stdout.writeln(' set Set the version of a dependency'); + stdout.writeln(' lower-max Lower the maximum allowed version (exclusive) of a dependency.'); + stdout.writeln(' raise-max Raise the maximum allowed version (exclusive) of a dependency.'); + stdout.writeln(' raise-min Raise the minimum allowed version (inclusive) of a dependency.'); + stdout.writeln('(sdk)'); + stdout.writeln(' set-sdk Set the SDK version constraint in environment.sdk.'); + stdout.writeln(' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); + stdout.writeln(' raise-min-sdk Raise the minimum allowed SDK version (inclusive) in environment.sdk.'); +} + +bool _isSdkCommand(String commandName) { + return commandName == _commandSetSdk || + commandName == _commandRaiseMinSdk || + commandName == _commandRaiseMaxSdk; +} + +List _findPubspecFiles({required bool recursive}) { + final root = Directory.current; + if (!recursive) { + final file = File('${root.path}${Platform.pathSeparator}pubspec.yaml'); + return file.existsSync() ? [file.absolute.path] : const []; + } + + final paths = {}; + for (final entity in root.listSync(recursive: true, followLinks: false)) { + if (entity is! File) { + continue; + } + + final parts = entity.path.split(Platform.pathSeparator); + if (parts.contains('.git')) { + continue; + } + + if (parts.isEmpty || parts.last != 'pubspec.yaml') { + continue; + } + paths.add(entity.absolute.path); + } + + final result = paths.toList()..sort(); + return result; +} + +bool _hasHigherOrEqualInclusiveMin( + _ConstraintBounds bounds, + semver.Version target, +) { + final min = bounds.min; + if (min == null) { + return false; + } + final cmp = min.compareTo(target); + if (cmp > 0) { + return true; + } + if (cmp < 0) { + return false; + } + return bounds.includeMin; +} + +bool _hasHigherOrEqualExclusiveMax( + _ConstraintBounds bounds, + semver.Version target, +) { + final max = bounds.max; + if (max == null) { + return false; + } + final cmp = max.compareTo(target); + if (cmp > 0) { + return true; + } + if (cmp < 0) { + return false; + } + return !bounds.includeMax; +} + +bool _hasLowerOrEqualExclusiveMax( + _ConstraintBounds bounds, + semver.Version target, +) { + final max = bounds.max; + if (max == null) { + return false; + } + final cmp = max.compareTo(target); + if (cmp < 0) { + return true; + } + if (cmp > 0) { + return false; + } + return !bounds.includeMax; +} + +enum _Operation { + lowerMax, + raiseMax, + raiseMin, + set; + + static _Operation? fromCommand(String command) { + switch (command) { + case _commandLowerMax: + return _Operation.lowerMax; + case _commandRaiseMax: + case _commandRaiseMaxSdk: + return _Operation.raiseMax; + case _commandRaiseMin: + case _commandRaiseMinSdk: + return _Operation.raiseMin; + case _commandSet: + case _commandSetSdk: + return _Operation.set; + } + return null; + } +} + +class _ConstraintBounds { + _ConstraintBounds({ + this.min, + this.includeMin = true, + this.max, + this.includeMax = false, + }); + + semver.Version? min; + bool includeMin; + semver.Version? max; + bool includeMax; + + static _ConstraintBounds? fromConstraint(semver.VersionConstraint constraint) { + if (constraint == semver.VersionConstraint.any) { + return _ConstraintBounds(min: null, max: null); + } + + if (constraint is semver.Version) { + return _ConstraintBounds( + min: constraint, + includeMin: true, + max: constraint, + includeMax: true, + ); + } + + if (constraint is semver.VersionRange) { + return _ConstraintBounds( + min: constraint.min, + includeMin: constraint.includeMin, + max: constraint.max, + includeMax: constraint.includeMax, + ); + } + + return null; + } + + _ConstraintBounds copy() => _ConstraintBounds( + min: min, + includeMin: includeMin, + max: max, + includeMax: includeMax, + ); + + bool isValid() { + if (min == null || max == null) { + return true; + } + final cmp = min!.compareTo(max!); + if (cmp < 0) { + return true; + } + if (cmp > 0) { + return false; + } + return includeMin && includeMax; + } + + String toConstraintString() { + if (min == null && max == null) { + return 'any'; + } + + if (min != null && max != null && min == max && includeMin && includeMax) { + return min.toString(); + } + + final pieces = []; + if (min != null) { + pieces.add('${includeMin ? '>=' : '>'}$min'); + } + if (max != null) { + final displayMax = includeMax ? max! : _normalizeExclusiveMax(max!); + pieces.add('${includeMax ? '<=' : '<'}$displayMax'); + } + return pieces.join(' '); + } +} + +semver.Version _normalizeExclusiveMax(semver.Version version) { + if (!version.isPreRelease) { + return version; + } + + final pre = version.preRelease; + if (pre.length == 1 && pre.first == 0) { + return semver.Version(version.major, version.minor, version.patch); + } + + return version; +} + +String _stripMatchingQuotes(String text) { + if (text.length < 2) { + return text; + } + + final first = text[0]; + final last = text[text.length - 1]; + if ((first == '\'' || first == '"') && first == last) { + return text.substring(1, text.length - 1).trim(); + } + return text; +} + +String _toYamlConstraint(String value, String existingValue) { + final trimmed = value.trim(); + final needsQuoting = RegExp(r'\s').hasMatch(trimmed); + if (!needsQuoting) { + return trimmed; + } + + final existingTrimmed = existingValue.trim(); + final useDoubleQuote = + existingTrimmed.length >= 2 && + existingTrimmed.startsWith('"') && + existingTrimmed.endsWith('"'); + + if (useDoubleQuote) { + final escaped = trimmed + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"'); + return '"$escaped"'; + } + + final escaped = trimmed.replaceAll("'", "''"); + return "'$escaped'"; +} + +String _buildUpdateMessage(String name, String before, String after) { + final beforeDisplay = _formatConstraintForMessage(before); + final afterDisplay = _formatConstraintForMessage(after); + return '$name $beforeDisplay updated to $afterDisplay'; +} + +String _formatConstraintForMessage(String value) { + final normalized = _stripMatchingQuotes(value.trim()); + if (_looksLikeRangeConstraint(normalized)) { + final escaped = normalized.replaceAll("'", "''"); + return "'$escaped'"; + } + return normalized; +} + +bool _looksLikeRangeConstraint(String value) { + if (value.contains(' ') || value.contains('||')) { + return true; + } + return value.startsWith('>=') || + value.startsWith('<=') || + value.startsWith('>') || + value.startsWith('<'); +} diff --git a/pubspec.yaml b/pubspec.yaml index 1802e82..bf6bd9c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,18 +4,19 @@ description: An easy to use cross-platform regex replace command line util. repository: https://github.com/robrbecker/replace environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: args: ^2.0.0 cli_script: ^0.2.3 file: ^6.0.0 glob: ^2.0.0 + pubspec_manager: ^4.0.1 + +dev_dependencies: + path: ^1.9.0 + test: ^1.25.0 executables: replace: - -cider: - link_template: - tag: https://github.com/robrbecker/replace/releases/tag/%tag% - diff: https://github.com/robrbecker/replace/compare/%from%...%to% \ No newline at end of file + pubmod: diff --git a/test/fixtures/pubmod/basic/pubspec.yaml b/test/fixtures/pubmod/basic/pubspec.yaml new file mode 100644 index 0000000..3231e53 --- /dev/null +++ b/test/fixtures/pubmod/basic/pubspec.yaml @@ -0,0 +1,10 @@ +name: basic_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 +dev_dependencies: + path: + hosted: https://pub.dev + version: ^1.9.0 diff --git a/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml new file mode 100644 index 0000000..9e2d38d --- /dev/null +++ b/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml @@ -0,0 +1,5 @@ +name: malformed_fixture +version: 0.0.1 +dependencies: + foo: ^1.0.0 + foo: ^2.0.0 diff --git a/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml new file mode 100644 index 0000000..5e50854 --- /dev/null +++ b/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml @@ -0,0 +1,6 @@ +name: recursive_child_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: '>=1.8.0 <2.0.0' diff --git a/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml new file mode 100644 index 0000000..f75e13c --- /dev/null +++ b/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml @@ -0,0 +1,6 @@ +name: recursive_double_quoted_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ">=1.7.0 <2.0.0" diff --git a/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml new file mode 100644 index 0000000..a6f8ef5 --- /dev/null +++ b/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml @@ -0,0 +1,8 @@ +name: recursive_hosted_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: + hosted: https://pub.dev + version: ^1.8.0 diff --git a/test/fixtures/pubmod/recursive/pubspec.yaml b/test/fixtures/pubmod/recursive/pubspec.yaml new file mode 100644 index 0000000..5a39e4a --- /dev/null +++ b/test/fixtures/pubmod/recursive/pubspec.yaml @@ -0,0 +1,6 @@ +name: recursive_root_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_basic/pubspec.yaml b/test/fixtures/pubmod/sdk_basic/pubspec.yaml new file mode 100644 index 0000000..cc189c0 --- /dev/null +++ b/test/fixtures/pubmod/sdk_basic/pubspec.yaml @@ -0,0 +1,6 @@ +name: sdk_basic_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml new file mode 100644 index 0000000..882c441 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml @@ -0,0 +1,5 @@ +name: sdk_recursive_bad_fixture +version: 0.0.1 +dependencies: + foo: ^1.0.0 + foo: ^2.0.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml new file mode 100644 index 0000000..9d301a6 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml @@ -0,0 +1,6 @@ +name: sdk_recursive_child_fixture +version: 0.0.1 +environment: + sdk: '>=3.1.0 <4.0.0' +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml new file mode 100644 index 0000000..fe92ee6 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml @@ -0,0 +1,6 @@ +name: sdk_recursive_double_quoted_fixture +version: 0.0.1 +environment: + sdk: ">=3.2.0 <4.0.0" +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml new file mode 100644 index 0000000..409ea16 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml @@ -0,0 +1,4 @@ +name: sdk_recursive_no_env_fixture +version: 0.0.1 +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/pubspec.yaml new file mode 100644 index 0000000..ad29209 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/pubspec.yaml @@ -0,0 +1,6 @@ +name: sdk_recursive_root_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 diff --git a/test/pm_test.dart b/test/pm_test.dart new file mode 100644 index 0000000..b128abd --- /dev/null +++ b/test/pm_test.dart @@ -0,0 +1,278 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + late Directory scratchRoot; + + setUp(() { + scratchRoot = Directory.systemTemp.createTempSync('pubmod_test_'); + }); + + tearDown(() { + if (scratchRoot.existsSync()) { + scratchRoot.deleteSync(recursive: true); + } + }); + + group('pubmod CLI', () { + test('set-sdk updates environment sdk constraint in current pubspec', () async { + final workDir = _copyFixture('sdk_basic', scratchRoot); + + final result = await _runPubmod( + ['set-sdk', '>=3.3.0 <4.0.0'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect( + result.stdout.toString(), + contains("sdk '>=3.0.0 <4.0.0' updated to '>=3.3.0 <4.0.0'"), + ); + + final content = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_hasSdkConstraint(content, '>=3.3.0 <4.0.0'), isTrue); + }); + + test('raise-min-sdk updates minimum sdk recursively', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min-sdk', '3.4.0', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + final noEnv = + File(p.join(workDir.path, 'packages', 'no_env', 'pubspec.yaml')).readAsStringSync(); + + expect(_hasSdkConstraint(root, '>=3.4.0 <4.0.0'), isTrue); + expect(_hasSdkConstraint(child, '>=3.4.0 <4.0.0'), isTrue); + expect(_hasSdkConstraint(doubleQuoted, '>=3.4.0 <4.0.0'), isTrue); + expect(noEnv, isNot(contains('environment:'))); + }); + + test('raise-max-sdk updates upper sdk bound recursively', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-max-sdk', '5.0.0', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + + expect(_hasSdkConstraint(root, '>=3.0.0 <5.0.0'), isTrue); + expect(_hasSdkConstraint(child, '>=3.1.0 <5.0.0'), isTrue); + expect(_hasSdkConstraint(doubleQuoted, '>=3.2.0 <5.0.0'), isTrue); + }); + + test('sdk commands respect fail-on-parse-error', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min-sdk', '3.4.0', '--fail-on-parse-error', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 1); + expect(result.stderr.toString(), contains('Unable to parse')); + }); + + test('set updates plain and hosted dependency styles', () async { + final workDir = _copyFixture('basic', scratchRoot); + + final result = await _runPubmod( + ['set', 'path', '1.9.1'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect(result.stdout.toString(), contains("path ^1.9.0 updated to 1.9.1")); + + final content = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(content, contains('path: 1.9.1')); + expect(content, contains('version: 1.9.1')); + }); + + test('raise-min updates recursive plain constraints including quoted ranges', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min', 'path', '1.9.1', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect( + result.stdout.toString(), + contains("path '>=1.8.0 <2.0.0' updated to '>=1.9.1 <2.0.0'"), + ); + expect( + result.stdout.toString(), + contains("path '>=1.7.0 <2.0.0' updated to '>=1.9.1 <2.0.0'"), + ); + + final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + final hosted = + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(root, 'path', '>=1.9.1 <2.0.0'), isTrue); + expect(_hasConstraint(child, 'path', '>=1.9.1 <2.0.0'), isTrue); + expect(_hasConstraint(doubleQuoted, 'path', '>=1.9.1 <2.0.0'), isTrue); + expect(_hasConstraint(hosted, 'version', '>=1.9.1 <2.0.0'), isTrue); + }); + + test('raise-max and lower-max update exclusive upper bound only when needed', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final raiseResult = await _runPubmod( + ['raise-max', 'path', '3.0.0', '-r'], + workingDirectory: workDir.path, + ); + expect(raiseResult.exitCode, 0, reason: raiseResult.stderr.toString()); + + final loweredResult = await _runPubmod( + ['lower-max', 'path', '2.5.0', '-r'], + workingDirectory: workDir.path, + ); + expect(loweredResult.exitCode, 0, reason: loweredResult.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(raiseResult.stdout.toString()); + _expectUpdateOutput(loweredResult.stdout.toString()); + expect( + loweredResult.stdout.toString(), + contains("path '>=1.8.0 <3.0.0' updated to '>=1.8.0 <2.5.0'"), + ); + + final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + final hosted = + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(root, 'path', '>=1.9.0 <2.5.0'), isTrue); + expect(_hasConstraint(child, 'path', '>=1.8.0 <2.5.0'), isTrue); + expect(_hasConstraint(doubleQuoted, 'path', '>=1.7.0 <2.5.0'), isTrue); + expect(_hasConstraint(hosted, 'version', '>=1.8.0 <2.5.0'), isTrue); + }); + + test('fail-on-parse-error returns non-zero on malformed pubspec', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPubmod( + ['set', 'path', '1.9.1', '--fail-on-parse-error', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 1); + expect(result.stderr.toString(), contains('Unable to parse')); + }); + }); +} + +Future _assertPubGetParses(String workingDirectory) async { + final result = await Process.run('dart', ['pub', 'get'], workingDirectory: workingDirectory); + expect( + result.exitCode, + 0, + reason: 'dart pub get failed in $workingDirectory\nstdout: ${result.stdout}\nstderr: ${result.stderr}', + ); +} + +Directory _copyFixture(String fixtureName, Directory parent) { + final source = Directory( + p.join(_repoRoot.path, 'test', 'fixtures', 'pubmod', fixtureName), + ); + if (!source.existsSync()) { + throw StateError('Fixture not found: ${source.path}'); + } + + final target = Directory(p.join(parent.path, fixtureName))..createSync(recursive: true); + _copyDirectory(source, target); + return target; +} + +void _copyDirectory(Directory source, Directory target) { + for (final entity in source.listSync(recursive: true, followLinks: false)) { + final relative = p.relative(entity.path, from: source.path); + final destinationPath = p.join(target.path, relative); + + if (entity is Directory) { + Directory(destinationPath).createSync(recursive: true); + } else if (entity is File) { + File(destinationPath) + ..createSync(recursive: true) + ..writeAsBytesSync(entity.readAsBytesSync()); + } + } +} + +Future _runPubmod( + List args, { + required String workingDirectory, +}) { + final packageConfig = p.join(_repoRoot.path, '.dart_tool', 'package_config.json'); + final script = p.join(_repoRoot.path, 'bin', 'pm.dart'); + + return Process.run( + 'dart', + ['--packages=$packageConfig', script, ...args], + workingDirectory: workingDirectory, + ); +} + +Directory get _repoRoot => Directory.current; + +bool _hasConstraint(String content, String key, String constraint) { + return content.contains("$key: $constraint") || + content.contains("$key: '$constraint'") || + content.contains('$key: "$constraint"'); +} + +bool _hasSdkConstraint(String content, String constraint) { + return content.contains('sdk: $constraint') || + content.contains("sdk: '$constraint'") || + content.contains('sdk: "$constraint"'); +} + +void _expectUpdateOutput(String stdout) { + expect( + stdout, + matches(RegExp(r'^[^\s]+ .+ updated to .+$', multiLine: true)), + reason: 'Expected update output lines in format: updated to ', + ); +} From c3ff1a2ed1fff47f68fded7ed06e0173d7f1d75a Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 14:06:01 -0600 Subject: [PATCH 2/2] Add --[no-]tighten --- README.md | 41 +- bin/pm.dart | 1233 ++++++++++++++++++++++++--------------------- test/pm_test.dart | 215 +++++++- 3 files changed, 870 insertions(+), 619 deletions(-) diff --git a/README.md b/README.md index eb8bd24..7f117f4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # replace + Easy to use cross-platform regex replace command line util. Can't remember the arguments to the `find` command? or how `xargs` works? Maybe `sed` is a little different on your Mac than in Linux? @@ -10,10 +11,11 @@ It's meant to be used in a directory under source control so you can see what files have been changed using a diff. It ignores dotfiles (especially the .git directory) except these - - .gitignore - - .pubignore - - .travis.yml - - .travis.yaml + +- .gitignore +- .pubignore +- .travis.yml +- .travis.yaml Globs don't seem to go into dotfile directories by default so if you want to do that, just include another glob in the command for those (Ex: `replace fish zebra .github/**.md **/*.md`) @@ -21,21 +23,25 @@ include another glob in the command for those (Ex: `replace fish zebra .github/* If you need to replace in dotfiles other than these, you can submit a PR to this repo to allow it or work around it by renaming the file, replacing, and renaming it back. -### Installing +## Installing + `pub global activate replace` This installs both command line tools from this package: + - `replace` for regex find/replace in files - `pm` for editing dependency constraints in pubspec.yaml files or more advanced install + - clone the project - `pub get` - `dart compile exe bin/replace.dart -o replace` - `dart compile exe bin/pm.dart -o pm` - Place the `replace` and `pm` executable in your path -### How to use replace +## How to use replace + `replace ...` This means you can pass as many globs, directory names, or filenames @@ -50,7 +56,8 @@ replacement, you can use quotes and `noglob`. Example: `noglob replace "key & peele" "ren || stimpy" **/*.md` Regexes and globs are Dart style -Glob Syntax: https://pub.dev/packages/glob#syntax + +Glob Syntax: [https://pub.dev/packages/glob#syntax](https://pub.dev/packages/glob#syntax) The replacement may contain references to the capture groups in regexp using a backslash followed by the group number. Backslashes not followed by a number return the character immediately following them. @@ -77,11 +84,14 @@ Match a word at the end of a line `pm [global options] ` Global options: + - `-h, --help` Print usage information - `-r, --recursive` Recurse through subdirectories and process all pubspec.yaml files - `--fail-on-parse-error` Exit with non-zero if any pubspec.yaml cannot be parsed +- `--[no-]tighten` Tighten is enabled by default. Use `--no-tighten` to keep explicit range output. Commands: + - `set` Set dependency constraint exactly as provided - `set-sdk` Set `environment.sdk` constraint exactly as provided - `raise-min` Raise the minimum bound (inclusive) of a version range @@ -91,12 +101,17 @@ Commands: - `lower-max` Lower the maximum bound (exclusive) of a version range Notes: + - Updates both `dependencies` and `dev_dependencies` - SDK commands update `environment.sdk` only - `set` accepts any valid Dart version constraint, for example `^1.9.0` or `'>=1.9.0 <2.0.0'` - `set-sdk` accepts any valid Dart SDK constraint, for example `'>=3.3.0 <4.0.0'` - `raise-min`, `raise-max`, and `lower-max` expect a specific semantic version, for example `1.9.1` - `raise-min-sdk` and `raise-max-sdk` expect a specific semantic version, for example `3.4.0` +- Tightening is enabled by default and only rewrites when the updated range is exactly equivalent to a caret constraint + - `'>=3.0.0 <4.0.0'` becomes `^3.0.0` + - `'>=0.18.2 <0.19.0'` becomes `^0.18.2` + - `'>=1.2.3 <4.0.0'` stays as a range (not equivalent to a caret constraint) ### Examples @@ -132,6 +147,12 @@ Raise minimum version recursively across a monorepo: pm raise-min path 1.9.1 -r ``` +Raise minimum version recursively and opt out of tightening: + +```sh +pm raise-min path 1.9.1 -r --no-tighten +``` + Range to single version when the new minimum meets or exceeds the old maximum: ```sh @@ -190,6 +211,12 @@ Raise SDK minimum recursively across a monorepo: pm raise-min-sdk 3.4.0 -r ``` +Raise SDK minimum recursively and opt out of tightening: + +```sh +pm raise-min-sdk 3.4.0 -r --no-tighten +``` + Raise SDK maximum recursively: ```sh diff --git a/bin/pm.dart b/bin/pm.dart index 059ad54..55ee57a 100644 --- a/bin/pm.dart +++ b/bin/pm.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:pub_semver/pub_semver.dart' as semver; import 'package:pubspec_manager/pubspec_manager.dart' - hide Version, VersionConstraint; + hide Version, VersionConstraint; const _commandLowerMax = 'lower-max'; const _commandRaiseMax = 'raise-max'; @@ -16,649 +16,714 @@ const _commandSetSdk = 'set-sdk'; const _usageHeader = 'Usage: dart run pubmod [arguments]'; Future main(List args) async { - final exitCode = await _run(args); - if (exitCode != 0) { - // ignore: avoid_print - stderr.writeln('pubmod failed with exit code $exitCode.'); - } - exit(exitCode); + final exitCode = await _run(args); + if (exitCode != 0) { + // ignore: avoid_print + stderr.writeln('pubmod failed with exit code $exitCode.'); + } + exit(exitCode); } Future _run(List args) async { - final parser = _buildParser(); - - late ArgResults results; - try { - results = parser.parse(args); - } on FormatException catch (e) { - stderr.writeln(e.message); - stderr.writeln(''); - _printUsage(parser); - return 64; - } - - final showHelp = results['help'] as bool; - if (showHelp) { - _printUsage(parser); - return 0; - } - - final command = results.command; - if (command == null) { - _printUsage(parser); - return 64; - } - - final commandName = command.name; - if (commandName == null) { - _printUsage(parser); - return 64; - } - final targetsSdk = _isSdkCommand(commandName); - final rest = command.rest; - late String dependencyName; - late String versionText; - if (targetsSdk) { - if (rest.length != 1) { - stderr.writeln( - 'Command "$commandName" requires exactly 1 argument: ', - ); - stderr.writeln(''); - _printUsage(parser); - return 64; - } - dependencyName = 'sdk'; - versionText = rest[0].trim(); - if (versionText.isEmpty) { - stderr.writeln('Version must not be empty.'); - return 64; - } - } else { - if (rest.length != 2) { - stderr.writeln( - 'Command "$commandName" requires exactly 2 arguments: ', - ); - stderr.writeln(''); - _printUsage(parser); - return 64; - } - - dependencyName = rest[0].trim(); - versionText = rest[1].trim(); - if (dependencyName.isEmpty || versionText.isEmpty) { - stderr.writeln('Dependency and version must not be empty.'); - return 64; - } - } - - final failOnParseError = results['fail-on-parse-error'] as bool; - final recursive = results['recursive'] as bool; - - final op = _Operation.fromCommand(commandName); - if (op == null) { - stderr.writeln('Unknown command: $commandName'); - return 64; - } - - if (op == _Operation.set) { - try { - semver.VersionConstraint.parse(versionText); - } on FormatException catch (e) { - stderr.writeln('Invalid version constraint "$versionText": ${e.message}'); - return 64; - } - } else { - try { - semver.Version.parse(versionText); - } on FormatException catch (e) { - stderr.writeln('Invalid version "$versionText": ${e.message}'); - return 64; - } - } - - final pubspecFiles = _findPubspecFiles(recursive: recursive); - if (pubspecFiles.isEmpty) { - return 0; - } - - var hadParseError = false; - for (final path in pubspecFiles) { - PubSpec pubspec; - try { - pubspec = PubSpec.loadFromPath(path); - } catch (e) { - hadParseError = true; - stderr.writeln('Unable to parse $path: $e'); - if (failOnParseError) { - return 1; - } - continue; - } - - final before = File(path).readAsStringSync(); - - final updateMessages = []; - if (targetsSdk) { - updateMessages.addAll(_applyToSdk(pubspec, op, versionText)); - } else { - updateMessages.addAll( - _applyToDependencies(pubspec.dependencies, dependencyName, op, versionText), - ); - updateMessages.addAll( - _applyToDependencies( - pubspec.devDependencies, - dependencyName, - op, - versionText, - ), - ); - } - - final changed = updateMessages.isNotEmpty; - - if (!changed) { - continue; - } - - final after = pubspec.toString(); - if (before == after) { - continue; - } - - pubspec.saveTo(path); - for (final message in updateMessages) { - stdout.writeln(message); - } - } - - if (failOnParseError && hadParseError) { - return 1; - } - - return 0; + final parser = _buildParser(); + + late ArgResults results; + try { + results = parser.parse(args); + } on FormatException catch (e) { + stderr.writeln(e.message); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + final showHelp = results['help'] as bool; + if (showHelp) { + _printUsage(parser); + return 0; + } + + final command = results.command; + if (command == null) { + _printUsage(parser); + return 64; + } + + final commandName = command.name; + if (commandName == null) { + _printUsage(parser); + return 64; + } + final targetsSdk = _isSdkCommand(commandName); + final rest = command.rest; + late String dependencyName; + late String versionText; + if (targetsSdk) { + if (rest.length != 1) { + stderr.writeln( + 'Command "$commandName" requires exactly 1 argument: ', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + dependencyName = 'sdk'; + versionText = rest[0].trim(); + if (versionText.isEmpty) { + stderr.writeln('Version must not be empty.'); + return 64; + } + } else { + if (rest.length != 2) { + stderr.writeln( + 'Command "$commandName" requires exactly 2 arguments: ', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + dependencyName = rest[0].trim(); + versionText = rest[1].trim(); + if (dependencyName.isEmpty || versionText.isEmpty) { + stderr.writeln('Dependency and version must not be empty.'); + return 64; + } + } + + final failOnParseError = results['fail-on-parse-error'] as bool; + final recursive = results['recursive'] as bool; + final tighten = results['tighten'] as bool; + + final op = _Operation.fromCommand(commandName); + if (op == null) { + stderr.writeln('Unknown command: $commandName'); + return 64; + } + + if (op == _Operation.set) { + try { + semver.VersionConstraint.parse(versionText); + } on FormatException catch (e) { + stderr.writeln('Invalid version constraint "$versionText": ${e.message}'); + return 64; + } + } else { + try { + semver.Version.parse(versionText); + } on FormatException catch (e) { + stderr.writeln('Invalid version "$versionText": ${e.message}'); + return 64; + } + } + + final pubspecFiles = _findPubspecFiles(recursive: recursive); + if (pubspecFiles.isEmpty) { + return 0; + } + + var hadParseError = false; + for (final path in pubspecFiles) { + PubSpec pubspec; + try { + pubspec = PubSpec.loadFromPath(path); + } catch (e) { + hadParseError = true; + stderr.writeln('Unable to parse $path: $e'); + if (failOnParseError) { + return 1; + } + continue; + } + + final before = File(path).readAsStringSync(); + + final updateMessages = []; + if (targetsSdk) { + updateMessages + .addAll(_applyToSdk(pubspec, op, versionText, tighten: tighten)); + } else { + updateMessages.addAll( + _applyToDependencies( + pubspec.dependencies, + dependencyName, + op, + versionText, + tighten: tighten, + ), + ); + updateMessages.addAll( + _applyToDependencies( + pubspec.devDependencies, + dependencyName, + op, + versionText, + tighten: tighten, + ), + ); + } + + final changed = updateMessages.isNotEmpty; + + if (!changed) { + continue; + } + + final after = pubspec.toString(); + if (before == after) { + continue; + } + + pubspec.saveTo(path); + for (final message in updateMessages) { + stdout.writeln(message); + } + } + + if (failOnParseError && hadParseError) { + return 1; + } + + return 0; } List _applyToDependencies( - Dependencies dependencies, - String dependencyName, - _Operation operation, - String inputVersion, -) { - final dependency = dependencies[dependencyName]; - if (dependency == null) { - return const []; - } - - if (dependency is! DependencyVersioned) { - return const []; - } - - final versionedDependency = dependency as DependencyVersioned; - final currentText = versionedDependency.versionConstraint.trim(); - - if (operation == _Operation.set) { - final nextText = _toYamlConstraint(inputVersion.trim(), currentText); - if (nextText == currentText) { - return const []; - } - versionedDependency.versionConstraint = nextText; - return [_buildUpdateMessage(dependency.name, currentText, nextText)]; - } - - final target = semver.Version.parse(inputVersion); - final normalizedCurrent = _stripMatchingQuotes(currentText); - - semver.VersionConstraint currentConstraint; - try { - currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); - } on FormatException catch (e) { - stderr.writeln( - 'Skipping ${dependency.name}: invalid version constraint "$currentText" (${e.message})', - ); - return const []; - } - final bounds = _ConstraintBounds.fromConstraint(currentConstraint); - if (bounds == null) { - stderr.writeln( - 'Skipping ${dependency.name}: unsupported version constraint "$currentText"', - ); - return const []; - } - - final nextBounds = bounds.copy(); - switch (operation) { - case _Operation.lowerMax: - if (_hasLowerOrEqualExclusiveMax(bounds, target)) { - return const []; - } - nextBounds.max = target; - nextBounds.includeMax = false; - case _Operation.raiseMax: - if (_hasHigherOrEqualExclusiveMax(bounds, target)) { - return const []; - } - nextBounds.max = target; - nextBounds.includeMax = false; - case _Operation.raiseMin: - if (_hasHigherOrEqualInclusiveMin(bounds, target)) { - return const []; - } - nextBounds.min = target; - nextBounds.includeMin = true; - case _Operation.set: - throw StateError('Unexpected operation in bounds transform.'); - } - - if (!nextBounds.isValid()) { - stderr.writeln( - 'Skipping ${dependency.name}: resulting range would be empty (${nextBounds.toConstraintString()})', - ); - return const []; - } - - final nextText = _toYamlConstraint( - nextBounds.toConstraintString(), - currentText, - ); - if (nextText == currentText) { - return const []; - } - - versionedDependency.versionConstraint = nextText; - return [_buildUpdateMessage(dependency.name, currentText, nextText)]; + Dependencies dependencies, + String dependencyName, + _Operation operation, + String inputVersion, { + required bool tighten, +}) { + final dependency = dependencies[dependencyName]; + if (dependency == null) { + return const []; + } + + if (dependency is! DependencyVersioned) { + return const []; + } + + final versionedDependency = dependency as DependencyVersioned; + final currentText = versionedDependency.versionConstraint.trim(); + + if (operation == _Operation.set) { + final nextText = _toYamlConstraint(inputVersion.trim(), currentText); + if (nextText == currentText) { + return const []; + } + versionedDependency.versionConstraint = nextText; + return [_buildUpdateMessage(dependency.name, currentText, nextText)]; + } + + final target = semver.Version.parse(inputVersion); + final normalizedCurrent = _stripMatchingQuotes(currentText); + + semver.VersionConstraint currentConstraint; + try { + currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); + } on FormatException catch (e) { + stderr.writeln( + 'Skipping ${dependency.name}: invalid version constraint "$currentText" (${e.message})', + ); + return const []; + } + final bounds = _ConstraintBounds.fromConstraint(currentConstraint); + if (bounds == null) { + stderr.writeln( + 'Skipping ${dependency.name}: unsupported version constraint "$currentText"', + ); + return const []; + } + + final nextBounds = bounds.copy(); + switch (operation) { + case _Operation.lowerMax: + if (_hasLowerOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMax: + if (_hasHigherOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMin: + if (_hasHigherOrEqualInclusiveMin(bounds, target)) { + return const []; + } + nextBounds.min = target; + nextBounds.includeMin = true; + case _Operation.set: + throw StateError('Unexpected operation in bounds transform.'); + } + + if (!nextBounds.isValid()) { + stderr.writeln( + 'Skipping ${dependency.name}: resulting range would be empty (${nextBounds.toConstraintString(tighten: false)})', + ); + return const []; + } + + final nextText = _toYamlConstraint( + nextBounds.toConstraintString(tighten: tighten), + currentText, + ); + if (nextText == currentText) { + return const []; + } + + versionedDependency.versionConstraint = nextText; + return [_buildUpdateMessage(dependency.name, currentText, nextText)]; } List _applyToSdk( - PubSpec pubspec, - _Operation operation, - String inputVersion, -) { - final currentText = pubspec.environment.sdk.trim(); - if (currentText.isEmpty) { - return const []; - } - - if (operation == _Operation.set) { - final nextText = _toYamlConstraint(inputVersion.trim(), currentText); - if (nextText == currentText) { - return const []; - } - pubspec.environment.sdk = nextText; - return [_buildUpdateMessage('sdk', currentText, nextText)]; - } - - final target = semver.Version.parse(inputVersion); - final normalizedCurrent = _stripMatchingQuotes(currentText); - - semver.VersionConstraint currentConstraint; - try { - currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); - } on FormatException catch (e) { - stderr.writeln( - 'Skipping sdk: invalid version constraint "$currentText" (${e.message})', - ); - return const []; - } - - final bounds = _ConstraintBounds.fromConstraint(currentConstraint); - if (bounds == null) { - stderr.writeln( - 'Skipping sdk: unsupported version constraint "$currentText"', - ); - return const []; - } - - final nextBounds = bounds.copy(); - switch (operation) { - case _Operation.lowerMax: - if (_hasLowerOrEqualExclusiveMax(bounds, target)) { - return const []; - } - nextBounds.max = target; - nextBounds.includeMax = false; - case _Operation.raiseMax: - if (_hasHigherOrEqualExclusiveMax(bounds, target)) { - return const []; - } - nextBounds.max = target; - nextBounds.includeMax = false; - case _Operation.raiseMin: - if (_hasHigherOrEqualInclusiveMin(bounds, target)) { - return const []; - } - nextBounds.min = target; - nextBounds.includeMin = true; - case _Operation.set: - throw StateError('Unexpected operation in bounds transform.'); - } - - if (!nextBounds.isValid()) { - stderr.writeln( - 'Skipping sdk: resulting range would be empty (${nextBounds.toConstraintString()})', - ); - return const []; - } - - final nextText = _toYamlConstraint(nextBounds.toConstraintString(), currentText); - if (nextText == currentText) { - return const []; - } - - pubspec.environment.sdk = nextText; - return [_buildUpdateMessage('sdk', currentText, nextText)]; + PubSpec pubspec, + _Operation operation, + String inputVersion, { + required bool tighten, +}) { + final currentText = pubspec.environment.sdk.trim(); + if (currentText.isEmpty) { + return const []; + } + + if (operation == _Operation.set) { + final nextText = _toYamlConstraint(inputVersion.trim(), currentText); + if (nextText == currentText) { + return const []; + } + pubspec.environment.sdk = nextText; + return [_buildUpdateMessage('sdk', currentText, nextText)]; + } + + final target = semver.Version.parse(inputVersion); + final normalizedCurrent = _stripMatchingQuotes(currentText); + + semver.VersionConstraint currentConstraint; + try { + currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); + } on FormatException catch (e) { + stderr.writeln( + 'Skipping sdk: invalid version constraint "$currentText" (${e.message})', + ); + return const []; + } + + final bounds = _ConstraintBounds.fromConstraint(currentConstraint); + if (bounds == null) { + stderr.writeln( + 'Skipping sdk: unsupported version constraint "$currentText"', + ); + return const []; + } + + final nextBounds = bounds.copy(); + switch (operation) { + case _Operation.lowerMax: + if (_hasLowerOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMax: + if (_hasHigherOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMin: + if (_hasHigherOrEqualInclusiveMin(bounds, target)) { + return const []; + } + nextBounds.min = target; + nextBounds.includeMin = true; + case _Operation.set: + throw StateError('Unexpected operation in bounds transform.'); + } + + if (!nextBounds.isValid()) { + stderr.writeln( + 'Skipping sdk: resulting range would be empty (${nextBounds.toConstraintString(tighten: false)})', + ); + return const []; + } + + final nextText = _toYamlConstraint( + nextBounds.toConstraintString(tighten: tighten), + currentText, + ); + if (nextText == currentText) { + return const []; + } + + pubspec.environment.sdk = nextText; + return [_buildUpdateMessage('sdk', currentText, nextText)]; } ArgParser _buildParser() { - final parser = ArgParser() - ..addFlag('help', abbr: 'h', negatable: false, help: 'Print this usage information.') - ..addFlag( - 'fail-on-parse-error', - negatable: false, - help: 'If a pubspec.yaml cannot be parsed, fail immediately and set the exit code', - ) - ..addFlag( - 'recursive', - abbr: 'r', - negatable: false, - help: 'Recurse through subdirectories and run on all pubspec.yaml files', - ); - - for (final command in [ - _commandLowerMax, - _commandRaiseMax, - _commandRaiseMaxSdk, - _commandRaiseMin, - _commandRaiseMinSdk, - _commandSet, - _commandSetSdk, - ]) { - parser.addCommand(command); - } - - return parser; + final parser = ArgParser() + ..addFlag('help', + abbr: 'h', negatable: false, help: 'Print this usage information.') + ..addFlag( + 'fail-on-parse-error', + negatable: false, + help: + 'If a pubspec.yaml cannot be parsed, fail immediately and set the exit code', + ) + ..addFlag( + 'recursive', + abbr: 'r', + negatable: false, + help: 'Recurse through subdirectories and run on all pubspec.yaml files', + ) + ..addFlag( + 'tighten', + defaultsTo: true, + help: + 'When updating range constraints, rewrite equivalent ranges as caret constraints (for example >=3.0.0 <4.0.0 to ^3.0.0). Disable with --no-tighten.', + ); + + for (final command in [ + _commandLowerMax, + _commandRaiseMax, + _commandRaiseMaxSdk, + _commandRaiseMin, + _commandRaiseMinSdk, + _commandSet, + _commandSetSdk, + ]) { + parser.addCommand(command); + } + + return parser; } void _printUsage(ArgParser parser) { - stdout.writeln(_usageHeader); - stdout.writeln(''); - stdout.writeln('Global options:'); - stdout.writeln(parser.usage); - stdout.writeln('Available commands:'); + stdout.writeln(_usageHeader); + stdout.writeln(''); + stdout.writeln('Global options:'); + stdout.writeln(parser.usage); + stdout.writeln('Available commands:'); stdout.writeln('(dependencies)'); stdout.writeln(' set Set the version of a dependency'); - stdout.writeln(' lower-max Lower the maximum allowed version (exclusive) of a dependency.'); - stdout.writeln(' raise-max Raise the maximum allowed version (exclusive) of a dependency.'); - stdout.writeln(' raise-min Raise the minimum allowed version (inclusive) of a dependency.'); + stdout.writeln( + ' lower-max Lower the maximum allowed version (exclusive) of a dependency.'); + stdout.writeln( + ' raise-max Raise the maximum allowed version (exclusive) of a dependency.'); + stdout.writeln( + ' raise-min Raise the minimum allowed version (inclusive) of a dependency.'); stdout.writeln('(sdk)'); - stdout.writeln(' set-sdk Set the SDK version constraint in environment.sdk.'); - stdout.writeln(' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); - stdout.writeln(' raise-min-sdk Raise the minimum allowed SDK version (inclusive) in environment.sdk.'); + stdout.writeln( + ' set-sdk Set the SDK version constraint in environment.sdk.'); + stdout.writeln( + ' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); + stdout.writeln( + ' raise-min-sdk Raise the minimum allowed SDK version (inclusive) in environment.sdk.'); } bool _isSdkCommand(String commandName) { - return commandName == _commandSetSdk || - commandName == _commandRaiseMinSdk || - commandName == _commandRaiseMaxSdk; + return commandName == _commandSetSdk || + commandName == _commandRaiseMinSdk || + commandName == _commandRaiseMaxSdk; } List _findPubspecFiles({required bool recursive}) { - final root = Directory.current; - if (!recursive) { - final file = File('${root.path}${Platform.pathSeparator}pubspec.yaml'); - return file.existsSync() ? [file.absolute.path] : const []; - } - - final paths = {}; - for (final entity in root.listSync(recursive: true, followLinks: false)) { - if (entity is! File) { - continue; - } - - final parts = entity.path.split(Platform.pathSeparator); - if (parts.contains('.git')) { - continue; - } - - if (parts.isEmpty || parts.last != 'pubspec.yaml') { - continue; - } - paths.add(entity.absolute.path); - } - - final result = paths.toList()..sort(); - return result; + final root = Directory.current; + if (!recursive) { + final file = File('${root.path}${Platform.pathSeparator}pubspec.yaml'); + return file.existsSync() ? [file.absolute.path] : const []; + } + + final paths = {}; + for (final entity in root.listSync(recursive: true, followLinks: false)) { + if (entity is! File) { + continue; + } + + final parts = entity.path.split(Platform.pathSeparator); + if (parts.contains('.git')) { + continue; + } + + if (parts.isEmpty || parts.last != 'pubspec.yaml') { + continue; + } + paths.add(entity.absolute.path); + } + + final result = paths.toList()..sort(); + return result; } bool _hasHigherOrEqualInclusiveMin( - _ConstraintBounds bounds, - semver.Version target, + _ConstraintBounds bounds, + semver.Version target, ) { - final min = bounds.min; - if (min == null) { - return false; - } - final cmp = min.compareTo(target); - if (cmp > 0) { - return true; - } - if (cmp < 0) { - return false; - } - return bounds.includeMin; + final min = bounds.min; + if (min == null) { + return false; + } + final cmp = min.compareTo(target); + if (cmp > 0) { + return true; + } + if (cmp < 0) { + return false; + } + return bounds.includeMin; } bool _hasHigherOrEqualExclusiveMax( - _ConstraintBounds bounds, - semver.Version target, + _ConstraintBounds bounds, + semver.Version target, ) { - final max = bounds.max; - if (max == null) { - return false; - } - final cmp = max.compareTo(target); - if (cmp > 0) { - return true; - } - if (cmp < 0) { - return false; - } - return !bounds.includeMax; + final max = bounds.max; + if (max == null) { + return false; + } + final cmp = max.compareTo(target); + if (cmp > 0) { + return true; + } + if (cmp < 0) { + return false; + } + return !bounds.includeMax; } bool _hasLowerOrEqualExclusiveMax( - _ConstraintBounds bounds, - semver.Version target, + _ConstraintBounds bounds, + semver.Version target, ) { - final max = bounds.max; - if (max == null) { - return false; - } - final cmp = max.compareTo(target); - if (cmp < 0) { - return true; - } - if (cmp > 0) { - return false; - } - return !bounds.includeMax; + final max = bounds.max; + if (max == null) { + return false; + } + final cmp = max.compareTo(target); + if (cmp < 0) { + return true; + } + if (cmp > 0) { + return false; + } + return !bounds.includeMax; } enum _Operation { - lowerMax, - raiseMax, - raiseMin, - set; - - static _Operation? fromCommand(String command) { - switch (command) { - case _commandLowerMax: - return _Operation.lowerMax; - case _commandRaiseMax: - case _commandRaiseMaxSdk: - return _Operation.raiseMax; - case _commandRaiseMin: - case _commandRaiseMinSdk: - return _Operation.raiseMin; - case _commandSet: - case _commandSetSdk: - return _Operation.set; - } - return null; - } + lowerMax, + raiseMax, + raiseMin, + set; + + static _Operation? fromCommand(String command) { + switch (command) { + case _commandLowerMax: + return _Operation.lowerMax; + case _commandRaiseMax: + case _commandRaiseMaxSdk: + return _Operation.raiseMax; + case _commandRaiseMin: + case _commandRaiseMinSdk: + return _Operation.raiseMin; + case _commandSet: + case _commandSetSdk: + return _Operation.set; + } + return null; + } } class _ConstraintBounds { - _ConstraintBounds({ - this.min, - this.includeMin = true, - this.max, - this.includeMax = false, - }); - - semver.Version? min; - bool includeMin; - semver.Version? max; - bool includeMax; - - static _ConstraintBounds? fromConstraint(semver.VersionConstraint constraint) { - if (constraint == semver.VersionConstraint.any) { - return _ConstraintBounds(min: null, max: null); - } - - if (constraint is semver.Version) { - return _ConstraintBounds( - min: constraint, - includeMin: true, - max: constraint, - includeMax: true, - ); - } - - if (constraint is semver.VersionRange) { - return _ConstraintBounds( - min: constraint.min, - includeMin: constraint.includeMin, - max: constraint.max, - includeMax: constraint.includeMax, - ); - } - - return null; - } - - _ConstraintBounds copy() => _ConstraintBounds( - min: min, - includeMin: includeMin, - max: max, - includeMax: includeMax, - ); - - bool isValid() { - if (min == null || max == null) { - return true; - } - final cmp = min!.compareTo(max!); - if (cmp < 0) { - return true; - } - if (cmp > 0) { - return false; - } - return includeMin && includeMax; - } - - String toConstraintString() { - if (min == null && max == null) { - return 'any'; - } - - if (min != null && max != null && min == max && includeMin && includeMax) { - return min.toString(); - } - - final pieces = []; - if (min != null) { - pieces.add('${includeMin ? '>=' : '>'}$min'); - } - if (max != null) { - final displayMax = includeMax ? max! : _normalizeExclusiveMax(max!); - pieces.add('${includeMax ? '<=' : '<'}$displayMax'); - } - return pieces.join(' '); - } + _ConstraintBounds({ + this.min, + this.includeMin = true, + this.max, + this.includeMax = false, + }); + + semver.Version? min; + bool includeMin; + semver.Version? max; + bool includeMax; + + static _ConstraintBounds? fromConstraint( + semver.VersionConstraint constraint) { + if (constraint == semver.VersionConstraint.any) { + return _ConstraintBounds(min: null, max: null); + } + + if (constraint is semver.Version) { + return _ConstraintBounds( + min: constraint, + includeMin: true, + max: constraint, + includeMax: true, + ); + } + + if (constraint is semver.VersionRange) { + return _ConstraintBounds( + min: constraint.min, + includeMin: constraint.includeMin, + max: constraint.max, + includeMax: constraint.includeMax, + ); + } + + return null; + } + + _ConstraintBounds copy() => _ConstraintBounds( + min: min, + includeMin: includeMin, + max: max, + includeMax: includeMax, + ); + + bool isValid() { + if (min == null || max == null) { + return true; + } + final cmp = min!.compareTo(max!); + if (cmp < 0) { + return true; + } + if (cmp > 0) { + return false; + } + return includeMin && includeMax; + } + + String toConstraintString({required bool tighten}) { + if (tighten) { + final tightened = _toCaretConstraint(); + if (tightened != null) { + return tightened; + } + } + + if (min == null && max == null) { + return 'any'; + } + + if (min != null && max != null && min == max && includeMin && includeMax) { + return min.toString(); + } + + final pieces = []; + if (min != null) { + pieces.add('${includeMin ? '>=' : '>'}$min'); + } + if (max != null) { + final displayMax = includeMax ? max! : _normalizeExclusiveMax(max!); + pieces.add('${includeMax ? '<=' : '<'}$displayMax'); + } + return pieces.join(' '); + } + + String? _toCaretConstraint() { + if (min == null || max == null) { + return null; + } + if (!includeMin || includeMax) { + return null; + } + + final minVersion = min!; + if (minVersion.isPreRelease) { + return null; + } + + final expectedMax = _caretUpperBound(minVersion); + final normalizedMax = _normalizeExclusiveMax(max!); + if (expectedMax != normalizedMax) { + return null; + } + + return '^$minVersion'; + } +} + +semver.Version _caretUpperBound(semver.Version version) { + if (version.major > 0) { + return semver.Version(version.major + 1, 0, 0); + } + if (version.minor > 0) { + return semver.Version(0, version.minor + 1, 0); + } + return semver.Version(0, 0, version.patch + 1); } semver.Version _normalizeExclusiveMax(semver.Version version) { - if (!version.isPreRelease) { - return version; - } + if (!version.isPreRelease) { + return version; + } - final pre = version.preRelease; - if (pre.length == 1 && pre.first == 0) { - return semver.Version(version.major, version.minor, version.patch); - } + final pre = version.preRelease; + if (pre.length == 1 && pre.first == 0) { + return semver.Version(version.major, version.minor, version.patch); + } - return version; + return version; } String _stripMatchingQuotes(String text) { - if (text.length < 2) { - return text; - } - - final first = text[0]; - final last = text[text.length - 1]; - if ((first == '\'' || first == '"') && first == last) { - return text.substring(1, text.length - 1).trim(); - } - return text; + if (text.length < 2) { + return text; + } + + final first = text[0]; + final last = text[text.length - 1]; + if ((first == '\'' || first == '"') && first == last) { + return text.substring(1, text.length - 1).trim(); + } + return text; } String _toYamlConstraint(String value, String existingValue) { - final trimmed = value.trim(); - final needsQuoting = RegExp(r'\s').hasMatch(trimmed); - if (!needsQuoting) { - return trimmed; - } - - final existingTrimmed = existingValue.trim(); - final useDoubleQuote = - existingTrimmed.length >= 2 && - existingTrimmed.startsWith('"') && - existingTrimmed.endsWith('"'); - - if (useDoubleQuote) { - final escaped = trimmed - .replaceAll('\\', '\\\\') - .replaceAll('"', '\\"'); - return '"$escaped"'; - } - - final escaped = trimmed.replaceAll("'", "''"); - return "'$escaped'"; + final trimmed = value.trim(); + final needsQuoting = RegExp(r'\s').hasMatch(trimmed); + if (!needsQuoting) { + return trimmed; + } + + final existingTrimmed = existingValue.trim(); + final useDoubleQuote = existingTrimmed.length >= 2 && + existingTrimmed.startsWith('"') && + existingTrimmed.endsWith('"'); + + if (useDoubleQuote) { + final escaped = trimmed.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + return '"$escaped"'; + } + + final escaped = trimmed.replaceAll("'", "''"); + return "'$escaped'"; } String _buildUpdateMessage(String name, String before, String after) { - final beforeDisplay = _formatConstraintForMessage(before); - final afterDisplay = _formatConstraintForMessage(after); - return '$name $beforeDisplay updated to $afterDisplay'; + final beforeDisplay = _formatConstraintForMessage(before); + final afterDisplay = _formatConstraintForMessage(after); + return '$name $beforeDisplay updated to $afterDisplay'; } String _formatConstraintForMessage(String value) { - final normalized = _stripMatchingQuotes(value.trim()); - if (_looksLikeRangeConstraint(normalized)) { - final escaped = normalized.replaceAll("'", "''"); - return "'$escaped'"; - } - return normalized; + final normalized = _stripMatchingQuotes(value.trim()); + if (_looksLikeRangeConstraint(normalized)) { + final escaped = normalized.replaceAll("'", "''"); + return "'$escaped'"; + } + return normalized; } bool _looksLikeRangeConstraint(String value) { - if (value.contains(' ') || value.contains('||')) { - return true; - } - return value.startsWith('>=') || - value.startsWith('<=') || - value.startsWith('>') || - value.startsWith('<'); + if (value.contains(' ') || value.contains('||')) { + return true; + } + return value.startsWith('>=') || + value.startsWith('<=') || + value.startsWith('>') || + value.startsWith('<'); } diff --git a/test/pm_test.dart b/test/pm_test.dart index b128abd..6fde2b0 100644 --- a/test/pm_test.dart +++ b/test/pm_test.dart @@ -17,7 +17,8 @@ void main() { }); group('pubmod CLI', () { - test('set-sdk updates environment sdk constraint in current pubspec', () async { + test('set-sdk updates environment sdk constraint in current pubspec', + () async { final workDir = _copyFixture('sdk_basic', scratchRoot); final result = await _runPubmod( @@ -33,7 +34,8 @@ void main() { contains("sdk '>=3.0.0 <4.0.0' updated to '>=3.3.0 <4.0.0'"), ); - final content = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); expect(_hasSdkConstraint(content, '>=3.3.0 <4.0.0'), isTrue); }); @@ -41,7 +43,7 @@ void main() { final workDir = _copyFixture('sdk_recursive', scratchRoot); final result = await _runPubmod( - ['raise-min-sdk', '3.4.0', '-r'], + ['raise-min-sdk', '3.4.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -49,14 +51,17 @@ void main() { await _assertPubGetParses(workDir.path); _expectUpdateOutput(result.stdout.toString()); - final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); final doubleQuoted = File( p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), ).readAsStringSync(); final noEnv = - File(p.join(workDir.path, 'packages', 'no_env', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'no_env', 'pubspec.yaml')) + .readAsStringSync(); expect(_hasSdkConstraint(root, '>=3.4.0 <4.0.0'), isTrue); expect(_hasSdkConstraint(child, '>=3.4.0 <4.0.0'), isTrue); @@ -68,7 +73,7 @@ void main() { final workDir = _copyFixture('sdk_recursive', scratchRoot); final result = await _runPubmod( - ['raise-max-sdk', '5.0.0', '-r'], + ['raise-max-sdk', '5.0.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -76,9 +81,11 @@ void main() { await _assertPubGetParses(workDir.path); _expectUpdateOutput(result.stdout.toString()); - final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); final doubleQuoted = File( p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), ).readAsStringSync(); @@ -111,18 +118,22 @@ void main() { expect(result.exitCode, 0, reason: result.stderr.toString()); await _assertPubGetParses(workDir.path); _expectUpdateOutput(result.stdout.toString()); - expect(result.stdout.toString(), contains("path ^1.9.0 updated to 1.9.1")); + expect( + result.stdout.toString(), contains("path ^1.9.0 updated to 1.9.1")); - final content = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); expect(content, contains('path: 1.9.1')); expect(content, contains('version: 1.9.1')); }); - test('raise-min updates recursive plain constraints including quoted ranges', () async { + test( + 'raise-min updates recursive plain constraints including quoted ranges', + () async { final workDir = _copyFixture('recursive', scratchRoot); final result = await _runPubmod( - ['raise-min', 'path', '1.9.1', '-r'], + ['raise-min', 'path', '1.9.1', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -138,14 +149,17 @@ void main() { contains("path '>=1.7.0 <2.0.0' updated to '>=1.9.1 <2.0.0'"), ); - final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); final doubleQuoted = File( p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), ).readAsStringSync(); final hosted = - File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) + .readAsStringSync(); expect(_hasConstraint(root, 'path', '>=1.9.1 <2.0.0'), isTrue); expect(_hasConstraint(child, 'path', '>=1.9.1 <2.0.0'), isTrue); @@ -153,20 +167,23 @@ void main() { expect(_hasConstraint(hosted, 'version', '>=1.9.1 <2.0.0'), isTrue); }); - test('raise-max and lower-max update exclusive upper bound only when needed', () async { + test( + 'raise-max and lower-max update exclusive upper bound only when needed', + () async { final workDir = _copyFixture('recursive', scratchRoot); final raiseResult = await _runPubmod( - ['raise-max', 'path', '3.0.0', '-r'], + ['raise-max', 'path', '3.0.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); expect(raiseResult.exitCode, 0, reason: raiseResult.stderr.toString()); final loweredResult = await _runPubmod( - ['lower-max', 'path', '2.5.0', '-r'], + ['lower-max', 'path', '2.5.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); - expect(loweredResult.exitCode, 0, reason: loweredResult.stderr.toString()); + expect(loweredResult.exitCode, 0, + reason: loweredResult.stderr.toString()); await _assertPubGetParses(workDir.path); _expectUpdateOutput(raiseResult.stdout.toString()); _expectUpdateOutput(loweredResult.stdout.toString()); @@ -175,14 +192,17 @@ void main() { contains("path '>=1.8.0 <3.0.0' updated to '>=1.8.0 <2.5.0'"), ); - final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); final doubleQuoted = File( p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), ).readAsStringSync(); final hosted = - File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) + .readAsStringSync(); expect(_hasConstraint(root, 'path', '>=1.9.0 <2.5.0'), isTrue); expect(_hasConstraint(child, 'path', '>=1.8.0 <2.5.0'), isTrue); @@ -190,6 +210,131 @@ void main() { expect(_hasConstraint(hosted, 'version', '>=1.8.0 <2.5.0'), isTrue); }); + test('tighten defaults to true for dependency range updates', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min', 'path', '1.9.1', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect(result.stdout.toString(), contains('updated to ^1.9.1')); + + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + final hosted = + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) + .readAsStringSync(); + + expect(_hasConstraint(root, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(child, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(doubleQuoted, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(hosted, 'version', '^1.9.1'), isTrue); + }); + + test('tighten defaults to true for sdk range updates', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min-sdk', '3.4.0', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect(result.stdout.toString(), contains('sdk')); + expect(result.stdout.toString(), contains('updated to ^3.4.0')); + + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + + expect(_hasSdkConstraint(root, '^3.4.0'), isTrue); + expect(_hasSdkConstraint(child, '^3.4.0'), isTrue); + expect(_hasSdkConstraint(doubleQuoted, '^3.4.0'), isTrue); + }); + + test('--tighten supports 0.x caret compression', () async { + final workDir = _writePubspecFixture(scratchRoot, ''' +name: zero_major_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: '>=0.17.0 <0.19.0' +'''); + + final result = await _runPubmod( + ['raise-min', 'path', '0.18.2'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + _expectUpdateOutput(result.stdout.toString()); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_hasConstraint(content, 'path', '^0.18.2'), isTrue); + }); + + test('--tighten keeps non-equivalent ranges as ranges', () async { + final workDir = _writePubspecFixture(scratchRoot, ''' +name: crossing_major_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: '>=1.2.3 <4.0.0' +'''); + + final result = await _runPubmod( + ['raise-min', 'path', '1.3.0'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_hasConstraint(content, 'path', '>=1.3.0 <4.0.0'), isTrue); + expect(content, isNot(contains('path: ^1.3.0'))); + }); + + test('--no-tighten opts out and preserves explicit range output', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min', 'path', '1.9.1', '-r', '--no-tighten'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_hasConstraint(content, 'path', '>=1.9.1 <2.0.0'), isTrue); + expect(content, isNot(contains('path: ^1.9.1'))); + }); + test('fail-on-parse-error returns non-zero on malformed pubspec', () async { final workDir = _copyFixture('recursive', scratchRoot); @@ -205,11 +350,13 @@ void main() { } Future _assertPubGetParses(String workingDirectory) async { - final result = await Process.run('dart', ['pub', 'get'], workingDirectory: workingDirectory); + final result = await Process.run('dart', ['pub', 'get'], + workingDirectory: workingDirectory); expect( result.exitCode, 0, - reason: 'dart pub get failed in $workingDirectory\nstdout: ${result.stdout}\nstderr: ${result.stderr}', + reason: + 'dart pub get failed in $workingDirectory\nstdout: ${result.stdout}\nstderr: ${result.stderr}', ); } @@ -221,11 +368,21 @@ Directory _copyFixture(String fixtureName, Directory parent) { throw StateError('Fixture not found: ${source.path}'); } - final target = Directory(p.join(parent.path, fixtureName))..createSync(recursive: true); + final target = Directory(p.join(parent.path, fixtureName)) + ..createSync(recursive: true); _copyDirectory(source, target); return target; } +Directory _writePubspecFixture(Directory parent, String pubspecContent) { + final workDir = Directory( + p.join(parent.path, 'custom_${DateTime.now().microsecondsSinceEpoch}')) + ..createSync(recursive: true); + File(p.join(workDir.path, 'pubspec.yaml')) + .writeAsStringSync(pubspecContent.trimLeft()); + return workDir; +} + void _copyDirectory(Directory source, Directory target) { for (final entity in source.listSync(recursive: true, followLinks: false)) { final relative = p.relative(entity.path, from: source.path); @@ -245,7 +402,8 @@ Future _runPubmod( List args, { required String workingDirectory, }) { - final packageConfig = p.join(_repoRoot.path, '.dart_tool', 'package_config.json'); + final packageConfig = + p.join(_repoRoot.path, '.dart_tool', 'package_config.json'); final script = p.join(_repoRoot.path, 'bin', 'pm.dart'); return Process.run( @@ -273,6 +431,7 @@ void _expectUpdateOutput(String stdout) { expect( stdout, matches(RegExp(r'^[^\s]+ .+ updated to .+$', multiLine: true)), - reason: 'Expected update output lines in format: updated to ', + reason: + 'Expected update output lines in format: updated to ', ); }