lint_fix: various quality of life improvements

This revamps lint_fix to do as much as possible
directly in python, rather than stitching together shell commands.

It also adds a few small features to lint_fix to save time and
confusion:
- Rather than having to specify the `full/math/to/a/module`, one can
  just specify the module name: e.g. `framework-minus-apex` instead
  of `frameworks/base/framework-minus-apex`
- Add a `--lint-module` argument to specify a custom lint module to
  be passed to the lint invocation. This is useful when we want to run
  all checks from that module (rather than specifying each one
  individually).
- Add a `--print` argument to print the contents of the lint report at
  the end. This is useful for invocations that lead to warnings but
  not errors.

Bug: 232058525
Test: TH
Change-Id: Ic1e038061618e185a652cf3d2293d895ec09762c
diff --git a/tools/lint/fix/Android.bp b/tools/lint/fix/Android.bp
index 7375c16..43f2122 100644
--- a/tools/lint/fix/Android.bp
+++ b/tools/lint/fix/Android.bp
@@ -23,9 +23,8 @@
 
 python_binary_host {
     name: "lint_fix",
-    main: "lint_fix.py",
-    srcs: ["lint_fix.py"],
-    libs: ["soong_lint_fix"],
+    main: "soong_lint_fix.py",
+    srcs: ["soong_lint_fix.py"],
 }
 
 python_library_host {
diff --git a/tools/lint/fix/README.md b/tools/lint/fix/README.md
index 367d0bc..a5ac2be 100644
--- a/tools/lint/fix/README.md
+++ b/tools/lint/fix/README.md
@@ -5,9 +5,12 @@
 ## What is this?
 
 It's a python script that runs the framework linter,
-and then copies modified files back into the source tree.\
+and then (optionally) copies modified files back into the source tree.\
 Why python, you ask?  Because python is cool ¯\_(ツ)_/¯.
 
+Incidentally, this exposes a much simpler way to run individual lint checks
+against individual modules, so it's useful beyond applying fixes.
+
 ## Why?
 
 Lint is not allowed to modify source files directly via lint's `--apply-suggestions` flag.
@@ -17,30 +20,11 @@
 ## How do I run it?
 **WARNING: You probably want to commit/stash any changes to your working tree before doing this...**
 
-From this directory, run `python lint_fix.py -h`.
-The script's help output explains things that are omitted here.
-
-Alternatively, there is a python binary target you can build to make this
-available anywhere in your tree:
 ```
+source build/envsetup.sh
+lunch cf_x86_64_phone-userdebug # or any lunch target
 m lint_fix
 lint_fix -h
 ```
 
-**Gotcha**: You must have run `source build/envsetup.sh` and `lunch` first.
-
-Example: `lint_fix frameworks/base/services/core/services.core.unboosted UseEnforcePermissionAnnotation --dry-run`
-```shell
-(
-export ANDROID_LINT_CHECK=UseEnforcePermissionAnnotation;
-cd $ANDROID_BUILD_TOP;
-source build/envsetup.sh;
-rm out/soong/.intermediates/frameworks/base/services/core/services.core.unboosted/android_common/lint/lint-report.html;
-m out/soong/.intermediates/frameworks/base/services/core/services.core.unboosted/android_common/lint/lint-report.html;
-cd out/soong/.intermediates/frameworks/base/services/core/services.core.unboosted/android_common/lint;
-unzip suggested-fixes.zip -d suggested-fixes;
-cd suggested-fixes;
-find . -path ./out -prune -o -name '*.java' -print | xargs -n 1 sh -c 'cp $1 $ANDROID_BUILD_TOP/$1' --;
-rm -rf suggested-fixes
-)
-```
+The script's help output explains things that are omitted here.
diff --git a/tools/lint/fix/lint_fix.py b/tools/lint/fix/lint_fix.py
deleted file mode 100644
index 1c83f7b..0000000
--- a/tools/lint/fix/lint_fix.py
+++ /dev/null
@@ -1,29 +0,0 @@
-#  Copyright (C) 2023 The Android Open Source Project
-#
-#  Licensed under the Apache License, Version 2.0 (the "License");
-#  you may not use this file except in compliance with the License.
-#  You may obtain a copy of the License at
-#
-#       http://www.apache.org/licenses/LICENSE-2.0
-#
-#  Unless required by applicable law or agreed to in writing, software
-#  distributed under the License is distributed on an "AS IS" BASIS,
-#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-#  See the License for the specific language governing permissions and
-#  limitations under the License.
-#
-#  Licensed under the Apache License, Version 2.0 (the "License");
-#  you may not use this file except in compliance with the License.
-#  You may obtain a copy of the License at
-#
-#       http://www.apache.org/licenses/LICENSE-2.0
-#
-#  Unless required by applicable law or agreed to in writing, software
-#  distributed under the License is distributed on an "AS IS" BASIS,
-#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-#  See the License for the specific language governing permissions and
-#  limitations under the License.
-
-from soong_lint_fix import SoongLintFix
-
-SoongLintFix().run()
diff --git a/tools/lint/fix/soong_lint_fix.py b/tools/lint/fix/soong_lint_fix.py
index 3308df6..cd4d778d 100644
--- a/tools/lint/fix/soong_lint_fix.py
+++ b/tools/lint/fix/soong_lint_fix.py
@@ -13,14 +13,21 @@
 #  limitations under the License.
 
 import argparse
+import json
 import os
+import shutil
 import subprocess
 import sys
+import zipfile
 
 ANDROID_BUILD_TOP = os.environ.get("ANDROID_BUILD_TOP")
+ANDROID_PRODUCT_OUT = os.environ.get("ANDROID_PRODUCT_OUT")
+PRODUCT_OUT = ANDROID_PRODUCT_OUT.removeprefix(f"{ANDROID_BUILD_TOP}/")
+
+SOONG_UI = "build/soong/soong_ui.bash"
 PATH_PREFIX = "out/soong/.intermediates"
 PATH_SUFFIX = "android_common/lint"
-FIX_DIR = "suggested-fixes"
+FIX_ZIP = "suggested-fixes.zip"
 
 class SoongLintFix:
     """
@@ -28,14 +35,12 @@
     apply lint fixes to the platform via the necessary
     combination of soong and shell commands.
 
-    It provides some basic hooks for experimental code
-    to tweak the generation of the resulting shell script.
+    It breaks up these operations into a few "private" methods
+    that are intentionally exposed so experimental code can tweak behavior.
 
-    By default, it will apply lint fixes using the intermediate `suggested-fixes`
-    directory that soong creates during its invocation of lint.
-
-    The default argument parser configures a number of command line arguments to
-    facilitate running lint via soong.
+    The entry point, `run`, will apply lint fixes using the
+    intermediate `suggested-fixes` directory that soong creates during its
+    invocation of lint.
 
     Basic usage:
     ```
@@ -45,99 +50,95 @@
     ```
     """
     def __init__(self):
-        self._commands = None
+        self._parser = _setup_parser()
         self._args = None
+        self._kwargs = None
         self._path = None
         self._target = None
-        self._parser = _setup_parser()
 
 
-    def add_argument(self, *args, **kwargs):
-        """
-        If necessary, add arguments to the underlying argparse.ArgumentParser before running
-        """
-        self._parser.add_argument(*args, **kwargs)
-
-
-    def run(self, add_setup_commands=None, override_fix_commands=None):
+    def run(self, additional_setup=None, custom_fix=None):
         """
         Run the script
-        :param add_setup_commands: OPTIONAL function to add additional setup commands
-            passed the command line arguments, path, and build target
-            must return a list of strings (the additional commands)
-        :param override_fix_commands: OPTIONAL function to override the fix commands
-            passed the command line arguments, path, and build target
-            must return a list of strings (the fix commands)
         """
         self._setup()
-        if add_setup_commands:
-            self._commands += add_setup_commands(self._args, self._path, self._target)
-
-        self._add_lint_report_commands()
+        self._find_module()
+        self._lint()
 
         if not self._args.no_fix:
-            if override_fix_commands:
-                self._commands += override_fix_commands(self._args, self._path, self._target)
-            else:
-                self._commands += [
-                    f"cd {self._path}",
-                    f"unzip {FIX_DIR}.zip -d {FIX_DIR}",
-                    f"cd {FIX_DIR}",
-                    # Find all the java files in the fix directory, excluding the ./out subdirectory,
-                    # and copy them back into the same path within the tree.
-                    f"find . -path ./out -prune -o -name '*.java' -print | xargs -n 1 sh -c 'cp $1 $ANDROID_BUILD_TOP/$1 || exit 255' --",
-                    f"rm -rf {FIX_DIR}"
-                ]
+            self._fix()
 
-
-        if self._args.dry_run:
-            print(self._get_commands_str())
-        else:
-            self._execute()
-
+        if self._args.print:
+            self._print()
 
     def _setup(self):
         self._args = self._parser.parse_args()
-        self._commands = []
-        self._path = f"{PATH_PREFIX}/{self._args.build_path}/{PATH_SUFFIX}"
-        self._target = f"{self._path}/lint-report.html"
-
-        if not self._args.dry_run:
-            self._commands += [f"export ANDROID_BUILD_TOP={ANDROID_BUILD_TOP}"]
-
+        env = os.environ.copy()
         if self._args.check:
-            self._commands += [f"export ANDROID_LINT_CHECK={self._args.check}"]
+            env["ANDROID_LINT_CHECK"] = self._args.check
+        if self._args.lint_module:
+            env["ANDROID_LINT_CHECK_EXTRA_MODULES"] = self._args.lint_module
+
+        self._kwargs = {
+            "env": env,
+            "executable": "/bin/bash",
+            "shell": True,
+        }
+
+        os.chdir(ANDROID_BUILD_TOP)
 
 
-    def _add_lint_report_commands(self):
-        self._commands += [
-            "cd $ANDROID_BUILD_TOP",
-            "source build/envsetup.sh",
-            # remove the file first so soong doesn't think there is no work to do
-            f"rm {self._target}",
-            # remove in case there are fixes from a prior run,
-            # that we don't want applied if this run fails
-            f"rm {self._path}/{FIX_DIR}.zip",
-            f"m {self._target}",
-        ]
+    def _find_module(self):
+        print("Refreshing soong modules...")
+        try:
+            os.mkdir(ANDROID_PRODUCT_OUT)
+        except OSError:
+            pass
+        subprocess.call(f"{SOONG_UI} --make-mode {PRODUCT_OUT}/module-info.json", **self._kwargs)
+        print("done.")
+
+        with open(f"{ANDROID_PRODUCT_OUT}/module-info.json") as f:
+            module_info = json.load(f)
+
+        if self._args.module not in module_info:
+            sys.exit(f"Module {self._args.module} not found!")
+
+        module_path = module_info[self._args.module]["path"][0]
+        print(f"Found module {module_path}/{self._args.module}.")
+
+        self._path = f"{PATH_PREFIX}/{module_path}/{self._args.module}/{PATH_SUFFIX}"
+        self._target = f"{self._path}/lint-report.txt"
 
 
-    def _get_commands_str(self):
-        prefix = "(\n"
-        delimiter = ";\n"
-        suffix = "\n)"
-        return f"{prefix}{delimiter.join(self._commands)}{suffix}"
+    def _lint(self):
+        print("Cleaning up any old lint results...")
+        try:
+            os.remove(f"{self._target}")
+            os.remove(f"{self._path}/{FIX_ZIP}")
+        except FileNotFoundError:
+            pass
+        print("done.")
+
+        print(f"Generating {self._target}")
+        subprocess.call(f"{SOONG_UI} --make-mode {self._target}", **self._kwargs)
+        print("done.")
 
 
-    def _execute(self, with_echo=True):
-        if with_echo:
-            exec_commands = []
-            for c in self._commands:
-                exec_commands.append(f'echo "{c}"')
-                exec_commands.append(c)
-            self._commands = exec_commands
+    def _fix(self):
+        print("Copying suggested fixes to the tree...")
+        with zipfile.ZipFile(f"{self._path}/{FIX_ZIP}") as zip:
+            for name in zip.namelist():
+                if name.startswith("out") or not name.endswith(".java"):
+                    continue
+                with zip.open(name) as src, open(f"{ANDROID_BUILD_TOP}/{name}", "wb") as dst:
+                    shutil.copyfileobj(src, dst)
+            print("done.")
 
-        subprocess.call(self._get_commands_str(), executable='/bin/bash', shell=True)
+
+    def _print(self):
+        print("### lint-report.txt ###", end="\n\n")
+        with open(self._target, "r") as f:
+            print(f.read())
 
 
 def _setup_parser():
@@ -147,23 +148,26 @@
         2. Run lint on the specified target.
         3. Copy the modified files, from soong's intermediate directory, back into the tree.
 
-        **Gotcha**: You must have run `source build/envsetup.sh` and `lunch`
-        so that the `ANDROID_BUILD_TOP` environment variable has been set.
-        Alternatively, set it manually in your shell.
+        **Gotcha**: You must have run `source build/envsetup.sh` and `lunch` first.
         """, formatter_class=argparse.RawTextHelpFormatter)
 
-    parser.add_argument('build_path', metavar='build_path', type=str,
-                        help='The build module to run '
-                             '(e.g. frameworks/base/framework-minus-apex or '
-                             'frameworks/base/services/core/services.core.unboosted)')
+    parser.add_argument('module',
+                        help='The soong build module to run '
+                             '(e.g. framework-minus-apex or services.core.unboosted)')
 
-    parser.add_argument('--check', metavar='check', type=str,
+    parser.add_argument('--check',
                         help='Which lint to run. Passed to the ANDROID_LINT_CHECK environment variable.')
 
-    parser.add_argument('--dry-run', dest='dry_run', action='store_true',
-                        help='Just print the resulting shell script instead of running it.')
+    parser.add_argument('--lint-module',
+                            help='Specific lint module to run. Passed to the ANDROID_LINT_CHECK_EXTRA_MODULES environment variable.')
 
-    parser.add_argument('--no-fix', dest='no_fix', action='store_true',
+    parser.add_argument('--no-fix', action='store_true',
                         help='Just build and run the lint, do NOT apply the fixes.')
 
+    parser.add_argument('--print', action='store_true',
+                        help='Print the contents of the generated lint-report.txt at the end.')
+
     return parser
+
+if __name__ == "__main__":
+    SoongLintFix().run()
\ No newline at end of file