asmdiff 0.1.0__tar.gz → 0.1.2__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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python3 test_asmdiff.py)",
5
+ "Bash(git -C /home/fatta/esp-idf/tools/asmdiff log --oneline -5)",
6
+ "Bash(git -C /home/fatta/esp-idf/tools/asmdiff diff main...HEAD --stat)"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,2 @@
1
+ __pycache__/
2
+ *.py[cod]
@@ -0,0 +1,28 @@
1
+ # asmdiff
2
+
3
+ Single-file, stdlib-only Python CLI that compares per-function assembly
4
+ between paired C implementations across a compiler matrix. Published to
5
+ PyPI as `asmdiff` (hatchling build, see pyproject.toml).
6
+
7
+ ## Toolchain
8
+
9
+ - Runtime: Python >= 3.8; config files (asmdiff.toml) need >= 3.11 (tomllib).
10
+ - No third-party dependencies, in source or tests. Keep it that way.
11
+ - Compilers invoked at runtime: whatever the user's matrix names
12
+ (host gcc/clang, xtensa/riscv ESP toolchains via glob patterns).
13
+
14
+ ## Commands
15
+
16
+ - Run tests: `python3 test_asmdiff.py` (unittest; add `-v` for detail).
17
+ - Run the tool: `python3 asmdiff.py asmdiff_example.c`.
18
+ - Everything is safe/reversible: the tool only compiles to `-S` in a
19
+ temp dir and prints. No flashing, no shared infra.
20
+
21
+ ## Layout
22
+
23
+ - `asmdiff.py` — the entire tool.
24
+ - `test_asmdiff.py` — unit tests, one class per feature area.
25
+ - `asmdiff.example.toml` — documented starter config; keep it generic
26
+ (portable gcc/clang defaults, ESP targets as commented examples).
27
+ - README.md documents every user-visible behavior; changes to flags or
28
+ config keys update README, example toml, and the module docstring.
@@ -1,7 +1,30 @@
1
- # asmdiff
1
+ Metadata-Version: 2.4
2
+ Name: asmdiff
3
+ Version: 0.1.2
4
+ Summary: Compare per-function assembly between paired C implementations
5
+ Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
6
+ Project-URL: Repository, https://github.com/rt-rtos/asmdiff
7
+ Author: Rasmus Tikkanen
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: arm,assembly,cfg,clang,cli,codegen,compiler,devtools,diff,disassembly,dsp,embedded,firmware,gcc,loop-analysis,riscv,static-analysis,xtensa
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Compilers
15
+ Classifier: Topic :: Software Development :: Debuggers
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+
19
+ # [asmdiff](https://pypi.org/project/asmdiff/)
2
20
  ## per-function assembly comparison for paired C implementations
3
21
 
4
- > asmdiff is a command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for investigating compiler code generation rather than benchmarking runtime performance.
22
+ > asmdiff is a stdlib only command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for investigating compiler code generation rather than benchmarking runtime performance.
23
+
24
+ ### Try it yourself:
25
+
26
+ `$ uvx asmdiff` / `$ pipx asmdiff`
27
+ ---
5
28
 
6
29
  `asmdiff.py` answers one question fast: **when I rewrite a C construct, what
7
30
  does the compiler actually emit - before and after?** It compiles a small
@@ -10,12 +33,9 @@ assembly, and prints side-by-side listings plus a summary of instruction
10
33
  counts, outbound calls, and loop spans.
11
34
 
12
35
  Compilers and flags are configured per project through named targets in an
13
- `asmdiff.toml` file — the tool itself has no project-specific defaults and
14
- parses any GNU-as ELF assembly.
36
+ `asmdiff.toml` file and parses any GNU-as ELF assembly.
15
37
 
16
- Its home use case: checking whether an expression that used to constant-fold
17
- (e.g. `x * exp2f(5)` → one multiply) turns into a library call (e.g.
18
- `ldexpf(x, 5)` → `jmp ldexpf@PLT`) after a "cleanup". That distinction is
38
+ Whether something constant folds or turns into a libcall is a distinction that is
19
39
  invisible in source review and decisive on hot paths.
20
40
 
21
41
  ## Quick start
@@ -74,7 +94,7 @@ $ asmdiff.py asmdiff_example.c
74
94
  ```
75
95
  asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
76
96
  [--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
77
- [-- EXTRA_FLAGS...]
97
+ [--compile-commands [PATH]] [-- EXTRA_FLAGS...]
78
98
  ```
79
99
 
80
100
  | Option | Meaning |
@@ -85,6 +105,7 @@ asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
85
105
  | `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
86
106
  | `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
87
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). |
88
109
  | `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
89
110
 
90
111
  With no `--cc` and no `--target`, the config file's top-level
@@ -119,19 +140,19 @@ each compiler+flags combination once:
119
140
 
120
141
  ```toml
121
142
  # asmdiff.toml — next to your harnesses, in CWD, or in ~/.config/
122
- default = "s3-amy" # target(s) used when no --cc/--target is given
143
+ default = "esp32s3" # target(s) used when no --cc/--target is given
123
144
 
124
- [s3-amy] # production-like ESP32-S3 codegen
145
+ [esp32s3] # production-like ESP32-S3 codegen
125
146
  cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
126
147
  flags = [
127
- "-O2", "-DAMY_USE_FIXEDPOINT", "-DNDEBUG",
148
+ "-O2", "-DMY_FEATURE", "-DNDEBUG",
128
149
  "-Wno-strict-aliasing", "-mlongcalls",
129
- "-I$HOME/project/components/amy/src",
150
+ "-I$HOME/project/components/dsp/include",
130
151
  ]
131
152
 
132
- [host-fixed] # same defines on host gcc
153
+ [host] # same defines on host gcc
133
154
  cc = "gcc"
134
- flags = ["-O2", "-DAMY_USE_FIXEDPOINT", "-I$HOME/amy/src"]
155
+ flags = ["-O2", "-DMY_FEATURE", "-I$HOME/project/components/dsp/include"]
135
156
  ```
136
157
 
137
158
  `cc` values expand `~` and `$VARS` and may be glob patterns, so a config
@@ -148,8 +169,8 @@ shapes:
148
169
 
149
170
  ```bash
150
171
  asmdiff.py h.c # config default target(s)
151
- asmdiff.py h.c --target s3-amy --target host-fixed # two-target matrix
152
- asmdiff.py h.c --across f --target s3-amy --cc 'gcc -O2' # mix freely
172
+ asmdiff.py h.c --target esp32s3 --target host # two-target matrix
173
+ asmdiff.py h.c --across f --target esp32s3 --cc 'gcc -O2' # mix freely
153
174
  ```
154
175
 
155
176
  A config placed next to your harness files travels with them: any invocation
@@ -159,6 +180,90 @@ included `asmdiff.example.toml` is a starting point. If a flag or include
159
180
  path must vary per machine, that's what per-machine config files are for —
160
181
  nothing lives in the tool.
161
182
 
183
+ ### Borrowing includes from `compile_commands.json`
184
+
185
+ A real project source rarely compiles with a handful of `-I` flags. An
186
+ ESP-IDF component pulls in `freertos/FreeRTOS.h`, `esp_*` headers, and a
187
+ *generated* `sdkconfig.h`, reachable only through the dozens of `-I`/`-D`
188
+ flags the build system computes — none of which live in the source file.
189
+ That is why `asmdiff.py component.c` fails with `freertos/FreeRTOS.h: No
190
+ such file or directory`: not a wrong compiler (the xtensa toolchain ships
191
+ no FreeRTOS either), just missing include paths. Transcribing them by hand
192
+ is miserable.
193
+
194
+ So don't. Any build that uses CMake or Ninja can emit a
195
+ [`compile_commands.json`](https://clang.llvm.org/docs/JSONCompilationDatabase.html)
196
+ recording the exact flags for every source it builds (ESP-IDF writes one to
197
+ `build/compile_commands.json` on every `idf.py build`). Point a target at it:
198
+
199
+ ```toml
200
+ [esp32s3-idf]
201
+ cc = "xtensa-esp32s3-elf-gcc"
202
+ flags = ["-O2", "-mlongcalls"]
203
+ compile_commands = "$HOME/myproject/build/compile_commands.json"
204
+
205
+ ```
206
+
207
+ Now `asmdiff.py $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf`
208
+ finds that file's entry in the database and adds the include/define flags
209
+ it recorded — `-I`, `-isystem`, `-iquote`, `-idirafter`, `-include`,
210
+ `-imacros`, `-D`, `-U` — to this target's command. Everything else the
211
+ database records (its own compiler, `-O`/`-std`/`-W` flags, `-c`, `-o`, the
212
+ source) is ignored: **you** own the compiler and optimisation flags via
213
+ `cc`/`flags`; only the header environment is borrowed. That split is the
214
+ point — you can now sweep *your* `-O`/`-m` variations over a source that
215
+ only ever compiled one way under the build system.
216
+
217
+ Details that make it robust:
218
+
219
+ - **Paths are made absolute** against each entry's `directory`, so a
220
+ database full of build-relative `-I../include` flags still resolves when
221
+ asmdiff runs from anywhere.
222
+ - **Per source file.** The lookup keys on the source you pass (matched by
223
+ resolved absolute path), so two files in an `--across`/summary run each
224
+ get their own recorded flags.
225
+ - **Absent source is an error**, not a silent empty flag set — otherwise
226
+ you'd just hit the missing-header failure this feature exists to prevent.
227
+ The message flags a same-basename entry recorded under a different path.
228
+ - **`compile_commands` expands `~` and `$VARS`.** The `==` header always
229
+ prints the resolved compiler command; run with `-- -v` if you want to see
230
+ every include path the compiler actually received.
231
+
232
+ ### Auto-discovering the database
233
+
234
+ When you run asmdiff from inside the project anyway, the path is
235
+ redundant. Two opt-ins skip it:
236
+
237
+ ```toml
238
+ [esp32s3-idf]
239
+ cc = "xtensa-esp32s3-elf-gcc"
240
+ flags = ["-O2", "-mlongcalls"]
241
+ compile_commands = true # search instead of naming a path
242
+ ```
243
+
244
+ ```bash
245
+ asmdiff.py biquad.c --compile-commands # same, for any matrix
246
+ asmdiff.py biquad.c --compile-commands path/to/compile_commands.json
247
+ ```
248
+
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.
255
+
256
+ The precedence is what you'd hope: a target that names its own
257
+ `compile_commands` path always keeps it; `--compile-commands` (with or
258
+ without a path) fills in every other matrix entry, including `--cc`
259
+ strings and the built-in gcc/clang fallback.
260
+
261
+ One behavioral difference: with a *discovered* database, a source that has
262
+ no entry is compiled without borrowed flags after a one-line stderr note,
263
+ instead of being an error. That keeps standalone harness files working
264
+ when a `build/` directory happens to sit nearby; an explicitly named
265
+ database still treats an absent source as the error it is.
266
+
162
267
  ## Whole-file summary
163
268
 
164
269
  With no `--pair`, no `--across`, and no `old_*`/`new_*` functions to
@@ -1,25 +1,12 @@
1
- Metadata-Version: 2.4
2
- Name: asmdiff
3
- Version: 0.1.0
4
- Summary: Compare per-function assembly between paired C implementations
5
- Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
6
- Project-URL: Repository, https://github.com/rt-rtos/asmdiff
7
- Author: Rasmus Tikkanen
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Keywords: assembly,clang,codegen,compiler,disassembly,gcc
11
- Classifier: Environment :: Console
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Topic :: Software Development :: Compilers
15
- Classifier: Topic :: Software Development :: Debuggers
16
- Requires-Python: >=3.8
17
- Description-Content-Type: text/markdown
18
-
19
- # asmdiff
1
+ # [asmdiff](https://pypi.org/project/asmdiff/)
20
2
  ## per-function assembly comparison for paired C implementations
21
3
 
22
- > asmdiff is a command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for investigating compiler code generation rather than benchmarking runtime performance.
4
+ > asmdiff is a stdlib only command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for investigating compiler code generation rather than benchmarking runtime performance.
5
+
6
+ ### Try it yourself:
7
+
8
+ `$ uvx asmdiff` / `$ pipx asmdiff`
9
+ ---
23
10
 
24
11
  `asmdiff.py` answers one question fast: **when I rewrite a C construct, what
25
12
  does the compiler actually emit - before and after?** It compiles a small
@@ -28,12 +15,9 @@ assembly, and prints side-by-side listings plus a summary of instruction
28
15
  counts, outbound calls, and loop spans.
29
16
 
30
17
  Compilers and flags are configured per project through named targets in an
31
- `asmdiff.toml` file — the tool itself has no project-specific defaults and
32
- parses any GNU-as ELF assembly.
18
+ `asmdiff.toml` file and parses any GNU-as ELF assembly.
33
19
 
34
- Its home use case: checking whether an expression that used to constant-fold
35
- (e.g. `x * exp2f(5)` → one multiply) turns into a library call (e.g.
36
- `ldexpf(x, 5)` → `jmp ldexpf@PLT`) after a "cleanup". That distinction is
20
+ Whether something constant folds or turns into a libcall is a distinction that is
37
21
  invisible in source review and decisive on hot paths.
38
22
 
39
23
  ## Quick start
@@ -92,7 +76,7 @@ $ asmdiff.py asmdiff_example.c
92
76
  ```
93
77
  asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
94
78
  [--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
95
- [-- EXTRA_FLAGS...]
79
+ [--compile-commands [PATH]] [-- EXTRA_FLAGS...]
96
80
  ```
97
81
 
98
82
  | Option | Meaning |
@@ -103,6 +87,7 @@ asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
103
87
  | `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
104
88
  | `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
105
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). |
106
91
  | `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
107
92
 
108
93
  With no `--cc` and no `--target`, the config file's top-level
@@ -137,19 +122,19 @@ each compiler+flags combination once:
137
122
 
138
123
  ```toml
139
124
  # asmdiff.toml — next to your harnesses, in CWD, or in ~/.config/
140
- default = "s3-amy" # target(s) used when no --cc/--target is given
125
+ default = "esp32s3" # target(s) used when no --cc/--target is given
141
126
 
142
- [s3-amy] # production-like ESP32-S3 codegen
127
+ [esp32s3] # production-like ESP32-S3 codegen
143
128
  cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
144
129
  flags = [
145
- "-O2", "-DAMY_USE_FIXEDPOINT", "-DNDEBUG",
130
+ "-O2", "-DMY_FEATURE", "-DNDEBUG",
146
131
  "-Wno-strict-aliasing", "-mlongcalls",
147
- "-I$HOME/project/components/amy/src",
132
+ "-I$HOME/project/components/dsp/include",
148
133
  ]
149
134
 
150
- [host-fixed] # same defines on host gcc
135
+ [host] # same defines on host gcc
151
136
  cc = "gcc"
152
- flags = ["-O2", "-DAMY_USE_FIXEDPOINT", "-I$HOME/amy/src"]
137
+ flags = ["-O2", "-DMY_FEATURE", "-I$HOME/project/components/dsp/include"]
153
138
  ```
154
139
 
155
140
  `cc` values expand `~` and `$VARS` and may be glob patterns, so a config
@@ -166,8 +151,8 @@ shapes:
166
151
 
167
152
  ```bash
168
153
  asmdiff.py h.c # config default target(s)
169
- asmdiff.py h.c --target s3-amy --target host-fixed # two-target matrix
170
- asmdiff.py h.c --across f --target s3-amy --cc 'gcc -O2' # mix freely
154
+ asmdiff.py h.c --target esp32s3 --target host # two-target matrix
155
+ asmdiff.py h.c --across f --target esp32s3 --cc 'gcc -O2' # mix freely
171
156
  ```
172
157
 
173
158
  A config placed next to your harness files travels with them: any invocation
@@ -177,6 +162,90 @@ included `asmdiff.example.toml` is a starting point. If a flag or include
177
162
  path must vary per machine, that's what per-machine config files are for —
178
163
  nothing lives in the tool.
179
164
 
165
+ ### Borrowing includes from `compile_commands.json`
166
+
167
+ A real project source rarely compiles with a handful of `-I` flags. An
168
+ ESP-IDF component pulls in `freertos/FreeRTOS.h`, `esp_*` headers, and a
169
+ *generated* `sdkconfig.h`, reachable only through the dozens of `-I`/`-D`
170
+ flags the build system computes — none of which live in the source file.
171
+ That is why `asmdiff.py component.c` fails with `freertos/FreeRTOS.h: No
172
+ such file or directory`: not a wrong compiler (the xtensa toolchain ships
173
+ no FreeRTOS either), just missing include paths. Transcribing them by hand
174
+ is miserable.
175
+
176
+ So don't. Any build that uses CMake or Ninja can emit a
177
+ [`compile_commands.json`](https://clang.llvm.org/docs/JSONCompilationDatabase.html)
178
+ recording the exact flags for every source it builds (ESP-IDF writes one to
179
+ `build/compile_commands.json` on every `idf.py build`). Point a target at it:
180
+
181
+ ```toml
182
+ [esp32s3-idf]
183
+ cc = "xtensa-esp32s3-elf-gcc"
184
+ flags = ["-O2", "-mlongcalls"]
185
+ compile_commands = "$HOME/myproject/build/compile_commands.json"
186
+
187
+ ```
188
+
189
+ Now `asmdiff.py $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf`
190
+ finds that file's entry in the database and adds the include/define flags
191
+ it recorded — `-I`, `-isystem`, `-iquote`, `-idirafter`, `-include`,
192
+ `-imacros`, `-D`, `-U` — to this target's command. Everything else the
193
+ database records (its own compiler, `-O`/`-std`/`-W` flags, `-c`, `-o`, the
194
+ source) is ignored: **you** own the compiler and optimisation flags via
195
+ `cc`/`flags`; only the header environment is borrowed. That split is the
196
+ point — you can now sweep *your* `-O`/`-m` variations over a source that
197
+ only ever compiled one way under the build system.
198
+
199
+ Details that make it robust:
200
+
201
+ - **Paths are made absolute** against each entry's `directory`, so a
202
+ database full of build-relative `-I../include` flags still resolves when
203
+ asmdiff runs from anywhere.
204
+ - **Per source file.** The lookup keys on the source you pass (matched by
205
+ resolved absolute path), so two files in an `--across`/summary run each
206
+ get their own recorded flags.
207
+ - **Absent source is an error**, not a silent empty flag set — otherwise
208
+ you'd just hit the missing-header failure this feature exists to prevent.
209
+ The message flags a same-basename entry recorded under a different path.
210
+ - **`compile_commands` expands `~` and `$VARS`.** The `==` header always
211
+ prints the resolved compiler command; run with `-- -v` if you want to see
212
+ every include path the compiler actually received.
213
+
214
+ ### Auto-discovering the database
215
+
216
+ When you run asmdiff from inside the project anyway, the path is
217
+ redundant. Two opt-ins skip it:
218
+
219
+ ```toml
220
+ [esp32s3-idf]
221
+ cc = "xtensa-esp32s3-elf-gcc"
222
+ flags = ["-O2", "-mlongcalls"]
223
+ compile_commands = true # search instead of naming a path
224
+ ```
225
+
226
+ ```bash
227
+ asmdiff.py biquad.c --compile-commands # same, for any matrix
228
+ asmdiff.py biquad.c --compile-commands path/to/compile_commands.json
229
+ ```
230
+
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.
237
+
238
+ The precedence is what you'd hope: a target that names its own
239
+ `compile_commands` path always keeps it; `--compile-commands` (with or
240
+ without a path) fills in every other matrix entry, including `--cc`
241
+ strings and the built-in gcc/clang fallback.
242
+
243
+ One behavioral difference: with a *discovered* database, a source that has
244
+ no entry is compiled without borrowed flags after a one-line stderr note,
245
+ instead of being an error. That keeps standalone harness files working
246
+ when a `build/` directory happens to sit nearby; an explicitly named
247
+ database still treats an absent source as the error it is.
248
+
180
249
  ## Whole-file summary
181
250
 
182
251
  With no `--pair`, no `--across`, and no `old_*`/`new_*` functions to
@@ -0,0 +1,50 @@
1
+ # asmdiff config — named compiler+flags targets.
2
+ #
3
+ # Copy to one of the locations asmdiff searches (first hit wins):
4
+ # 1. the path given with --config
5
+ # 2. asmdiff.toml next to the SOURCE.c being compiled
6
+ # 3. asmdiff.toml in the current directory
7
+ # 4. ~/.config/asmdiff.toml
8
+ #
9
+ # Each [table] is a target usable as `--target NAME`; the optional
10
+ # top-level `default` names the target(s) used when no --cc/--target
11
+ # is given (a list runs several: default = ["gcc", "clang"]).
12
+ # Compile at the flags your project ships with — that is the whole
13
+ # point of the tool.
14
+
15
+ default = "gcc"
16
+
17
+ [gcc]
18
+ cc = "gcc"
19
+ flags = [
20
+ "-O3", "-Wall", "-Wextra",
21
+ # Project-specific include paths and defines go here:
22
+ # "-I/path/to/project/src", "-DMY_FEATURE",
23
+ ]
24
+
25
+ [clang]
26
+ cc = "clang"
27
+ flags = ["-O3", "-Wall", "-Wextra"]
28
+
29
+ # cc values may use ~, $VARS, and glob patterns. A glob that matches
30
+ # several installed toolchains resolves to the highest version-sorted
31
+ # one (announced on stderr); pin the exact esp-NN directory instead if
32
+ # reproducibility across sessions matters more than convenience.
33
+ [esp32s3]
34
+ cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
35
+ flags = ["-O2", "-mlongcalls", "-DNDEBUG"]
36
+
37
+ # A project source that pulls in framework headers (ESP-IDF's freertos/*,
38
+ # esp_*, a generated sdkconfig.h, ...) needs the dozens of -I/-D flags its
39
+ # build system passes. Point `compile_commands` at the database that build
40
+ # emits (idf.py leaves build/compile_commands.json), and asmdiff copies the
41
+ # include/define flags recorded there for the file being compiled — so you
42
+ # never transcribe include paths by hand. The compiler and -O/-m flags
43
+ # still come from cc/flags; only the header environment is borrowed.
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).
47
+ [esp32s3-idf]
48
+ cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
49
+ flags = ["-O2", "-mlongcalls"]
50
+ compile_commands = true
@@ -12,6 +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
16
  [-- EXTRA_FLAGS...]
16
17
 
17
18
  Three modes:
@@ -28,10 +29,20 @@ Compilers come from --cc strings, from named targets in an asmdiff.toml
28
29
  config file (--target NAME), from the config's `default` entry, or —
29
30
  failing all of those — plain `gcc -O3` and `clang -O3`.
30
31
  Flags after a bare `--` are appended to every compiler invocation.
32
+
33
+ A config target may name a compile_commands.json (compile_commands = PATH):
34
+ the include/define flags recorded there for the source being compiled are
35
+ added to that target's command, so a real project source resolves its
36
+ headers the way its own build system does (e.g. an ESP-IDF component).
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.
31
41
  """
32
42
  import argparse
33
43
  import glob
34
44
  import hashlib
45
+ import json
35
46
  import os
36
47
  import re
37
48
  import shlex
@@ -51,6 +62,171 @@ DEFAULT_COMPILERS = ["gcc", "clang"]
51
62
  FALLBACK_FLAGS = "-O3"
52
63
  CONFIG_NAME = "asmdiff.toml"
53
64
 
65
+ # Preprocessor flags lifted from a compile_commands.json entry so a project
66
+ # source compiles the way its build system compiles it: header search paths,
67
+ # forced includes, and defines. Everything else the entry records (the
68
+ # compiler, -O/-std/-W flags, -c, -o OUT, the source itself) is ignored —
69
+ # asmdiff supplies the compiler and optimisation flags from the target.
70
+ # Path-bearing flags may be glued (-Ipath) or split (-I path); defines too
71
+ # (-DFOO / -D FOO). None of the path flags is a prefix of another and -I is
72
+ # case-distinct from -i*, so a single left-to-right scan is unambiguous.
73
+ _CC_DB_PATH_FLAGS = ("-I", "-iquote", "-isystem", "-idirafter",
74
+ "-include", "-imacros", "-isysroot")
75
+ _CC_DB_PLAIN_FLAGS = ("-D", "-U")
76
+ _DB_CACHE = {}
77
+ _MISS_NOTED = set()
78
+
79
+
80
+ class Target(str):
81
+ """A compiler-matrix entry: the ``CC FLAGS`` command string, plus an
82
+ optional compile_commands.json whose per-source include/define flags are
83
+ appended at compile time. Subclassing str means it prints, compares, and
84
+ shlex-splits as the bare command everywhere the matrix is consumed, so
85
+ only compile_to_asm needs to know about the extra attributes.
86
+
87
+ ``db_discovered`` marks a database that was found by searching near the
88
+ CWD rather than named explicitly; a source absent from a discovered
89
+ database is tolerated (compiled without borrowed flags, with a note),
90
+ where an explicit database makes that an error."""
91
+
92
+ def __new__(cls, cmd, compile_commands=None, db_discovered=False):
93
+ self = super().__new__(cls, cmd)
94
+ self.compile_commands = compile_commands
95
+ self.db_discovered = db_discovered
96
+ return self
97
+
98
+
99
+ def _abs_against(directory, value):
100
+ """Resolve a path recorded in a compile_commands entry against that
101
+ entry's ``directory``, so a relative -I still works from asmdiff's CWD."""
102
+ p = Path(value)
103
+ if directory and not p.is_absolute():
104
+ p = Path(directory) / p
105
+ return str(p)
106
+
107
+
108
+ def include_flags(tokens, directory):
109
+ """Pick header-search, forced-include, and define flags out of one
110
+ recorded compile command; make relative paths absolute against
111
+ ``directory``. Glued and split spellings are both recognised; path
112
+ flags are re-emitted in split form (-I path), which every driver accepts.
113
+ """
114
+ flags = []
115
+ i, n = 0, len(tokens)
116
+ while i < n:
117
+ tok, extra, matched = tokens[i], 0, False
118
+ for f in _CC_DB_PATH_FLAGS:
119
+ if tok == f and i + 1 < n: # -I path
120
+ flags += [f, _abs_against(directory, tokens[i + 1])]
121
+ extra, matched = 1, True
122
+ break
123
+ if tok.startswith(f) and len(tok) > len(f): # -Ipath
124
+ flags += [f, _abs_against(directory, tok[len(f):])]
125
+ matched = True
126
+ break
127
+ if not matched:
128
+ for f in _CC_DB_PLAIN_FLAGS:
129
+ if tok == f and i + 1 < n: # -D FOO
130
+ flags.append(f + tokens[i + 1])
131
+ extra = 1
132
+ break
133
+ if tok.startswith(f) and len(tok) > len(f): # -DFOO
134
+ flags.append(tok)
135
+ break
136
+ i += 1 + extra
137
+ return flags
138
+
139
+
140
+ 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.
147
+ """
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)
153
+ return None
154
+
155
+
156
+ def _discovered_db(who):
157
+ """find_compile_commands() for a caller that opted in (compile_commands
158
+ = true, or a bare --compile-commands): finding nothing is then an error,
159
+ not a silent no-op."""
160
+ found = find_compile_commands()
161
+ if found is None:
162
+ sys.exit(f"error: {who}: no compile_commands.json found in "
163
+ "./, ./build, ../, or ../build")
164
+ return found
165
+
166
+
167
+ def load_compile_commands(path):
168
+ """Parse a compile_commands.json into its list of entries (cached, since
169
+ one database is queried once per source per compiler in the matrix)."""
170
+ if path in _DB_CACHE:
171
+ return _DB_CACHE[path]
172
+ try:
173
+ data = json.loads(Path(path).read_text())
174
+ except FileNotFoundError:
175
+ sys.exit(f"error: compile_commands.json not found: {path}")
176
+ except (json.JSONDecodeError, OSError) as exc:
177
+ sys.exit(f"error: {path}: {exc}")
178
+ if not isinstance(data, list):
179
+ sys.exit(f"error: {path}: expected a JSON array of compile entries")
180
+ _DB_CACHE[path] = data
181
+ return data
182
+
183
+
184
+ def compile_commands_flags(db_path, source, missing_ok=False):
185
+ """Header/define flags for ``source`` taken from a compile_commands.json.
186
+
187
+ The entry whose ``file`` resolves to the same path as ``source`` supplies
188
+ its include/define flags (relative paths made absolute against the entry's
189
+ ``directory``). A ``command`` string is tokenised; an ``arguments`` array
190
+ is used as-is. A source absent from the database is an error — compiling
191
+ it would otherwise fail on the very header this feature exists to supply —
192
+ and the message flags a same-name entry recorded under a different path.
193
+ With ``missing_ok`` (auto-discovered databases) an absent source instead
194
+ compiles without borrowed flags, after a one-time note per (db, source).
195
+ """
196
+ entries = load_compile_commands(db_path)
197
+ want = Path(source).resolve()
198
+ name_seen = False
199
+ for entry in entries:
200
+ f = entry.get("file")
201
+ if not f:
202
+ continue
203
+ directory = entry.get("directory", "")
204
+ fp = Path(f)
205
+ if not fp.is_absolute():
206
+ fp = Path(directory) / fp
207
+ try:
208
+ same = fp.resolve() == want
209
+ except OSError:
210
+ same = False
211
+ if same:
212
+ args = entry.get("arguments")
213
+ tokens = (list(args) if isinstance(args, list)
214
+ else shlex.split(entry.get("command", "")))
215
+ return include_flags(tokens, directory)
216
+ if Path(f).name == want.name:
217
+ name_seen = True
218
+ if missing_ok:
219
+ key = (db_path, str(want))
220
+ if key not in _MISS_NOTED:
221
+ _MISS_NOTED.add(key)
222
+ print(f"note: {source} not in {db_path}; "
223
+ "compiling without its include/define flags",
224
+ file=sys.stderr)
225
+ return []
226
+ hint = (f"; an entry named {want.name} exists under a different path — "
227
+ "pass the source as it appears in the database") if name_seen else ""
228
+ sys.exit(f"error: {source} not found in {db_path}{hint}")
229
+
54
230
  # A label at column 0 that is not a local (.L*) label starts a function.
55
231
  FUNC_LABEL = re.compile(r"^([A-Za-z_][\w$.]*):")
56
232
  # Assembler directives that carry no information worth reading.
@@ -60,6 +236,15 @@ NOISE = re.compile(
60
236
  )
61
237
  # Compiler-generated bracketing labels that add nothing (.LFB0:, .Lfunc_end0:).
62
238
  NOISE_LABEL = re.compile(r"^\.(LFB|LFE|Lfunc_begin|Lfunc_end)\d*:")
239
+ # Data emitted *inside* a function body: switch jump tables (.long/.word
240
+ # entries), inline constants, strings. These are not instructions, so they
241
+ # must not be counted; and a self-relative table entry (".long .L5-.L4")
242
+ # references its base label from below, which the loop-span scan would
243
+ # otherwise read as a backward branch and report as a phantom loop.
244
+ DATA = re.compile(
245
+ r"^\.(long|quad|word|hword|short|byte|[248]byte|value|zero|octa|"
246
+ r"string|ascii|asciz|single|double|float|dc(\.[abwlq])?)\b"
247
+ )
63
248
 
64
249
 
65
250
  def extract_functions(asm_text):
@@ -67,9 +252,10 @@ def extract_functions(asm_text):
67
252
 
68
253
  A function body runs from its column-0 label to the matching .size
69
254
  directive (gcc and clang both emit one on ELF) or the next function
70
- label. Comment lines, CFI/section/alignment directives, and
71
- compiler bracketing labels are dropped; instructions and meaningful
72
- local labels (loop targets) are kept, whitespace-stripped.
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.
73
259
  """
74
260
  funcs = {}
75
261
  current = None
@@ -87,7 +273,7 @@ def extract_functions(asm_text):
87
273
  line = raw.strip()
88
274
  if not line or line.startswith(("#", "//")):
89
275
  continue
90
- if NOISE.match(line) or NOISE_LABEL.match(line):
276
+ if NOISE.match(line) or NOISE_LABEL.match(line) or DATA.match(line):
91
277
  continue
92
278
  funcs[current].append(line)
93
279
  return funcs
@@ -257,26 +443,52 @@ def target_command(config, name, config_path):
257
443
  if isinstance(flags, str) or not all(isinstance(f, str) for f in flags):
258
444
  sys.exit(f"error: target [{name}]: flags must be an array of strings")
259
445
  flags = [os.path.expandvars(f) for f in flags]
260
- return shlex.join([resolve_cc(cc, name), *flags])
261
-
262
-
263
- def build_matrix(cc_args, target_args, config, config_path):
446
+ db = entry.get("compile_commands")
447
+ discovered = False
448
+ if db is True: # opt in to CWD-based discovery
449
+ db, discovered = _discovered_db(f"target [{name}]"), True
450
+ elif db is False:
451
+ db = None
452
+ elif db is not None:
453
+ if not isinstance(db, str):
454
+ sys.exit(f"error: target [{name}]: compile_commands must be a "
455
+ "path to a compile_commands.json, or true to search "
456
+ "./, ./build, ../, ../build")
457
+ db = os.path.expandvars(os.path.expanduser(db))
458
+ return Target(shlex.join([resolve_cc(cc, name), *flags]), db, discovered)
459
+
460
+
461
+ def build_matrix(cc_args, target_args, config, config_path, db_arg=None):
264
462
  """Resolve the compiler matrix.
265
463
 
266
464
  --cc strings verbatim, then --target entries, in that order. With
267
465
  neither, the config's `default` (a target name or list of names);
268
466
  with no config or no default, plain gcc/clang at -O3.
467
+
468
+ ``db_arg`` is the --compile-commands value: a PATH applies that
469
+ database to every entry, True discovers one near the CWD. A target
470
+ whose config names its own compile_commands keeps it.
269
471
  """
270
472
  entries = list(cc_args)
271
473
  entries += [target_command(config, name, config_path)
272
474
  for name in target_args]
273
- if entries:
274
- return entries
275
- default = (config or {}).get("default")
276
- if default:
277
- names = [default] if isinstance(default, str) else list(default)
278
- return [target_command(config, name, config_path) for name in names]
279
- return [f"{cc} {FALLBACK_FLAGS}" for cc in DEFAULT_COMPILERS]
475
+ if not entries:
476
+ default = (config or {}).get("default")
477
+ if default:
478
+ names = [default] if isinstance(default, str) else list(default)
479
+ entries = [target_command(config, name, config_path)
480
+ for name in names]
481
+ else:
482
+ entries = [f"{cc} {FALLBACK_FLAGS}" for cc in DEFAULT_COMPILERS]
483
+ if db_arg is not None:
484
+ if db_arg is True:
485
+ db, discovered = _discovered_db("--compile-commands"), True
486
+ else:
487
+ db = os.path.expandvars(os.path.expanduser(db_arg))
488
+ discovered = False
489
+ entries = [e if getattr(e, "compile_commands", None) is not None
490
+ else Target(e, db, discovered) for e in entries]
491
+ return entries
280
492
 
281
493
 
282
494
  def asm_output_name(cc_cmd, harness):
@@ -295,7 +507,9 @@ def asm_output_name(cc_cmd, harness):
295
507
  def compile_to_asm(cc_cmd, extra_flags, harness, out_dir):
296
508
  """Run one compiler to -S; return the asm text.
297
509
 
298
- Returns None (with a warning) if the compiler is not on PATH.
510
+ If the matrix entry names a compile_commands.json, this source's
511
+ include/define flags from that database are inserted before any bare-``--``
512
+ flags. Returns None (with a warning) if the compiler is not on PATH.
299
513
  Exits with the compiler's stderr on a compile failure.
300
514
  """
301
515
  argv = shlex.split(cc_cmd)
@@ -303,8 +517,14 @@ def compile_to_asm(cc_cmd, extra_flags, harness, out_dir):
303
517
  print(f"warning: {argv[0]} not found on PATH, skipping",
304
518
  file=sys.stderr)
305
519
  return None
306
- out_s = Path(out_dir) / asm_output_name(cc_cmd, harness)
307
- cmd = argv + list(extra_flags) + ["-S", "-o", str(out_s), str(harness)]
520
+ db = getattr(cc_cmd, "compile_commands", None)
521
+ db_flags = (compile_commands_flags(
522
+ db, harness,
523
+ missing_ok=getattr(cc_cmd, "db_discovered", False))
524
+ if db else [])
525
+ out_s = Path(out_dir) / asm_output_name(shlex.join(argv + db_flags), harness)
526
+ cmd = (argv + db_flags + list(extra_flags)
527
+ + ["-S", "-o", str(out_s), str(harness)])
308
528
  proc = subprocess.run(cmd, capture_output=True, text=True)
309
529
  if proc.returncode != 0:
310
530
  sys.exit(f"error: compile failed: {' '.join(cmd)}\n{proc.stderr}")
@@ -539,6 +759,12 @@ def main(argv=None):
539
759
  help="named [table] from the config file, resolved "
540
760
  "to a --cc entry (repeatable; appended to the "
541
761
  "matrix after --cc entries)")
762
+ parser.add_argument("--compile-commands", nargs="?", const=True,
763
+ default=None, metavar="PATH",
764
+ help="borrow each source's include/define flags from "
765
+ "a compile_commands.json; with no PATH, search "
766
+ "./, ./build, ../, ../build. A target whose "
767
+ "config names its own compile_commands keeps it")
542
768
  parser.add_argument("--config", metavar="PATH",
543
769
  help=f"config file; default search: {CONFIG_NAME} "
544
770
  "next to SOURCE.c, in the current directory, "
@@ -554,7 +780,8 @@ def main(argv=None):
554
780
  "use --across FUNC for two files")
555
781
  config_path = find_config(args.config, args.sources)
556
782
  config = load_config(config_path) if config_path else None
557
- matrix = build_matrix(args.cc, args.target, config, config_path)
783
+ matrix = build_matrix(args.cc, args.target, config, config_path,
784
+ args.compile_commands)
558
785
  if args.across and len(args.sources) == 1 and len(matrix) < 2:
559
786
  parser.error("--across on one file needs at least two --cc entries")
560
787
  for spec in args.pair:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asmdiff"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Compare per-function assembly between paired C implementations"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -13,7 +13,11 @@ requires-python = ">=3.8"
13
13
  authors = [
14
14
  { name = "Rasmus Tikkanen" },
15
15
  ]
16
- keywords = ["assembly", "compiler", "gcc", "clang", "codegen", "disassembly"]
16
+ keywords = [
17
+ "assembly", "compiler", "gcc", "clang", "disassembly", "codegen",
18
+ "diff", "static-analysis", "embedded", "firmware", "dsp",
19
+ "xtensa", "arm", "riscv", "cli", "devtools", "loop-analysis", "cfg"
20
+ ]
17
21
  classifiers = [
18
22
  "Environment :: Console",
19
23
  "Intended Audience :: Developers",
@@ -2,6 +2,7 @@
2
2
  """Unit tests for asmdiff.py. Run: python3 tools/asmdiff/test_asmdiff.py -v"""
3
3
  import contextlib
4
4
  import io
5
+ import json
5
6
  import os
6
7
  import tempfile
7
8
  import unittest
@@ -74,6 +75,77 @@ looper:
74
75
  \t.size\tlooper, .-looper
75
76
  """
76
77
 
78
+ # A switch lowered to a jump table, faithful to `gcc -O2 -S`. The table is
79
+ # emitted *inside* the function body (between the label and `.size`) via a
80
+ # .rodata/.text toggle, with self-relative entries `.long .Lx-.L4` — data,
81
+ # not instructions, and their .L4 operand must not read as a backward branch.
82
+ SWITCH_ASM = """\
83
+ \t.globl\tsel
84
+ \t.type\tsel, @function
85
+ sel:
86
+ .LFB0:
87
+ \t.cfi_startproc
88
+ \tendbr64
89
+ \tcmpl\t$4, %edi
90
+ \tja\t.L9
91
+ \tleaq\t.L4(%rip), %rcx
92
+ \tmovl\t%edi, %edi
93
+ \tmovslq\t(%rcx,%rdi,4), %rax
94
+ \taddq\t%rcx, %rax
95
+ \tnotrack jmp\t*%rax
96
+ \t.section\t.rodata
97
+ \t.align 4
98
+ .L4:
99
+ \t.long\t.L8-.L4
100
+ \t.long\t.L7-.L4
101
+ \t.long\t.L6-.L4
102
+ \t.long\t.L5-.L4
103
+ \t.long\t.L3-.L4
104
+ \t.text
105
+ \t.p2align 4,,10
106
+ .L5:
107
+ \tmovl\t%esi, %eax
108
+ \txorl\t%edx, %eax
109
+ \tret
110
+ .L3:
111
+ \tmovl\t%esi, %eax
112
+ \torl\t%edx, %eax
113
+ \tret
114
+ .L8:
115
+ \tleal\t(%rsi,%rdx), %eax
116
+ \tret
117
+ .L7:
118
+ \tmovl\t%esi, %eax
119
+ \tsubl\t%edx, %eax
120
+ \tret
121
+ .L6:
122
+ \tmovl\t%esi, %eax
123
+ \timull\t%edx, %eax
124
+ \tret
125
+ \t.cfi_endproc
126
+ \t.size\tsel, .-sel
127
+ """
128
+
129
+ # Jump tables on other targets use plain (non-self-relative) label entries,
130
+ # plus stray inline constants; all are data directives, none are branches.
131
+ DATA_DIRECTIVES_ASM = """\
132
+ \t.type\ttbl, @function
133
+ tbl:
134
+ \t.cfi_startproc
135
+ \tjx\ta8
136
+ .Ltab:
137
+ \t.word\t.La
138
+ \t.word\t.Lb
139
+ \t.byte\t3
140
+ \t.quad\t0
141
+ .La:
142
+ \tadd.n\ta2, a2, a2
143
+ \tretw.n
144
+ .Lb:
145
+ \tretw.n
146
+ \t.size\ttbl, .-tbl
147
+ """
148
+
77
149
 
78
150
  class TestExtractFunctions(unittest.TestCase):
79
151
  def test_gcc_functions_found(self):
@@ -173,6 +245,37 @@ class TestLoopSpans(unittest.TestCase):
173
245
  self.assertEqual(asmdiff.loop_spans(lines), [])
174
246
 
175
247
 
248
+ class TestJumpTableData(unittest.TestCase):
249
+ """Inline data (switch jump tables, constants) emitted inside a function
250
+ body is not counted as instructions and never reads as a loop span."""
251
+
252
+ def test_table_entries_stripped_from_body(self):
253
+ body = asmdiff.extract_functions(SWITCH_ASM)["sel"]
254
+ self.assertFalse(any(".long" in line for line in body))
255
+ self.assertIn(".L4:", body) # the table's anchor label is kept
256
+
257
+ def test_table_entries_not_counted_as_instructions(self):
258
+ body = asmdiff.extract_functions(SWITCH_ASM)["sel"]
259
+ insns, calls = asmdiff.analyze(body)
260
+ self.assertEqual(insns, 22) # 27 before the fix (5 .long entries)
261
+ self.assertEqual(calls, []) # notrack jmp *%rax is not a call
262
+
263
+ def test_self_relative_table_is_not_a_phantom_span(self):
264
+ # `.long .L5-.L4` references the table base .L4 from below; without
265
+ # stripping, that reads as a backward branch and invents a loop.
266
+ body = asmdiff.extract_functions(SWITCH_ASM)["sel"]
267
+ self.assertEqual(asmdiff.loop_spans(body), [])
268
+
269
+ def test_various_data_directives_stripped(self):
270
+ # .word/.byte/.quad jump tables and constants on other targets.
271
+ body = asmdiff.extract_functions(DATA_DIRECTIVES_ASM)["tbl"]
272
+ for directive in (".word", ".byte", ".quad"):
273
+ self.assertFalse(any(directive in line for line in body), directive)
274
+ insns, _ = asmdiff.analyze(body)
275
+ self.assertEqual(insns, 4) # 8 before the fix (4 data entries)
276
+ self.assertEqual(asmdiff.loop_spans(body), [])
277
+
278
+
176
279
  class TestAutoPairs(unittest.TestCase):
177
280
  def test_pairs_by_convention(self):
178
281
  names = ["old_const", "new_const", "old_rt", "new_rt", "helper"]
@@ -244,6 +347,242 @@ class TestBuildMatrix(unittest.TestCase):
244
347
  asmdiff.build_matrix([], ["bad"], cfg, "cfg.toml")
245
348
 
246
349
 
350
+ class TestIncludeFlags(unittest.TestCase):
351
+ """Lifting include/define flags out of one recorded compile command."""
352
+
353
+ def test_glued_and_split_include_paths(self):
354
+ toks = ["cc", "-Iinc", "-I", "inc2", "-c", "a.c"]
355
+ self.assertEqual(asmdiff.include_flags(toks, "/build"),
356
+ ["-I", "/build/inc", "-I", "/build/inc2"])
357
+
358
+ def test_absolute_paths_left_alone(self):
359
+ self.assertEqual(asmdiff.include_flags(["-I/abs/inc"], "/build"),
360
+ ["-I", "/abs/inc"])
361
+
362
+ def test_defines_glued_and_split(self):
363
+ self.assertEqual(
364
+ asmdiff.include_flags(["-DFOO=1", "-D", "BAR", "-UNDEBUG"], "/b"),
365
+ ["-DFOO=1", "-DBAR", "-UNDEBUG"])
366
+
367
+ def test_system_and_forced_include_families(self):
368
+ toks = ["-isystem", "sys", "-iquote", "q", "-idirafter", "d",
369
+ "-include", "cfg.h", "-imacros", "m.h"]
370
+ self.assertEqual(
371
+ asmdiff.include_flags(toks, "/build"),
372
+ ["-isystem", "/build/sys", "-iquote", "/build/q",
373
+ "-idirafter", "/build/d", "-include", "/build/cfg.h",
374
+ "-imacros", "/build/m.h"])
375
+
376
+ def test_non_include_flags_and_source_dropped(self):
377
+ toks = ["gcc", "-O2", "-std=c11", "-Wall", "-g", "-c", "a.c",
378
+ "-o", "a.o", "-Iinc"]
379
+ self.assertEqual(asmdiff.include_flags(toks, "/b"), ["-I", "/b/inc"])
380
+
381
+ def test_dangling_flag_at_end_ignored(self):
382
+ self.assertEqual(asmdiff.include_flags(["-Iinc", "-I"], "/b"),
383
+ ["-I", "/b/inc"])
384
+
385
+ def test_lowercase_isystem_not_split_as_capital_I(self):
386
+ # -isystem must not be read as -I + "system".
387
+ self.assertEqual(asmdiff.include_flags(["-isystem", "/s"], ""),
388
+ ["-isystem", "/s"])
389
+
390
+
391
+ class TestCompileCommandsFlags(unittest.TestCase):
392
+ def _db(self, tmp, entries):
393
+ path = Path(tmp) / "compile_commands.json"
394
+ path.write_text(json.dumps(entries))
395
+ asmdiff._DB_CACHE.clear()
396
+ return str(path)
397
+
398
+ def test_matches_by_resolved_path_command_string(self):
399
+ with tempfile.TemporaryDirectory() as tmp:
400
+ src = Path(tmp) / "src" / "foo.c"
401
+ src.parent.mkdir()
402
+ src.touch()
403
+ db = self._db(tmp, [{
404
+ "directory": tmp, "file": str(src),
405
+ "command": f"cc -Iinc -DX=1 -c {src} -o foo.o"}])
406
+ self.assertEqual(asmdiff.compile_commands_flags(db, str(src)),
407
+ ["-I", f"{tmp}/inc", "-DX=1"])
408
+
409
+ def test_matches_relative_file_and_arguments_array(self):
410
+ with tempfile.TemporaryDirectory() as tmp:
411
+ src = Path(tmp) / "foo.c"
412
+ src.touch()
413
+ db = self._db(tmp, [{
414
+ "directory": tmp, "file": "foo.c",
415
+ "arguments": ["cc", "-Iinc", "-c", "foo.c"]}])
416
+ self.assertEqual(asmdiff.compile_commands_flags(db, str(src)),
417
+ ["-I", f"{tmp}/inc"])
418
+
419
+ def test_missing_source_errors(self):
420
+ with tempfile.TemporaryDirectory() as tmp:
421
+ db = self._db(tmp, [{"directory": tmp, "file": f"{tmp}/a.c",
422
+ "command": "cc -c a.c"}])
423
+ with self.assertRaises(SystemExit) as ctx:
424
+ asmdiff.compile_commands_flags(db, f"{tmp}/b.c")
425
+ self.assertIn("not found", str(ctx.exception))
426
+
427
+ def test_same_name_different_path_hint(self):
428
+ with tempfile.TemporaryDirectory() as tmp:
429
+ db = self._db(tmp, [{"directory": tmp,
430
+ "file": f"{tmp}/other/foo.c",
431
+ "command": "cc -c foo.c"}])
432
+ with self.assertRaises(SystemExit) as ctx:
433
+ asmdiff.compile_commands_flags(db, f"{tmp}/foo.c")
434
+ self.assertIn("different path", str(ctx.exception))
435
+
436
+ def test_bad_json_shape_errors(self):
437
+ with tempfile.TemporaryDirectory() as tmp:
438
+ path = Path(tmp) / "cc.json"
439
+ path.write_text('{"not": "a list"}')
440
+ asmdiff._DB_CACHE.clear()
441
+ with self.assertRaises(SystemExit):
442
+ asmdiff.compile_commands_flags(str(path), "x.c")
443
+
444
+
445
+ class TestTargetCompileCommands(unittest.TestCase):
446
+ def test_target_carries_expanded_db_path(self):
447
+ os.environ["ASMDIFF_TEST_DB"] = "/proj/build"
448
+ try:
449
+ cfg = {"t": {"cc": "gcc", "flags": ["-O2"],
450
+ "compile_commands": "$ASMDIFF_TEST_DB/cc.json"}}
451
+ matrix = asmdiff.build_matrix([], ["t"], cfg, "c")
452
+ self.assertEqual(matrix, ["gcc -O2"]) # str value unchanged
453
+ self.assertEqual(matrix[0].compile_commands,
454
+ "/proj/build/cc.json") # attribute carried
455
+ finally:
456
+ del os.environ["ASMDIFF_TEST_DB"]
457
+
458
+ def test_compile_commands_must_be_a_string(self):
459
+ cfg = {"t": {"cc": "gcc", "flags": [], "compile_commands": ["x"]}}
460
+ with self.assertRaises(SystemExit):
461
+ asmdiff.build_matrix([], ["t"], cfg, "c")
462
+
463
+ def test_cc_entries_have_no_db_attribute(self):
464
+ matrix = asmdiff.build_matrix(["gcc -O3"], [], None, None)
465
+ self.assertIsNone(getattr(matrix[0], "compile_commands", None))
466
+
467
+
468
+ @contextlib.contextmanager
469
+ def _inside(directory):
470
+ """Run a block with CWD set to ``directory`` (discovery is CWD-based)."""
471
+ prev = os.getcwd()
472
+ os.chdir(directory)
473
+ try:
474
+ yield
475
+ finally:
476
+ os.chdir(prev)
477
+
478
+
479
+ class TestFindCompileCommands(unittest.TestCase):
480
+ """Auto-discovery of compile_commands.json near the current directory."""
481
+
482
+ def _touch_db(self, directory):
483
+ directory.mkdir(parents=True, exist_ok=True)
484
+ (directory / "compile_commands.json").write_text("[]")
485
+
486
+ def test_search_order_cwd_build_parent_parentbuild(self):
487
+ with tempfile.TemporaryDirectory() as tmp:
488
+ root = Path(tmp)
489
+ cwd = root / "sub"
490
+ cwd.mkdir()
491
+ for where, expect in [(root / "build", root / "build"),
492
+ (root, root),
493
+ (cwd / "build", cwd / "build"),
494
+ (cwd, cwd)]:
495
+ self._touch_db(where)
496
+ with _inside(cwd):
497
+ self.assertEqual(asmdiff.find_compile_commands(),
498
+ str(expect / "compile_commands.json"))
499
+
500
+ def test_nothing_found_returns_none(self):
501
+ with tempfile.TemporaryDirectory() as tmp:
502
+ cwd = Path(tmp) / "a" / "b"
503
+ cwd.mkdir(parents=True)
504
+ with _inside(cwd):
505
+ self.assertIsNone(asmdiff.find_compile_commands())
506
+
507
+
508
+ class TestDiscoveredCompileCommands(unittest.TestCase):
509
+ """compile_commands = true in a target / bare --compile-commands."""
510
+
511
+ def test_target_true_discovers_and_is_soft(self):
512
+ with tempfile.TemporaryDirectory() as tmp:
513
+ db = Path(tmp) / "build" / "compile_commands.json"
514
+ db.parent.mkdir()
515
+ db.write_text("[]")
516
+ cfg = {"t": {"cc": "gcc", "compile_commands": True}}
517
+ with _inside(tmp):
518
+ matrix = asmdiff.build_matrix([], ["t"], cfg, "c")
519
+ self.assertEqual(matrix[0].compile_commands, str(db))
520
+ self.assertTrue(matrix[0].db_discovered)
521
+
522
+ def test_target_true_with_no_db_anywhere_errors(self):
523
+ with tempfile.TemporaryDirectory() as tmp:
524
+ cwd = Path(tmp) / "a" / "b"
525
+ cwd.mkdir(parents=True)
526
+ cfg = {"t": {"cc": "gcc", "compile_commands": True}}
527
+ with _inside(cwd), self.assertRaises(SystemExit) as ctx:
528
+ asmdiff.build_matrix([], ["t"], cfg, "c")
529
+ self.assertIn("compile_commands.json", str(ctx.exception))
530
+
531
+ def test_target_false_means_off(self):
532
+ cfg = {"t": {"cc": "gcc", "compile_commands": False}}
533
+ matrix = asmdiff.build_matrix([], ["t"], cfg, "c")
534
+ self.assertIsNone(matrix[0].compile_commands)
535
+
536
+ def test_explicit_path_stays_hard(self):
537
+ cfg = {"t": {"cc": "gcc", "compile_commands": "/x/cc.json"}}
538
+ matrix = asmdiff.build_matrix([], ["t"], cfg, "c")
539
+ self.assertEqual(matrix[0].compile_commands, "/x/cc.json")
540
+ self.assertFalse(matrix[0].db_discovered)
541
+
542
+ def test_cli_bare_flag_fills_cc_and_default_entries(self):
543
+ with tempfile.TemporaryDirectory() as tmp:
544
+ db = Path(tmp) / "compile_commands.json"
545
+ db.write_text("[]")
546
+ with _inside(tmp):
547
+ matrix = asmdiff.build_matrix(["gcc -O3"], [], None, None,
548
+ db_arg=True)
549
+ fallback = asmdiff.build_matrix([], [], None, None,
550
+ db_arg=True)
551
+ for entry in [matrix[0]] + list(fallback):
552
+ self.assertEqual(entry.compile_commands, str(db))
553
+ self.assertTrue(entry.db_discovered)
554
+
555
+ def test_cli_path_is_explicit_and_expanded(self):
556
+ os.environ["ASMDIFF_TEST_DB2"] = "/proj"
557
+ try:
558
+ matrix = asmdiff.build_matrix(["gcc -O3"], [], None, None,
559
+ db_arg="$ASMDIFF_TEST_DB2/cc.json")
560
+ finally:
561
+ del os.environ["ASMDIFF_TEST_DB2"]
562
+ self.assertEqual(matrix[0].compile_commands, "/proj/cc.json")
563
+ self.assertFalse(matrix[0].db_discovered)
564
+
565
+ def test_target_own_path_beats_cli(self):
566
+ cfg = {"t": {"cc": "gcc", "compile_commands": "/own/cc.json"}}
567
+ matrix = asmdiff.build_matrix([], ["t"], cfg, "c",
568
+ db_arg="/cli/cc.json")
569
+ self.assertEqual(matrix[0].compile_commands, "/own/cc.json")
570
+
571
+ def test_missing_source_soft_skips_with_note(self):
572
+ with tempfile.TemporaryDirectory() as tmp:
573
+ path = Path(tmp) / "compile_commands.json"
574
+ path.write_text(json.dumps([{"directory": tmp,
575
+ "file": f"{tmp}/a.c",
576
+ "command": "cc -Iinc -c a.c"}]))
577
+ asmdiff._DB_CACHE.clear()
578
+ err = io.StringIO()
579
+ with contextlib.redirect_stderr(err):
580
+ flags = asmdiff.compile_commands_flags(
581
+ str(path), f"{tmp}/b.c", missing_ok=True)
582
+ self.assertEqual(flags, [])
583
+ self.assertIn("b.c", err.getvalue())
584
+
585
+
247
586
  class TestResolveCc(unittest.TestCase):
248
587
  def test_home_and_env_vars_expand(self):
249
588
  resolved = asmdiff.resolve_cc("~/bin/mycc", "t")
@@ -1,8 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Read(//home/fatta/esp-idf/amy/tools/**)",
5
- "Read(//home/fatta/esp-idf/amy/**)"
6
- ]
7
- }
8
- }
@@ -1,36 +0,0 @@
1
- # asmdiff config — named compiler+flags targets.
2
- #
3
- # Copy to one of the locations asmdiff searches (first hit wins):
4
- # 1. the path given with --config
5
- # 2. asmdiff.toml next to the SOURCE.c being compiled
6
- # 3. asmdiff.toml in the current directory
7
- # 4. ~/.config/asmdiff.toml
8
- #
9
- # Each [table] is a target usable as `--target NAME`; the optional
10
- # top-level `default` names the target(s) used when no --cc/--target
11
- # is given. Compile at the flags your project ships with — that is
12
- # the whole point of the tool.
13
-
14
- default = "amy-host"
15
-
16
- [amy-host]
17
- cc = "gcc"
18
- flags = [
19
- "-O3", "-Wall", "-Wno-strict-aliasing", "-Wextra",
20
- "-Wno-unused-parameter", "-Wpointer-arith", "-Wno-float-conversion",
21
- "-Wno-missing-declarations", "-DAMY_WAVETABLE",
22
- # For harnesses that #include "amy.h", point at the AMY sources:
23
- # "-I/path/to/amy/src",
24
- ]
25
-
26
- # cc values may use ~, $VARS, and glob patterns. A glob that matches
27
- # several installed toolchains resolves to the highest version-sorted
28
- # one (announced on stderr); pin the exact esp-NN directory instead if
29
- # reproducibility across sessions matters more than convenience.
30
- [s3-amy]
31
- cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
32
- flags = [
33
- "-O2", "-DAMY_USE_FIXEDPOINT", "-DNDEBUG",
34
- "-Wno-strict-aliasing", "-Wno-float-conversion",
35
- "-mlongcalls", "-Wpointer-arith",
36
- ]
File without changes
File without changes