pw_env_setup: Dump actions to json

Dump environment variable operations to JSON for easy parsing by tools
that can't grab their environment from the shell, like certain IDEs.

Change-Id: I3bf58564473d1d022286a1c6690bc74d30bf63c1
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/21460
Reviewed-by: Michael Spang <spang@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
diff --git a/bootstrap.sh b/bootstrap.sh
index 6d00895..8321bf3 100644
--- a/bootstrap.sh
+++ b/bootstrap.sh
@@ -84,7 +84,7 @@
 if [ "$(basename "$_BOOTSTRAP_PATH")" = "bootstrap.sh" ] || \
   [ ! -f "$SETUP_SH" ] || \
   [ ! -s "$SETUP_SH" ]; then
-  pw_bootstrap --shell-file "$SETUP_SH" --install-dir "$_PW_ACTUAL_ENVIRONMENT_ROOT" --use-pigweed-defaults
+  pw_bootstrap --shell-file "$SETUP_SH" --install-dir "$_PW_ACTUAL_ENVIRONMENT_ROOT" --use-pigweed-defaults --json-file "$_PW_ACTUAL_ENVIRONMENT_ROOT/actions.json"
   pw_finalize bootstrap "$SETUP_SH"
 else
   pw_activate
diff --git a/pw_env_setup/py/pw_env_setup/env_setup.py b/pw_env_setup/py/pw_env_setup/env_setup.py
index a642f29..b213249 100755
--- a/pw_env_setup/py/pw_env_setup/env_setup.py
+++ b/pw_env_setup/py/pw_env_setup/env_setup.py
@@ -174,7 +174,7 @@
     def __init__(self, pw_root, cipd_cache_dir, shell_file, quiet, install_dir,
                  use_pigweed_defaults, cipd_package_file,
                  virtualenv_requirements, virtualenv_setup_py_root,
-                 cargo_package_file, enable_cargo, *args, **kwargs):
+                 cargo_package_file, enable_cargo, json_file, *args, **kwargs):
         super(EnvSetup, self).__init__(*args, **kwargs)
         self._env = environment.Environment()
         self._pw_root = pw_root
@@ -198,6 +198,8 @@
         self._cargo_package_file = []
         self._enable_cargo = enable_cargo
 
+        self._json_file = json_file
+
         setup_root = os.path.join(pw_root, 'pw_env_setup', 'py',
                                   'pw_env_setup')
 
@@ -337,6 +339,9 @@
             outs.write(
                 json.dumps(config, indent=4, separators=(',', ': ')) + '\n')
 
+        with open(os.path.join(self._json_file), 'w') as outs:
+            self._env.json(outs)
+
         return 0
 
     def cipd(self):
@@ -510,6 +515,12 @@
         action='store_true',
     )
 
+    parser.add_argument(
+        '--json-file',
+        help='Dump environment variable operations to a JSON file.',
+        default=None,
+    )
+
     args = parser.parse_args(argv)
 
     one_required = (
diff --git a/pw_env_setup/py/pw_env_setup/environment.py b/pw_env_setup/py/pw_env_setup/environment.py
index 57b3fca..efe523b 100644
--- a/pw_env_setup/py/pw_env_setup/environment.py
+++ b/pw_env_setup/py/pw_env_setup/environment.py
@@ -14,6 +14,7 @@
 """Stores the environment changes necessary for Pigweed."""
 
 import contextlib
+import json
 import os
 import re
 
@@ -49,6 +50,9 @@
     def unapply(self, env, orig_env):  # pylint: disable=no-self-use
         del env, orig_env  # Only used in _VariableAction and subclasses.
 
+    def json(self, data):  # pylint: disable=no-self-use
+        del data  # Unused.
+
 
 class _VariableAction(_Action):
     # pylint: disable=redefined-builtin,too-few-public-methods
@@ -121,6 +125,9 @@
     def apply(self, env):
         env[self.name] = self.value
 
+    def json(self, data):
+        data['set'][self.name] = self.value
+
 
 class Clear(_VariableAction):
     """Remove a variable from the environment."""
@@ -140,6 +147,14 @@
         if self.name in env:
             del env[self.name]
 
+    def json(self, data):
+        data['set'][self.name] = None
+
+
+def _initialize_path_like_variable(data, name):
+    default = {'append': [], 'prepend': [], 'remove': []}
+    data['modify'].setdefault(name, default)
+
 
 class Remove(_VariableAction):
     """Remove a value from a PATH-like variable."""
@@ -179,6 +194,14 @@
         env[self.name] = env[self.name].replace(
             '{}{}'.format(self._pathsep, self.value), '')
 
+    def json(self, data):
+        _initialize_path_like_variable(data, self.name)
+        data['modify'][self.name]['remove'].append(self.value)
+        if self.value in data['modify'][self.name]['append']:
+            data['modify'][self.name]['append'].remove(self.value)
+        if self.value in data['modify'][self.name]['prepend']:
+            data['modify'][self.name]['prepend'].remove(self.value)
+
 
 class BadVariableValue(ValueError):
     pass
@@ -216,6 +239,12 @@
         super(Prepend, self)._check()
         _append_prepend_check(self)
 
+    def json(self, data):
+        _initialize_path_like_variable(data, self.name)
+        data['modify'][self.name]['prepend'].append(self.value)
+        if self.value in data['modify'][self.name]['remove']:
+            data['modify'][self.name]['remove'].remove(self.value)
+
 
 class Append(_VariableAction):
     """Append a value to a PATH-like variable. (Uncommon, see Prepend.)"""
@@ -244,6 +273,12 @@
         super(Append, self)._check()
         _append_prepend_check(self)
 
+    def json(self, data):
+        _initialize_path_like_variable(data, self.name)
+        data['modify'][self.name]['append'].append(self.value)
+        if self.value in data['modify'][self.name]['remove']:
+            data['modify'][self.name]['remove'].remove(self.value)
+
 
 class BadEchoValue(ValueError):
     pass
@@ -495,6 +530,18 @@
         if self._windows:
             outs.write(':{}\n'.format(_SCRIPT_END_LABEL))
 
+    def json(self, outs):
+        data = {
+            'modify': {},
+            'set': {},
+        }
+
+        for action in self._actions:
+            action.json(data)
+
+        json.dump(data, outs, indent=4, separators=(',', ': '))
+        outs.write('\n')
+
     @contextlib.contextmanager
     def __call__(self, export=True):
         """Set environment as if this was written to a file and sourced.