blob: 6eaab177198a08550daa20177e6cdea846737547 [file] [log] [blame]
// Copyright (c) 2017, 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.
/// @docImport 'package:analysis_server_plugin/src/plugin_server.dart';
/// @docImport 'package:analysis_server/src/plugin/plugin_watcher.dart';
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform, Process, ProcessResult;
import 'package:analysis_server/src/analytics/percentile_calculator.dart';
import 'package:analysis_server/src/plugin/notification_manager.dart';
import 'package:analysis_server/src/plugin/plugin_isolate.dart';
import 'package:analysis_server/src/utilities/sdk.dart';
import 'package:analyzer/dart/analysis/context_root.dart' as analyzer;
import 'package:analyzer/exception/exception.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/instrumentation/instrumentation.dart';
import 'package:analyzer/src/generated/source.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:analyzer/src/util/glob.dart';
import 'package:analyzer/src/workspace/blaze.dart';
import 'package:analyzer/src/workspace/workspace.dart';
import 'package:analyzer/utilities/package_config_file_builder.dart';
import 'package:analyzer_plugin/protocol/protocol.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:analyzer_plugin/protocol/protocol_generated.dart';
import 'package:analyzer_plugin/src/protocol/protocol_internal.dart';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:meta/meta.dart';
import 'package:watcher/watcher.dart' as watcher;
import 'package:yaml/yaml.dart';
const _builtAsAot = bool.fromEnvironment('built_as_aot');
/// An indication of a problem with the execution of a plugin that occurs prior
/// to the execution of the plugin's entry point in an isolate.
class PluginException implements Exception {
/// A message describing the problem.
final String message;
/// Initialize a newly created exception to have the given [message].
PluginException(this.message);
@override
String toString() => message;
}
/// The necessary files that define an analyzer plugin on disk.
class PluginFiles {
/// The plugin entry point.
final File execution;
/// The plugin package config file.
final File packageConfig;
PluginFiles(this.execution, this.packageConfig);
}
/// An object used to manage the currently running plugins.
class PluginManager {
/// A table, keyed by both a plugin and a request method, to a list of the
/// times that it took the plugin to return a response to requests with the
/// method.
static Map<PluginIsolate, Map<String, PercentileCalculator>>
pluginResponseTimes = <PluginIsolate, Map<String, PercentileCalculator>>{};
/// The console environment key used by the pub tool.
static const String _pubEnvironmentKey = 'PUB_ENVIRONMENT';
/// The resource provider used to access the file system.
final ResourceProvider _resourceProvider;
/// The absolute path of the directory containing the on-disk byte store, or
/// `null` if there is no on-disk store.
final String? _byteStorePath;
/// The absolute path of the directory containing the SDK.
final String _sdkPath;
/// The object used to manage the receiving and sending of notifications.
final AbstractNotificationManager _notificationManager;
/// The instrumentation service that is being used by the analysis server.
final InstrumentationService instrumentationService;
/// A table mapping the paths of plugins to information about those plugins.
final Map<String, PluginIsolate> _pluginMap = <String, PluginIsolate>{};
/// The parameters for the last 'analysis.setPriorityFiles' request that was
/// received from the client. Because plugins are lazily discovered, this
/// needs to be retained so that it can be sent after a plugin has been
/// started.
AnalysisSetPriorityFilesParams? _analysisSetPriorityFilesParams;
/// The parameters for the last 'analysis.setSubscriptions' request that was
/// received from the client. Because plugins are lazily discovered, this
/// needs to be retained so that it can be sent after a plugin has been
/// started.
AnalysisSetSubscriptionsParams? _analysisSetSubscriptionsParams;
/// The current state of content overlays. Because plugins are lazily
/// discovered, the state needs to be retained so that it can be sent after a
/// plugin has been started.
final Map<String, AddContentOverlay> _overlayState = {};
final StreamController<void> _pluginsChanged = StreamController.broadcast();
/// Whether plugins are "initialized."
///
/// Plugins are declared to be initialized either (a) when the [PluginWatcher]
/// has determined no plugins are configured to be run, or (b) when the
/// plugins are configured and the first status notification is received by
/// the analysis server.
Completer<void> initializedCompleter = Completer();
/// Initialize a newly created plugin manager. The notifications from the
/// running plugins will be handled by the given [_notificationManager].
PluginManager(
this._resourceProvider,
this._byteStorePath,
this._sdkPath,
this._notificationManager,
this.instrumentationService,
);
/// Return a list of all of the plugins that are currently known.
List<PluginIsolate> get pluginIsolates => _pluginMap.values.toList();
/// Stream emitting an event when known [pluginIsolates] change.
Stream<void> get pluginsChanged => _pluginsChanged.stream;
/// Adds the plugin with the given [path] to the list of plugins that should
/// be used when analyzing code for the given [contextRoot].
///
/// If the plugin had not yet been started, then it will be started by this
/// method.
///
/// Specify whether this is a legacy plugin with [isLegacyPlugin].
Future<void> addPluginToContextRoot(
analyzer.ContextRoot contextRoot,
String path, {
required bool isLegacyPlugin,
}) async {
var pluginIsolate = _pluginMap[path];
var isNew = false;
if (pluginIsolate == null) {
isNew = true;
PluginFiles pluginFiles;
try {
pluginFiles = filesFor(path, isLegacyPlugin: isLegacyPlugin);
} catch (exception, stackTrace) {
pluginIsolate = PluginIsolate(
path,
null,
null,
_notificationManager,
instrumentationService,
);
pluginIsolate.reportException(CaughtException(exception, stackTrace));
_pluginMap[path] = pluginIsolate;
return;
}
pluginIsolate = PluginIsolate(
path,
pluginFiles.execution.path,
pluginFiles.packageConfig.path,
_notificationManager,
instrumentationService,
);
_pluginMap[path] = pluginIsolate;
try {
instrumentationService.logInfo('Starting plugin "$pluginIsolate"');
var session = await pluginIsolate.start(_byteStorePath, _sdkPath);
unawaited(
session?.onDone.then((_) {
if (_pluginMap[path] == pluginIsolate) {
_pluginMap.remove(path);
_notifyPluginsChanged();
}
}),
);
} catch (exception, stackTrace) {
// Record the exception (for debugging purposes) and record the fact
// that we should not try to communicate with the plugin.
pluginIsolate.reportException(CaughtException(exception, stackTrace));
isNew = false;
}
_notifyPluginsChanged();
}
pluginIsolate.addContextRoot(contextRoot);
if (isNew) {
var analysisSetSubscriptionsParams = _analysisSetSubscriptionsParams;
if (analysisSetSubscriptionsParams != null) {
pluginIsolate.sendRequest(analysisSetSubscriptionsParams);
}
if (_overlayState.isNotEmpty) {
pluginIsolate.sendRequest(AnalysisUpdateContentParams(_overlayState));
}
var analysisSetPriorityFilesParams = _analysisSetPriorityFilesParams;
if (analysisSetPriorityFilesParams != null) {
pluginIsolate.sendRequest(analysisSetPriorityFilesParams);
}
}
}
/// Broadcast a request built from the given [params] to all of the plugins
/// that are currently associated with the given [contextRoot]. Return a list
/// containing futures that will complete when each of the plugins have sent a
/// response.
Map<PluginIsolate, Future<Response>> broadcastRequest(
RequestParams params, {
analyzer.ContextRoot? contextRoot,
}) {
var pluginIsolates = pluginsForContextRoot(contextRoot);
var responseMap = <PluginIsolate, Future<Response>>{};
for (var pluginIsolate in pluginIsolates) {
var request = pluginIsolate.currentSession?.sendRequest(params);
// Only add an entry to the map if we have sent a request.
if (request != null) {
responseMap[pluginIsolate] = request;
}
}
return responseMap;
}
/// Broadcast the given [watchEvent] to all of the plugins that are analyzing
/// in contexts containing the file associated with the event. Return a list
/// containing futures that will complete when each of the plugins have sent a
/// response.
Future<List<Future<Response>>> broadcastWatchEvent(
watcher.WatchEvent watchEvent,
) async {
var filePath = watchEvent.path;
/// Return `true` if the given glob [pattern] matches the file being
/// watched.
bool matches(String pattern) => Glob(
_resourceProvider.pathContext.separator,
pattern,
).matches(filePath);
WatchEvent? event;
var responses = <Future<Response>>[];
for (var pluginIsolate in _pluginMap.values) {
var session = pluginIsolate.currentSession;
var interestingFiles = session?.interestingFiles;
if (session != null &&
pluginIsolate.isAnalyzing(filePath) &&
interestingFiles != null &&
interestingFiles.any(matches)) {
// The list of interesting file globs is `null` if the plugin has not
// yet responded to the plugin.versionCheck request. If that happens
// then the plugin hasn't had a chance to analyze anything yet, and
// hence it does not needed to get watch events.
event ??= _convertWatchEvent(watchEvent);
var params = AnalysisHandleWatchEventsParams([event]);
responses.add(session.sendRequest(params));
}
}
return responses;
}
/// Returns the files associated with the plugin at the given [pluginPath].
///
/// In some cases, the plugin's sources are copied to a special directory. If
/// [pluginPath] does not include a `pubspec.yaml` file, we do not. If
/// [pluginPath] exists in a [BlazeWorkspace], we do not.
///
/// Throws a [PluginException] if there is a problem that prevents the plugin
/// from being executing.
@visibleForTesting
PluginFiles filesFor(String pluginPath, {required bool isLegacyPlugin}) {
var pluginFolder = _resourceProvider.getFolder(pluginPath);
var pubspecFile = pluginFolder.getChildAssumingFile(file_paths.pubspecYaml);
if (!pubspecFile.exists) {
// If there's no pubspec file, then we don't need to copy the package
// because we won't be running pub.
return _computeFiles(pluginFolder);
}
var workspace = BlazeWorkspace.find(_resourceProvider, pluginFolder.path);
if (workspace != null) {
// Similarly, we won't be running pub if we're in a workspace because
// there is exactly one version of each package.
return _computeFiles(pluginFolder, workspace: workspace);
}
if (!isLegacyPlugin) {
return _computeFiles(pluginFolder, pubCommand: 'upgrade');
}
// Copy the plugin directory to a unique subdirectory of the plugin
// manager's state location. The subdirectory's name is selected such that
// it will be invariant across sessions, reducing the number of times we
// copy the plugin contents, and the number of times we run `pub`.
var parentFolder = pluginStateFolder(pluginPath);
if (parentFolder.exists) {
var executionFolder = parentFolder.getChildAssumingFolder(
pluginFolder.shortName,
);
return _computeFiles(executionFolder, pubCommand: 'upgrade');
}
var executionFolder = pluginFolder.copyTo(parentFolder);
return _computeFiles(executionFolder, pubCommand: 'get');
}
/// Return a list of all of the plugin isolates that are currently associated
/// with the given [contextRoot].
@visibleForTesting
List<PluginIsolate> pluginsForContextRoot(analyzer.ContextRoot? contextRoot) {
if (contextRoot == null) {
return _pluginMap.values.toList();
}
return [
for (var pluginIsolate in _pluginMap.values)
if (pluginIsolate.contextRoots.contains(contextRoot)) pluginIsolate,
];
}
/// Returns the "plugin state" folder for a plugin at [pluginPath].
///
/// This is a directory under the state location for '.plugin_manager', named
/// with a hash based on [pluginPath].
Folder pluginStateFolder(String pluginPath) {
var stateFolder = _resourceProvider.getStateLocation('.plugin_manager');
if (stateFolder == null) {
throw PluginException('No state location, so plugin could not be copied');
}
var stateName = _uniqueDirectoryName(pluginPath);
return stateFolder.getChildAssumingFolder(stateName);
}
/// The given [contextRoot] is no longer being analyzed.
void removedContextRoot(analyzer.ContextRoot contextRoot) {
var plugins = _pluginMap.values.toList();
for (var plugin in plugins) {
plugin.removeContextRoot(contextRoot);
if (plugin.contextRoots.isEmpty) {
_pluginMap.remove(plugin.pluginId);
_notifyPluginsChanged();
try {
plugin.stop();
} catch (e, st) {
instrumentationService.logException(
SilentException('Issue stopping a plugin', e, st),
);
}
}
}
}
/// Restart all currently running plugins.
Future<void> restartPlugins() async {
for (var plugin in _pluginMap.values.toList()) {
if (plugin.currentSession != null) {
//
// Capture needed state.
//
var contextRoots = plugin.contextRoots;
var path = plugin.pluginId;
//
// Stop the plugin.
//
await plugin.stop();
//
// Restart the plugin.
//
_pluginMap[path] = plugin;
var session = await plugin.start(_byteStorePath, _sdkPath);
unawaited(
session?.onDone.then((_) {
_pluginMap.remove(path);
}),
);
//
// Re-initialize the plugin.
//
plugin.addContextRoots(contextRoots);
var analysisSetSubscriptionsParams = _analysisSetSubscriptionsParams;
if (analysisSetSubscriptionsParams != null) {
plugin.sendRequest(analysisSetSubscriptionsParams);
}
if (_overlayState.isNotEmpty) {
plugin.sendRequest(AnalysisUpdateContentParams(_overlayState));
}
var analysisSetPriorityFilesParams = _analysisSetPriorityFilesParams;
if (analysisSetPriorityFilesParams != null) {
plugin.sendRequest(analysisSetPriorityFilesParams);
}
}
}
}
/// Send a request based on the given [params] to existing plugins to set the
/// priority files to those specified by the [params]. As a side-effect,
/// record the parameters so that they can be sent to any newly started
/// plugins.
void setAnalysisSetPriorityFilesParams(
AnalysisSetPriorityFilesParams params,
) {
for (var plugin in _pluginMap.values) {
plugin.sendRequest(params);
}
_analysisSetPriorityFilesParams = params;
}
/// Send a request based on the given [params] to existing plugins to set the
/// subscriptions to those specified by the [params]. As a side-effect, record
/// the parameters so that they can be sent to any newly started plugins.
void setAnalysisSetSubscriptionsParams(
AnalysisSetSubscriptionsParams params,
) {
for (var plugin in _pluginMap.values) {
plugin.sendRequest(params);
}
_analysisSetSubscriptionsParams = params;
}
/// Send a request based on the given [params] to existing plugins to set the
/// content overlays to those specified by the [params]. As a side-effect,
/// update the overlay state so that it can be sent to any newly started
/// plugins.
void setAnalysisUpdateContentParams(AnalysisUpdateContentParams params) {
for (var plugin in _pluginMap.values) {
plugin.sendRequest(params);
}
var files = params.files;
for (var file in files.keys) {
var overlay = files[file];
if (overlay is RemoveContentOverlay) {
_overlayState.remove(file);
} else if (overlay is AddContentOverlay) {
_overlayState[file] = overlay;
} else if (overlay is ChangeContentOverlay) {
var previousOverlay = _overlayState[file]!;
var newContent = SourceEdit.applySequence(
previousOverlay.content,
overlay.edits,
);
_overlayState[file] = AddContentOverlay(newContent);
} else {
throw ArgumentError('Invalid class of overlay: ${overlay.runtimeType}');
}
}
}
/// Stops all of the plugin isolates that are currently running.
Future<List<void>> stopAll() {
return Future.wait(
_pluginMap.values.map((pluginIsolate) async {
try {
await pluginIsolate.stop();
} catch (e, st) {
instrumentationService.logException(e, st);
}
}),
);
}
/// Compiles [entrypoint] to an AOT snapshot and records timing to the
/// instrumentation log.
ProcessResult _compileAotSnapshot(String entrypoint) {
instrumentationService.logInfo(
'Running "dart compile aot-snapshot $entrypoint".',
);
var stopwatch = Stopwatch()..start();
var result = Process.runSync(
sdk.dart,
['compile', 'aot-snapshot', entrypoint],
stderrEncoding: utf8,
stdoutEncoding: utf8,
);
stopwatch.stop();
instrumentationService.logInfo(
'Running "dart compile aot-snapshot" took ${stopwatch.elapsed}.',
);
return result;
}
/// Compiles [pluginFile], in [pluginFolder], to an AOT snapshot, and returns
/// the [File] for the snapshot.
File _compileAsAot({required File pluginFile, required Folder pluginFolder}) {
// When the Dart Analysis Server is built as AOT, then all spawned
// Isolates must also be built as AOT.
var aotResult = _compileAotSnapshot(pluginFile.path);
if (aotResult.exitCode != 0) {
var buffer = StringBuffer();
buffer.writeln(
'Failed to compile "${pluginFile.path}" to an AOT snapshot.',
);
buffer.writeln(' pluginFolder = ${pluginFolder.path}');
buffer.writeln(' exitCode = ${aotResult.exitCode}');
buffer.writeln(' stdout = ${aotResult.stdout}');
buffer.writeln(' stderr = ${aotResult.stderr}');
var exceptionReason = buffer.toString();
instrumentationService.logError(exceptionReason);
throw PluginException(exceptionReason);
}
return pluginFolder
.getChildAssumingFolder('bin')
.getChildAssumingFile('plugin.aot');
}
/// Computes the plugin files, given that the plugin should exist in
/// [pluginFolder].
///
/// Runs `pub` if [pubCommand] is not `null`.
PluginFiles _computeFiles(
Folder pluginFolder, {
String? pubCommand,
Workspace? workspace,
}) {
var pluginFile = pluginFolder
.getChildAssumingFolder('bin')
.getChildAssumingFile('plugin.dart');
if (!pluginFile.exists) {
throw PluginException("File '${pluginFile.path}' does not exist.");
}
File? packageConfigFile = pluginFolder
.getChildAssumingFolder(file_paths.dotDartTool)
.getChildAssumingFile(file_paths.packageConfigJson);
if (pubCommand != null) {
var pubResult = _runPubCommand(pubCommand, pluginFolder);
String? exceptionReason;
if (pubResult.exitCode != 0) {
var buffer = StringBuffer();
buffer.writeln(
'An error occurred while setting up the analyzer plugin package at '
"'${pluginFolder.path}'. The `dart pub $pubCommand` command failed:",
);
buffer.writeln(' exitCode = ${pubResult.exitCode}');
buffer.writeln(' stdout = ${pubResult.stdout}');
buffer.writeln(' stderr = ${pubResult.stderr}');
exceptionReason = buffer.toString();
instrumentationService.logError(exceptionReason);
_notificationManager.handlePluginError(exceptionReason);
}
if (!packageConfigFile.exists) {
exceptionReason ??= 'File "${packageConfigFile.path}" does not exist.';
throw PluginException(exceptionReason);
}
if (_builtAsAot) {
// Update the entrypoint path to be the AOT-compiled file.
pluginFile = _compileAsAot(
pluginFile: pluginFile,
pluginFolder: pluginFolder,
);
}
return PluginFiles(pluginFile, packageConfigFile);
}
if (!packageConfigFile.exists) {
if (workspace == null) {
throw PluginException('Could not create "${packageConfigFile.path}".');
}
packageConfigFile = _createPackageConfigFile(
pluginFolder,
workspace.packageUriResolver,
);
if (packageConfigFile == null) {
throw PluginException(
"Could not create the '${file_paths.packageConfigJson}' file in "
"the workspace at '$workspace'.",
);
}
}
if (_builtAsAot) {
// Update the entrypoint path to be the AOT-compiled file.
pluginFile = _compileAsAot(
pluginFile: pluginFile,
pluginFolder: pluginFolder,
);
}
return PluginFiles(pluginFile, packageConfigFile);
}
WatchEventType _convertChangeType(watcher.ChangeType type) {
return switch (type) {
watcher.ChangeType.ADD => WatchEventType.ADD,
watcher.ChangeType.MODIFY => WatchEventType.MODIFY,
watcher.ChangeType.REMOVE => WatchEventType.REMOVE,
_ => throw StateError('Unknown change type: $type'),
};
}
WatchEvent _convertWatchEvent(watcher.WatchEvent watchEvent) {
return WatchEvent(_convertChangeType(watchEvent.type), watchEvent.path);
}
/// Returns a temporary `package_config.json` file that is appropriate for
/// the plugin in the given [pluginFolder].
///
/// The [packageUriResolver] is used to determine the location of the
/// packages that need to be included in the package config file.
File? _createPackageConfigFile(
Folder pluginFolder,
UriResolver packageUriResolver,
) {
var pluginPath = pluginFolder.path;
var stateFolder = _resourceProvider.getStateLocation('.plugin_manager')!;
var stateName = '${_uniqueDirectoryName(pluginPath)}.packages';
var packageConfigFile = stateFolder.getChildAssumingFile(stateName);
if (!packageConfigFile.exists) {
var pluginPubspec = pluginFolder.getChildAssumingFile(
file_paths.pubspecYaml,
);
if (!pluginPubspec.exists) {
return null;
}
try {
var visitedPackageNames = <String>{};
var packages = <_Package>[];
var context = _resourceProvider.pathContext;
packages.add(_Package(context.basename(pluginPath), pluginFolder));
var pubspecFiles = <File>[];
pubspecFiles.add(pluginPubspec);
while (pubspecFiles.isNotEmpty) {
var pubspecFile = pubspecFiles.removeLast();
for (var packageName in _readDependencies(pubspecFile)) {
if (visitedPackageNames.add(packageName)) {
var uri = Uri.parse('package:$packageName/$packageName.dart');
var packageSource = packageUriResolver.resolveAbsolute(uri);
if (packageSource != null) {
var packageRoot =
_resourceProvider
.getFile(packageSource.fullName)
.parent
.parent;
packages.add(_Package(packageName, packageRoot));
pubspecFiles.add(
packageRoot.getChildAssumingFile(file_paths.pubspecYaml),
);
}
}
}
}
packages.sort((a, b) => a.name.compareTo(b.name));
var packageConfigBuilder = PackageConfigFileBuilder();
for (var package in packages) {
packageConfigBuilder.add(
name: package.name,
rootPath: package.root.path,
);
}
packageConfigFile.writeAsStringSync(
packageConfigBuilder.toContent(
pathContext: _resourceProvider.pathContext,
),
);
} catch (exception) {
// If we are not able to produce a package config file, return `null` so
// that callers will not try to load the plugin.
return null;
}
}
return packageConfigFile;
}
void _notifyPluginsChanged() => _pluginsChanged.add(null);
/// Return the names of packages that are listed as dependencies in the given
/// [pubspecFile].
Iterable<String> _readDependencies(File pubspecFile) {
var document = loadYamlDocument(
pubspecFile.readAsStringSync(),
sourceUrl: pubspecFile.toUri(),
);
var contents = document.contents;
if (contents is YamlMap) {
var dependencies = contents['dependencies'] as YamlNode?;
if (dependencies is YamlMap) {
return dependencies.keys.cast<String>();
}
}
return const <String>[];
}
/// Runs (and records timing to the instrumentation log) a Pub command
/// [pubCommand] in [folder].
ProcessResult _runPubCommand(String pubCommand, Folder folder) {
instrumentationService.logInfo(
'Running "pub $pubCommand" in "${folder.path}".',
);
var stopwatch = Stopwatch()..start();
var result = Process.runSync(
sdk.dart,
['pub', pubCommand],
stderrEncoding: utf8,
stdoutEncoding: utf8,
workingDirectory: folder.path,
environment: {_pubEnvironmentKey: _getPubEnvironmentValue()},
);
stopwatch.stop();
instrumentationService.logInfo(
'Running "pub $pubCommand" took ${stopwatch.elapsed}.',
);
return result;
}
/// Returns a hex-encoded MD5 signature of the given file [path].
String _uniqueDirectoryName(String path) {
var bytes = md5.convert(path.codeUnits).bytes;
return hex.encode(bytes);
}
/// Record the fact that the given [pluginIsolate] responded to a request with
/// the given [method] in the given [time].
static void recordResponseTime(
PluginIsolate pluginIsolate,
String method,
int time,
) {
pluginResponseTimes
.putIfAbsent(pluginIsolate, () => <String, PercentileCalculator>{})
.putIfAbsent(method, () => PercentileCalculator())
.addValue(time);
}
/// Returns the environment value that should be used when running pub.
///
/// Includes any existing environment value, if one exists.
static String _getPubEnvironmentValue() {
// DO NOT update this function without contacting kevmoo.
// We have server-side tooling that assumes the values are consistent.
var values = <String>[];
var existing = Platform.environment[_pubEnvironmentKey];
// If there is an existing value for this var, make sure to include it.
if ((existing != null) && existing.isNotEmpty) {
values.add(existing);
}
values.add('analysis_server.plugin_manager');
return values.join(':');
}
}
class _Package {
final String name;
final Folder root;
_Package(this.name, this.root);
}