blob: 6e8f95b7f3c5f6e98632467564b0a8ec0dcdfdaa [file]
#! /bin/env dart
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Utility for checking which package configuration applies to a specific file
/// or path.
library;
import 'dart:convert';
import 'dart:io';
import 'package:package_config/package_config.dart';
import 'package:path/path.dart' as p;
/// Output modes.
const _printText = 0;
const _printJsonLines = 1;
const _printJsonList = 2;
void main(List<String> args) async {
// Basic command line parser. No fancy.
var files = <String>[];
var stopAtPubspec = false;
var noParent = false;
var hasPrintedUsage = false;
var parseFlags = true;
var printFormat = _printText;
for (var arg in args) {
if (parseFlags && arg.startsWith('-')) {
switch (arg) {
case '-b':
noParent = true;
case '-g':
stopAtPubspec = false;
case '-h':
if (!hasPrintedUsage) {
hasPrintedUsage = true;
stdout.writeln(usage);
}
case '-j':
printFormat = _printJsonLines;
case '-jl':
printFormat = _printJsonList;
case '-p':
stopAtPubspec = true;
case '--':
parseFlags = false;
default:
stderr.writeln('Unexpected flag: $arg');
if (!hasPrintedUsage) {
hasPrintedUsage = true;
stderr.writeln(usage);
}
}
} else {
files.add(arg);
}
}
if (hasPrintedUsage) return;
/// Check current directory if no PATHs on command line.
if (files.isEmpty) {
files.add(p.current);
}
var loader = PackageConfigLoader(
stopAtPubspec: stopAtPubspec,
noParent: noParent,
);
// Collects infos if printing as single JSON value.
// Otherwise prints output for each file as soon as it's available.
var jsonList = <Map<String, Object?>>[];
for (var arg in files) {
var fileInfo = await _resolveInfo(arg, loader);
if (fileInfo == null) continue; // File does not exist, already reported.
if (printFormat == _printText) {
// Display as "readable" text.
print(fileInfo);
} else if (printFormat == _printJsonLines) {
// Write JSON on a single line.
stdout.writeln(
const JsonEncoder.withIndent(null).convert(fileInfo.toJson()),
);
} else {
// Store JSON in list, print entire list later.
assert(printFormat == _printJsonList);
jsonList.add(fileInfo.toJson());
}
}
if (printFormat == _printJsonList) {
stdout.writeln(const JsonEncoder.withIndent(' ').convert(jsonList));
}
}
/// Finds package information for command line provided path.
Future<ConfigInfo?> _resolveInfo(String arg, PackageConfigLoader loader) async {
var path = p.normalize(arg);
var file = File(path);
if (file.existsSync()) {
file = file.absolute;
var directory = Directory(p.dirname(file.path));
return await _resolvePackageConfig(directory, file, loader);
}
var directory = Directory(path);
if (directory.existsSync()) {
return await _resolvePackageConfig(directory.absolute, null, loader);
}
stderr.writeln('Cannot find file or directory: $arg');
return null;
}
// --------------------------------------------------------------------
// Convert package configuration information to a simple model object.
/// Extract package configuration information for a file from a configuration.
Future<ConfigInfo> _resolvePackageConfig(
Directory path,
File? file,
PackageConfigLoader loader,
) async {
var originPath = path.path;
var targetPath = file?.path ?? originPath;
var (configPath, config) = await loader.findPackageConfig(originPath);
Package? package;
Uri? packageUri;
LanguageVersion? overrideVersion;
if (config != null) {
var uri = file?.uri ?? path.uri;
package = config.packageOf(uri);
if (package != null) {
packageUri = config.toPackageUri(uri);
}
}
if (file != null) {
overrideVersion = _readOverrideVersion(file);
}
return ConfigInfo(
targetPath,
configPath,
package,
packageUri,
overrideVersion,
);
}
/// Gathered package configuration information for [path].
final class ConfigInfo {
/// Original path being resolved.
final String path;
/// Path to package configuration file, if any.
final String? configPath;
/// Package that path belongs to, if any.
final Package? package;
/// Package URI for [path], if it has one.
/// Always `null` if [package] is `null`.
final Uri? packageUri;
/// Language version override in file, if any.
final LanguageVersion? languageVersionOverride;
ConfigInfo(
this.path,
this.configPath,
this.package,
this.packageUri,
this.languageVersionOverride,
);
Map<String, Object?> toJson() {
return {
JsonKey.path: path,
if (configPath != null) JsonKey.configPath: configPath,
if (package case var package?)
JsonKey.package: {
JsonKey.name: package.name,
JsonKey.root: _fileUriPath(package.root),
if (package.languageVersion case var languageVersion?)
JsonKey.languageVersion: languageVersion.toString(),
if (packageUri case var packageUri?) ...{
JsonKey.packageUri: packageUri.toString(),
if (package.root != package.packageUriRoot)
JsonKey.lib: _fileUriPath(package.packageUriRoot),
},
},
if (languageVersionOverride case var override?)
JsonKey.languageVersionOverride: override.toString(),
};
}
/// Package configuration information for a path in a readable format.
@override
String toString() {
var buffer = StringBuffer();
var sep = Platform.pathSeparator;
var kind = path.endsWith(Platform.pathSeparator) ? 'directory' : 'file';
buffer.writeln('Package configuration for $kind: ${p.relative(path)}');
if (configPath case var configPath?) {
buffer.writeln('- Package configuration: ${p.relative(configPath)}');
if (package case var package?) {
buffer.writeln('- In package: ${package.name}');
if (package.languageVersion case var version?) {
buffer.writeln(' - default language version: $version');
}
var rootUri = _fileUriPath(package.root);
var rootPath = p.relative(Directory.fromUri(Uri.parse(rootUri)).path);
buffer.writeln(' - with root: $rootPath$sep');
if (packageUri case var packageUri?) {
buffer.writeln(' - Has package URI: $packageUri');
if (package.root != package.packageUriRoot) {
var libPath = p.relative(
Directory.fromUri(package.packageUriRoot).path,
);
buffer.writeln(' - relative to: $libPath$sep');
} else {
buffer.writeln(' - relative to root');
}
}
} else {
buffer.writeln('- Is not part of any package');
}
} else {
buffer.writeln('- No package configuration found');
assert(package == null);
}
if (languageVersionOverride case var override?) {
buffer.writeln('- Language version override: // @dart=$override');
}
return buffer.toString();
}
static String _fileUriPath(Uri uri) {
assert(uri.isScheme('file'));
return File.fromUri(uri).path;
}
}
// Constants for all used JSON keys to prevent mis-typing.
extension type const JsonKey(String value) implements String {
static const JsonKey path = JsonKey('path');
static const JsonKey configPath = JsonKey('configPath');
static const JsonKey package = JsonKey('package');
static const JsonKey name = JsonKey('name');
static const JsonKey root = JsonKey('root');
static const JsonKey packageUri = JsonKey('packageUri');
static const JsonKey lib = JsonKey('lib');
static const JsonKey languageVersion = JsonKey('languageVersion');
static const JsonKey languageVersionOverride = JsonKey(
'languageVersionOverride',
);
}
// --------------------------------------------------------------------
// Find language version override marker in file.
/// Tries to find a language override marker in a Dart file.
///
/// Uses a best-effort approach to scan for a line
/// of the form `// @dart=X.Y` before any Dart directive.
/// Any consistently and idiomatically formatted file will be recognized
/// correctly. A file with a multiline `/*...*/` comment where
/// internal lines do not start with `*`, or where a Dart directive
/// follows a `*/` on the same line, may be misinterpreted.
LanguageVersion? _readOverrideVersion(File file) {
String fileContent;
try {
fileContent = file.readAsStringSync();
} catch (e) {
stderr
..writeln('Error reading ${file.path} as UTF-8 text:')
..writeln(e);
return null;
}
// Skip BOM only at start.
const bom = '\uFEFF';
var contentStart = 0;
// Skip BOM only at start.
if (fileContent.startsWith(bom)) contentStart = 1;
// Skip `#! ...` line only at start.
if (fileContent.startsWith('#!', contentStart)) {
// Skip until end-of-line, whether ended by `\n` or `\r\n`.
var endOfHashBang = fileContent.indexOf('\n', contentStart + 2);
if (endOfHashBang < 0) return null; // No EOL after `#!`.
contentStart = endOfHashBang + 1;
}
// Match lines until one that looks like a version override or not a comment.
for (var match in leadRegExp.allMatches(fileContent, contentStart)) {
if (match.namedGroup('major') case var major?) {
// Found `// @dart=<major>.<minor>` line.
var minor = match.namedGroup('minor')!;
return LanguageVersion(int.parse(major), int.parse(minor));
} else if (match.namedGroup('other') != null) {
// Found non-comment, so too late for language version markers.
break;
}
}
return null;
}
/// Heuristic scanner for finding leading comment lines.
///
/// Finds leading comments and any language version override marker
/// within them.
///
/// Accepts empty lines or lines starting with `/*`, `*` or `//` as
/// initial comment lines, and any other non-space/tab first character
/// as a non-comment, which ends the section
/// that can contain language overrides.
///
/// It's possible to construct files where that's not correct, fx.
/// ```
/// /* something */ import "banana.dart";
/// // @dart=2.14
/// ```
/// To be absolutely certain, the code would need to properly tokenize
/// *nested* comments, which is not a job for a RegExp.
/// This RegExp should work for well-behaved and -formatted files.
final leadRegExp = RegExp(
r'^[ \t]*'
r'(?:'
r'$' // Empty line
r'|'
r'/?\*' // Line starting with `/*` or `*`, assumed a comment continuation.
r'|'
// Line starting with `//`, and possibly followed by language override.
r'//(?: *@ *dart *= *(?<major>\d+) *\. *(?<minor>\d+) *$)?'
r'|'
r'(?<other>[^ \t/*])' // Any other line, assumed to end initial comments.
r')',
multiLine: true,
);
// --------------------------------------------------------------------
// Find and load (and cache) package configurations
class PackageConfigLoader {
/// Stop searching at the current working directory.
final bool noParent;
/// Stop searching if finding a `pubspec.yaml` with no package configuration.
final bool stopAtPubspec;
/// Cache lookup results in case someone does more lookups on the same path.
final Map<
(String path, bool stopAtPubspec),
(String? configPath, PackageConfig? config)
>
_packageConfigCache = {};
PackageConfigLoader({this.stopAtPubspec = false, this.noParent = false});
/// Finds a package configuration relative to [path].
///
/// Caches result for each directory looked at.
/// If someone does multiple lookups in the same directory, there is no need
/// to find and parse the same configuration more than once.
Future<(String? path, PackageConfig? config)> findPackageConfig(
String path,
) async =>
_packageConfigCache[(
path,
stopAtPubspec,
)] ??= await _findPackageConfigNoCache(path);
Future<(String? path, PackageConfig? config)> _findPackageConfigNoCache(
String path,
) async {
var configPath = p.join(path, '.dart_tool', 'package_config.json');
var configFile = File(configPath);
if (configFile.existsSync()) {
var hasError = false;
var config = await loadPackageConfig(
configFile.absolute,
onError: (error) {
stderr.writeln(
'Error parsing package configuration config ($configPath):\n'
' $error',
);
hasError = true;
},
);
return (configPath, hasError ? null : config);
}
if (stopAtPubspec) {
var pubspecPath = p.join(path, 'pubspec.yaml');
var pubspecFile = File(pubspecPath);
if (pubspecFile.existsSync()) {
stderr
..writeln('Found pubspec.yaml with no .dart_tool/package_config.json')
..writeln(' at $path');
return (null, null);
}
}
if (noParent && path == p.current) return (null, null);
var parentPath = p.dirname(path);
if (parentPath == path) return (null, null);
// Recurse on parent path.
return findPackageConfig(parentPath);
}
}
const String usage = '''
Usage: dart package_config_of.dart [-p|-j|-jl|-h] PATH*
Searches from (each) PATH for a `.dart_tool/package_config.json` file,
loads that, and prints information about that package configuration
and the configuration for PATH in it.
If no PATH is given, the current directory is used.
Flags:
-p : Stop when finding a 'pubspec.yaml' file without a package config.
The default is to ignore `pubspec.yaml` files and only look for
`.dart_tool/package_config.json` files, which will work correctly
for Pub workspaces.
-b : Stop if reaching the current working directory.
When looking for a package configuration, don't try further out
than the current directory.
Only works for starting PATHs inside the current directory.
-j : Output as JSON. Emits a single JSON object for each file, on
a line of its own (JSON-lines format). See format below.
Default is to output human readable text.
-jl : Emits as a single JSON list containing the JSON outputs.
See format of individual elements below.
Default is to output human readable text.
-h : Print this help text. Ignore any PATHs.
A JSON object written using -j or -jl has following entries:
"path": "PATH", normalized and ends in a `/` if a directory.
"configPath": path to config file. Omitted if no or invalid config.
"package": The package that PATH belongs to, if any. A map with:
"name": Package name.
"root": Path to package root.
"languageVersion": "A.B", default language version for package.
"packageUri": The package: URI of PATH, if one exists.
"lib": If package URI exists, the package URI root,
unless it's the same as the package root.
"languageVersionOverride": "A.B", if file has '//@dart=' version override.
''';