From d728dca5277add98110774abdca50ed4c4d4c7d9 Mon Sep 17 00:00:00 2001
From: Felix Fontein <felix@fontein.de>
Date: Thu, 17 Feb 2022 08:24:32 +0100
Subject: [PATCH] Use ansible-doc's --metadata-dump for ansible-core 2.13+.

---
 src/antsibull_changelog/ansible.py |   2 +-
 src/antsibull_changelog/plugins.py | 104 ++++++++++++++++++++++++++---
 2 files changed, 96 insertions(+), 10 deletions(-)

diff --git a/src/antsibull_changelog/ansible.py b/src/antsibull_changelog/ansible.py
index 180c37fa..e335255c 100644
--- a/src/antsibull_changelog/ansible.py
+++ b/src/antsibull_changelog/ansible.py
@@ -38,7 +38,7 @@
 
 def get_documentable_plugins() -> Tuple[str, ...]:
     """
-    Retrieve plugin types that can be documented. Does not include 'module'.
+    Retrieve plugin types that can be documented.
     """
     if HAS_ANSIBLE_CONSTANTS:
         return C.DOCUMENTABLE_PLUGINS
diff --git a/src/antsibull_changelog/plugins.py b/src/antsibull_changelog/plugins.py
index 026bc40a..3f5b8478 100644
--- a/src/antsibull_changelog/plugins.py
+++ b/src/antsibull_changelog/plugins.py
@@ -10,13 +10,18 @@
 
 import json
 import os
+import re
 import shutil
 import subprocess
 import tempfile
 
 from typing import Any, Dict, List, Optional
 
-from .ansible import get_documentable_plugins, get_documentable_objects, PLUGIN_EXCEPTIONS
+import packaging.version
+
+from .ansible import (
+    get_ansible_release, get_documentable_plugins, get_documentable_objects, PLUGIN_EXCEPTIONS,
+)
 from .config import CollectionDetails, PathsConfig
 from .logger import LOGGER
 from .yaml import load_yaml, store_yaml
@@ -101,7 +106,8 @@ def extract_namespace(paths: PathsConfig, collection_name: Optional[str], filena
 def jsondoc_to_metadata(paths: PathsConfig,  # pylint: disable=too-many-arguments
                         collection_name: Optional[str],
                         plugin_type: str, name: str, data: Dict[str, Any],
-                        category: str = 'plugin') -> Dict[str, Any]:
+                        category: str = 'plugin',
+                        is_ansible_core_2_13: bool = False) -> Dict[str, Any]:
     """
     Convert ``ansible-doc --json`` output to plugin metadata.
 
@@ -111,6 +117,7 @@ def jsondoc_to_metadata(paths: PathsConfig,  # pylint: disable=too-many-argument
     :arg name: The plugin's name
     :arg data: The JSON for this plugin returned by ``ansible-doc --json``
     :arg category: Set to ``object`` for roles and playbooks
+    :arg is_ansible_core_2_13: Set to ``True`` for ``--metadata-dump`` output
     """
     namespace: Optional[str] = None
     if collection_name and name.startswith(collection_name + '.'):
@@ -121,12 +128,20 @@ def jsondoc_to_metadata(paths: PathsConfig,  # pylint: disable=too-many-argument
         if 'main' in entrypoints:
             docs = entrypoints['main']
     if category == 'plugin' and plugin_type == 'module':
-        filename: Optional[str] = docs.get('filename')
-        if filename:
-            namespace = extract_namespace(paths, collection_name, filename)
-        if '.' in name:
-            # Flatmapping
-            name = name[name.rfind('.') + 1:]
+        if is_ansible_core_2_13:
+            last_dot = name.rindex('.')
+            if last_dot >= 0:
+                namespace = name[:last_dot]
+                name = name[last_dot + 1:]
+            else:
+                namespace = ''
+        else:
+            filename: Optional[str] = docs.get('filename')
+            if filename:
+                namespace = extract_namespace(paths, collection_name, filename)
+            if '.' in name:
+                # Flatmapping
+                name = name[name.rfind('.') + 1:]
     return {
         'description': docs.get('short_description'),
         'name': name,
@@ -256,6 +271,20 @@ def run_ansible_doc(paths: PathsConfig, playbook_dir: Optional[str],
     return json.loads(output.decode('utf-8'))
 
 
+def run_ansible_doc_metadata_dump(paths: PathsConfig, playbook_dir: Optional[str],
+                                  collection_name: Optional[str]) -> dict:
+    """
+    Runs ansible-doc to retrieve documentation for all plugins in a collection.
+    """
+    command = [paths.ansible_doc_path, '--metadata-dump', '--dont-fail-on-errors']
+    if collection_name:
+        command.append(collection_name)
+    if playbook_dir:
+        command.extend(['--playbook-dir', playbook_dir])
+    output = subprocess.check_output(command)
+    return json.loads(output.decode('utf-8'))
+
+
 def load_plugin_metadata(paths: PathsConfig,  # pylint: disable=too-many-arguments
                          playbook_dir: Optional[str],
                          plugin_type: str,
@@ -324,6 +353,40 @@ def __exit__(self, type_, value, traceback_):
         shutil.rmtree(self.dir, ignore_errors=True)
 
 
+def _load_plugins_2_13(plugins_data: Dict[str, Any],
+                       paths: PathsConfig,
+                       collection_name: str,
+                       playbook_dir: Optional[str] = None) -> None:
+    data = run_ansible_doc_metadata_dump(paths, playbook_dir, collection_name)['all']
+
+    for category, category_types in (
+        ('plugins', get_documentable_plugins()),
+        ('objects', get_documentable_objects()),
+    ):
+        for plugin_type in category_types:
+            if plugin_type in data:
+                plugins_data[category][plugin_type] = {}
+                for plugin_name, plugin_data in data[plugin_type].items():
+                    if plugin_name.startswith('ansible.builtin._'):
+                        plugin_name = plugin_name.replace('_', '', 1)
+                    processed_data = jsondoc_to_metadata(
+                        paths, collection_name, plugin_type, plugin_name,
+                        plugin_data, category=category[:-1])
+                    plugins_data[category][plugin_type][processed_data['name']] = processed_data
+
+
+def _load_collection_plugins_2_13(plugins_data: Dict[str, Any],
+                                  paths: PathsConfig,
+                                  collection_details: CollectionDetails) -> None:
+    collection_name = '{}.{}'.format(
+        collection_details.get_namespace(), collection_details.get_name())
+
+    with CollectionCopier(
+            paths, collection_details.get_namespace(), collection_details.get_name()
+    ) as (playbook_dir, new_paths):
+        _load_plugins_2_13(plugins_data, new_paths, collection_name, playbook_dir=playbook_dir)
+
+
 def _load_collection_plugins(plugins_data: Dict[str, Any],
                              paths: PathsConfig,
                              collection_details: CollectionDetails,
@@ -352,6 +415,23 @@ def _load_ansible_plugins(plugins_data: Dict[str, Any], paths: PathsConfig,
             paths, None, plugin_type, None, use_ansible_doc=use_ansible_doc)
 
 
+def _get_ansible_core_version(paths: PathsConfig) -> packaging.version.Version:
+    try:
+        version, _ = get_ansible_release()
+        return packaging.version.Version(version)
+    except ValueError:
+        pass
+
+    command = [paths.ansible_doc_path, '--version']
+    output = subprocess.check_output(command).decode('utf-8')
+    for regex in (r'^ansible-doc \[(?:core|base) ([^\]]+)\]', r'^ansible-doc ([^\s]+)'):
+        match = re.match(regex, output)
+        if match:
+            return packaging.version.Version(match.group(1))
+    raise Exception(
+        f'Cannot extract ansible-core version from ansible-doc --version output:\n{output}')
+
+
 def _refresh_plugin_cache(paths: PathsConfig,
                           collection_details: CollectionDetails,
                           version: str,
@@ -364,7 +444,13 @@ def _refresh_plugin_cache(paths: PathsConfig,
         'objects': {},
     }
 
-    if paths.is_collection:
+    core_version = _get_ansible_core_version(paths)
+    if core_version >= packaging.version.Version('2.13.0.dev0'):
+        if paths.is_collection:
+            _load_collection_plugins_2_13(plugins_data, paths, collection_details)
+        else:
+            _load_plugins_2_13(plugins_data, paths, 'ansible.builtin')
+    elif paths.is_collection:
         _load_collection_plugins(plugins_data, paths, collection_details, use_ansible_doc)
     else:
         _load_ansible_plugins(plugins_data, paths, use_ansible_doc)