blob: db78765dbeb8b80391a9a1406a3b44dd6dc9d38e [file] [log] [blame]
// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'package:analysis_server/src/services/pub/pub_api.dart';
import 'package:analysis_server/src/services/pub/pub_command.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/instrumentation/service.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
/// Information about Pub packages that can be converted to/from JSON and
/// cached to disk.
class PackageDetailsCache {
static const cacheVersion = 3;
static const maxCacheAge = Duration(hours: 18);
static const maxPackageDetailsRequestsInFlight = 5;
/// Requests to write the cache from fetching packge details will be debounced
/// by this duration to prevent many writes while the user may be cursoring
/// though completion requests that will trigger fetching descriptions/versions.
static const _writeCacheDebounceDuration = Duration(seconds: 3);
final Map<String, PubPackage> packages;
DateTime lastUpdatedUtc;
PackageDetailsCache._(this.packages, DateTime lastUpdated)
: lastUpdatedUtc = lastUpdated.toUtc();
Duration get cacheTimeRemaining {
final cacheAge = DateTime.now().toUtc().difference(lastUpdatedUtc);
final cacheTimeRemaining = maxCacheAge - cacheAge;
return cacheTimeRemaining < Duration.zero
? Duration.zero
: cacheTimeRemaining;
}
Map<String, Object> toJson() {
return {
'version': cacheVersion,
'lastUpdated': lastUpdatedUtc.toIso8601String(),
'packages': packages.values.toList(),
};
}
static PackageDetailsCache empty() {
return PackageDetailsCache._({}, DateTime.utc(2000));
}
static PackageDetailsCache fromApiResults(List<PubApiPackage> apiPackages) {
final packages = Map.fromEntries(apiPackages.map((package) =>
MapEntry(package.packageName, PubPackage.fromName(package))));
return PackageDetailsCache._(packages, DateTime.now().toUtc());
}
/// Deserializes cached package data from JSON.
///
/// If the JSON version does not match the current version, will return null.
static PackageDetailsCache? fromJson(Map<String, Object?> json) {
if (json['version'] != cacheVersion) {
return null;
}
final packagesJson = json['packages'];
if (packagesJson is! List<Object?>) {
return null;
}
final packages = <PubPackage>[];
for (final packageJson in packagesJson) {
if (packageJson is! Map<String, Object?>) {
return null;
}
final nameJson = packageJson['packageName'];
if (nameJson is! String) {
return null;
}
packages.add(PubPackage.fromJson(packageJson));
}
final packageMap = Map.fromEntries(
packages.map(
(package) => MapEntry(package.packageName, package),
),
);
final lastUpdatedJson = json['lastUpdated'];
if (lastUpdatedJson is! String) {
return null;
}
final lastUpdated = DateTime.tryParse(lastUpdatedJson);
if (lastUpdated == null) {
return null;
}
return PackageDetailsCache._(packageMap, lastUpdated);
}
}
/// Information about a single Pub package.
class PubPackage {
String packageName;
String? description;
String? latestVersion;
PubPackage.fromDetails(PubApiPackageDetails package)
: packageName = package.packageName,
description = package.description,
latestVersion = package.latestVersion;
PubPackage.fromJson(Map<String, Object?> json)
: packageName = json['packageName'] as String,
description = json['description'] as String?,
latestVersion = json['latestVersion'] as String?;
PubPackage.fromName(PubApiPackage package)
: packageName = package.packageName;
Map<String, Object> toJson() {
return {
'packageName': packageName,
if (description != null) 'description': description!,
if (latestVersion != null) 'latestVersion': latestVersion!,
};
}
}
/// A service for providing Pub package information.
///
/// Uses a [PubApi] to communicate with the Pub API and a [PubCommand] to
/// interact with the local `pub` command.
///
/// Expensive results are cached to disk using [resourceProvider].
class PubPackageService {
final InstrumentationService _instrumentationService;
final PubApi _api;
/// A wrapper over the "pub" command line too.
///
/// This can be null when not running on a real file system because it may
/// try to interact with folders that don't really exist.
final PubCommand? _command;
Timer? _nextPackageNameListRequestTimer;
Timer? _nextWriteDiskCacheTimer;
/// [ResourceProvider] used for accessing the disk for caches and checking
/// project types. This will be a [PhysicalResourceProvider] outside of tests.
final ResourceProvider resourceProvider;
/// The current cache of package information. Initially `null`, but
/// overwritten after first read of cache from disk or fetch from the API.
@visibleForTesting
PackageDetailsCache? packageCache;
int _packageDetailsRequestsInFlight = 0;
/// A cache of version numbers from running the "pub outdated" command used
/// for completion in pubspec.yaml.
final _pubspecPackageVersions =
<String, Map<String, PubOutdatedPackageDetails>>{};
PubPackageService(this._instrumentationService, this.resourceProvider,
this._api, this._command);
/// Gets the last set of package results from the Pub API or an empty List if
/// no results.
///
/// This data is used for completion of package names in pubspec.yaml
/// and for clients that support lazy resolution of completion items may also
/// include their descriptions and/or version numbers.
List<PubPackage> get cachedPackages =>
packageCache?.packages.values.toList() ?? [];
@visibleForTesting
bool get isPackageNamesTimerRunning =>
_nextPackageNameListRequestTimer != null;
@visibleForTesting
File get packageCacheFile {
final cacheFolder = resourceProvider
.getStateLocation('.pub-package-details-cache')!
..create();
return cacheFolder.getChildAssumingFile('packages.json');
}
/// Begins preloading caches for package names and pub versions.
void beginCachePreloads(List<String> pubspecs) {
beginPackageNamePreload();
for (final pubspec in pubspecs) {
fetchPackageVersionsViaPubOutdated(pubspec, pubspecWasModified: false);
}
}
/// Begin a timer to pre-load and update the package name list if one has not
/// already been started.
void beginPackageNamePreload() {
if (isPackageNamesTimerRunning) {
return;
}
// If first time, try to read from disk.
var cache = packageCache;
if (cache == null) {
cache ??= readDiskCache() ?? PackageDetailsCache.empty();
packageCache = cache;
}
// If there is no queued request, initialize one when the current cache expires.
_nextPackageNameListRequestTimer ??=
Timer(cache.cacheTimeRemaining, _fetchFromServer);
}
/// Gets the latest cached package version fetched from the Pub API for the
/// package [packageName].
String? cachedPubApiLatestVersion(String packageName) =>
packageCache?.packages[packageName]?.latestVersion;
/// Gets the package versions cached using "pub outdated" for the package
/// [packageName] for the project using [pubspecPath].
///
/// Versions in here might only be available for packages that are in the
/// pubspec on disk. Newly-added packages in the overlay might not be
/// available.
PubOutdatedPackageDetails? cachedPubOutdatedVersions(
String pubspecPath, String packageName) {
final pubspecCache = _pubspecPackageVersions[pubspecPath];
return pubspecCache != null ? pubspecCache[packageName] : null;
}
/// Begin a request to pre-load package versions using the "pub outdated"
/// command.
///
/// If [pubspecWasModified] is true, the command will always be run. Otherwise it
/// will only be run if data is not already cached.
Future<void> fetchPackageVersionsViaPubOutdated(String pubspecPath,
{required bool pubspecWasModified}) async {
final pubCommand = _command;
if (pubCommand == null) {
return;
}
// If we already have a cache for the file and it was not modified (only
// opened) we do not need to re-run the command.
if (!pubspecWasModified &&
_pubspecPackageVersions.containsKey(pubspecPath)) {
return;
}
// Check if this pubspec is inside a DEPS-managed folder, and if so
// just cache an empty set of results since Pub is not managing
// dependencies.
if (_hasAncestorDEPSFile(pubspecPath)) {
_pubspecPackageVersions.putIfAbsent(pubspecPath, () => {});
return;
}
final results = await pubCommand.outdatedVersions(pubspecPath);
final cache = _pubspecPackageVersions.putIfAbsent(pubspecPath, () => {});
for (final package in results) {
// We use the versions from the "pub outdated" results but only cache them
// in-memory for this specific pubspec, as the resolved version may be
// restricted by constraints/dependencies in the pubspec. The "pub"
// command does caching of the JSON versions to make "pub outdated" fast.
cache[package.packageName] = package;
}
}
/// Clears package caches for [pubspecPath].
///
/// Does not remove other caches that are not pubspec-specific (for example
/// the latest version pulled directly from the Pub API independant of
/// pubspec).
Future<void> flushPackageCaches(String pubspecPath) async {
_pubspecPackageVersions.remove(pubspecPath);
}
/// Gets package details for package [packageName].
///
/// If the package details are not cached, will call the Pub API and cache
/// the result. Results are cached for the same period as the main package
/// list cache - that is, when the package list cache expires, all cached
/// package details will go with it.
Future<PubPackage?> packageDetails(String packageName) async {
var packageData = packageCache?.packages[packageName];
// If we don't have the version for this package, we don't have its full details.
if (packageData?.latestVersion == null &&
// Limit the number of package details requests that can be in-flight at
// once since an editor may send many of these requests as the user
// cursors through the results (a good editor will cancel the resolve
// requests, but we may have already started the requests synchronously
// before handling a cancellation).
_packageDetailsRequestsInFlight <=
PackageDetailsCache.maxPackageDetailsRequestsInFlight) {
_packageDetailsRequestsInFlight++;
try {
final details = await _api.packageInfo(packageName);
if (details != null) {
packageData = PubPackage.fromDetails(details);
packageCache?.packages[packageName] = packageData;
_writeDiskCacheDebounced();
}
} finally {
_packageDetailsRequestsInFlight--;
}
}
return packageData;
}
@visibleForTesting
PackageDetailsCache? readDiskCache() {
final file = packageCacheFile;
if (!file.exists) {
return null;
}
try {
final contents = file.readAsStringSync();
final json = jsonDecode(contents);
if (json is Map<String, Object?>) {
return PackageDetailsCache.fromJson(json);
} else {
return null;
}
} catch (e) {
_instrumentationService.logError('Error reading pub cache file: $e');
return null;
}
}
void shutdown() {
_nextPackageNameListRequestTimer?.cancel();
_command?.shutdown();
}
@visibleForTesting
void writeDiskCache([PackageDetailsCache? cache]) {
cache ??= packageCache;
if (cache == null) {
return;
}
final file = packageCacheFile;
file.writeAsStringSync(jsonEncode(cache.toJson()));
}
Future<void> _fetchFromServer() async {
try {
final packages = await _api.allPackages();
// If we never got a valid response, just skip until the next refresh.
if (packages == null) {
return;
}
final packageCache = PackageDetailsCache.fromApiResults(packages);
this.packageCache = packageCache;
writeDiskCache();
} catch (e) {
_instrumentationService.logError('Failed to fetch packages from Pub: $e');
} finally {
_nextPackageNameListRequestTimer =
Timer(PackageDetailsCache.maxCacheAge, _fetchFromServer);
}
}
/// Checks whether there is a DEPS file in any folder walking up from the
/// pubspec at [pubspecPath].
bool _hasAncestorDEPSFile(String pubspecPath) {
var folder = path.dirname(pubspecPath);
do {
if (resourceProvider.getFile(path.join(folder, 'DEPS')).exists) {
return true;
}
folder = path.dirname(folder);
} while (folder != path.dirname(folder));
return false;
}
/// Writes the package cache to disk after
/// [PackageDetailsCache._writeCacheDebounceDuration] has elapsed, restarting
/// the timer each time this method is called.
void _writeDiskCacheDebounced() {
_nextWriteDiskCacheTimer?.cancel();
_nextWriteDiskCacheTimer =
Timer(PackageDetailsCache._writeCacheDebounceDuration, writeDiskCache);
}
}