pw_presubmit: Refactor GN formatting support
Refactors GN build file formatting support to be more modular.
Bug: b/326309165
Change-Id: I4e20641cf51d65a12ab743ff97e02b106287f747
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/194415
Pigweed-Auto-Submit: Armando Montanez <amontanez@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed-service-accounts.iam.gserviceaccount.com>
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
diff --git a/pw_presubmit/BUILD.gn b/pw_presubmit/BUILD.gn
index cfe8f5b..d700cfe 100644
--- a/pw_presubmit/BUILD.gn
+++ b/pw_presubmit/BUILD.gn
@@ -27,6 +27,7 @@
"py/pw_presubmit/cli.py",
"py/pw_presubmit/format/core.py",
"py/pw_presubmit/format/cpp.py",
+ "py/pw_presubmit/format/gn.py",
"py/pw_presubmit/presubmit.py",
]
}
diff --git a/pw_presubmit/format.rst b/pw_presubmit/format.rst
index 79b295b..07daf96 100644
--- a/pw_presubmit/format.rst
+++ b/pw_presubmit/format.rst
@@ -36,3 +36,7 @@
.. autoclass:: pw_presubmit.format.cpp.ClangFormatFormatter
:members:
:noindex:
+
+.. autoclass:: pw_presubmit.format.gn.GnFormatter
+ :members:
+ :noindex:
diff --git a/pw_presubmit/py/BUILD.gn b/pw_presubmit/py/BUILD.gn
index f38b1de..15c1dd7 100644
--- a/pw_presubmit/py/BUILD.gn
+++ b/pw_presubmit/py/BUILD.gn
@@ -30,6 +30,7 @@
"pw_presubmit/format/__init__.py",
"pw_presubmit/format/core.py",
"pw_presubmit/format/cpp.py",
+ "pw_presubmit/format/gn.py",
"pw_presubmit/format/test_data/__init__.py",
"pw_presubmit/format_code.py",
"pw_presubmit/git_repo.py",
@@ -57,15 +58,20 @@
"pw_presubmit/format/test_data/clang_format_config",
"pw_presubmit/format/test_data/cpp_test_data.cc",
"pw_presubmit/format/test_data/cpp_test_data_golden.cc",
+ "pw_presubmit/format/test_data/gn_test_data.gn",
+ "pw_presubmit/format/test_data/gn_test_data_golden.gn",
+ "pw_presubmit/format/test_data/malformed_file.txt",
]
tests = [
"bazel_parser_test.py",
"context_test.py",
"cpp_checks_test.py",
"cpp_format_test.py",
+ "format_testing_utils.py",
"format_core_test.py",
"git_repo_test.py",
"gitmodules_test.py",
+ "gn_format_test.py",
"keep_sorted_test.py",
"ninja_parser_test.py",
"presubmit_test.py",
diff --git a/pw_presubmit/py/cpp_format_test.py b/pw_presubmit/py/cpp_format_test.py
index 2c4f829..aa59a19 100644
--- a/pw_presubmit/py/cpp_format_test.py
+++ b/pw_presubmit/py/cpp_format_test.py
@@ -15,12 +15,11 @@
import importlib.resources
from pathlib import Path
-import subprocess
from tempfile import TemporaryDirectory
from typing import Final, Sequence
import unittest
-from pw_presubmit.format.core import ToolRunner
+from format_testing_utils import CapturingToolRunner
from pw_presubmit.format.cpp import ClangFormatFormatter
_TEST_DATA_FILES = importlib.resources.files('pw_presubmit.format.test_data')
@@ -34,18 +33,6 @@
)
-class CapturingToolRunner(ToolRunner):
- def __init__(self):
- self.command_history: list[str] = []
-
- def _run_tool(
- self, tool: str, args, **kwargs
- ) -> subprocess.CompletedProcess:
- cmd = [tool] + args
- self.command_history.append(' '.join([str(arg) for arg in cmd]))
- return subprocess.run(cmd, **kwargs)
-
-
class TestClangFormatFormatter(unittest.TestCase):
"""Tests for the ClangFormatFormatter."""
diff --git a/pw_presubmit/py/format_testing_utils.py b/pw_presubmit/py/format_testing_utils.py
new file mode 100644
index 0000000..825f6a3
--- /dev/null
+++ b/pw_presubmit/py/format_testing_utils.py
@@ -0,0 +1,37 @@
+# Copyright 2024 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+"""Utilities common to format tests."""
+
+import subprocess
+from typing import List
+
+from pw_presubmit.format.core import ToolRunner
+
+
+class CapturingToolRunner(ToolRunner):
+ """A ToolRunner that captures executed commands and their arguments."""
+
+ def __init__(self):
+ self.command_history: List[str] = []
+
+ def _run_tool(
+ self, tool: str, args, **kwargs
+ ) -> subprocess.CompletedProcess:
+ """Runs the requested tool with the provided arguments.
+
+ The full command is appended to ``command_history``.
+ """
+ cmd = [tool] + args
+ self.command_history.append(' '.join([str(arg) for arg in cmd]))
+ return subprocess.run(cmd, **kwargs)
diff --git a/pw_presubmit/py/gn_format_test.py b/pw_presubmit/py/gn_format_test.py
new file mode 100644
index 0000000..e49ca87
--- /dev/null
+++ b/pw_presubmit/py/gn_format_test.py
@@ -0,0 +1,151 @@
+# Copyright 2024 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+"""Tests for GN's built-in formatter."""
+
+import importlib.resources
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+
+from format_testing_utils import CapturingToolRunner
+from pw_presubmit.format.gn import GnFormatter
+
+
+_TEST_DATA_FILES = importlib.resources.files('pw_presubmit.format.test_data')
+_TEST_SRC_FILE = _TEST_DATA_FILES / 'gn_test_data.gn'
+_TEST_GOLDEN = _TEST_DATA_FILES / 'gn_test_data_golden.gn'
+_TEST_MALFORMED = _TEST_DATA_FILES / 'malformed_file.txt'
+
+
+class TestGnFormatter(unittest.TestCase):
+ """Tests for the GnFormatter."""
+
+ def test_check_file(self):
+ tool_runner = CapturingToolRunner()
+ formatter = GnFormatter()
+ formatter.run_tool = tool_runner
+
+ result = formatter.format_file_in_memory(
+ _TEST_SRC_FILE, _TEST_SRC_FILE.read_bytes()
+ )
+ self.assertTrue(result.ok)
+ self.assertEqual(result.error_message, None)
+ self.assertMultiLineEqual(
+ result.formatted_file_contents.decode(), _TEST_GOLDEN.read_text()
+ )
+
+ self.assertEqual(
+ tool_runner.command_history.pop(0),
+ ' '.join(
+ (
+ 'gn',
+ 'format',
+ '--stdin',
+ )
+ ),
+ )
+
+ def test_check_file_error(self):
+ tool_runner = CapturingToolRunner()
+ formatter = GnFormatter()
+ formatter.run_tool = tool_runner
+
+ result = formatter.format_file_in_memory(
+ _TEST_MALFORMED, _TEST_MALFORMED.read_bytes()
+ )
+ self.assertFalse(result.ok)
+ self.assertTrue(result.error_message.startswith('ERROR at'))
+ self.assertTrue('Invalid token' in result.error_message)
+ self.assertEqual(result.formatted_file_contents, b'')
+
+ self.assertEqual(
+ tool_runner.command_history.pop(0),
+ ' '.join(
+ (
+ 'gn',
+ 'format',
+ '--stdin',
+ )
+ ),
+ )
+
+ def test_fix_file(self):
+ """Tests that formatting is properly applied to files."""
+
+ tool_runner = CapturingToolRunner()
+ formatter = GnFormatter()
+ formatter.run_tool = tool_runner
+
+ with TemporaryDirectory() as temp_dir:
+ file_to_fix = Path(temp_dir) / _TEST_SRC_FILE.name
+ file_to_fix.write_bytes(_TEST_SRC_FILE.read_bytes())
+
+ malformed_file = Path(temp_dir) / _TEST_MALFORMED.name
+ malformed_file.write_bytes(_TEST_MALFORMED.read_bytes())
+
+ errors = list(formatter.format_files([file_to_fix, malformed_file]))
+
+ # Should see three separate commands, one where we try to format
+ # both files together, and then the fallback logic that formats
+ # them individually to isolate errors.
+ self.assertEqual(
+ tool_runner.command_history.pop(0),
+ ' '.join(
+ (
+ 'gn',
+ 'format',
+ str(file_to_fix),
+ str(malformed_file),
+ )
+ ),
+ )
+
+ self.assertEqual(
+ tool_runner.command_history.pop(0),
+ ' '.join(
+ (
+ 'gn',
+ 'format',
+ str(file_to_fix),
+ )
+ ),
+ )
+
+ self.assertEqual(
+ tool_runner.command_history.pop(0),
+ ' '.join(
+ (
+ 'gn',
+ 'format',
+ str(malformed_file),
+ )
+ ),
+ )
+
+ # Check good build file.
+ self.assertMultiLineEqual(
+ file_to_fix.read_text(), _TEST_GOLDEN.read_text()
+ )
+
+ # Check malformed file.
+ self.assertEqual(len(errors), 1)
+ malformed_files = [malformed_file]
+ for file_path, error in errors:
+ self.assertIn(file_path, malformed_files)
+ self.assertFalse(error.ok)
+ self.assertTrue(error.error_message.startswith('ERROR at'))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/pw_presubmit/py/pw_presubmit/format/gn.py b/pw_presubmit/py/pw_presubmit/format/gn.py
new file mode 100644
index 0000000..6223b1d
--- /dev/null
+++ b/pw_presubmit/py/pw_presubmit/format/gn.py
@@ -0,0 +1,102 @@
+# Copyright 2024 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+"""Code formatter plugin for GN build files."""
+
+from pathlib import Path
+from typing import Final, Iterable, Iterator, Sequence, Tuple
+
+from pw_presubmit.format.core import (
+ FileFormatter,
+ FormattedFileContents,
+ FormatFixStatus,
+)
+
+
+class GnFormatter(FileFormatter):
+ """A formatter that runs `gn format` on files."""
+
+ DEFAULT_FLAGS: Final[Sequence[str]] = ()
+
+ def __init__(self, tool_flags: Sequence[str] = DEFAULT_FLAGS, **kwargs):
+ super().__init__(**kwargs)
+ self.gn_format_flags = list(tool_flags)
+
+ def format_file_in_memory(
+ self, file_path: Path, file_contents: bytes
+ ) -> FormattedFileContents:
+ """Uses ``gn format`` to check the formatting of the requested file.
+
+ The file at ``file_path`` is NOT modified by this check.
+
+ Returns:
+ A populated
+ :py:class:`pw_presubmit.format.core.FormattedFileContents` that
+ contains either the result of formatting the file, or an error
+ message.
+ """
+ # `gn format --dry-run` only signals which files need to be updated,
+ # not what the effects are. To get the formatted result, we pipe the
+ # file in via stdin and then return the result.
+ proc = self.run_tool(
+ 'gn',
+ ['format'] + self.gn_format_flags + ['--stdin'],
+ input=file_contents,
+ )
+ ok = proc.returncode == 0
+ return FormattedFileContents(
+ ok=ok,
+ formatted_file_contents=proc.stdout if ok else b'',
+ # `gn format` emits errors over stdout, not stderr.
+ error_message=None if ok else proc.stdout.decode(),
+ )
+
+ def format_file(self, file_path: Path) -> FormatFixStatus:
+ """Formats the provided file in-place using ``gn format``.
+
+ Returns:
+ A FormatFixStatus that contains relevant errors/warnings.
+ """
+ proc = self.run_tool(
+ 'gn',
+ ['format'] + self.gn_format_flags + [file_path],
+ )
+ ok = proc.returncode == 0
+ return FormatFixStatus(
+ ok=ok,
+ # `gn format` emits errors over stdout, not stderr.
+ error_message=None if ok else proc.stdout.decode(),
+ )
+
+ def format_files(
+ self, paths: Iterable[Path], keep_warnings: bool = True
+ ) -> Iterator[Tuple[Path, FormatFixStatus]]:
+ """Uses ``gn format`` to fix formatting of the specified files in-place.
+
+ Returns:
+ An iterator of ``Path`` and
+ :py:class:`pw_presubmit.format.core.FormatFixStatus` pairs for each
+ file that was not successfully formatted. If ``keep_warnings`` is
+ ``True``, any successful format operations with warnings will also
+ be returned.
+ """
+ # Try to format all files in one `gn` command.
+ proc = self.run_tool(
+ 'gn',
+ ['format'] + self.gn_format_flags + list(paths),
+ )
+
+ # If there's an error, fall back to per-file formatting to figure out
+ # which file has problems.
+ if proc.returncode != 0:
+ yield from super().format_files(paths, keep_warnings)
diff --git a/pw_presubmit/py/pw_presubmit/format/test_data/gn_test_data.gn b/pw_presubmit/py/pw_presubmit/format/test_data/gn_test_data.gn
new file mode 100644
index 0000000..6655eca
--- /dev/null
+++ b/pw_presubmit/py/pw_presubmit/format/test_data/gn_test_data.gn
@@ -0,0 +1,25 @@
+import("//build_overrides/pigweed.gni")
+
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("public_include_path") {
+ include_dirs = [ "public" ]
+ visibility = [ ":*" ]
+}
+
+pw_source_set("my_library") {
+ public = [ "public/my_library/foo.h" ]
+ deps = [":an", ":unsorted", ":list"]
+ public_configs = [ ":public_include_path",
+ ]
+}
+
+pw_doc_group("docs") { sources = [ "docs.rst" ] }
+
+pw_test_group("tests") {
+
+
+}
diff --git a/pw_presubmit/py/pw_presubmit/format/test_data/gn_test_data_golden.gn b/pw_presubmit/py/pw_presubmit/format/test_data/gn_test_data_golden.gn
new file mode 100644
index 0000000..cd6c044
--- /dev/null
+++ b/pw_presubmit/py/pw_presubmit/format/test_data/gn_test_data_golden.gn
@@ -0,0 +1,28 @@
+import("//build_overrides/pigweed.gni")
+
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("public_include_path") {
+ include_dirs = [ "public" ]
+ visibility = [ ":*" ]
+}
+
+pw_source_set("my_library") {
+ public = [ "public/my_library/foo.h" ]
+ deps = [
+ ":an",
+ ":list",
+ ":unsorted",
+ ]
+ public_configs = [ ":public_include_path" ]
+}
+
+pw_doc_group("docs") {
+ sources = [ "docs.rst" ]
+}
+
+pw_test_group("tests") {
+}
diff --git a/pw_presubmit/py/pw_presubmit/format/test_data/malformed_file.txt b/pw_presubmit/py/pw_presubmit/format/test_data/malformed_file.txt
new file mode 100644
index 0000000..3a08e00
--- /dev/null
+++ b/pw_presubmit/py/pw_presubmit/format/test_data/malformed_file.txt
@@ -0,0 +1,6 @@
+This is just a text file with random contents!
+Some formatters will take this and gracefully try to format it, others will
+flag that this file has syntax errors (which is unsurprising).
+
+Use this file to ensure errors are properly propagated.
+Some characters to potentially cause breakages: [[{\\\\**
diff --git a/pw_presubmit/py/pw_presubmit/format_code.py b/pw_presubmit/py/pw_presubmit/format_code.py
index cee4a60..bff782c 100755
--- a/pw_presubmit/py/pw_presubmit/format_code.py
+++ b/pw_presubmit/py/pw_presubmit/format_code.py
@@ -62,6 +62,7 @@
)
from pw_presubmit.format.core import FormattedDiff, FormatFixStatus
from pw_presubmit.format.cpp import ClangFormatFormatter
+from pw_presubmit.format.gn import GnFormatter
from pw_presubmit.tools import (
exclude_paths,
file_summary,
@@ -201,22 +202,19 @@
def check_gn_format(ctx: _Context) -> dict[Path, str]:
"""Checks formatting; returns {path: diff} for files with bad formatting."""
- return _check_files(
- ctx.paths,
- lambda _, data: log_run(
- ['gn', 'format', '--stdin'],
- input=data,
- stdout=subprocess.PIPE,
- check=True,
- ).stdout,
- ctx.dry_run,
+ formatter = GnFormatter(tool_runner=PresubmitToolRunner())
+ return _make_formatting_diff_dict(
+ formatter.get_formatting_diffs(
+ ctx.paths,
+ ctx.dry_run,
+ )
)
def fix_gn_format(ctx: _Context) -> dict[Path, str]:
"""Fixes formatting for the provided files in place."""
- log_run(['gn', 'format', *ctx.paths], check=True)
- return {}
+ formatter = GnFormatter(tool_runner=PresubmitToolRunner())
+ return _make_format_fix_error_output_dict(formatter.format_files(ctx.paths))
def check_bazel_format(ctx: _Context) -> dict[Path, str]: