From 7aa3051311b80fb3ceb778cef1b585929b055e51 Mon Sep 17 00:00:00 2001 From: "Lasse R.H. Nielsen" Date: Thu, 30 Oct 2025 15:18:56 +0100 Subject: [PATCH 1/5] Add script to remove experiments from tests when they have launched. --- tool/update_sdk.dart | 495 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 tool/update_sdk.dart diff --git a/tool/update_sdk.dart b/tool/update_sdk.dart new file mode 100644 index 00000000..122127f1 --- /dev/null +++ b/tool/update_sdk.dart @@ -0,0 +1,495 @@ +// 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. + +/// Script to update the SDK dependency to the newest version, +/// and to remove references to experiments that have been released. +library; + +import 'dart:convert' show LineSplitter; +import 'dart:io'; +import 'dart:io' as io show exit; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart' as y; + +// Command line can contain: +// +// - Explicit version: `3.10`, which is used as version in pubspec.yaml. +// - Directory of SDK, may be relative to CWD. +// - `-n` for dry-run. +void main(List args) { + try { + var flagErrors = false; + // General use of verbosity levels: + // 0: No extra output. + // 1+: Say what is done + // 2+: Also say when no change is made. + var verbose = 0; + var dryRun = false; + var nonFlagArgs = []; + for (var arg in args) { + if (arg.startsWith('-')) { + for (var i = 1; i < arg.length; i++) { + switch (arg[i]) { + case 'n': + dryRun = true; + case 'v': + verbose++; + case var char: + stderr.writeln('Invalid flag: "$char"'); + flagErrors = true; + } + } + } else { + nonFlagArgs.add(arg); + } + } + if (flagErrors) { + stderr.writeln(usage); + exit(1); + } + if (verbose > 1) { + stdout.writeln('Verbosity: $verbose'); + } + if (verbose > 0) { + if (dryRun || verbose > 1) stdout.writeln('Dry-run: $dryRun'); + } + + var stylePackageDir = _findStylePackageDir(); + if (verbose > 0) { + stdout.writeln('dart_style root: ${stylePackageDir.path}'); + } + if (findExperimentsFile(nonFlagArgs) case var experimentsFile?) { + // A version number on the command line will be "current version", + // otherwise use the maximal version of released experiments in the + // experiments file. + Version? version; + var experiments = _parseExperiments(experimentsFile); + for (var arg in nonFlagArgs) { + if (Version.tryParse(arg) case var explicitVersion?) { + version = explicitVersion; + if (verbose > 0) { + stdout.writeln('SDK version from command line: $version'); + } + break; + } + } + if (version == null) { + version = experiments.values.nonNulls.reduce(Version.max); + if (verbose > 0) { + stdout.writeln('SDK version from experiments: $version'); + } + } + + Updater( + stylePackageDir, + version, + experiments, + verbose: verbose, + dryRun: dryRun, + ).run(); + } else { + stderr.writeln('Cannot find experiments file.'); + stderr.writeln(usage); + exit(1); + } + } catch (e) { + if (e case (:int exitCode)) { + stdout.flush().then((_) { + stderr.flush().then((_) { + io.exit(exitCode); + }); + }); + } + } +} + +class Updater { + final Directory root; + final Version currentVersion; + final int verbose; + final Map experiments; + final FileCache files; + Updater( + this.root, + this.currentVersion, + this.experiments, { + this.verbose = 0, + bool dryRun = false, + }) : files = FileCache(verbose: verbose, dryRun: dryRun); + void run() { + _updatePubspec(); + _updateTests(); + files.flushSaves(); + } + + bool _updatePubspec() { + var file = File(p.join(root.path, 'pubspec.yaml')); + var pubspecText = files.load(file); + var versionRE = RegExp( + r'(?<=^environment:\n sdk: \^)([\w\-.+]+)(?=[ \t]*$)', + multiLine: true, + ); + var change = false; + Version? unchangedVersion; + pubspecText = pubspecText.replaceFirstMapped(versionRE, (m) { + var versionText = m[0]!; + var existingVersion = Version.parse(versionText); + if (existingVersion < currentVersion) { + change = true; + return '$currentVersion.0'; + } + unchangedVersion = existingVersion; + return versionText; // No change. + }); + if (change) { + if (verbose > 0) { + stdout.writeln('Updated pubspec.yaml SDK to $currentVersion'); + } + files.save(file, pubspecText); + return true; + } + if (unchangedVersion == null) { + throw UnsupportedError('Cannot find SDK version in pubspec.yaml'); + } + if (verbose > 1) { + stdout.writeln('Pubspec SDK version unchanged: $unchangedVersion'); + } + return false; + } + + void _updateTests() { + var testDirectory = Directory(p.join(root.path, 'test')); + for (var file in testDirectory.listSync(recursive: true)) { + if (file is File && file.path.endsWith('.stmt')) { + if (_updateTest(file) && verbose > 0) { + stdout.writeln('Updated test: ${file.path}'); + } + } + } + } + + // Matches an `(experiment exp-name)` entry, plus a single prior space. + // Captures: + // - name: The exp-name + static final _experimentRE = RegExp(r' ?\(experiment (?[\w\-]+)\)'); + + /// Matches a language version on a format test output line. + static final _languageVersionRE = RegExp(r'(?<=^<<< )\d+\.\d+\b'); + + bool _updateTest(File testFile) { + var source = files.load(testFile); + if (!source.contains('(experiment ')) return false; + // Experiments can be written in two places: + // - at top, after first line, as a lone of `(experiment exp-name)` + // in which case it applies to every test. + // - on individual test as `>>> (experiment exp-name) Test name` + // where it only applies to that test. + // + // Language version can occur after first part of source + // - `<<< 3.8 optional description` + + var output = []; + // Set when an enabled experiment is removed from the header, + // and the feature requires this version. + // Is the *minimum language version* allowed for tests in the file. + Version? globalVersion; + // Set when an enabled experiment is removed from a single test. + // Cleared when the next test starts. + Version? localVersion; + var inHeader = true; + var change = false; + for (var line in LineSplitter.split(source)) { + if (line.startsWith('>>>')) { + inHeader = false; + localVersion = null; + } + if (line.startsWith('<<<')) { + if (_languageVersionRE.firstMatch(line) case var m?) { + var minVersion = localVersion ?? globalVersion; + if (minVersion != null) { + var lineVersion = Version.parse(m[0]!); + if (lineVersion < minVersion) { + change = true; + line = line.replaceRange(m.start, m.end, minVersion.toString()); + } + } + } else { + // If we have a minimum version imposed by a removed experiment, + // put it on any output that doesn't have a version. + var minVersion = localVersion ?? globalVersion; + if (minVersion != null) { + line = line.replaceRange(3, 3, ' $minVersion'); + change = true; + } + } + } else if (line.isNotEmpty) { + line = line.replaceAllMapped(_experimentRE, (m) { + m as RegExpMatch; + var experimentName = m.namedGroup('name')!; + var release = experiments[experimentName]; + if (release == null || release > currentVersion) { + // Not released yet. + if (!experiments.containsKey(experimentName)) { + stderr.writeln( + 'Unrecognized experiment name "$experimentName"' + ' in ${testFile.path}', + ); + } + return m[0]!; // Keep experiment. + } + // Remove the experiment entry, the experiment is released. + // Ensure language level, if specified, is high enough to enable + // the feature without a flag. + var currentMinVersion = localVersion ?? globalVersion; + if (currentMinVersion == null || release > currentMinVersion) { + if (inHeader) { + globalVersion = release; + } else { + localVersion = release; + } + } + change = true; + return ''; + }); + // Top-level experiment lines only, + if (line.isEmpty) continue; + } + output.add(line); + } + if (change) { + if (output.isNotEmpty && output.last.isNotEmpty) { + output.add(''); // Make sure to have final newline. + } + files.save(testFile, output.join('\n')); + return true; + } + return false; + } +} + +// -------------------------------------------------------------------- +// Parse the `experimental_features.yaml` file to find experiments +// and their 'enabled' version. +Map _parseExperiments(File experimentsFile) { + var result = {}; + var yaml = + y.loadYaml( + experimentsFile.readAsStringSync(), + sourceUrl: experimentsFile.uri, + ) + as y.YamlMap; + var features = yaml['features'] as y.YamlMap; + for (var MapEntry(key: name as String, value: info as y.YamlMap) + in features.entries) { + Version? version; + if (info['enabledIn'] case String enabledString) { + version = Version.tryParse(enabledString); + } + result[name] = version; + } + return result; +} + +// -------------------------------------------------------------------- +// File system abstraction which caches changes, so they can be written +// atomically at the end. + +class FileCache { + final int verbose; + final bool dryRun; + final Map _cache = {}; + + FileCache({this.verbose = 0, this.dryRun = false}); + + String load(File path) { + if (verbose > 0) { + var fromString = + verbose > 1 + ? ' from ${_cache.containsKey(path) ? 'cache' : 'disk'}' + : ''; + stdout.writeln('Reading ${path.path}$fromString.'); + } + + return (_cache[path] ??= (content: path.readAsStringSync(), changed: false)) + .content; + } + + void save(File path, String content) { + var existing = _cache[path]; + if (verbose == 1) stdout.writeln('Saving ${path.path}.'); + if (existing != null) { + if (existing.content == content) { + if (verbose > 2) stdout.writeln('Saving ${path.path} with no changes'); + return; + } + if (verbose > 2) stdout.writeln('Save updates ${path.path}'); + } else if (verbose > 2) { + stdout.writeln('Save ${path.path}, not in cache'); + } + _cache[path] = (content: content, changed: true); + } + + void flushSaves() { + var count = 0; + var prefix = dryRun ? 'Dry-run: ' : ''; + _cache.updateAll((file, value) { + if (!value.changed) return value; + var content = value.content; + if (!dryRun) file.writeAsStringSync(content); + if (verbose > 1) { + stdout.writeln('${prefix}Flushing updated ${file.path}'); + } + count++; + return (content: content, changed: false); + }); + if (verbose > 0) { + if (count > 0) { + stdout.writeln( + '${prefix}Flushed $count changed file${_plural(count)}.', + ); + } else if (verbose > 1) { + stdout.writeln('${prefix}Flushing file cache with no changed files.'); + } + } + } +} + +// -------------------------------------------------------------------- +// Find the root directory of the `dart_style` package. + +Directory _findStylePackageDir() { + var cwd = Directory.current; + if (_isStylePackageDir(cwd)) return cwd; + var scriptDir = p.dirname(p.absolute(p.fromUri(Platform.script))); + var scriptParentDir = Directory(p.dirname(scriptDir)); + if (_isStylePackageDir(scriptParentDir)) return scriptParentDir; + var cursor = p.absolute(cwd.path); + while (true) { + var parentPath = p.dirname(cursor); + if (cursor == parentPath) break; + cursor = parentPath; + var directory = Directory(cursor); + if (_isStylePackageDir(directory)) return directory; + } + throw UnsupportedError( + "Couldn't find package root. Run from inside package.", + ); +} + +bool _isStylePackageDir(Directory directory) { + var pubspec = File(p.join(directory.path, 'pubspec.yaml')); + return pubspec.existsSync() && + LineSplitter.split(pubspec.readAsStringSync()).first == + 'name: dart_style'; +} + +// -------------------------------------------------------------------- +// Find version and experiments file in SDK. + +File? findExperimentsFile(List args) { + // Check if argument is SDK directory. + if (args.isNotEmpty) { + for (var arg in args) { + var directory = Directory(p.absolute(arg)); + if (directory.existsSync()) { + if (_experimentsInSdkDirectory(directory) case var file?) { + return file; + } + } + } + } + // Try relative to `dart` executable. + var cursor = Platform.resolvedExecutable; + if (p.basenameWithoutExtension(cursor) == 'dart') { + while (true) { + var parent = p.dirname(cursor); + if (parent == cursor) break; + cursor = parent; + var directory = Directory(cursor); + if (_experimentsInSdkDirectory(directory) case var file?) { + return file; + } + } + } + return null; +} + +File? _experimentsInSdkDirectory(Directory directory) { + var experimentsFile = File( + p.join(directory.path, 'tools', 'experimental_features.yaml'), + ); + if (experimentsFile.existsSync()) return experimentsFile; + return null; +} + +class Version implements Comparable { + final int major, minor; + Version(this.major, this.minor); + + static Version max(Version v1, Version v2) => v1.compareTo(v2) >= 0 ? v1 : v2; + + static Version min(Version v1, Version v2) => v1.compareTo(v2) <= 0 ? v1 : v2; + + static Version parse(String version) => + tryParse(version) ?? + (throw FormatException('Not a version string', version)); + + static Version? tryParse(String version) { + var majorEnd = version.indexOf('.'); + if (majorEnd < 0) return null; + var minorEnd = version.indexOf('.', majorEnd + 1); + if (minorEnd < 0) minorEnd = version.length; // Accept `3.5`. + var major = int.tryParse(version.substring(0, majorEnd)); + if (major == null) return null; + var minor = int.tryParse(version.substring(majorEnd + 1, minorEnd)); + if (minor == null) return null; + return Version(major, minor); + } + + @override + int compareTo(Version other) { + var delta = major.compareTo(other.major); + if (delta == 0) delta = minor.compareTo(other.minor); + return delta; + } + + @override + int get hashCode => Object.hash(major, minor); + @override + bool operator ==(Object other) => + other is Version && major == other.major && minor == other.minor; + @override + String toString() => '$major.$minor'; + + bool operator <(Version other) => + major < other.major || major == other.major && minor < other.minor; + bool operator <=(Version other) => + major < other.major || major == other.major && minor <= other.minor; + bool operator >(Version other) => other < this; + bool operator >=(Version other) => other <= this; +} + +/// Trailing `'s'` if number is not `1`. +String _plural(int number) => number == 1 ? '' : 's'; + +final String usage = ''' +dart tool/update_sdk.dart [-v] [-n] [VERSION] [SDKDIR] + +Run from inside dart_style directory to be sure to be able to find it. +Uses path to `dart` executable to look for SDK directory. + +VERSION SemVer or `major.minor` version. + If provided, use that as SDK version in pubspec.yaml. +SDKDIR Path to SDK repository containing experimental_features.yaml file. + Will be searched for if not provided. + +-v Verbosity. Can be used multiple times. +-n Dryrun. If set, does not write changed files back. +'''; + +void exit(int value) { + // ignore: only_throw_errors + throw (exitCode: value); +} From 507b6ad4c6fdc5fe5bba435393f31c27b052a97e Mon Sep 17 00:00:00 2001 From: "Lasse R.H. Nielsen" Date: Mon, 3 Nov 2025 14:55:07 +0100 Subject: [PATCH 2/5] Rewritten a little. --- tool/update_sdk.dart | 235 ++++++++++++++++++++++++++++--------------- 1 file changed, 154 insertions(+), 81 deletions(-) diff --git a/tool/update_sdk.dart b/tool/update_sdk.dart index 122127f1..2ef23c87 100644 --- a/tool/update_sdk.dart +++ b/tool/update_sdk.dart @@ -20,34 +20,16 @@ import 'package:yaml/yaml.dart' as y; // - `-n` for dry-run. void main(List args) { try { - var flagErrors = false; - // General use of verbosity levels: - // 0: No extra output. - // 1+: Say what is done - // 2+: Also say when no change is made. - var verbose = 0; - var dryRun = false; - var nonFlagArgs = []; - for (var arg in args) { - if (arg.startsWith('-')) { - for (var i = 1; i < arg.length; i++) { - switch (arg[i]) { - case 'n': - dryRun = true; - case 'v': - verbose++; - case var char: - stderr.writeln('Invalid flag: "$char"'); - flagErrors = true; - } - } - } else { - nonFlagArgs.add(arg); - } - } - if (flagErrors) { - stderr.writeln(usage); - exit(1); + var ( + int verbose, + bool dryRun, + File? experimentsFile, + Version? targetVersion, + ) = _parseArguments(args); + + var stylePackageDir = _findStylePackageDir(); + if (verbose > 0) { + stdout.writeln('dart_style root: ${stylePackageDir.path}'); } if (verbose > 1) { stdout.writeln('Verbosity: $verbose'); @@ -56,44 +38,42 @@ void main(List args) { if (dryRun || verbose > 1) stdout.writeln('Dry-run: $dryRun'); } - var stylePackageDir = _findStylePackageDir(); - if (verbose > 0) { - stdout.writeln('dart_style root: ${stylePackageDir.path}'); - } - if (findExperimentsFile(nonFlagArgs) case var experimentsFile?) { - // A version number on the command line will be "current version", - // otherwise use the maximal version of released experiments in the - // experiments file. - Version? version; - var experiments = _parseExperiments(experimentsFile); - for (var arg in nonFlagArgs) { - if (Version.tryParse(arg) case var explicitVersion?) { - version = explicitVersion; - if (verbose > 0) { - stdout.writeln('SDK version from command line: $version'); - } - break; - } + if (experimentsFile == null) { + experimentsFile = _findExperimentsFile(); + if (experimentsFile == null) { + stderr + ..writeln('Cannot find experiments file or SDK directory,') + ..writeln('provide path to either on command line.') + ..writeln(usage); + exit(1); + return; // Unreachable, but `exit` has return type `void`. } - if (version == null) { - version = experiments.values.nonNulls.reduce(Version.max); - if (verbose > 0) { - stdout.writeln('SDK version from experiments: $version'); - } + if (verbose > 0) { + stdout.writeln('Experiments file found: ${experimentsFile.path}'); } - - Updater( - stylePackageDir, - version, - experiments, - verbose: verbose, - dryRun: dryRun, - ).run(); - } else { - stderr.writeln('Cannot find experiments file.'); - stderr.writeln(usage); + } else if (verbose > 0) { + stdout.writeln( + 'Experiments file from command line: ${experimentsFile.path}', + ); + } + var experiments = _parseExperiments(experimentsFile); + var latestReleasedExperiment = experiments.values.fold( + null, + Version.maxOrNull, + ); + if (latestReleasedExperiment == null) { + stderr.writeln('No released experiments in experiments file.'); exit(1); + return; } + + Updater( + stylePackageDir, + targetVersion ?? latestReleasedExperiment, + experiments, + verbose: verbose, + dryRun: dryRun, + ).run(); } catch (e) { if (e case (:int exitCode)) { stdout.flush().then((_) { @@ -105,6 +85,64 @@ void main(List args) { } } +(int verbose, bool dryRun, File? experimentsFile, Version? version) +_parseArguments(List args) { + // Parse argument list. + var flagErrors = false; + // General use of verbosity levels: + // 0: No extra output. + // 1+: Say what is done. + // 2+: Also say when no change is made (what is *not* done). + var verbose = 0; + var dryRun = false; + var printHelp = false; + File? experimentsFile; + Version? targetVersion; + for (var arg in args) { + if (arg.startsWith('-')) { + for (var i = 1; i < arg.length; i++) { + switch (arg[i]) { + case 'n': + dryRun = true; + case 'v': + verbose++; + case 'h': + printHelp = true; + case var char: + stderr.writeln('Invalid flag: "$char"'); + flagErrors = true; + } + } + } else if (Version.tryParse(arg) case var version?) { + if (targetVersion != null) { + stderr.writeln( + 'More than one version argument: $targetVersion, $version', + ); + flagErrors = true; + } + targetVersion = version; + } else if (_checkExperimentsFileOrSdk(arg) case var file?) { + if (experimentsFile != null) { + stderr.writeln('More than one experiments or SDK argument: $arg'); + flagErrors = true; + } + experimentsFile = file; + } else { + stderr.writeln('Unrecognized argument: $arg'); + flagErrors = true; + } + } + if (flagErrors) { + stderr.writeln(usage); + exit(1); + } + if (printHelp) { + stdout.writeln(usage); + exit(0); + } + return (verbose, dryRun, experimentsFile, targetVersion); +} + class Updater { final Directory root; final Version currentVersion; @@ -118,6 +156,7 @@ class Updater { this.verbose = 0, bool dryRun = false, }) : files = FileCache(verbose: verbose, dryRun: dryRun); + void run() { _updatePubspec(); _updateTests(); @@ -174,7 +213,7 @@ class Updater { // Captures: // - name: The exp-name static final _experimentRE = RegExp(r' ?\(experiment (?[\w\-]+)\)'); - + /// Matches a language version on a format test output line. static final _languageVersionRE = RegExp(r'(?<=^<<< )\d+\.\d+\b'); @@ -380,6 +419,7 @@ Directory _findStylePackageDir() { bool _isStylePackageDir(Directory directory) { var pubspec = File(p.join(directory.path, 'pubspec.yaml')); + // Could read less, but is unlikely to matter. return pubspec.existsSync() && LineSplitter.split(pubspec.readAsStringSync()).first == 'name: dart_style'; @@ -388,16 +428,19 @@ bool _isStylePackageDir(Directory directory) { // -------------------------------------------------------------------- // Find version and experiments file in SDK. -File? findExperimentsFile(List args) { - // Check if argument is SDK directory. - if (args.isNotEmpty) { - for (var arg in args) { - var directory = Directory(p.absolute(arg)); - if (directory.existsSync()) { - if (_experimentsInSdkDirectory(directory) case var file?) { - return file; - } - } +/// Used on command line arguments to see if they point to SDK or experiments. +File? _checkExperimentsFileOrSdk(String path) => + _tryExperimentsFile(path) ?? _tryExperimentsFileInSdkPath(path); + +/// Used to find the experiments file if no command line path is given. +/// +/// +/// Tries to locate an SDK that has a `tools/experimental_features.yaml` file. +File? _findExperimentsFile() { + var envSdk = Platform.environment['DART_SDK']; + if (envSdk != null) { + if (_tryExperimentsFileInSdkPath(envSdk) case var file?) { + return file; } } // Try relative to `dart` executable. @@ -408,7 +451,7 @@ File? findExperimentsFile(List args) { if (parent == cursor) break; cursor = parent; var directory = Directory(cursor); - if (_experimentsInSdkDirectory(directory) case var file?) { + if (_tryExperimentsFileInSdkDirectory(directory) case var file?) { return file; } } @@ -416,7 +459,23 @@ File? findExperimentsFile(List args) { return null; } -File? _experimentsInSdkDirectory(Directory directory) { +File? _tryExperimentsFile(String path) { + if (p.basename(path) == 'experimental_features.yaml') { + var file = File(path); + if (file.existsSync()) return file; + } + return null; +} + +File? _tryExperimentsFileInSdkPath(String path) { + var directory = Directory(p.normalize(path)); + if (directory.existsSync()) { + return _tryExperimentsFileInSdkDirectory(directory); + } + return null; +} + +File? _tryExperimentsFileInSdkDirectory(Directory directory) { var experimentsFile = File( p.join(directory.path, 'tools', 'experimental_features.yaml'), ); @@ -428,9 +487,13 @@ class Version implements Comparable { final int major, minor; Version(this.major, this.minor); - static Version max(Version v1, Version v2) => v1.compareTo(v2) >= 0 ? v1 : v2; + static Version? maxOrNull(Version? v1, Version? v2) { + if (v1 == null) return v2; + if (v2 == null) return v1; + return max(v1, v2); + } - static Version min(Version v1, Version v2) => v1.compareTo(v2) <= 0 ? v1 : v2; + static Version max(Version v1, Version v2) => v1 >= v2 ? v1 : v2; static Version parse(String version) => tryParse(version) ?? @@ -457,17 +520,24 @@ class Version implements Comparable { @override int get hashCode => Object.hash(major, minor); + @override bool operator ==(Object other) => other is Version && major == other.major && minor == other.minor; + @override String toString() => '$major.$minor'; + // TODO: (https://dartbug.com/61891) - Remove ignores when issue is fixed. + // ignore: unreachable_from_main bool operator <(Version other) => major < other.major || major == other.major && minor < other.minor; + // ignore: unreachable_from_main bool operator <=(Version other) => major < other.major || major == other.major && minor <= other.minor; + // ignore: unreachable_from_main bool operator >(Version other) => other < this; + // ignore: unreachable_from_main bool operator >=(Version other) => other <= this; } @@ -475,18 +545,21 @@ class Version implements Comparable { String _plural(int number) => number == 1 ? '' : 's'; final String usage = ''' -dart tool/update_sdk.dart [-v] [-n] [VERSION] [SDKDIR] +dart tool/update_sdk.dart [-h] [-v] [-n] [VERSION] [PATH] Run from inside dart_style directory to be sure to be able to find it. Uses path to `dart` executable to look for SDK directory. -VERSION SemVer or `major.minor` version. +VERSION SemVer or 'major.minor' version. If provided, use that as SDK version in pubspec.yaml. -SDKDIR Path to SDK repository containing experimental_features.yaml file. + If not provided, uses most recent feature release version. +PATH Path to "experimental_features.yaml" or to an SDK repository containing + that file in "tools". Will be searched for if not provided. -v Verbosity. Can be used multiple times. --n Dryrun. If set, does not write changed files back. +-n Dryrun. If set, does not write changed files back. +-h Show usage. '''; void exit(int value) { From d6c34cbadad5742553f9d9a07b276d8daa90abd9 Mon Sep 17 00:00:00 2001 From: "Lasse R.H. Nielsen" Date: Wed, 5 Nov 2025 13:58:59 +0100 Subject: [PATCH 3/5] More tweaks. --- tool/update_sdk.dart | 150 +++++++++++++++++++++++++++---------------- 1 file changed, 95 insertions(+), 55 deletions(-) diff --git a/tool/update_sdk.dart b/tool/update_sdk.dart index 2ef23c87..84e2f370 100644 --- a/tool/update_sdk.dart +++ b/tool/update_sdk.dart @@ -18,6 +18,7 @@ import 'package:yaml/yaml.dart' as y; // - Explicit version: `3.10`, which is used as version in pubspec.yaml. // - Directory of SDK, may be relative to CWD. // - `-n` for dry-run. +// - '-v' for more logging verbosity. void main(List args) { try { var ( @@ -46,7 +47,6 @@ void main(List args) { ..writeln('provide path to either on command line.') ..writeln(usage); exit(1); - return; // Unreachable, but `exit` has return type `void`. } if (verbose > 0) { stdout.writeln('Experiments file found: ${experimentsFile.path}'); @@ -64,7 +64,6 @@ void main(List args) { if (latestReleasedExperiment == null) { stderr.writeln('No released experiments in experiments file.'); exit(1); - return; } Updater( @@ -75,6 +74,7 @@ void main(List args) { dryRun: dryRun, ).run(); } catch (e) { + // Flush output before actually exiting. if (e case (:int exitCode)) { stdout.flush().then((_) { stderr.flush().then((_) { @@ -165,28 +165,29 @@ class Updater { bool _updatePubspec() { var file = File(p.join(root.path, 'pubspec.yaml')); - var pubspecText = files.load(file); var versionRE = RegExp( r'(?<=^environment:\n sdk: \^)([\w\-.+]+)(?=[ \t]*$)', multiLine: true, ); var change = false; Version? unchangedVersion; - pubspecText = pubspecText.replaceFirstMapped(versionRE, (m) { - var versionText = m[0]!; - var existingVersion = Version.parse(versionText); - if (existingVersion < currentVersion) { - change = true; - return '$currentVersion.0'; - } - unchangedVersion = existingVersion; - return versionText; // No change. - }); + files.edit( + file, + (pubspecText) => pubspecText.replaceFirstMapped(versionRE, (m) { + var versionText = m[0]!; + var existingVersion = Version.parse(versionText); + if (existingVersion < currentVersion) { + change = true; + return '$currentVersion.0'; + } + unchangedVersion = existingVersion; + return versionText; // No change. + }), + ); if (change) { if (verbose > 0) { stdout.writeln('Updated pubspec.yaml SDK to $currentVersion'); } - files.save(file, pubspecText); return true; } if (unchangedVersion == null) { @@ -217,9 +218,8 @@ class Updater { /// Matches a language version on a format test output line. static final _languageVersionRE = RegExp(r'(?<=^<<< )\d+\.\d+\b'); - bool _updateTest(File testFile) { - var source = files.load(testFile); - if (!source.contains('(experiment ')) return false; + bool _updateTest(File testFile) => files.edit(testFile, (source) { + if (!source.contains('(experiment ')) return null; // Experiments can be written in two places: // - at top, after first line, as a lone of `(experiment exp-name)` // in which case it applies to every test. @@ -301,11 +301,10 @@ class Updater { if (output.isNotEmpty && output.last.isNotEmpty) { output.add(''); // Make sure to have final newline. } - files.save(testFile, output.join('\n')); - return true; + return output.join('\n'); } - return false; - } + return null; + }); } // -------------------------------------------------------------------- @@ -338,51 +337,85 @@ Map _parseExperiments(File experimentsFile) { class FileCache { final int verbose; final bool dryRun; - final Map _cache = {}; + // Contains string if it has been changed. + // Contains `null` if currently being edited. + final Map _cache = {}; FileCache({this.verbose = 0, this.dryRun = false}); - String load(File path) { - if (verbose > 0) { - var fromString = - verbose > 1 - ? ' from ${_cache.containsKey(path) ? 'cache' : 'disk'}' - : ''; - stdout.writeln('Reading ${path.path}$fromString.'); + /// Edit file with the given [path]. + /// + /// Cannot edit a file while it's already being edited. + /// + /// The [editor] function is called with the content of the file, + /// either read from the file system or cached already modified file content. + /// The [editor] function should return the new content of the file. + /// If it returns `null` or the same string, the file has not changed. + /// + /// Returns whether the file content changed. + bool edit(File path, String? Function(String content) editor) { + if (verbose > 1) { + var fromString = ' from ${_cache.containsKey(path) ? 'cache' : 'disk'}'; + stdout.writeln('Loading ${path.path}$fromString.'); } - return (_cache[path] ??= (content: path.readAsStringSync(), changed: false)) - .content; - } - - void save(File path, String content) { - var existing = _cache[path]; - if (verbose == 1) stdout.writeln('Saving ${path.path}.'); - if (existing != null) { - if (existing.content == content) { - if (verbose > 2) stdout.writeln('Saving ${path.path} with no changes'); - return; + var existingContent = _cache[path]; + String content; + if (existingContent != null) { + content = existingContent; + } else if (_cache.containsKey(path)) { + throw ConcurrentModificationError(path); + } else { + content = path.readAsStringSync(); + _cache[path] = null; + } + var change = false; + String? newContent; + try { + newContent = editor(content); + change = newContent != null && newContent != content; + } finally { + // No change if function threw, or if it returned `null` or `content`. + if (verbose > 1) { + if (change) { + var first = (existingContent == null) ? '' : ', first change to file'; + stdout.writeln('Saving changes to ${path.path}$first.'); + } else if (verbose > 2) { + stdout.writeln('No changes to ${path.path}'); + } + } + if (change) { + // Put text back after editing. + _cache[path] = newContent; + } else if (existingContent != null) { + _cache[path] = existingContent; + } else { + _cache.remove(path); // No longer being edited. } - if (verbose > 2) stdout.writeln('Save updates ${path.path}'); - } else if (verbose > 2) { - stdout.writeln('Save ${path.path}, not in cache'); } - _cache[path] = (content: content, changed: true); + return change; } + /// Saves all cached file changes to disk. + /// + /// Does nothing if dry-running. void flushSaves() { var count = 0; var prefix = dryRun ? 'Dry-run: ' : ''; - _cache.updateAll((file, value) { - if (!value.changed) return value; - var content = value.content; - if (!dryRun) file.writeAsStringSync(content); + for (var file in [..._cache.keys]) { + var content = _cache[file]; + if (content == null) { + throw ConcurrentModificationError('Flushing cache while editing'); + } + if (!dryRun) { + file.writeAsStringSync(content); + _cache.remove(file); + } if (verbose > 1) { stdout.writeln('${prefix}Flushing updated ${file.path}'); } count++; - return (content: content, changed: false); - }); + } if (verbose > 0) { if (count > 0) { stdout.writeln( @@ -399,11 +432,16 @@ class FileCache { // Find the root directory of the `dart_style` package. Directory _findStylePackageDir() { + // Check current directory. Script is run in the package root dir. var cwd = Directory.current; if (_isStylePackageDir(cwd)) return cwd; + // Check parent directory of script file, + // if run as `dart /tool/update_sdk.dart`. var scriptDir = p.dirname(p.absolute(p.fromUri(Platform.script))); var scriptParentDir = Directory(p.dirname(scriptDir)); if (_isStylePackageDir(scriptParentDir)) return scriptParentDir; + // Check ancestor directories of current directory, + // if run from, fx, inside `/tool/`. var cursor = p.absolute(cwd.path); while (true) { var parentPath = p.dirname(cursor); @@ -412,9 +450,9 @@ Directory _findStylePackageDir() { var directory = Directory(cursor); if (_isStylePackageDir(directory)) return directory; } - throw UnsupportedError( - "Couldn't find package root. Run from inside package.", - ); + // Nothing worked. + stderr.writeln("Couldn't find package root. Run from inside package."); + exit(1); } bool _isStylePackageDir(Directory directory) { @@ -500,10 +538,11 @@ class Version implements Comparable { (throw FormatException('Not a version string', version)); static Version? tryParse(String version) { + // Parse `###.###` optionally followed by `.`. var majorEnd = version.indexOf('.'); if (majorEnd < 0) return null; var minorEnd = version.indexOf('.', majorEnd + 1); - if (minorEnd < 0) minorEnd = version.length; // Accept `3.5`. + if (minorEnd < 0) minorEnd = version.length; // Accept semver prefix, `3.5`. var major = int.tryParse(version.substring(0, majorEnd)); if (major == null) return null; var minor = int.tryParse(version.substring(majorEnd + 1, minorEnd)); @@ -562,7 +601,8 @@ PATH Path to "experimental_features.yaml" or to an SDK repository containing -h Show usage. '''; -void exit(int value) { +/// Caught by [main] to flush output before actually exiting. +Never exit(int value) { // ignore: only_throw_errors throw (exitCode: value); } From 95b9340c30175a393179d137b2f61d631535c029 Mon Sep 17 00:00:00 2001 From: "Lasse R.H. Nielsen" Date: Tue, 16 Dec 2025 14:18:27 +0100 Subject: [PATCH 4/5] Tweaks. --- tool/update_sdk.dart | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/tool/update_sdk.dart b/tool/update_sdk.dart index 84e2f370..6d56b369 100644 --- a/tool/update_sdk.dart +++ b/tool/update_sdk.dart @@ -35,8 +35,8 @@ void main(List args) { if (verbose > 1) { stdout.writeln('Verbosity: $verbose'); } - if (verbose > 0) { - if (dryRun || verbose > 1) stdout.writeln('Dry-run: $dryRun'); + if (verbose > 1 || (dryRun && verbose > 0)) { + stdout.writeln('Dry-run: $dryRun'); } if (experimentsFile == null) { @@ -148,19 +148,19 @@ class Updater { final Version currentVersion; final int verbose; final Map experiments; - final FileCache files; + final FileEditor files; Updater( this.root, this.currentVersion, this.experiments, { this.verbose = 0, bool dryRun = false, - }) : files = FileCache(verbose: verbose, dryRun: dryRun); + }) : files = FileEditor(verbose: verbose - 1, dryRun: dryRun); void run() { _updatePubspec(); _updateTests(); - files.flushSaves(); + files.flushChanges(); } bool _updatePubspec() { @@ -331,17 +331,29 @@ Map _parseExperiments(File experimentsFile) { } // -------------------------------------------------------------------- -// File system abstraction which caches changes, so they can be written -// atomically at the end. +// File system abstraction which caches changes to text files, +// so they can be written atomically at the end. -class FileCache { +/// Cached edits of text files. +/// +/// Use [edit] to edit a text file and return the new content. +/// Changes are cached and given to later edits of the same file. +/// +/// Changed files can be flushed to disk using [flushChanges]. +/// +/// If [verbose] is positive, operations may print information +/// about what they do. +/// +/// If [dryRun] is `true`, [flushChanges] does nothing, other than print +/// what it would have done. +class FileEditor { final int verbose; final bool dryRun; // Contains string if it has been changed. // Contains `null` if currently being edited. final Map _cache = {}; - FileCache({this.verbose = 0, this.dryRun = false}); + FileEditor({this.verbose = 0, this.dryRun = false}); /// Edit file with the given [path]. /// @@ -354,7 +366,7 @@ class FileCache { /// /// Returns whether the file content changed. bool edit(File path, String? Function(String content) editor) { - if (verbose > 1) { + if (verbose > 0) { var fromString = ' from ${_cache.containsKey(path) ? 'cache' : 'disk'}'; stdout.writeln('Loading ${path.path}$fromString.'); } @@ -376,11 +388,11 @@ class FileCache { change = newContent != null && newContent != content; } finally { // No change if function threw, or if it returned `null` or `content`. - if (verbose > 1) { + if (verbose > 0) { if (change) { var first = (existingContent == null) ? '' : ', first change to file'; stdout.writeln('Saving changes to ${path.path}$first.'); - } else if (verbose > 2) { + } else if (verbose > 1) { stdout.writeln('No changes to ${path.path}'); } } @@ -399,7 +411,7 @@ class FileCache { /// Saves all cached file changes to disk. /// /// Does nothing if dry-running. - void flushSaves() { + void flushChanges() { var count = 0; var prefix = dryRun ? 'Dry-run: ' : ''; for (var file in [..._cache.keys]) { From c98e93717fc5443254e3b95b89af4b56920fd732 Mon Sep 17 00:00:00 2001 From: "Lasse R.H. Nielsen" Date: Tue, 16 Dec 2025 14:31:06 +0100 Subject: [PATCH 5/5] Tell user to run pub get and format if updating pubspec. --- tool/update_sdk.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tool/update_sdk.dart b/tool/update_sdk.dart index 6d56b369..4a55b5f2 100644 --- a/tool/update_sdk.dart +++ b/tool/update_sdk.dart @@ -26,7 +26,9 @@ void main(List args) { bool dryRun, File? experimentsFile, Version? targetVersion, - ) = _parseArguments(args); + ) = _parseArguments( + args, + ); var stylePackageDir = _findStylePackageDir(); if (verbose > 0) { @@ -147,6 +149,7 @@ class Updater { final Directory root; final Version currentVersion; final int verbose; + final bool dryRun; final Map experiments; final FileEditor files; Updater( @@ -154,13 +157,18 @@ class Updater { this.currentVersion, this.experiments, { this.verbose = 0, - bool dryRun = false, + this.dryRun = false, }) : files = FileEditor(verbose: verbose - 1, dryRun: dryRun); void run() { - _updatePubspec(); + var updatedPubspecVersion = _updatePubspec(); _updateTests(); files.flushChanges(); + if (updatedPubspecVersion && !dryRun) { + stdout.writeln( + 'Updated PubSpec version. Run `dart pub get` and `dart format`.', + ); + } } bool _updatePubspec() {