From c0c94cf6032971626dfeb0d9a751b8ea91746f10 Mon Sep 17 00:00:00 2001 From: Gulliby-development <20404543+Gulliby@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:37:21 +0200 Subject: [PATCH] Add --struct option to structpad layout Print the layout of only the named structs, once each and in file order, with the same numbers as the full listing. Documented in the README and covered by new tests. --- README.md | 24 +++++ src/structpad/cli.py | 32 +++++- src/structpad/render.py | 13 ++- tests/test_cli.py | 2 +- tests/test_struct_select.py | 205 ++++++++++++++++++++++++++++++++++++ 5 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 tests/test_struct_select.py diff --git a/README.md b/README.md index 8478d0c..018f78b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,30 @@ Frame: 12 bytes of padding (limit 0) 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. +`--struct NAME` is available only with `layout` and may be repeated; `--struct=NAME` is also supported. Only the named structs are printed, once each and in file order, with the same numbers as in the full listing, because nested structs are still resolved from the whole file. + +```sh +structpad layout --struct Frame --struct Wire net.sp +``` + +```text +struct Wire: size 5, align 1, padding 0, packed +offset size field + 0 1 kind: u8 + 1 4 value: u32 + +struct Frame: size 56, align 8, padding 12 +offset size field + 0 1 version: u8 + 1 7 (padding) + 8 32 head: struct Packet + 40 10 tail: struct Wire[2] + 50 1 ok: bool + 51 5 (padding) +``` + +An invalid name such as `1x` produces `invalid --struct "1x"`, a name not defined in the file produces `unknown struct "Nope" in --struct`, and `--struct` with `suggest` or `check` produces `suggest does not take --struct` or `check does not take --struct`. All of these exit with status 2. + Select the ABI with `--abi lp64` (the default) or `--abi ilp32`; for example: ```sh diff --git a/src/structpad/cli.py b/src/structpad/cli.py index 39f0c07..5b846e1 100644 --- a/src/structpad/cli.py +++ b/src/structpad/cli.py @@ -10,7 +10,7 @@ from structpad.render import render_check, render_layout, render_suggest _USAGE = ( - "usage: structpad layout [--abi lp64|ilp32] FILE\n" + "usage: structpad layout [--abi lp64|ilp32] [--struct NAME]... FILE\n" " structpad suggest [--abi lp64|ilp32] FILE\n" " structpad check [--abi lp64|ilp32] [--max-padding N] [--ignore NAME]... FILE\n" ) @@ -29,10 +29,11 @@ def _write_error(stderr: BinaryIO, message: str) -> None: def _parse_options( command: str, arguments: list[str] -) -> tuple[str, int, str, tuple[str, ...]] | str: +) -> tuple[str, int, str, tuple[str, ...], tuple[str, ...]] | str: abi = "lp64" max_padding = 0 ignored: list[str] = [] + selected: list[str] = [] file_name: str | None = None index = 0 @@ -88,6 +89,22 @@ def _parse_options( ignored.append(value) continue + if argument == "--struct" or argument.startswith("--struct="): + if command != "layout": + return f"{command} does not take --struct" + if argument == "--struct": + if index + 1 >= len(arguments): + return "--struct needs a value" + value = arguments[index + 1] + index += 2 + else: + value = argument[len("--struct=") :] + index += 1 + if _NAME_RE.fullmatch(value) is None: + return f'invalid --struct "{_escape_controls(value)}"' + selected.append(value) + continue + if argument.startswith("-") and argument != "-": return f'unknown option "{_escape_controls(argument)}"' if file_name is not None: @@ -97,7 +114,7 @@ def _parse_options( if file_name is None: return "missing FILE" - return abi, max_padding, file_name, tuple(ignored) + return abi, max_padding, file_name, tuple(ignored), tuple(selected) def run(args: list[str], stdin: BinaryIO, stdout: BinaryIO, stderr: BinaryIO) -> int: @@ -118,7 +135,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, ignored_names = parsed_options + abi, max_padding, file_name, ignored_names, selected_names = parsed_options display = "" if file_name == "-" else file_name.replace("\\", "/") try: @@ -149,10 +166,15 @@ def run(args: list[str], stdin: BinaryIO, stdout: BinaryIO, stderr: BinaryIO) -> _write_error(stderr, f'unknown struct "{name}" in --ignore') return 2 + for name in selected_names: + if name not in definitions: + _write_error(stderr, f'unknown struct "{name}" in --struct') + return 2 + ignored = frozenset(ignored_names) if command == "layout": - stdout.write(render_layout(structs, abi)) + stdout.write(render_layout(structs, abi, selected_names or None)) return 0 if command == "suggest": stdout.write(render_suggest(structs, abi)) diff --git a/src/structpad/render.py b/src/structpad/render.py index 6012013..3665320 100644 --- a/src/structpad/render.py +++ b/src/structpad/render.py @@ -45,13 +45,18 @@ def _layout_block(layout: StructLayout) -> str: return "\n".join(lines) -def render_layout(structs: Sequence[Struct], abi: str) -> bytes: - """Render every struct's layout as UTF-8 bytes with LF line endings.""" +def render_layout( + structs: Sequence[Struct], abi: str, selected_names: Sequence[str] | None = None +) -> bytes: + """Render layouts as UTF-8 bytes with LF line endings.""" definitions = _definitions(structs) + selected = None if selected_names is None else frozenset(selected_names) blocks = [ - _layout_block(calculate_layout(struct, abi, definitions)) for struct in structs + _layout_block(calculate_layout(struct, abi, definitions)) + for struct in structs + if selected is None or struct.name in selected ] - return ("\n\n".join(blocks) + "\n").encode("utf-8") + return (("\n\n".join(blocks) + "\n") if blocks else "").encode("utf-8") def render_suggest(structs: Sequence[Struct], abi: str) -> bytes: diff --git a/tests/test_cli.py b/tests/test_cli.py index be6f779..4fb6bf6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,7 +16,7 @@ run = importlib.import_module("structpad.cli").run _USAGE = ( - b"usage: structpad layout [--abi lp64|ilp32] FILE\n" + b"usage: structpad layout [--abi lp64|ilp32] [--struct NAME]... FILE\n" b" structpad suggest [--abi lp64|ilp32] FILE\n" b" structpad check [--abi lp64|ilp32] [--max-padding N] [--ignore NAME]... FILE\n" ) diff --git a/tests/test_struct_select.py b/tests/test_struct_select.py new file mode 100644 index 0000000..47c6f2e --- /dev/null +++ b/tests/test_struct_select.py @@ -0,0 +1,205 @@ +"""Example-based tests for selecting layout structs.""" + +import importlib +import sys +from io import BytesIO +from pathlib import Path + +_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 = importlib.import_module("test_ignore")._NET_SOURCE + +_EXPECTED_FRAME_WIRE = ( + b"struct Wire: size 5, align 1, padding 0, packed\n" + b"offset size field\n" + b" 0 1 kind: u8\n" + b" 1 4 value: u32\n" + b"\n" + b"struct Frame: size 56, align 8, padding 12\n" + b"offset size field\n" + b" 0 1 version: u8\n" + b" 1 7 (padding)\n" + b" 8 32 head: struct Packet\n" + b" 40 10 tail: struct Wire[2]\n" + b" 50 1 ok: bool\n" + b" 51 5 (padding)\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 + + +def test_requested_frame_and_wire_output_is_exact() -> None: + status, stdout, stderr = _run( + ["layout", "--struct", "Frame", "--struct", "Wire", "-"] + ) + assert status == 0 + assert stdout == _EXPECTED_FRAME_WIRE + assert stderr == b"" + assert b"\r" not in stdout + + +def test_equals_form_selects_only_packet_from_full_listing() -> None: + status, full_output, stderr = _run(["layout", "-"]) + assert (status, stderr) == (0, b"") + + status, selected_output, stderr = _run(["layout", "--struct=Packet", "-"]) + assert status == 0 + assert selected_output == full_output.split(b"\n\n")[0] + b"\n" + assert b"struct Wire:" not in selected_output + assert b"struct Frame:" not in selected_output + assert stderr == b"" + + +def test_selection_matches_full_listing_under_both_abis() -> None: + for abi in ("lp64", "ilp32"): + status, full_output, stderr = _run(["layout", "--abi", abi, "-"]) + assert (status, stderr) == (0, b"") + full_blocks = full_output.rstrip(b"\n").split(b"\n\n") + expected = b"\n\n".join((full_blocks[1], full_blocks[2])) + b"\n" + + status, selected_output, stderr = _run( + ["layout", "--abi", abi, "--struct", "Frame", "--struct", "Wire", "-"] + ) + assert status == 0 + assert selected_output == expected + assert stderr == b"" + if abi == "ilp32": + assert b"struct Frame: size 40, align 4, padding 4\n" in selected_output + assert b" 4 24 head: struct Packet\n" in selected_output + assert b" 38 1 ok: bool\n 39 1 (padding)\n" in selected_output + + +def test_repeats_are_unique_and_output_stays_in_file_order() -> None: + status, stdout, stderr = _run( + [ + "layout", + "--struct", + "Frame", + "--struct=Wire", + "--struct", + "Frame", + "--struct=Wire", + "-", + ] + ) + assert status == 0 + assert stdout == _EXPECTED_FRAME_WIRE + assert stderr == b"" + + +def test_struct_options_work_before_and_after_the_file() -> None: + full_output = _run(["layout", "-"])[1] + expected_wire = full_output.split(b"\n\n")[1] + b"\n" + for args in ( + ["layout", "--struct", "Wire", "-"], + ["layout", "--struct=Wire", "-"], + ["layout", "-", "--struct", "Wire"], + ["layout", "-", "--struct=Wire"], + ): + status, stdout, stderr = _run(args) + assert (status, stdout, stderr) == (0, expected_wire, b"") + + +def test_struct_name_grammar_accepts_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( + ["layout", "--struct=_", "--struct", longest_name, "-"], source + ) + assert status == 0 + assert stdout.startswith(b"struct _: size 1, align 1, padding 0\n") + assert f"struct {longest_name}: size 1, align 1, padding 0\n".encode() in stdout + assert stderr == b"" + + +def test_suggest_and_check_reject_struct_before_checking_its_value() -> None: + _assert_error(["suggest", "--struct"], "suggest does not take --struct") + _assert_error(["suggest", "--struct="], "suggest does not take --struct") + _assert_error(["check", "--struct"], "check does not take --struct") + _assert_error(["check", "--struct=Wire", "-"], "check does not take --struct") + + +def test_missing_and_invalid_struct_values_are_distinguished() -> None: + _assert_error(["layout", "--struct"], "--struct needs a value") + _assert_error( + ["layout", "--struct", "--unknown", "-"], 'invalid --struct "--unknown"' + ) + _assert_error(["layout", "--struct=", "-"], 'invalid --struct ""') + + +def test_invalid_struct_names_are_rejected_and_controls_escaped() -> None: + invalid_values = ("1x", "A" + "x" * 64, "é", "A\r\nB") + for value in invalid_values: + escaped = value.replace("\r", r"\r").replace("\n", r"\n") + _assert_error( + ["layout", "--struct", value, "-"], f'invalid --struct "{escaped}"' + ) + + +def test_unknown_structs_are_checked_in_argument_order() -> None: + _assert_error( + ["layout", "--struct", "Frame", "--struct", "Nope", "--struct=Nope2", "-"], + 'unknown struct "Nope" in --struct', + ) + _assert_error( + ["layout", "--struct=Nope2", "--struct", "Nope", "-"], + 'unknown struct "Nope2" in --struct', + ) + _assert_error(["layout", "--struct", "frame", "-"], 'unknown struct "frame" in --struct') + + +def test_parse_errors_precede_unknown_struct_names() -> None: + _assert_error( + ["layout", "--struct", "Nope", "-"], + ": no structs found", + b"// empty\n", + ) + + +def test_missing_file_precedes_unknown_struct_names(tmp_path: Path) -> None: + missing = tmp_path / "missing.sp" + shown = str(missing).replace("\\", "/") + _assert_error( + ["layout", "--struct", "Wire", str(missing)], + f"{shown}: cannot read the file", + ) + + +def test_readme_documents_the_exact_struct_selection_example() -> None: + readme_path = Path(__file__).resolve().parents[1] / "README.md" + readme = readme_path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + plain_readme = readme.replace(chr(96), "") + assert ( + "--struct NAME is available only with layout and may be repeated; " + "--struct=NAME is also supported. Only the named structs are printed, once each " + "and in file order, with the same numbers as in the full listing, because nested " + "structs are still resolved from the whole file." + ) in plain_readme + assert "structpad layout --struct Frame --struct Wire net.sp" in readme + assert _EXPECTED_FRAME_WIRE.decode().removesuffix("\n") in readme + assert 'invalid --struct "1x"' in readme + assert 'unknown struct "Nope" in --struct' in readme + assert "suggest does not take --struct" in readme + assert "check does not take --struct" in readme