ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
funcsize_sh.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Shell function-body measurement for ``check_function_size.py``.
4
5Shell has two body forms and this tree uses both deliberately:
6
7 name() { ... } the ordinary function
8 name() ( ... ) a SUBSHELL body
9
10The subshell form is load-bearing in ``scripts/ci.sh``: every multi-command
11gate body is ``gate_x() ( set -e; ... )`` so that ``set -e`` is scoped to the
12gate and a mid-body failure cannot be swallowed. A parser that only knew
13``{`` would silently measure nothing for the majority of gate bodies -- the
14exact "gate that quietly stopped matching" failure this repository keeps
15finding, so both forms are handled and the selftest asserts both.
16
17Like the C side this is a textual scan, and for the same reason: there is no
18shell equivalent of a compile database to lean on. Depth tracking ignores
19delimiters inside single and double quotes, inside ``#`` comments, and inside
20heredoc bodies -- a heredoc carrying JSON or a CMake fragment is full of braces
21that open no scope.
22"""
23
24from __future__ import annotations
25
26import re
27from pathlib import Path
28
29# `name() {`, `name() (`, `function name {`, and the same with the delimiter on
30# the following line.
31_OPEN_RE = re.compile(r"^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_:.-]*)\s*(?:\‍(\‍))\s*([({])?\s*$")
32
33_PAIRS = {"{": "}", "(": ")"}
34
35
36def _strip_inert(line: str) -> str:
37 r"""Blank out quoted spans and trailing comments, keeping length irrelevant.
38
39 Only the delimiters matter downstream, so quoted text is replaced rather
40 than removed. Escapes are honoured so ``\\"`` does not end a string.
41 """
42 out: list[str] = []
43 quote = ""
44 i = 0
45 while i < len(line):
46 ch = line[i]
47 if quote:
48 if ch == "\\" and quote == '"':
49 i += 2
50 continue
51 if ch == quote:
52 quote = ""
53 i += 1
54 continue
55 if ch == "\\":
56 i += 2
57 continue
58 if ch in ("'", '"'):
59 quote = ch
60 i += 1
61 continue
62 if ch == "#" and (not out or out[-1].isspace()):
63 break
64 out.append(ch)
65 i += 1
66 return "".join(out)
67
68
69def _heredoc_tag(line: str) -> str | None:
70 """The delimiter of a heredoc opened on `line`, or None."""
71 match = re.search(r"<<-?\s*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?", line)
72 return match.group(1) if match else None
73
74
75def _body_end(lines: list[str], open_idx: int, opener: str) -> int:
76 """Index one past the body whose opening delimiter is on `lines[open_idx]`."""
77 closer = _PAIRS[opener]
78 depth = 0
79 heredoc: str | None = None
80 idx = open_idx
81 while idx < len(lines):
82 raw = lines[idx]
83 if heredoc is not None:
84 if raw.strip() == heredoc:
85 heredoc = None
86 idx += 1
87 continue
88 clean = _strip_inert(raw)
89 depth += clean.count(opener) - clean.count(closer)
90 tag = _heredoc_tag(clean)
91 if tag is not None:
92 heredoc = tag
93 if depth <= 0:
94 return idx + 1
95 idx += 1
96 return len(lines)
97
98
99def scan(path: Path, threshold: int) -> list[tuple[int, int, str]]:
100 """Return (start_line, length, signature) for functions over `threshold`."""
101 try:
102 lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
103 except OSError:
104 return []
105
106 out: list[tuple[int, int, str]] = []
107 idx = 0
108 while idx < len(lines):
109 match = _OPEN_RE.match(lines[idx])
110 if match is None:
111 idx += 1
112 continue
113 opener = match.group(2)
114 open_idx = idx
115 if opener is None:
116 # The delimiter is on the next non-blank line, or this is not a
117 # function definition at all.
118 nxt = idx + 1
119 while nxt < len(lines) and not lines[nxt].strip():
120 nxt += 1
121 if nxt >= len(lines) or lines[nxt].strip() not in ("{", "("):
122 idx += 1
123 continue
124 opener = lines[nxt].strip()
125 open_idx = nxt
126 end = _body_end(lines, open_idx, opener)
127 length = end - idx
128 if length > threshold:
129 out.append((idx + 1, length, lines[idx].strip()))
130 idx = max(end, idx + 1)
131 return out