asmdiff 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asmdiff
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Compare per-function assembly between paired C implementations
5
5
  Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
6
6
  Project-URL: Repository, https://github.com/rt-rtos/asmdiff
@@ -94,7 +94,7 @@ $ asmdiff.py asmdiff_example.c
94
94
  ```
95
95
  asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
96
96
  [--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
97
- [--compile-commands [PATH]] [-- EXTRA_FLAGS...]
97
+ [--compile-commands [PATH]] [-v] [-- EXTRA_FLAGS...]
98
98
  ```
99
99
 
100
100
  | Option | Meaning |
@@ -105,7 +105,8 @@ asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
105
105
  | `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
106
106
  | `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
107
107
  | `--config PATH` | Config file to use. Default search: `asmdiff.toml` next to `SOURCE.c`, then in the current directory, then `~/.config/`. First hit wins. |
108
- | `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, search `./`, `./build`, `../`, `../build`. See [below](#borrowing-includes-from-compile_commandsjson). |
108
+ | `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, walk up from the CWD checking each directory and its `build/` until the repository root. See [below](#borrowing-includes-from-compile_commandsjson). |
109
+ | `-v`, `--verbose` | On compile failure, print the full compiler command and complete error output. Default shows only the compiler, the source, and the first error lines. |
109
110
  | `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
110
111
 
111
112
  With no `--cc` and no `--target`, the config file's top-level
@@ -207,7 +208,8 @@ compile_commands = "$HOME/myproject/build/compile_commands.json"
207
208
  Now `asmdiff.py $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf`
208
209
  finds that file's entry in the database and adds the include/define flags
209
210
  it recorded — `-I`, `-isystem`, `-iquote`, `-idirafter`, `-include`,
210
- `-imacros`, `-D`, `-U` — to this target's command. Everything else the
211
+ `-imacros`, `-D`, `-U`, plus the header-environment driver flags `-specs`
212
+ and `--sysroot` — to this target's command. Everything else the
211
213
  database records (its own compiler, `-O`/`-std`/`-W` flags, `-c`, `-o`, the
212
214
  source) is ignored: **you** own the compiler and optimisation flags via
213
215
  `cc`/`flags`; only the header environment is borrowed. That split is the
@@ -219,6 +221,12 @@ Details that make it robust:
219
221
  - **Paths are made absolute** against each entry's `directory`, so a
220
222
  database full of build-relative `-I../include` flags still resolves when
221
223
  asmdiff runs from anywhere.
224
+ - **`@file` response files are expanded.** Build systems park flags in
225
+ them — ESP-IDF v6 hides `-specs=picolibc.specs` in
226
+ `build/toolchain/cflags`, and without it every libc header breaks —
227
+ so the flag scan reads them (nested ones too) instead of skipping the
228
+ token. A bare specs name is left for the compiler's own search
229
+ directories; a specs path resolves like any other recorded path.
222
230
  - **Per source file.** The lookup keys on the source you pass (matched by
223
231
  resolved absolute path), so two files in an `--across`/summary run each
224
232
  get their own recorded flags.
@@ -246,12 +254,15 @@ asmdiff.py biquad.c --compile-commands # same, for any matrix
246
254
  asmdiff.py biquad.c --compile-commands path/to/compile_commands.json
247
255
  ```
248
256
 
249
- Both search `./`, `./build`, `../`, `../build` — first hit wins — which
250
- covers running from a project root or from one directory below it (a
251
- component dir), with the database where CMake/idf.py leaves it. Finding
252
- nothing is an error: discovery is opt-in, so if you asked for it, silence
253
- would be a lie. It is never on by default — a target without
254
- `compile_commands` and no `--compile-commands` flag borrows nothing.
257
+ Both walk up from the current directory, checking each level for
258
+ `compile_commands.json` and then `build/compile_commands.json` (where
259
+ CMake and idf.py leave it) — first hit wins. The walk stops at the
260
+ repository root (the first directory with a `.git`), so running from any
261
+ depth of component directory finds the project database, but an unrelated
262
+ one further up the filesystem is never picked up. Finding nothing is an
263
+ error: discovery is opt-in, so if you asked for it, silence would be a
264
+ lie. It is never on by default — a target without `compile_commands` and
265
+ no `--compile-commands` flag borrows nothing.
255
266
 
256
267
  The precedence is what you'd hope: a target that names its own
257
268
  `compile_commands` path always keeps it; `--compile-commands` (with or
@@ -292,6 +303,12 @@ weight, did a call appear that shouldn't have? It sums parsed function
292
303
  bodies only (no literal pools, data, or alignment), so it is not a size
293
304
  measurement, and per-function rows are where the real information is.
294
305
 
306
+ Only labels the assembler types as functions are listed — global data
307
+ (string constants, state structs, lookup tables) gets column-0 labels too
308
+ but is not code. A `calls` list longer than 8 symbols is truncated to
309
+ `..., +N more`; real firmware dispatch functions call dozens of distinct
310
+ symbols and would otherwise make rows thousands of characters wide.
311
+
295
312
  ## Comparing the same function across two builds (`--across`)
296
313
 
297
314
  `--pair` needs both variants to coexist in one compilation. Real changes
@@ -76,7 +76,7 @@ $ asmdiff.py asmdiff_example.c
76
76
  ```
77
77
  asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
78
78
  [--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
79
- [--compile-commands [PATH]] [-- EXTRA_FLAGS...]
79
+ [--compile-commands [PATH]] [-v] [-- EXTRA_FLAGS...]
80
80
  ```
81
81
 
82
82
  | Option | Meaning |
@@ -87,7 +87,8 @@ asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
87
87
  | `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
88
88
  | `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
89
89
  | `--config PATH` | Config file to use. Default search: `asmdiff.toml` next to `SOURCE.c`, then in the current directory, then `~/.config/`. First hit wins. |
90
- | `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, search `./`, `./build`, `../`, `../build`. See [below](#borrowing-includes-from-compile_commandsjson). |
90
+ | `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, walk up from the CWD checking each directory and its `build/` until the repository root. See [below](#borrowing-includes-from-compile_commandsjson). |
91
+ | `-v`, `--verbose` | On compile failure, print the full compiler command and complete error output. Default shows only the compiler, the source, and the first error lines. |
91
92
  | `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
92
93
 
93
94
  With no `--cc` and no `--target`, the config file's top-level
@@ -189,7 +190,8 @@ compile_commands = "$HOME/myproject/build/compile_commands.json"
189
190
  Now `asmdiff.py $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf`
190
191
  finds that file's entry in the database and adds the include/define flags
191
192
  it recorded — `-I`, `-isystem`, `-iquote`, `-idirafter`, `-include`,
192
- `-imacros`, `-D`, `-U` — to this target's command. Everything else the
193
+ `-imacros`, `-D`, `-U`, plus the header-environment driver flags `-specs`
194
+ and `--sysroot` — to this target's command. Everything else the
193
195
  database records (its own compiler, `-O`/`-std`/`-W` flags, `-c`, `-o`, the
194
196
  source) is ignored: **you** own the compiler and optimisation flags via
195
197
  `cc`/`flags`; only the header environment is borrowed. That split is the
@@ -201,6 +203,12 @@ Details that make it robust:
201
203
  - **Paths are made absolute** against each entry's `directory`, so a
202
204
  database full of build-relative `-I../include` flags still resolves when
203
205
  asmdiff runs from anywhere.
206
+ - **`@file` response files are expanded.** Build systems park flags in
207
+ them — ESP-IDF v6 hides `-specs=picolibc.specs` in
208
+ `build/toolchain/cflags`, and without it every libc header breaks —
209
+ so the flag scan reads them (nested ones too) instead of skipping the
210
+ token. A bare specs name is left for the compiler's own search
211
+ directories; a specs path resolves like any other recorded path.
204
212
  - **Per source file.** The lookup keys on the source you pass (matched by
205
213
  resolved absolute path), so two files in an `--across`/summary run each
206
214
  get their own recorded flags.
@@ -228,12 +236,15 @@ asmdiff.py biquad.c --compile-commands # same, for any matrix
228
236
  asmdiff.py biquad.c --compile-commands path/to/compile_commands.json
229
237
  ```
230
238
 
231
- Both search `./`, `./build`, `../`, `../build` — first hit wins — which
232
- covers running from a project root or from one directory below it (a
233
- component dir), with the database where CMake/idf.py leaves it. Finding
234
- nothing is an error: discovery is opt-in, so if you asked for it, silence
235
- would be a lie. It is never on by default — a target without
236
- `compile_commands` and no `--compile-commands` flag borrows nothing.
239
+ Both walk up from the current directory, checking each level for
240
+ `compile_commands.json` and then `build/compile_commands.json` (where
241
+ CMake and idf.py leave it) — first hit wins. The walk stops at the
242
+ repository root (the first directory with a `.git`), so running from any
243
+ depth of component directory finds the project database, but an unrelated
244
+ one further up the filesystem is never picked up. Finding nothing is an
245
+ error: discovery is opt-in, so if you asked for it, silence would be a
246
+ lie. It is never on by default — a target without `compile_commands` and
247
+ no `--compile-commands` flag borrows nothing.
237
248
 
238
249
  The precedence is what you'd hope: a target that names its own
239
250
  `compile_commands` path always keeps it; `--compile-commands` (with or
@@ -274,6 +285,12 @@ weight, did a call appear that shouldn't have? It sums parsed function
274
285
  bodies only (no literal pools, data, or alignment), so it is not a size
275
286
  measurement, and per-function rows are where the real information is.
276
287
 
288
+ Only labels the assembler types as functions are listed — global data
289
+ (string constants, state structs, lookup tables) gets column-0 labels too
290
+ but is not code. A `calls` list longer than 8 symbols is truncated to
291
+ `..., +N more`; real firmware dispatch functions call dozens of distinct
292
+ symbols and would otherwise make rows thousands of characters wide.
293
+
277
294
  ## Comparing the same function across two builds (`--across`)
278
295
 
279
296
  `--pair` needs both variants to coexist in one compilation. Real changes
@@ -42,8 +42,9 @@ flags = ["-O2", "-mlongcalls", "-DNDEBUG"]
42
42
  # never transcribe include paths by hand. The compiler and -O/-m flags
43
43
  # still come from cc/flags; only the header environment is borrowed.
44
44
  # A path expands ~ and $VARS; `compile_commands = true` instead searches
45
- # ./, ./build, ../, ../build at run time (a source missing from a database
46
- # found that way is compiled without borrowed flags, with a note).
45
+ # at run time: each directory from the CWD up to the repository root,
46
+ # and its build/ subdirectory (a source missing from a database found
47
+ # that way is compiled without borrowed flags, with a note).
47
48
  [esp32s3-idf]
48
49
  cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
49
50
  flags = ["-O2", "-mlongcalls"]
@@ -12,7 +12,7 @@ Usage:
12
12
  tools/asmdiff/asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]...
13
13
  [--across FUNC]... [--cc 'CC FLAGS']...
14
14
  [--target NAME]... [--config PATH]
15
- [--compile-commands [PATH]]
15
+ [--compile-commands [PATH]] [-v]
16
16
  [-- EXTRA_FLAGS...]
17
17
 
18
18
  Three modes:
@@ -35,9 +35,10 @@ the include/define flags recorded there for the source being compiled are
35
35
  added to that target's command, so a real project source resolves its
36
36
  headers the way its own build system does (e.g. an ESP-IDF component).
37
37
  compile_commands = true in a target — or a bare --compile-commands on the
38
- command line — instead searches ./, ./build, ../, ../build for the
39
- database; a source absent from a database found that way is compiled
40
- without borrowed flags (with a note) rather than being an error.
38
+ command line — instead finds the database by checking each directory from
39
+ the CWD up to the repository root, and its build/ subdirectory; a source
40
+ absent from a database found that way is compiled without borrowed flags
41
+ (with a note) rather than being an error.
41
42
  """
42
43
  import argparse
43
44
  import glob
@@ -73,6 +74,11 @@ CONFIG_NAME = "asmdiff.toml"
73
74
  _CC_DB_PATH_FLAGS = ("-I", "-iquote", "-isystem", "-idirafter",
74
75
  "-include", "-imacros", "-isysroot")
75
76
  _CC_DB_PLAIN_FLAGS = ("-D", "-U")
77
+ # Driver-level flags that also shape the header environment: a specs file
78
+ # can swap the entire libc header set (ESP-IDF v6 selects picolibc via
79
+ # -specs=picolibc.specs), and --sysroot moves every system include. Both
80
+ # accept =-glued and split spellings; both are re-emitted =-glued.
81
+ _CC_DB_EQ_FLAGS = ("-specs", "--specs", "--sysroot")
76
82
  _DB_CACHE = {}
77
83
  _MISS_NOTED = set()
78
84
 
@@ -105,6 +111,43 @@ def _abs_against(directory, value):
105
111
  return str(p)
106
112
 
107
113
 
114
+ def _expand_response_files(tokens, directory, depth=0):
115
+ """Inline GCC @file response files: each @FILE token is replaced by the
116
+ shlex-split contents of FILE, resolved against the entry's ``directory``
117
+ (the CWD the driver ran from). Build systems park header-environment
118
+ flags there — ESP-IDF v6 hides -specs=picolibc.specs in one — so
119
+ skipping them silently loses flags this feature exists to borrow.
120
+ An unreadable file is warned about and dropped; nesting is bounded as
121
+ a cycle guard.
122
+ """
123
+ out = []
124
+ for tok in tokens:
125
+ if not tok.startswith("@") or len(tok) == 1:
126
+ out.append(tok)
127
+ continue
128
+ path = _abs_against(directory, tok[1:])
129
+ try:
130
+ content = Path(path).read_text()
131
+ except OSError:
132
+ print(f"warning: response file {path} not readable; "
133
+ "flags inside it are not borrowed", file=sys.stderr)
134
+ continue
135
+ inner = shlex.split(content)
136
+ if depth < 8:
137
+ inner = _expand_response_files(inner, directory, depth + 1)
138
+ out += inner
139
+ return out
140
+
141
+
142
+ def _specs_value(directory, value):
143
+ """A specs argument with a path separator is a file path (resolved like
144
+ any other); a bare name (picolibc.specs) is looked up in the compiler's
145
+ own search directories and must pass through untouched."""
146
+ if "/" in value or os.sep in value:
147
+ return _abs_against(directory, value)
148
+ return value
149
+
150
+
108
151
  def include_flags(tokens, directory):
109
152
  """Pick header-search, forced-include, and define flags out of one
110
153
  recorded compile command; make relative paths absolute against
@@ -124,6 +167,19 @@ def include_flags(tokens, directory):
124
167
  flags += [f, _abs_against(directory, tok[len(f):])]
125
168
  matched = True
126
169
  break
170
+ if not matched:
171
+ for f in _CC_DB_EQ_FLAGS:
172
+ value = None
173
+ if tok == f and i + 1 < n: # -specs file
174
+ value, extra = tokens[i + 1], 1
175
+ elif tok.startswith(f + "="): # -specs=file
176
+ value = tok[len(f) + 1:]
177
+ if value is not None:
178
+ resolve = (_specs_value if f.endswith("specs")
179
+ else _abs_against)
180
+ flags.append(f + "=" + resolve(directory, value))
181
+ matched = True
182
+ break
127
183
  if not matched:
128
184
  for f in _CC_DB_PLAIN_FLAGS:
129
185
  if tok == f and i + 1 < n: # -D FOO
@@ -138,18 +194,26 @@ def include_flags(tokens, directory):
138
194
 
139
195
 
140
196
  def find_compile_commands():
141
- """Locate a compile_commands.json near the current directory.
142
-
143
- Searched in order: ./, ./build, ../, ../build — first hit wins. This
144
- covers running asmdiff from a project root or from one directory below
145
- it (a component dir), with the database where CMake/idf.py leaves it.
146
- Returns None when nothing is found.
197
+ """Locate a compile_commands.json by walking up from the current
198
+ directory.
199
+
200
+ Each directory from the CWD upward is checked for the database itself,
201
+ then for build/compile_commands.json (where CMake and idf.py leave it),
202
+ so the search works from a project root or from any depth of component
203
+ directory. The walk stops at the first directory containing .git (the
204
+ repository root — checked after that directory's own candidates) or
205
+ after a bounded number of levels, so an unrelated database further up
206
+ the filesystem is never picked up. Returns None when nothing is found.
147
207
  """
148
- cwd = Path.cwd()
149
- for d in (cwd, cwd / "build", cwd.parent, cwd.parent / "build"):
150
- candidate = d / "compile_commands.json"
151
- if candidate.is_file():
152
- return str(candidate)
208
+ d = Path.cwd()
209
+ for _ in range(10):
210
+ for candidate in (d / "compile_commands.json",
211
+ d / "build" / "compile_commands.json"):
212
+ if candidate.is_file():
213
+ return str(candidate)
214
+ if (d / ".git").exists() or d.parent == d:
215
+ return None
216
+ d = d.parent
153
217
  return None
154
218
 
155
219
 
@@ -159,8 +223,9 @@ def _discovered_db(who):
159
223
  not a silent no-op."""
160
224
  found = find_compile_commands()
161
225
  if found is None:
162
- sys.exit(f"error: {who}: no compile_commands.json found in "
163
- "./, ./build, ../, or ../build")
226
+ sys.exit(f"error: {who}: no compile_commands.json found in the "
227
+ "current directory, its build/, or any parent up to the "
228
+ "repository root")
164
229
  return found
165
230
 
166
231
 
@@ -212,6 +277,7 @@ def compile_commands_flags(db_path, source, missing_ok=False):
212
277
  args = entry.get("arguments")
213
278
  tokens = (list(args) if isinstance(args, list)
214
279
  else shlex.split(entry.get("command", "")))
280
+ tokens = _expand_response_files(tokens, directory)
215
281
  return include_flags(tokens, directory)
216
282
  if Path(f).name == want.name:
217
283
  name_seen = True
@@ -229,10 +295,18 @@ def compile_commands_flags(db_path, source, missing_ok=False):
229
295
 
230
296
  # A label at column 0 that is not a local (.L*) label starts a function.
231
297
  FUNC_LABEL = re.compile(r"^([A-Za-z_][\w$.]*):")
232
- # Assembler directives that carry no information worth reading.
298
+ # .type NAME, @object|@function — gcc/clang emit this before the label on
299
+ # ELF. Objects (string constants like __func__, global state, LUTs) get
300
+ # column-0 labels too and must not be reported as functions. The type
301
+ # marker is @ on x86/Xtensa/RISC-V, % on ARM, # on some targets.
302
+ TYPE_DIRECTIVE = re.compile(r'^\s*\.type\s+([^,\s]+)\s*,\s*[@%#]?(\w+)')
303
+ # Assembler directives that carry no information worth reading. .local/
304
+ # .comm/.lcomm declare zero-initialised data, .literal/.literal_position
305
+ # are Xtensa literal-pool bookkeeping — none of them is an instruction.
233
306
  NOISE = re.compile(
234
307
  r"^\s*\.(cfi_|p2align|align\b|loc\b|file\b|text\b|globl\b|global\b|"
235
- r"type\b|section\b|ident\b|weak\b|hidden\b|addrsig|build_version)"
308
+ r"type\b|section\b|ident\b|weak\b|hidden\b|addrsig|build_version|"
309
+ r"local\b|comm\b|lcomm\b|literal_position|literal\b)"
236
310
  )
237
311
  # Compiler-generated bracketing labels that add nothing (.LFB0:, .Lfunc_end0:).
238
312
  NOISE_LABEL = re.compile(r"^\.(LFB|LFE|Lfunc_begin|Lfunc_end)\d*:")
@@ -252,18 +326,28 @@ def extract_functions(asm_text):
252
326
 
253
327
  A function body runs from its column-0 label to the matching .size
254
328
  directive (gcc and clang both emit one on ELF) or the next function
255
- label. Comment lines, CFI/section/alignment directives, compiler
256
- bracketing labels, and inline data (switch jump tables, constants) are
257
- dropped; instructions and meaningful local labels (loop targets) are
258
- kept, whitespace-stripped.
329
+ label. Labels typed ``@object`` (string constants, global state,
330
+ lookup tables) are data, not functions, and are skipped entirely;
331
+ an untyped label still counts as a function so hand-written asm
332
+ keeps working. Comment lines, CFI/section/alignment directives,
333
+ compiler bracketing labels, and inline data (switch jump tables,
334
+ constants) are dropped; instructions and meaningful local labels
335
+ (loop targets) are kept, whitespace-stripped.
259
336
  """
260
337
  funcs = {}
261
338
  current = None
339
+ data_labels = set()
262
340
  for raw in asm_text.splitlines():
341
+ t = TYPE_DIRECTIVE.match(raw)
342
+ if t and t.group(2) != "function":
343
+ data_labels.add(t.group(1))
263
344
  m = FUNC_LABEL.match(raw)
264
345
  if m:
265
- current = m.group(1)
266
- funcs[current] = []
346
+ if m.group(1) in data_labels:
347
+ current = None
348
+ else:
349
+ current = m.group(1)
350
+ funcs[current] = []
267
351
  continue
268
352
  if current is None:
269
353
  continue
@@ -453,7 +537,7 @@ def target_command(config, name, config_path):
453
537
  if not isinstance(db, str):
454
538
  sys.exit(f"error: target [{name}]: compile_commands must be a "
455
539
  "path to a compile_commands.json, or true to search "
456
- "./, ./build, ../, ../build")
540
+ "upward from the current directory")
457
541
  db = os.path.expandvars(os.path.expanduser(db))
458
542
  return Target(shlex.join([resolve_cc(cc, name), *flags]), db, discovered)
459
543
 
@@ -491,6 +575,31 @@ def build_matrix(cc_args, target_args, config, config_path, db_arg=None):
491
575
  return entries
492
576
 
493
577
 
578
+ # --verbose: print full compiler command lines and untrimmed stderr.
579
+ VERBOSE = False
580
+ # Without --verbose, a failed compile shows this many stderr lines — enough
581
+ # for the include chain plus the first error, which is the actionable part.
582
+ MAX_STDERR_LINES = 20
583
+
584
+
585
+ def _compile_failure(cmd, stderr):
586
+ """Exit for a failed compile.
587
+
588
+ A borrowed-flags command runs to hundreds of tokens and a broken
589
+ header environment produces pages of stderr; dumping both buries the
590
+ actual error. Default: compiler + source + the first stderr lines.
591
+ --verbose restores the complete command and output.
592
+ """
593
+ if VERBOSE:
594
+ sys.exit(f"error: compile failed: {shlex.join(cmd)}\n{stderr}")
595
+ lines = stderr.splitlines()
596
+ shown = "\n".join(lines[:MAX_STDERR_LINES])
597
+ dropped = len(lines) - MAX_STDERR_LINES
598
+ more = f"\n... {dropped} more stderr lines" if dropped > 0 else ""
599
+ sys.exit(f"error: {cmd[0]} failed on {cmd[-1]}\n{shown}{more}\n"
600
+ "(re-run with --verbose for the full command and output)")
601
+
602
+
494
603
  def asm_output_name(cc_cmd, harness):
495
604
  """Filesystem-safe .s name for one (compiler, source) compilation.
496
605
 
@@ -527,7 +636,7 @@ def compile_to_asm(cc_cmd, extra_flags, harness, out_dir):
527
636
  + ["-S", "-o", str(out_s), str(harness)])
528
637
  proc = subprocess.run(cmd, capture_output=True, text=True)
529
638
  if proc.returncode != 0:
530
- sys.exit(f"error: compile failed: {' '.join(cmd)}\n{proc.stderr}")
639
+ _compile_failure(cmd, proc.stderr)
531
640
  return out_s.read_text()
532
641
 
533
642
 
@@ -554,14 +663,28 @@ def format_spans(spans):
554
663
  return " ".join(f"{label}:{n}" for label, n in spans) or "-"
555
664
 
556
665
 
666
+ # Real firmware dispatch functions call dozens of distinct symbols; an
667
+ # uncapped list makes summary rows thousands of characters wide.
668
+ MAX_CALLS_SHOWN = 8
669
+
670
+
671
+ def format_calls(calls):
672
+ """Comma-joined callee list, capped at MAX_CALLS_SHOWN for readability."""
673
+ if not calls:
674
+ return "-"
675
+ if len(calls) > MAX_CALLS_SHOWN:
676
+ return (", ".join(calls[:MAX_CALLS_SHOWN])
677
+ + f", +{len(calls) - MAX_CALLS_SHOWN} more")
678
+ return ", ".join(calls)
679
+
680
+
557
681
  def summary_table(pairs, funcs):
558
682
  """Instruction counts, outbound calls, and loop spans per pair member."""
559
683
  rows = [("function", "role", "insns", "calls", "loop spans")]
560
684
  for old, new in pairs:
561
685
  for name, role in ((old, "baseline"), (new, "candidate")):
562
686
  insns, calls = analyze(funcs[name])
563
- rows.append((name, role, str(insns),
564
- ", ".join(calls) or "-",
687
+ rows.append((name, role, str(insns), format_calls(calls),
565
688
  format_spans(loop_spans(funcs[name]))))
566
689
  return render_table(rows)
567
690
 
@@ -582,10 +705,10 @@ def file_summary_table(funcs):
582
705
  for sym in calls:
583
706
  if sym not in all_calls:
584
707
  all_calls.append(sym)
585
- rows.append((name, str(insns), ", ".join(calls) or "-",
708
+ rows.append((name, str(insns), format_calls(calls),
586
709
  format_spans(loop_spans(lines))))
587
710
  rows.append((f"TOTAL ({len(funcs)} functions)", str(total_insns),
588
- ", ".join(all_calls) or "-", "-"))
711
+ format_calls(all_calls), "-"))
589
712
  return render_table(rows)
590
713
 
591
714
 
@@ -763,13 +886,20 @@ def main(argv=None):
763
886
  default=None, metavar="PATH",
764
887
  help="borrow each source's include/define flags from "
765
888
  "a compile_commands.json; with no PATH, search "
766
- "./, ./build, ../, ../build. A target whose "
889
+ "each directory (and its build/) from the CWD "
890
+ "up to the repository root. A target whose "
767
891
  "config names its own compile_commands keeps it")
768
892
  parser.add_argument("--config", metavar="PATH",
769
893
  help=f"config file; default search: {CONFIG_NAME} "
770
894
  "next to SOURCE.c, in the current directory, "
771
895
  "then in ~/.config/")
896
+ parser.add_argument("-v", "--verbose", action="store_true",
897
+ help="on compile failure, print the full compiler "
898
+ "command and complete error output instead of "
899
+ "the first lines")
772
900
  args = parser.parse_args(argv)
901
+ global VERBOSE
902
+ VERBOSE = args.verbose
773
903
 
774
904
  if len(args.sources) > 2:
775
905
  parser.error("at most two source files may be given")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asmdiff"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Compare per-function assembly between paired C implementations"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -388,6 +388,77 @@ class TestIncludeFlags(unittest.TestCase):
388
388
  ["-isystem", "/s"])
389
389
 
390
390
 
391
+ class TestSpecsAndSysroot(unittest.TestCase):
392
+ """Driver flags that change the header environment (-specs, --sysroot)."""
393
+
394
+ def test_specs_glued_bare_name_not_resolved(self):
395
+ # A bare specs name (no path separator) is found in the compiler's
396
+ # own search dirs; gluing a directory onto it would break it.
397
+ self.assertEqual(
398
+ asmdiff.include_flags(["-specs=picolibc.specs"], "/build"),
399
+ ["-specs=picolibc.specs"])
400
+
401
+ def test_specs_with_path_resolved_against_directory(self):
402
+ self.assertEqual(
403
+ asmdiff.include_flags(["-specs=./custom/my.specs"], "/build"),
404
+ ["-specs=/build/custom/my.specs"])
405
+
406
+ def test_specs_split_and_double_dash(self):
407
+ self.assertEqual(
408
+ asmdiff.include_flags(["-specs", "nano.specs",
409
+ "--specs=nosys.specs"], "/b"),
410
+ ["-specs=nano.specs", "--specs=nosys.specs"])
411
+
412
+ def test_sysroot_glued_and_split(self):
413
+ self.assertEqual(
414
+ asmdiff.include_flags(["--sysroot=sr", "--sysroot", "/abs"],
415
+ "/b"),
416
+ ["--sysroot=/b/sr", "--sysroot=/abs"])
417
+
418
+
419
+ class TestResponseFiles(unittest.TestCase):
420
+ """GCC @file response files inside compile_commands entries."""
421
+
422
+ def test_flags_inside_response_file_are_borrowed(self):
423
+ with tempfile.TemporaryDirectory() as tmp:
424
+ rsp = Path(tmp) / "toolchain" / "cflags"
425
+ rsp.parent.mkdir()
426
+ rsp.write_text("-mlongcalls\n-specs=picolibc.specs\n-Irspinc\n")
427
+ src = Path(tmp) / "a.c"
428
+ src.touch()
429
+ db = Path(tmp) / "compile_commands.json"
430
+ db.write_text(json.dumps([{
431
+ "directory": tmp, "file": str(src),
432
+ "command": f"cc -Iinc @{rsp} -c a.c"}]))
433
+ asmdiff._DB_CACHE.clear()
434
+ self.assertEqual(
435
+ asmdiff.compile_commands_flags(str(db), str(src)),
436
+ ["-I", f"{tmp}/inc", "-specs=picolibc.specs",
437
+ "-I", f"{tmp}/rspinc"])
438
+
439
+ def test_relative_response_file_resolves_against_directory(self):
440
+ with tempfile.TemporaryDirectory() as tmp:
441
+ (Path(tmp) / "cflags").write_text("-DFROMRSP")
442
+ self.assertEqual(
443
+ asmdiff._expand_response_files(["@cflags"], tmp),
444
+ ["-DFROMRSP"])
445
+
446
+ def test_nested_response_files(self):
447
+ with tempfile.TemporaryDirectory() as tmp:
448
+ (Path(tmp) / "outer").write_text("@inner -DOUTER")
449
+ (Path(tmp) / "inner").write_text("-DINNER")
450
+ self.assertEqual(
451
+ asmdiff._expand_response_files(["@outer"], tmp),
452
+ ["-DINNER", "-DOUTER"])
453
+
454
+ def test_missing_response_file_warns_and_continues(self):
455
+ err = io.StringIO()
456
+ with contextlib.redirect_stderr(err):
457
+ out = asmdiff._expand_response_files(["-DKEEP", "@/nope/x"], "/b")
458
+ self.assertEqual(out, ["-DKEEP"])
459
+ self.assertIn("/nope/x", err.getvalue())
460
+
461
+
391
462
  class TestCompileCommandsFlags(unittest.TestCase):
392
463
  def _db(self, tmp, entries):
393
464
  path = Path(tmp) / "compile_commands.json"
@@ -477,15 +548,22 @@ def _inside(directory):
477
548
 
478
549
 
479
550
  class TestFindCompileCommands(unittest.TestCase):
480
- """Auto-discovery of compile_commands.json near the current directory."""
551
+ """Auto-discovery of compile_commands.json by walking up from the CWD."""
481
552
 
482
553
  def _touch_db(self, directory):
483
554
  directory.mkdir(parents=True, exist_ok=True)
484
555
  (directory / "compile_commands.json").write_text("[]")
485
556
 
486
- def test_search_order_cwd_build_parent_parentbuild(self):
557
+ def _repo(self, tmp):
558
+ """A fake repo root: .git bounds the walk so tests never escape
559
+ the tempdir and pick up a stray database further up."""
560
+ root = Path(tmp)
561
+ (root / ".git").mkdir()
562
+ return root
563
+
564
+ def test_nearer_hits_win(self):
487
565
  with tempfile.TemporaryDirectory() as tmp:
488
- root = Path(tmp)
566
+ root = self._repo(tmp)
489
567
  cwd = root / "sub"
490
568
  cwd.mkdir()
491
569
  for where, expect in [(root / "build", root / "build"),
@@ -497,14 +575,127 @@ class TestFindCompileCommands(unittest.TestCase):
497
575
  self.assertEqual(asmdiff.find_compile_commands(),
498
576
  str(expect / "compile_commands.json"))
499
577
 
578
+ def test_walks_up_from_nested_component_dir(self):
579
+ # components/amy/src is three levels below the project root where
580
+ # idf.py leaves build/compile_commands.json.
581
+ with tempfile.TemporaryDirectory() as tmp:
582
+ root = self._repo(tmp)
583
+ self._touch_db(root / "build")
584
+ cwd = root / "components" / "amy" / "src"
585
+ cwd.mkdir(parents=True)
586
+ with _inside(cwd):
587
+ self.assertEqual(asmdiff.find_compile_commands(),
588
+ str(root / "build" / "compile_commands.json"))
589
+
590
+ def test_stops_at_repository_root(self):
591
+ with tempfile.TemporaryDirectory() as tmp:
592
+ self._touch_db(Path(tmp)) # db ABOVE the repo root
593
+ repo = Path(tmp) / "repo"
594
+ repo.mkdir()
595
+ (repo / ".git").write_text("gitdir: elsewhere") # worktree form
596
+ cwd = repo / "src"
597
+ cwd.mkdir()
598
+ with _inside(cwd):
599
+ self.assertIsNone(asmdiff.find_compile_commands())
600
+
500
601
  def test_nothing_found_returns_none(self):
501
602
  with tempfile.TemporaryDirectory() as tmp:
502
- cwd = Path(tmp) / "a" / "b"
603
+ root = self._repo(tmp)
604
+ cwd = root / "a" / "b"
503
605
  cwd.mkdir(parents=True)
504
606
  with _inside(cwd):
505
607
  self.assertIsNone(asmdiff.find_compile_commands())
506
608
 
507
609
 
610
+ class TestObjectLabels(unittest.TestCase):
611
+ """Data labels must not be reported as functions."""
612
+
613
+ ASM = """\
614
+ \t.type\tscale, @function
615
+ scale:
616
+ \tentry\tsp, 32
617
+ \tretw.n
618
+ \t.size\tscale, .-scale
619
+ \t.type\t__func__$1, @object
620
+ \t.size\t__func__$1, 9
621
+ __func__$1:
622
+ \t.string\t"app_main"
623
+ \t.local\ts_queue
624
+ \t.comm\ts_queue,4,4
625
+ \t.lcomm\ts_tmp,8
626
+ """
627
+
628
+ def test_object_labels_skipped(self):
629
+ funcs = asmdiff.extract_functions(self.ASM)
630
+ self.assertEqual(list(funcs), ["scale"])
631
+
632
+ def test_arm_percent_function_type_still_reported(self):
633
+ asm = "\t.type\tf, %function\nf:\n\tbx\tlr\n\t.size\tf, .-f\n"
634
+ self.assertEqual(list(asmdiff.extract_functions(asm)), ["f"])
635
+
636
+ def test_untyped_label_still_treated_as_function(self):
637
+ # Hand-written asm often has no .type at all.
638
+ asm = "myfunc:\n\tret\n"
639
+ self.assertEqual(list(asmdiff.extract_functions(asm)), ["myfunc"])
640
+
641
+ def test_local_comm_literal_lines_not_counted(self):
642
+ asm = ("f:\n\tmov.n\ta2, a3\n\t.literal_position\n"
643
+ "\t.literal .LC1, 4096\n\t.local\tx\n\t.comm\tx,4,4\n"
644
+ "\tretw.n\n")
645
+ funcs = asmdiff.extract_functions(asm)
646
+ insns, _ = asmdiff.analyze(funcs["f"])
647
+ self.assertEqual(insns, 2)
648
+
649
+
650
+ class TestCompileFailureOutput(unittest.TestCase):
651
+ CMD = ["xtensa-gcc", "-O2"] + [f"-I/inc{i}" for i in range(50)] + ["a.c"]
652
+ STDERR = "\n".join(f"err line {i}" for i in range(60))
653
+
654
+ def test_default_trims_flags_and_stderr(self):
655
+ asmdiff.VERBOSE = False
656
+ with self.assertRaises(SystemExit) as ctx:
657
+ asmdiff._compile_failure(self.CMD, self.STDERR)
658
+ msg = str(ctx.exception)
659
+ self.assertIn("xtensa-gcc failed on a.c", msg)
660
+ self.assertNotIn("-I/inc0", msg) # no flag dump
661
+ self.assertIn("err line 0", msg)
662
+ self.assertNotIn("err line 30", msg) # stderr trimmed
663
+ self.assertIn("40 more stderr lines", msg)
664
+ self.assertIn("--verbose", msg)
665
+
666
+ def test_verbose_shows_everything(self):
667
+ asmdiff.VERBOSE = True
668
+ try:
669
+ with self.assertRaises(SystemExit) as ctx:
670
+ asmdiff._compile_failure(self.CMD, self.STDERR)
671
+ finally:
672
+ asmdiff.VERBOSE = False
673
+ msg = str(ctx.exception)
674
+ self.assertIn("-I/inc0", msg)
675
+ self.assertIn("err line 59", msg)
676
+
677
+ def test_short_stderr_not_annotated_with_more(self):
678
+ asmdiff.VERBOSE = False
679
+ with self.assertRaises(SystemExit) as ctx:
680
+ asmdiff._compile_failure(["gcc", "x.c"], "one error\n")
681
+ self.assertNotIn("more stderr", str(ctx.exception))
682
+
683
+
684
+ class TestFormatCalls(unittest.TestCase):
685
+ def test_short_list_unchanged(self):
686
+ self.assertEqual(asmdiff.format_calls(["a", "b"]), "a, b")
687
+
688
+ def test_empty_is_dash(self):
689
+ self.assertEqual(asmdiff.format_calls([]), "-")
690
+
691
+ def test_long_list_capped(self):
692
+ calls = [f"fn{i}" for i in range(12)]
693
+ out = asmdiff.format_calls(calls)
694
+ self.assertTrue(out.endswith("+4 more"))
695
+ self.assertIn("fn7", out)
696
+ self.assertNotIn("fn8,", out)
697
+
698
+
508
699
  class TestDiscoveredCompileCommands(unittest.TestCase):
509
700
  """compile_commands = true in a target / bare --compile-commands."""
510
701
 
File without changes
File without changes
File without changes
File without changes