From 113dc9a0cf5da47fb0bf85d0dfe243b6bc8c4d86 Mon Sep 17 00:00:00 2001 From: Gulliby-development <20404543+Gulliby@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:53:13 +0200 Subject: [PATCH] Add repeatable --ignore option to structpad check Named structs are left out of the padding check and are reported in the summary as ignored. --- README.md | 17 ++- src/structpad/cli.py | 40 +++++-- src/structpad/render.py | 18 ++- tests/test_cli.py | 2 +- tests/test_ignore.py | 241 ++++++++++++++++++++++++++++++++++++++++ tests/test_readme.py | 14 +++ 6 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 tests/test_ignore.py diff --git a/README.md b/README.md index 4b906e2..8478d0c 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,22 @@ Frame: 12 bytes of padding (limit 8) 1 of 3 structs over the padding limit ``` -The check exits with status 1 when any struct exceeds the limit. Select the ABI with `--abi lp64` (the default) or `--abi ilp32`; for example: +The check exits with status 1 when any struct exceeds the limit. + +`--ignore NAME` is available only with `check` and may be repeated; `--ignore=NAME` is also supported. Ignored structs are omitted from the padding check but remain included in the summary's total. The summary ends with the number of distinct ignored names, so duplicates do not increase that count. + +```sh +structpad check --ignore Packet net.sp +``` + +```text +Frame: 12 bytes of padding (limit 0) +1 of 3 structs over the padding limit (1 ignored) +``` + +Ignoring both `Packet` and `Frame` prints `ok, 3 structs (2 ignored)` and exits with status 0. Invalid names such as `1x` produce `invalid --ignore "1x"`; a name not defined in the file produces `unknown struct "Nope" in --ignore`. Both errors exit with status 2. + +Select the ABI with `--abi lp64` (the default) or `--abi ilp32`; for example: ```sh structpad layout --abi ilp32 net.sp diff --git a/src/structpad/cli.py b/src/structpad/cli.py index deea231..39f0c07 100644 --- a/src/structpad/cli.py +++ b/src/structpad/cli.py @@ -12,9 +12,10 @@ _USAGE = ( "usage: structpad layout [--abi lp64|ilp32] FILE\n" " structpad suggest [--abi lp64|ilp32] FILE\n" - " structpad check [--abi lp64|ilp32] [--max-padding N] FILE\n" + " structpad check [--abi lp64|ilp32] [--max-padding N] [--ignore NAME]... FILE\n" ) _MAX_PADDING_RE = re.compile(r"[0-9]{1,6}\Z") +_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}\Z") def _escape_controls(value: str) -> str: @@ -28,9 +29,10 @@ def _write_error(stderr: BinaryIO, message: str) -> None: def _parse_options( command: str, arguments: list[str] -) -> tuple[str, int, str] | str: +) -> tuple[str, int, str, tuple[str, ...]] | str: abi = "lp64" max_padding = 0 + ignored: list[str] = [] file_name: str | None = None index = 0 @@ -70,6 +72,22 @@ def _parse_options( max_padding = int(value) continue + if argument == "--ignore" or argument.startswith("--ignore="): + if command != "check": + return f"{command} does not take --ignore" + if argument == "--ignore": + if index + 1 >= len(arguments): + return "--ignore needs a value" + value = arguments[index + 1] + index += 2 + else: + value = argument[len("--ignore=") :] + index += 1 + if _NAME_RE.fullmatch(value) is None: + return f'invalid --ignore "{_escape_controls(value)}"' + ignored.append(value) + continue + if argument.startswith("-") and argument != "-": return f'unknown option "{_escape_controls(argument)}"' if file_name is not None: @@ -79,7 +97,7 @@ def _parse_options( if file_name is None: return "missing FILE" - return abi, max_padding, file_name + return abi, max_padding, file_name, tuple(ignored) def run(args: list[str], stdin: BinaryIO, stdout: BinaryIO, stderr: BinaryIO) -> int: @@ -100,7 +118,7 @@ def run(args: list[str], stdin: BinaryIO, stdout: BinaryIO, stderr: BinaryIO) -> if isinstance(parsed_options, str): _write_error(stderr, parsed_options) return 2 - abi, max_padding, file_name = parsed_options + abi, max_padding, file_name, ignored_names = parsed_options display = "" if file_name == "-" else file_name.replace("\\", "/") try: @@ -125,6 +143,14 @@ def run(args: list[str], stdin: BinaryIO, stdout: BinaryIO, stderr: BinaryIO) -> _write_error(stderr, error.message) return 2 + definitions = {struct.name: struct for struct in structs} + for name in ignored_names: + if name not in definitions: + _write_error(stderr, f'unknown struct "{name}" in --ignore') + return 2 + + ignored = frozenset(ignored_names) + if command == "layout": stdout.write(render_layout(structs, abi)) return 0 @@ -132,11 +158,11 @@ def run(args: list[str], stdin: BinaryIO, stdout: BinaryIO, stderr: BinaryIO) -> stdout.write(render_suggest(structs, abi)) return 0 - output = render_check(structs, abi, max_padding) + output = render_check(structs, abi, max_padding, ignored) stdout.write(output) - definitions = {struct.name: struct for struct in structs} if any( - calculate_layout(struct, abi, definitions).padding > max_padding + struct.name not in ignored + and calculate_layout(struct, abi, definitions).padding > max_padding for struct in structs ): return 1 diff --git a/src/structpad/render.py b/src/structpad/render.py index eae9486..6012013 100644 --- a/src/structpad/render.py +++ b/src/structpad/render.py @@ -81,21 +81,33 @@ def render_suggest(structs: Sequence[Struct], abi: str) -> bytes: return ("\n\n".join(blocks) + "\n").encode("utf-8") -def render_check(structs: Sequence[Struct], abi: str, max_padding: int) -> bytes: +def render_check( + structs: Sequence[Struct], + abi: str, + max_padding: int, + ignored: frozenset[str] = frozenset(), +) -> bytes: """Render padding-limit results as UTF-8 bytes with LF line endings.""" definitions = _definitions(structs) over_limit: list[tuple[Struct, int]] = [] for struct in structs: + if struct.name in ignored: + continue padding = calculate_layout(struct, abi, definitions).padding if padding > max_padding: over_limit.append((struct, padding)) + ignored_suffix = f" ({len(ignored)} ignored)" if ignored else "" + if not over_limit: - return f"ok, {len(structs)} structs\n".encode() + return f"ok, {len(structs)} structs{ignored_suffix}\n".encode() lines = [] for struct, padding in over_limit: byte_word = "byte" if padding == 1 else "bytes" lines.append(f"{struct.name}: {padding} {byte_word} of padding (limit {max_padding})") - lines.append(f"{len(over_limit)} of {len(structs)} structs over the padding limit") + lines.append( + f"{len(over_limit)} of {len(structs)} structs over the padding limit" + f"{ignored_suffix}" + ) return ("\n".join(lines) + "\n").encode("utf-8") diff --git a/tests/test_cli.py b/tests/test_cli.py index 275dd54..be6f779 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,7 +18,7 @@ _USAGE = ( b"usage: structpad layout [--abi lp64|ilp32] FILE\n" b" structpad suggest [--abi lp64|ilp32] FILE\n" - b" structpad check [--abi lp64|ilp32] [--max-padding N] FILE\n" + b" structpad check [--abi lp64|ilp32] [--max-padding N] [--ignore NAME]... FILE\n" ) _MIXED = ( b"struct Mixed {\n" diff --git a/tests/test_ignore.py b/tests/test_ignore.py new file mode 100644 index 0000000..db8e5ac --- /dev/null +++ b/tests/test_ignore.py @@ -0,0 +1,241 @@ +"""Example-based tests for the check command's ignore option.""" + +import importlib +import sys +from io import BytesIO +from pathlib import Path + +import pytest + +_SOURCE_DIRECTORY = str(Path(__file__).resolve().parents[1] / "src") +if _SOURCE_DIRECTORY not in sys.path: + sys.path.insert(0, _SOURCE_DIRECTORY) +run = importlib.import_module("structpad.cli").run + +_NET_SOURCE = ( + b"// wire structs\n" + b"struct Packet {\n" + b" u8 tag;\n" + b" u32 length;\n" + b" u16 flags;\n" + b" char name[10];\n" + b" ptr next;\n" + b"}\n" + b"\n" + b"packed struct Wire {\n" + b" u8 kind;\n" + b" u32 value;\n" + b"}\n" + b"\n" + b"struct Frame {\n" + b" u8 version;\n" + b" struct Packet head;\n" + b" struct Wire tail[2];\n" + b" bool ok;\n" + b"}\n" +) + + +def _run( + args: list[str], source: bytes = _NET_SOURCE +) -> tuple[int, bytes, bytes]: + stdin = BytesIO(source) + stdout = BytesIO() + stderr = BytesIO() + status = run(args, stdin, stdout, stderr) + return status, stdout.getvalue(), stderr.getvalue() + + +def _assert_error( + args: list[str], message: str, source: bytes = _NET_SOURCE +) -> None: + status, stdout, stderr = _run(args, source) + assert status == 2 + assert stdout == b"" + assert stderr == f"structpad: {message}\n".encode() + assert stderr.count(b"\n") == 1 + assert b"\r" not in stderr + + +@pytest.mark.parametrize( + ("args", "expected_status", "expected_stdout"), + [ + ( + ["check", "--ignore", "Packet", "-"], + 1, + ( + b"Frame: 12 bytes of padding (limit 0)\n" + b"1 of 3 structs over the padding limit (1 ignored)\n" + ), + ), + ( + ["check", "--ignore", "Packet", "--ignore=Frame", "-"], + 0, + b"ok, 3 structs (2 ignored)\n", + ), + ( + ["check", "--ignore", "Frame", "--ignore", "Frame", "--max-padding", "8", "-"], + 0, + b"ok, 3 structs (1 ignored)\n", + ), + ( + ["check", "--ignore", "Wire", "--max-padding=8", "-"], + 1, + ( + b"Frame: 12 bytes of padding (limit 8)\n" + b"1 of 3 structs over the padding limit (1 ignored)\n" + ), + ), + ( + ["check", "--max-padding=8", "-"], + 1, + ( + b"Frame: 12 bytes of padding (limit 8)\n" + b"1 of 3 structs over the padding limit\n" + ), + ), + ], +) +def test_ignore_matches_worked_examples_and_preserves_no_ignore_output( + args: list[str], expected_status: int, expected_stdout: bytes +) -> None: + status, stdout, stderr = _run(args) + assert (status, stdout, stderr) == (expected_status, expected_stdout, b"") + + +def test_ignore_spellings_work_before_between_and_after_file() -> None: + expected_packet_ignored = ( + b"Frame: 12 bytes of padding (limit 0)\n" + b"1 of 3 structs over the padding limit (1 ignored)\n" + ) + for args in ( + ["check", "--ignore=Packet", "-"], + ["check", "-", "--ignore", "Packet"], + ): + status, stdout, stderr = _run(args) + assert (status, stdout, stderr) == ( + 1, + expected_packet_ignored, + b"", + ) + + expected_wire_ignored = ( + b"Frame: 12 bytes of padding (limit 8)\n" + b"1 of 3 structs over the padding limit (1 ignored)\n" + ) + for args in ( + ["check", "--max-padding", "8", "--ignore", "Wire", "-"], + ["check", "--max-padding=8", "-", "--ignore=Wire"], + ): + status, stdout, stderr = _run(args) + assert (status, stdout, stderr) == (1, expected_wire_ignored, b"") + + +def test_duplicate_ignore_names_only_count_once() -> None: + status, stdout, stderr = _run( + ["check", "--ignore=Packet", "--ignore", "Packet", "--ignore", "Wire", "-"] + ) + assert status == 1 + assert stdout == ( + b"Frame: 12 bytes of padding (limit 0)\n" + b"1 of 3 structs over the padding limit (2 ignored)\n" + ) + assert stderr == b"" + + +def test_all_structs_can_be_ignored() -> None: + status, stdout, stderr = _run( + [ + "check", + "--ignore", + "Packet", + "--ignore=Wire", + "--ignore", + "Frame", + "-", + ] + ) + assert status == 0 + assert stdout == b"ok, 3 structs (3 ignored)\n" + assert stderr == b"" + + +def test_ignore_uses_ascii_name_grammar_at_both_length_boundaries() -> None: + longest_name = "A" + "x" * 62 + "9" + source = ( + b"struct _ {\nu8 value;\n}\n" + + f"struct {longest_name} {{\nu8 value;\n}}\n".encode() + ) + status, stdout, stderr = _run( + ["check", "--ignore", "_", "--ignore=" + longest_name, "-"], source + ) + assert status == 0 + assert stdout == b"ok, 2 structs (2 ignored)\n" + assert stderr == b"" + + +@pytest.mark.parametrize("value", ("", "1x", "A" + "x" * 64, "é", "A-B", "A\r\nB")) +def test_invalid_ignore_names_are_escaped(value: str) -> None: + escaped = value.replace("\r", r"\r").replace("\n", r"\n") + _assert_error( + ["check", "--ignore=" + value, "-"], + f'invalid --ignore "{escaped}"', + ) + + +def test_ignore_option_errors_are_exact_and_ordered() -> None: + _assert_error(["check", "--ignore"], "--ignore needs a value") + _assert_error( + ["check", "--ignore", "--unknown", "-"], + 'invalid --ignore "--unknown"', + ) + _assert_error( + ["check", "--ignore", "1x", "--unknown", "-"], + 'invalid --ignore "1x"', + ) + _assert_error( + ["check", "--ignore", "Nope", "--unknown", "-"], + 'unknown option "--unknown"', + ) + _assert_error(["check", "--ignore", "Frame"], "missing FILE") + + for command, value in (("layout", "Packet"), ("suggest", "Packet")): + _assert_error( + [command, "--ignore", value, "-"], + f"{command} does not take --ignore", + ) + _assert_error( + [command, "--ignore=" + value, "-"], + f"{command} does not take --ignore", + ) + + +def test_unknown_names_are_case_sensitive_and_checked_in_argument_order() -> None: + _assert_error( + ["check", "--ignore", "Nope", "--ignore=frame", "-"], + 'unknown struct "Nope" in --ignore', + ) + _assert_error( + ["check", "--ignore=frame", "-"], + 'unknown struct "frame" in --ignore', + ) + + +def test_parse_errors_precede_unknown_ignore_names() -> None: + _assert_error( + ["check", "--ignore", "Nope", "-"], + ": no structs found", + b"// empty\n", + ) + + +def test_ignore_works_with_ilp32_abi_spelling() -> None: + status, stdout, stderr = _run( + ["check", "--abi=ilp32", "--ignore", "Frame", "-"] + ) + assert status == 1 + assert stdout == ( + b"Packet: 3 bytes of padding (limit 0)\n" + b"1 of 3 structs over the padding limit (1 ignored)\n" + ) + assert stderr == b"" diff --git a/tests/test_readme.py b/tests/test_readme.py index 89af2b5..168d0eb 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -42,6 +42,20 @@ def test_readme_has_required_sections_and_commands() -> None: assert command in readme +def test_readme_documents_ignore_option() -> None: + readme = _readme() + + for required_text in ( + "--ignore NAME", + "structpad check --ignore Packet net.sp", + "1 of 3 structs over the padding limit (1 ignored)", + "ok, 3 structs (2 ignored)", + 'invalid --ignore "1x"', + 'unknown struct "Nope" in --ignore', + ): + assert required_text in readme + + def test_readme_has_worked_input_and_exact_layout_output_as_fenced_blocks() -> None: blocks = _fenced_blocks(_readme())