analyzer plugins: parse version and path keys in YAML

This enables plugin sources to be specified in one of three ways:

* as a YamlScalar, like `plugin_name: ^1.2.3`
* with a 'version' key, like `plugin_name:\n  version: ^1.2.3`, which
  allows diagnostic configurations to be alongside.
* with a 'path' key, like `plugin_name:\n  path: foo/bar`, which
  allows diagnostic configurations to be alongside.

We introduce a PluginSource with a `toYaml` method to convert the
values back into YAML for the generates pubspec. We update
PluginPackageGenerator to use PluginSource.toYaml.

Change-Id: Ic5f372f0339edd6aa638dd9b568a83f41a023b08
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/393900
Commit-Queue: Samuel Rawlins <srawlins@google.com>
Reviewed-by: Phil Quitslund <pquitslund@google.com>
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/plugin/plugin_watcher.dart b/pkg/analysis_server/lib/src/plugin/plugin_watcher.dart
index 34ec5b2..236332c 100644
--- a/pkg/analysis_server/lib/src/plugin/plugin_watcher.dart
+++ b/pkg/analysis_server/lib/src/plugin/plugin_watcher.dart
@@ -41,8 +41,7 @@
     var contextRoot = driver.analysisContext!.contextRoot;
     _driverInfo[driver] = _DriverInfo(
         contextRoot, <String>[contextRoot.root.path, _getSdkPath(driver)]);
-    var enabledPlugins = driver.enabledLegacyPluginNames;
-    for (var hostPackageName in enabledPlugins) {
+    for (var hostPackageName in driver.enabledLegacyPluginNames) {
       //
       // Determine whether the package exists and defines a plugin.
       //
@@ -66,6 +65,8 @@
       manager.addPluginToContextRoot(
           driver.analysisContext!.contextRoot, pluginPath);
     }
+
+    // TODO(srawlins): Generate package for plugin configurations, with PluginPackageGenerator.
   }
 
   /// The context manager has just removed the given analysis [driver].
diff --git a/pkg/analysis_server/lib/src/plugin2/generator.dart b/pkg/analysis_server/lib/src/plugin2/generator.dart
index cab3548..725da06 100644
--- a/pkg/analysis_server/lib/src/plugin2/generator.dart
+++ b/pkg/analysis_server/lib/src/plugin2/generator.dart
@@ -2,6 +2,8 @@
 // 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 'package:analyzer/dart/analysis/analysis_options.dart';
+
 /// This class can generate various files to make up the shared plugin package.
 class PluginPackageGenerator {
   /// The plugin configuration, a map of plugin names to each plugin's
@@ -9,9 +11,9 @@
   ///
   /// This typically stems from plugin configuration in an analysis options
   /// file.
-  final Map<String, Object> _pluginConfiguration;
+  final List<PluginConfiguration> _pluginConfigurations;
 
-  PluginPackageGenerator(this._pluginConfiguration);
+  PluginPackageGenerator(this._pluginConfigurations);
 
   /// Generates the Dart entrpoint file which is to be spawned in a Dart
   /// isolate by the analysis server.
@@ -20,8 +22,8 @@
       "'package:analysis_server_plugin/src/plugin_server.dart'",
       "'package:analyzer/file_system/physical_file_system.dart'",
       "'package:analyzer_plugin/starter.dart'",
-      for (var name in _pluginConfiguration.keys)
-        "'package:$name/main.dart' as $name",
+      for (var configuration in _pluginConfigurations)
+        "'package:${configuration.name}/main.dart' as ${configuration.name}",
     ];
 
     var buffer = StringBuffer("import 'dart:isolate';");
@@ -36,8 +38,8 @@
     plugins: [
 ''');
     // TODO(srawlins): Format with the formatter, for readability.
-    for (var name in _pluginConfiguration.keys) {
-      buffer.writeln('      $name.plugin,');
+    for (var configuration in _pluginConfigurations) {
+      buffer.writeln('      ${configuration.name}.plugin,');
     }
     buffer.write('''
     ],
@@ -60,18 +62,8 @@
 dependencies:
 ''');
 
-    for (var MapEntry(key: name, value: configuration)
-        in _pluginConfiguration.entries) {
-      switch (configuration) {
-        case String():
-          buffer.writeln('  $name: $configuration');
-        case Map<String, Object>():
-          if (configuration case {'path': String pathValue}) {
-            buffer.writeln('  $name:\n    path: $pathValue');
-          } else if (configuration case {'git': String gitValue}) {
-            buffer.writeln('  $name:\n    git: $gitValue');
-          }
-      }
+    for (var configuration in _pluginConfigurations) {
+      buffer.write(configuration.sourceYaml());
     }
 
     return buffer.toString();
diff --git a/pkg/analysis_server/test/src/plugin2/generator_test.dart b/pkg/analysis_server/test/src/plugin2/generator_test.dart
index 7dfd3d7..b3b5785 100644
--- a/pkg/analysis_server/test/src/plugin2/generator_test.dart
+++ b/pkg/analysis_server/test/src/plugin2/generator_test.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'package:analysis_server/src/plugin2/generator.dart';
+import 'package:analyzer/src/generated/engine.dart';
 import 'package:test/test.dart';
 import 'package:test_reflective_loader/test_reflective_loader.dart';
 
@@ -15,10 +16,16 @@
 @reflectiveTest
 class GeneratorTest {
   void test_entrypointImportsPluginEntrypoints() {
-    var pluginPackageGenerator = PluginPackageGenerator({
-      'no_bools': '^1.0.0',
-      'no_ints': '^1.2.0',
-    });
+    var pluginPackageGenerator = PluginPackageGenerator([
+      PluginConfiguration(
+        name: 'no_bools',
+        source: VersionedPluginSource(constraint: '^1.0.0'),
+      ),
+      PluginConfiguration(
+        name: 'no_ints',
+        source: VersionedPluginSource(constraint: '^1.2.0'),
+      ),
+    ]);
     expect(
       pluginPackageGenerator.generateEntrypoint(),
       contains('''
@@ -29,10 +36,16 @@
   }
 
   void test_entrypointListsPluginInstances() {
-    var pluginPackageGenerator = PluginPackageGenerator({
-      'no_bools': '^1.0.0',
-      'no_ints': '^1.2.0',
-    });
+    var pluginPackageGenerator = PluginPackageGenerator([
+      PluginConfiguration(
+        name: 'no_bools',
+        source: VersionedPluginSource(constraint: '^1.0.0'),
+      ),
+      PluginConfiguration(
+        name: 'no_ints',
+        source: VersionedPluginSource(constraint: '^1.2.0'),
+      ),
+    ]);
     expect(
       pluginPackageGenerator.generateEntrypoint(),
       contains('''
@@ -45,24 +58,34 @@
   }
 
   void test_pubspecContainsGitDependencies() {
-    var pluginPackageGenerator = PluginPackageGenerator({
-      'no_bools': {'git': 'https://example.com/example.git'},
-    });
+    var pluginPackageGenerator = PluginPackageGenerator([
+      PluginConfiguration(
+        name: 'no_bools',
+        source: GitPluginSource(url: 'https://example.com/example.git'),
+      ),
+    ]);
     expect(
       pluginPackageGenerator.generatePubspec(),
       contains('''
 dependencies:
   no_bools:
-    git: https://example.com/example.git
+    git:
+      url: https://example.com/example.git
 '''),
     );
   }
 
   void test_pubspecContainsPathDependencies() {
-    var pluginPackageGenerator = PluginPackageGenerator({
-      'no_bools': {'path': '../no_bools_plugin'},
-      'no_ints': {'path': 'tools/no_ints_plugin'},
-    });
+    var pluginPackageGenerator = PluginPackageGenerator([
+      PluginConfiguration(
+        name: 'no_bools',
+        source: PathPluginSource(path: '../no_bools_plugin'),
+      ),
+      PluginConfiguration(
+        name: 'no_ints',
+        source: PathPluginSource(path: 'tools/no_ints_plugin'),
+      ),
+    ]);
     expect(
       pluginPackageGenerator.generatePubspec(),
       contains('''
@@ -76,10 +99,16 @@
   }
 
   void test_pubspecContainsVersionedDependencies() {
-    var pluginPackageGenerator = PluginPackageGenerator({
-      'no_bools': '^1.0.0',
-      'no_ints': '^1.2.0',
-    });
+    var pluginPackageGenerator = PluginPackageGenerator([
+      PluginConfiguration(
+        name: 'no_bools',
+        source: VersionedPluginSource(constraint: '^1.0.0'),
+      ),
+      PluginConfiguration(
+        name: 'no_ints',
+        source: VersionedPluginSource(constraint: '^1.2.0'),
+      ),
+    ]);
     expect(
       pluginPackageGenerator.generatePubspec(),
       contains('''
diff --git a/pkg/analysis_server_plugin/test/src/plugin_server_error_test.dart b/pkg/analysis_server_plugin/test/src/plugin_server_error_test.dart
index e2340a9..39c8fee 100644
--- a/pkg/analysis_server_plugin/test/src/plugin_server_error_test.dart
+++ b/pkg/analysis_server_plugin/test/src/plugin_server_error_test.dart
@@ -36,6 +36,7 @@
     newAnalysisOptionsYamlFile(packagePath, '''
 plugins:
   no_bools:
+    path: some/path
     diagnostics:
       - no_bools
 ''');
diff --git a/pkg/analysis_server_plugin/test/src/plugin_server_test.dart b/pkg/analysis_server_plugin/test/src/plugin_server_test.dart
index 8bc4dab..9904dbb 100644
--- a/pkg/analysis_server_plugin/test/src/plugin_server_test.dart
+++ b/pkg/analysis_server_plugin/test/src/plugin_server_test.dart
@@ -199,6 +199,7 @@
     var buffer = StringBuffer('''
 plugins:
   no_literals:
+    path: some/path
     diagnostics:
 ''');
     for (var MapEntry(key: diagnosticName, value: isEnabled)
diff --git a/pkg/analyzer/lib/dart/analysis/analysis_options.dart b/pkg/analyzer/lib/dart/analysis/analysis_options.dart
index 4f2901e..3003146 100644
--- a/pkg/analyzer/lib/dart/analysis/analysis_options.dart
+++ b/pkg/analyzer/lib/dart/analysis/analysis_options.dart
@@ -73,12 +73,55 @@
   bool isLintEnabled(String name);
 }
 
+final class GitPluginSource implements PluginSource {
+  final String _url;
+
+  final String? _path;
+
+  final String? _ref;
+
+  GitPluginSource({required String url, String? path, String? ref})
+      : _url = url,
+        _path = path,
+        _ref = ref;
+
+  @override
+  String toYaml({required String name}) {
+    var buffer = StringBuffer()
+      ..writeln('  $name:')
+      ..writeln('    git:')
+      ..writeln('      url: $_url');
+    if (_ref != null) {
+      buffer.writeln('      ref: $_ref');
+    }
+    if (_path != null) {
+      buffer.writeln('      path: $_path');
+    }
+    return buffer.toString();
+  }
+}
+
+final class PathPluginSource implements PluginSource {
+  final String _path;
+
+  PathPluginSource({required String path}) : _path = path;
+
+  @override
+  String toYaml({required String name}) => '''
+  $name:
+    path: $_path
+''';
+}
+
 /// The configuration of a Dart Analysis Server plugin, as specified by
 /// analysis options.
 final class PluginConfiguration {
   /// The name of the plugin being configured.
   final String name;
 
+  /// The source of the plugin being configured.
+  final PluginSource source;
+
   /// The list of specified [DiagnosticConfig]s.
   final Map<String, DiagnosticConfig> diagnosticConfigs;
 
@@ -87,7 +130,34 @@
 
   PluginConfiguration({
     required this.name,
-    required this.diagnosticConfigs,
-    required this.isEnabled,
+    required this.source,
+    this.diagnosticConfigs = const {},
+    this.isEnabled = true,
   });
+
+  String sourceYaml() => source.toYaml(name: name);
+}
+
+/// A description of the source of a plugin.
+
+/// We support all of the source formats documented at
+/// https://dart.dev/tools/pub/dependencies.
+sealed class PluginSource {
+  /// Returns the YAML-formatted source, using [name] as a key, for writing into
+  /// a pubspec 'dependencies' section.
+  String toYaml({required String name});
+}
+
+/// A plugin source using a version constraint, hosted either at pub.dev or
+/// another host.
+// TODO(srawlins): Support a different 'hosted' URL.
+final class VersionedPluginSource implements PluginSource {
+  /// The specified version constraint.
+  final String _constraint;
+
+  VersionedPluginSource({required String constraint})
+      : _constraint = constraint;
+
+  @override
+  String toYaml({required String name}) => '  $name: $_constraint\n';
 }
diff --git a/pkg/analyzer/lib/src/dart/analysis/analysis_options.dart b/pkg/analyzer/lib/src/dart/analysis/analysis_options.dart
index 262ff03..f81be6d 100644
--- a/pkg/analyzer/lib/src/dart/analysis/analysis_options.dart
+++ b/pkg/analyzer/lib/src/dart/analysis/analysis_options.dart
@@ -205,11 +205,14 @@
         return;
       }
       var pluginName = nameNode.toString();
-      if (pluginNode is YamlScalar) {
-        // TODO(srawlins): There may be value in storing the version constraint,
-        // `value`.
+
+      // If the plugin name just maps to a String, then that is the version
+      // constraint; use it and move on.
+      if (pluginNode case YamlScalar(:String value)) {
         pluginConfigurations.add(PluginConfiguration(
-            name: pluginName, diagnosticConfigs: const {}, isEnabled: true));
+          name: pluginName,
+          source: VersionedPluginSource(constraint: value),
+        ));
         return;
       }
 
@@ -217,18 +220,39 @@
         return;
       }
 
-      var diagnostics = pluginNode.valueAt(AnalyzerOptions.diagnostics);
-      if (diagnostics == null) {
+      // Grab either the source value from 'version', 'git', or 'path'. In the
+      // erroneous case that multiple are specified, just take the first. A
+      // warning should be reported by `OptionsFileValidator`.
+      // TODO(srawlins): In adition to 'version' and 'path', try 'git'.
+
+      PluginSource? source;
+      var versionSource = pluginNode.valueAt(AnalyzerOptions.version);
+      if (versionSource case YamlScalar(:String value)) {
+        // TODO(srawlins): Handle the 'hosted' key.
+        source = VersionedPluginSource(constraint: value);
+      } else {
+        var pathSource = pluginNode.valueAt(AnalyzerOptions.path);
+        if (pathSource case YamlScalar(:String value)) {
+          source = PathPluginSource(path: value);
+        }
+      }
+
+      if (source == null) {
+        // Either the source data is malformed, or neither 'version' nor 'git'
+        // was provided. A warning should be reported by OptionsFileValidator.
         return;
       }
 
-      var diagnosticConfigurations = parseDiagnosticsSection(diagnostics);
+      var diagnostics = pluginNode.valueAt(AnalyzerOptions.diagnostics);
+      var diagnosticConfigurations = diagnostics == null
+          ? const <String, RuleConfig>{}
+          : parseDiagnosticsSection(diagnostics);
 
       pluginConfigurations.add(PluginConfiguration(
         name: pluginName,
+        source: source,
         diagnosticConfigs: diagnosticConfigurations,
         // TODO(srawlins): Implement `enabled: false`.
-        isEnabled: true,
       ));
     });
   }
diff --git a/pkg/analyzer/lib/src/task/options.dart b/pkg/analyzer/lib/src/task/options.dart
index ea16d6e..cbbea02 100644
--- a/pkg/analyzer/lib/src/task/options.dart
+++ b/pkg/analyzer/lib/src/task/options.dart
@@ -285,6 +285,8 @@
 
   /// Plugin options.
   static const String diagnostics = 'diagnostics';
+  static const String path = 'path';
+  static const String version = 'version';
 
   static const String propagateLinterExceptions = 'propagate-linter-exceptions';
 
@@ -849,6 +851,7 @@
             sdkVersionConstraint: sdkVersionConstraint,
             sourceIsOptionsForContextRoot: sourceIsOptionsForContextRoot,
           ),
+          // TODO(srawlins): validate the top-level 'plugins' section.
         ];
 
   List<AnalysisError> validate(YamlMap options) {