asmdiff 0.1.6__tar.gz → 0.1.7__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.
Files changed (25) hide show
  1. {asmdiff-0.1.6 → asmdiff-0.1.7}/PKG-INFO +36 -31
  2. {asmdiff-0.1.6 → asmdiff-0.1.7}/README.md +35 -30
  3. asmdiff-0.1.7/Screenshot 2026-07-07 225145.jpg +0 -0
  4. {asmdiff-0.1.6 → asmdiff-0.1.7}/asmdiff.py +74 -29
  5. {asmdiff-0.1.6 → asmdiff-0.1.7}/pyproject.toml +1 -1
  6. {asmdiff-0.1.6 → asmdiff-0.1.7}/test_asmdiff.py +70 -8
  7. {asmdiff-0.1.6 → asmdiff-0.1.7}/.claude/settings.local.json +0 -0
  8. {asmdiff-0.1.6 → asmdiff-0.1.7}/.gitignore +0 -0
  9. {asmdiff-0.1.6 → asmdiff-0.1.7}/2026-07-06-inspect-mode.md +0 -0
  10. {asmdiff-0.1.6 → asmdiff-0.1.7}/2026-07-07-elf-mode-handoff.md +0 -0
  11. {asmdiff-0.1.6 → asmdiff-0.1.7}/CLAUDE.md +0 -0
  12. {asmdiff-0.1.6 → asmdiff-0.1.7}/FIELD-TEST-REPORT.md +0 -0
  13. {asmdiff-0.1.6 → asmdiff-0.1.7}/LICENSE +0 -0
  14. {asmdiff-0.1.6 → asmdiff-0.1.7}/asmdiff.example.toml +0 -0
  15. {asmdiff-0.1.6 → asmdiff-0.1.7}/asmdiff_example.c +0 -0
  16. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/.gitignore +0 -0
  17. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/asmdiff-0.1.1-py3-none-any.whl +0 -0
  18. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/asmdiff-0.1.1.tar.gz +0 -0
  19. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/asmdiff-0.1.2-py3-none-any.whl +0 -0
  20. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/asmdiff-0.1.2.tar.gz +0 -0
  21. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/asmdiff-0.1.3-py3-none-any.whl +0 -0
  22. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/asmdiff-0.1.3.tar.gz +0 -0
  23. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/asmdiff-0.1.4-py3-none-any.whl +0 -0
  24. {asmdiff-0.1.6 → asmdiff-0.1.7}/dist_1/asmdiff-0.1.4.tar.gz +0 -0
  25. {asmdiff-0.1.6 → asmdiff-0.1.7}/docs/superpowers/specs/2026-07-07-edit-config-design.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asmdiff
3
- Version: 0.1.6
3
+ Version: 0.1.7
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
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
30
30
  does the compiler actually emit - before and after?** It compiles a small
31
31
  harness file across a matrix of compilers, extracts each variant function's
32
32
  assembly, and prints side-by-side listings plus a summary of instruction
33
- counts, outbound calls, and loop spans.
33
+ counts, loop spans, and outbound calls.
34
34
 
35
35
  Compilers and flags are configured per project through named targets in an
36
36
  `asmdiff.toml` file, and any GNU-as ELF assembly is parsed.
@@ -94,9 +94,9 @@ endbr64 | endbr64
94
94
  mulss .LC0(%rip), %xmm0 | movl $-5, %edi
95
95
  ret | jmp ldexpf@PLT
96
96
 
97
- function role insns calls loop spans
98
- old_scale baseline 3 - -
99
- new_scale candidate 3 ldexpf -
97
+ function role insns loop spans calls
98
+ old_scale baseline 3 - -
99
+ new_scale candidate 3 - ldexpf
100
100
  ```
101
101
 
102
102
  Read the `calls` column first: `-` means the construct lowered to inline
@@ -157,9 +157,9 @@ call8 __udivdi3 |
157
157
  mov.n a2, a10 |
158
158
  retw.n |
159
159
 
160
- function role insns calls loop spans
161
- old_elapsed_ms baseline 10 __udivdi3 -
162
- new_elapsed_ms candidate 6 - -
160
+ function role insns loop spans calls
161
+ old_elapsed_ms baseline 10 - __udivdi3
162
+ new_elapsed_ms candidate 6 - -
163
163
  ```
164
164
 
165
165
  The candidate is six inline instructions ending in a multiply-high by
@@ -216,8 +216,8 @@ apply_gain:
216
216
  .L1:
217
217
  retw.n
218
218
 
219
- function insns calls loop spans
220
- apply_gain 13 - .L3_LEND:4
219
+ function insns loop spans calls
220
+ apply_gain 13 .L3_LEND:4 -
221
221
  ```
222
222
 
223
223
  The span `.L3_LEND:4` is the four-instruction body of the Xtensa
@@ -246,11 +246,11 @@ $ asmdiff build/S3-Amysynth.elf --filter '^render_' # stats table only
246
246
  ```
247
247
 
248
248
  ```
249
- function insns calls loop spans
250
- render_lut_cub 98 - .L32:74
251
- render_lut 51 - .L7e_LEND:32
252
- render_external_audio_in 32 - .L52_LEND:10
253
- render_partial 112 exp2f, __divsf3, ... .L66:31
249
+ function insns loop spans calls
250
+ render_lut_cub 98 .L32:74 -
251
+ render_lut 51 .L7e_LEND:32 -
252
+ render_external_audio_in 32 .L52_LEND:10 -
253
+ render_partial 112 .L66:31 exp2f, __divsf3, ...
254
254
  ```
255
255
 
256
256
  (Four of the eleven matched rows shown.) The columns are the same as
@@ -280,10 +280,10 @@ one row scan.
280
280
  ```
281
281
  $ asmdiff build/S3-Amysynth.elf --filter 'sequencer_(process_tick|recompute|timer_callback)'
282
282
 
283
- function insns calls loop spans
284
- sequencer_timer_callback$lto_priv$0 77 __divsf3, __udivdi3 .L21:41
285
- sequencer_recompute 46 __extendsfdf2, __divdf3, __muldf3, __fixunsdfsi, __divsf3 -
286
- sequencer_process_tick$lto_priv$0 117 xQueueSemaphoreTake, xQueueGenericSend, add_delta_to_queue, a8 .L2e:96 .L48:85 .L8d:6
283
+ function insns loop spans calls
284
+ sequencer_timer_callback$lto_priv$0 77 .L21:41 __divsf3, __udivdi3
285
+ sequencer_recompute 46 - __extendsfdf2, __divdf3, __muldf3, __fixunsdfsi, __divsf3
286
+ sequencer_process_tick$lto_priv$0 117 .L2e:96 .L48:85 .L8d:6 xQueueSemaphoreTake, xQueueGenericSend, add_delta_to_queue, a8
287
287
  ```
288
288
 
289
289
  A timer callback pays a software float divide *and* a 64-bit
@@ -625,14 +625,14 @@ $ asmdiff old/delay.c new/delay.c
625
625
 
626
626
  -- old/delay.c --
627
627
 
628
- function insns calls loop spans
629
- stereo_reverb 437 - .L108:327
628
+ function insns loop spans calls
629
+ stereo_reverb 437 .L108:327 -
630
630
  ...
631
- TOTAL (13 functions) 956 malloc_caps, free, ... -
631
+ TOTAL (13 functions) 956 - malloc_caps, free, ...
632
632
 
633
633
  -- new/delay.c --
634
634
  ...
635
- TOTAL (13 functions) 1028 malloc_caps, free, ... -
635
+ TOTAL (13 functions) 1028 - malloc_caps, free, ...
636
636
  ```
637
637
 
638
638
  The TOTAL row is a coarse sanity check — did this refactor move the file's
@@ -643,8 +643,13 @@ measurement, and per-function rows are where the real information is.
643
643
  Only labels the assembler types as functions are listed — global data
644
644
  (string constants, state structs, lookup tables) gets column-0 labels too
645
645
  but is not code. A `calls` list longer than 8 symbols is truncated to
646
- `..., +N more`; real firmware dispatch functions call dozens of distinct
647
- symbols and would otherwise make rows thousands of characters wide.
646
+ `..., Total Calls:N`; real firmware dispatch functions call dozens of
647
+ distinct symbols and would otherwise make rows thousands of characters
648
+ wide. When stdout is a terminal, rows are additionally trimmed to the
649
+ terminal width: callees are dropped from the end of the `calls` column
650
+ (never the first one) behind the same `Total Calls:N` summary, so every
651
+ row stays on one line. Piped or redirected output skips the width trim
652
+ and keeps the full capped list, so it stays stable and greppable.
648
653
 
649
654
  ## Comparing the same function across two builds (`--across`)
650
655
 
@@ -705,9 +710,9 @@ movl %r14d, %r11d | sarl $11, %ebp
705
710
  call SMULR6 | leal 1024(%rax), %edx
706
711
  [... 60 rows omitted ...]
707
712
 
708
- function role insns calls loop spans
709
- dsps_biquad_f32_ansi [cc#1] baseline 59 SMULR6 .L27:32
710
- dsps_biquad_f32_ansi [cc#2] candidate 89 - .L26:54
713
+ function role insns loop spans calls
714
+ dsps_biquad_f32_ansi [cc#1] baseline 59 .L27:32 SMULR6
715
+ dsps_biquad_f32_ansi [cc#2] candidate 89 .L26:54 -
711
716
  ```
712
717
 
713
718
  (The listing is abridged here; the tool prints all 92 rows. The columns
@@ -743,9 +748,9 @@ call ldexpf@PLT |
743
748
  leave |
744
749
  ret |
745
750
 
746
- function role insns calls loop spans
747
- new_rt [cc#1] baseline 13 ldexpf -
748
- new_rt [cc#2] candidate 2 ldexpf -
751
+ function role insns loop spans calls
752
+ new_rt [cc#1] baseline 13 - ldexpf
753
+ new_rt [cc#2] candidate 2 - ldexpf
749
754
  ```
750
755
 
751
756
  **Two files** — before/after versions of a source file (e.g. from a git
@@ -12,7 +12,7 @@
12
12
  does the compiler actually emit - before and after?** It compiles a small
13
13
  harness file across a matrix of compilers, extracts each variant function's
14
14
  assembly, and prints side-by-side listings plus a summary of instruction
15
- counts, outbound calls, and loop spans.
15
+ counts, loop spans, and outbound calls.
16
16
 
17
17
  Compilers and flags are configured per project through named targets in an
18
18
  `asmdiff.toml` file, and any GNU-as ELF assembly is parsed.
@@ -76,9 +76,9 @@ endbr64 | endbr64
76
76
  mulss .LC0(%rip), %xmm0 | movl $-5, %edi
77
77
  ret | jmp ldexpf@PLT
78
78
 
79
- function role insns calls loop spans
80
- old_scale baseline 3 - -
81
- new_scale candidate 3 ldexpf -
79
+ function role insns loop spans calls
80
+ old_scale baseline 3 - -
81
+ new_scale candidate 3 - ldexpf
82
82
  ```
83
83
 
84
84
  Read the `calls` column first: `-` means the construct lowered to inline
@@ -139,9 +139,9 @@ call8 __udivdi3 |
139
139
  mov.n a2, a10 |
140
140
  retw.n |
141
141
 
142
- function role insns calls loop spans
143
- old_elapsed_ms baseline 10 __udivdi3 -
144
- new_elapsed_ms candidate 6 - -
142
+ function role insns loop spans calls
143
+ old_elapsed_ms baseline 10 - __udivdi3
144
+ new_elapsed_ms candidate 6 - -
145
145
  ```
146
146
 
147
147
  The candidate is six inline instructions ending in a multiply-high by
@@ -198,8 +198,8 @@ apply_gain:
198
198
  .L1:
199
199
  retw.n
200
200
 
201
- function insns calls loop spans
202
- apply_gain 13 - .L3_LEND:4
201
+ function insns loop spans calls
202
+ apply_gain 13 .L3_LEND:4 -
203
203
  ```
204
204
 
205
205
  The span `.L3_LEND:4` is the four-instruction body of the Xtensa
@@ -228,11 +228,11 @@ $ asmdiff build/S3-Amysynth.elf --filter '^render_' # stats table only
228
228
  ```
229
229
 
230
230
  ```
231
- function insns calls loop spans
232
- render_lut_cub 98 - .L32:74
233
- render_lut 51 - .L7e_LEND:32
234
- render_external_audio_in 32 - .L52_LEND:10
235
- render_partial 112 exp2f, __divsf3, ... .L66:31
231
+ function insns loop spans calls
232
+ render_lut_cub 98 .L32:74 -
233
+ render_lut 51 .L7e_LEND:32 -
234
+ render_external_audio_in 32 .L52_LEND:10 -
235
+ render_partial 112 .L66:31 exp2f, __divsf3, ...
236
236
  ```
237
237
 
238
238
  (Four of the eleven matched rows shown.) The columns are the same as
@@ -262,10 +262,10 @@ one row scan.
262
262
  ```
263
263
  $ asmdiff build/S3-Amysynth.elf --filter 'sequencer_(process_tick|recompute|timer_callback)'
264
264
 
265
- function insns calls loop spans
266
- sequencer_timer_callback$lto_priv$0 77 __divsf3, __udivdi3 .L21:41
267
- sequencer_recompute 46 __extendsfdf2, __divdf3, __muldf3, __fixunsdfsi, __divsf3 -
268
- sequencer_process_tick$lto_priv$0 117 xQueueSemaphoreTake, xQueueGenericSend, add_delta_to_queue, a8 .L2e:96 .L48:85 .L8d:6
265
+ function insns loop spans calls
266
+ sequencer_timer_callback$lto_priv$0 77 .L21:41 __divsf3, __udivdi3
267
+ sequencer_recompute 46 - __extendsfdf2, __divdf3, __muldf3, __fixunsdfsi, __divsf3
268
+ sequencer_process_tick$lto_priv$0 117 .L2e:96 .L48:85 .L8d:6 xQueueSemaphoreTake, xQueueGenericSend, add_delta_to_queue, a8
269
269
  ```
270
270
 
271
271
  A timer callback pays a software float divide *and* a 64-bit
@@ -607,14 +607,14 @@ $ asmdiff old/delay.c new/delay.c
607
607
 
608
608
  -- old/delay.c --
609
609
 
610
- function insns calls loop spans
611
- stereo_reverb 437 - .L108:327
610
+ function insns loop spans calls
611
+ stereo_reverb 437 .L108:327 -
612
612
  ...
613
- TOTAL (13 functions) 956 malloc_caps, free, ... -
613
+ TOTAL (13 functions) 956 - malloc_caps, free, ...
614
614
 
615
615
  -- new/delay.c --
616
616
  ...
617
- TOTAL (13 functions) 1028 malloc_caps, free, ... -
617
+ TOTAL (13 functions) 1028 - malloc_caps, free, ...
618
618
  ```
619
619
 
620
620
  The TOTAL row is a coarse sanity check — did this refactor move the file's
@@ -625,8 +625,13 @@ measurement, and per-function rows are where the real information is.
625
625
  Only labels the assembler types as functions are listed — global data
626
626
  (string constants, state structs, lookup tables) gets column-0 labels too
627
627
  but is not code. A `calls` list longer than 8 symbols is truncated to
628
- `..., +N more`; real firmware dispatch functions call dozens of distinct
629
- symbols and would otherwise make rows thousands of characters wide.
628
+ `..., Total Calls:N`; real firmware dispatch functions call dozens of
629
+ distinct symbols and would otherwise make rows thousands of characters
630
+ wide. When stdout is a terminal, rows are additionally trimmed to the
631
+ terminal width: callees are dropped from the end of the `calls` column
632
+ (never the first one) behind the same `Total Calls:N` summary, so every
633
+ row stays on one line. Piped or redirected output skips the width trim
634
+ and keeps the full capped list, so it stays stable and greppable.
630
635
 
631
636
  ## Comparing the same function across two builds (`--across`)
632
637
 
@@ -687,9 +692,9 @@ movl %r14d, %r11d | sarl $11, %ebp
687
692
  call SMULR6 | leal 1024(%rax), %edx
688
693
  [... 60 rows omitted ...]
689
694
 
690
- function role insns calls loop spans
691
- dsps_biquad_f32_ansi [cc#1] baseline 59 SMULR6 .L27:32
692
- dsps_biquad_f32_ansi [cc#2] candidate 89 - .L26:54
695
+ function role insns loop spans calls
696
+ dsps_biquad_f32_ansi [cc#1] baseline 59 .L27:32 SMULR6
697
+ dsps_biquad_f32_ansi [cc#2] candidate 89 .L26:54 -
693
698
  ```
694
699
 
695
700
  (The listing is abridged here; the tool prints all 92 rows. The columns
@@ -725,9 +730,9 @@ call ldexpf@PLT |
725
730
  leave |
726
731
  ret |
727
732
 
728
- function role insns calls loop spans
729
- new_rt [cc#1] baseline 13 ldexpf -
730
- new_rt [cc#2] candidate 2 ldexpf -
733
+ function role insns loop spans calls
734
+ new_rt [cc#1] baseline 13 - ldexpf
735
+ new_rt [cc#2] candidate 2 - ldexpf
731
736
  ```
732
737
 
733
738
  **Two files** — before/after versions of a source file (e.g. from a git
@@ -3,8 +3,8 @@
3
3
 
4
4
  Compiles a harness C file across a matrix of compilers, extracts each
5
5
  variant function's assembly from the -S output, and prints side-by-side
6
- listings plus a summary of instruction counts, outbound calls, and loop
7
- spans (instructions between a local label and its last backward branch).
6
+ listings plus a summary of instruction counts, loop spans (instructions
7
+ between a local label and its last backward branch), and outbound calls.
8
8
  Automates fold-vs-libcall analysis when evaluating micro-optimisations
9
9
  (e.g. "does this still compile to one instruction, or is it a libcall?").
10
10
 
@@ -1050,14 +1050,54 @@ def side_by_side(left, right, ltitle, rtitle, width=44):
1050
1050
  return "\n".join(rows)
1051
1051
 
1052
1052
 
1053
- def render_table(rows):
1054
- """Column-aligned text for a list of equal-length string tuples."""
1053
+ TOTAL_CALLS_RE = re.compile(r"Total Calls:\d+")
1054
+
1055
+
1056
+ def _fit_calls(cell, budget):
1057
+ """Trim a comma-joined callee cell to at most budget columns by
1058
+ dropping whole callees from the end, closing with a Total Calls:N
1059
+ summary (N is always the full callee count, preserved from an
1060
+ existing summary when the cell was already capped). The first
1061
+ callee is never dropped; a cell that still overflows just wraps."""
1062
+ if len(cell) <= budget:
1063
+ return cell
1064
+ items = cell.split(", ")
1065
+ if TOTAL_CALLS_RE.fullmatch(items[-1]):
1066
+ items, summary = items[:-1], items[-1]
1067
+ else:
1068
+ summary = f"Total Calls:{len(items)}"
1069
+ if len(items) < 2:
1070
+ return cell
1071
+ for keep in range(len(items) - 1, 0, -1):
1072
+ cand = ", ".join(items[:keep]) + ", " + summary
1073
+ if len(cand) <= budget:
1074
+ return cand
1075
+ return items[0] + ", " + summary
1076
+
1077
+
1078
+ def render_table(rows, max_width=None):
1079
+ """Column-aligned text for a list of equal-length string tuples.
1080
+
1081
+ max_width (terminal columns) keeps each row on one line by
1082
+ trimming the last column's cells (see _fit_calls); the other
1083
+ columns are never touched. None renders untrimmed."""
1084
+ if max_width is not None:
1085
+ fixed = [max(len(row[i]) for row in rows)
1086
+ for i in range(len(rows[0]) - 1)]
1087
+ budget = max_width - sum(fixed) - 2 * len(fixed)
1088
+ rows = [row[:-1] + (_fit_calls(row[-1], budget),) for row in rows]
1055
1089
  widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]))]
1056
1090
  return "\n".join(
1057
1091
  " ".join(cell.ljust(w) for cell, w in zip(row, widths)).rstrip()
1058
1092
  for row in rows)
1059
1093
 
1060
1094
 
1095
+ def table_width():
1096
+ """Width budget for summary tables: the terminal width when stdout
1097
+ is a tty, else None so piped output keeps every callee greppable."""
1098
+ return shutil.get_terminal_size().columns if sys.stdout.isatty() else None
1099
+
1100
+
1061
1101
  def format_spans(spans):
1062
1102
  return " ".join(f"{label}:{n}" for label, n in spans) or "-"
1063
1103
 
@@ -1068,27 +1108,31 @@ MAX_CALLS_SHOWN = 8
1068
1108
 
1069
1109
 
1070
1110
  def format_calls(calls):
1071
- """Comma-joined callee list, capped at MAX_CALLS_SHOWN for readability."""
1111
+ """Comma-joined callee list; longer lists keep the first
1112
+ MAX_CALLS_SHOWN callees and close with a Total Calls:N summary."""
1072
1113
  if not calls:
1073
1114
  return "-"
1074
1115
  if len(calls) > MAX_CALLS_SHOWN:
1075
1116
  return (", ".join(calls[:MAX_CALLS_SHOWN])
1076
- + f", +{len(calls) - MAX_CALLS_SHOWN} more")
1117
+ + f", Total Calls:{len(calls)}")
1077
1118
  return ", ".join(calls)
1078
1119
 
1079
1120
 
1080
- def summary_table(pairs, funcs):
1081
- """Instruction counts, outbound calls, and loop spans per pair member."""
1082
- rows = [("function", "role", "insns", "calls", "loop spans")]
1121
+ def summary_table(pairs, funcs, max_width=None):
1122
+ """Instruction counts, loop spans, and outbound calls per pair
1123
+ member. Calls come last: the one unbounded column stays ragged
1124
+ right so the counts and spans keep their alignment."""
1125
+ rows = [("function", "role", "insns", "loop spans", "calls")]
1083
1126
  for old, new in pairs:
1084
1127
  for name, role in ((old, "baseline"), (new, "candidate")):
1085
1128
  insns, calls = analyze(funcs[name])
1086
- rows.append((name, role, str(insns), format_calls(calls),
1087
- format_spans(loop_spans(funcs[name]))))
1088
- return render_table(rows)
1129
+ rows.append((name, role, str(insns),
1130
+ format_spans(loop_spans(funcs[name])),
1131
+ format_calls(calls)))
1132
+ return render_table(rows, max_width)
1089
1133
 
1090
1134
 
1091
- def file_summary_table(funcs):
1135
+ def file_summary_table(funcs, max_width=None):
1092
1136
  """Per-function counts plus a whole-file total row.
1093
1137
 
1094
1138
  The total sums instruction counts over every function parsed from
@@ -1096,7 +1140,7 @@ def file_summary_table(funcs):
1096
1140
  check, not a code-size measurement (literal pools, data, and
1097
1141
  alignment are not included).
1098
1142
  """
1099
- rows = [("function", "insns", "calls", "loop spans")]
1143
+ rows = [("function", "insns", "loop spans", "calls")]
1100
1144
  total_insns, all_calls = 0, []
1101
1145
  for name, lines in funcs.items():
1102
1146
  insns, calls = analyze(lines)
@@ -1104,11 +1148,11 @@ def file_summary_table(funcs):
1104
1148
  for sym in calls:
1105
1149
  if sym not in all_calls:
1106
1150
  all_calls.append(sym)
1107
- rows.append((name, str(insns), format_calls(calls),
1108
- format_spans(loop_spans(lines))))
1151
+ rows.append((name, str(insns),
1152
+ format_spans(loop_spans(lines)), format_calls(calls)))
1109
1153
  rows.append((f"TOTAL ({len(funcs)} functions)", str(total_insns),
1110
- format_calls(all_calls), "-"))
1111
- return render_table(rows)
1154
+ "-", format_calls(all_calls)))
1155
+ return render_table(rows, max_width)
1112
1156
 
1113
1157
 
1114
1158
  def listing(name, lines):
@@ -1119,15 +1163,16 @@ def listing(name, lines):
1119
1163
  return "\n".join([f"{name}:"] + body)
1120
1164
 
1121
1165
 
1122
- def inspect_table(fn_names, funcs):
1166
+ def inspect_table(fn_names, funcs, max_width=None):
1123
1167
  """Stats rows for the inspected functions only - no pair roles and
1124
1168
  no whole-file total, unlike summary_table/file_summary_table."""
1125
- rows = [("function", "insns", "calls", "loop spans")]
1169
+ rows = [("function", "insns", "loop spans", "calls")]
1126
1170
  for name in fn_names:
1127
1171
  insns, calls = analyze(funcs[name])
1128
- rows.append((name, str(insns), format_calls(calls),
1129
- format_spans(loop_spans(funcs[name]))))
1130
- return render_table(rows)
1172
+ rows.append((name, str(insns),
1173
+ format_spans(loop_spans(funcs[name])),
1174
+ format_calls(calls)))
1175
+ return render_table(rows, max_width)
1131
1176
 
1132
1177
 
1133
1178
  def file_tags(a, b):
@@ -1158,7 +1203,7 @@ def report_across(fn_names, left_funcs, right_funcs, left_tag, right_tag):
1158
1203
  for lt, rt in pairs:
1159
1204
  print(side_by_side(decorated[lt], decorated[rt], lt, rt))
1160
1205
  print()
1161
- print(summary_table(pairs, decorated))
1206
+ print(summary_table(pairs, decorated, table_width()))
1162
1207
 
1163
1208
 
1164
1209
  def run_across(sources, matrix, fn_names, extra_flags, tmp):
@@ -1228,7 +1273,7 @@ def run_summary(sources, matrix, extra_flags, tmp):
1228
1273
  if len(sections) > 1:
1229
1274
  print(f"\n-- {tag} --")
1230
1275
  print()
1231
- print(file_summary_table(funcs) if funcs
1276
+ print(file_summary_table(funcs, table_width()) if funcs
1232
1277
  else "(no functions found)")
1233
1278
  if not ran_any:
1234
1279
  sys.exit("error: no usable compiler in the matrix")
@@ -1256,7 +1301,7 @@ def run_pairs(source, matrix, pair_specs, extra_flags, tmp):
1256
1301
  'declare the pairs extern "C" or use --pair with the '
1257
1302
  'mangled names', file=sys.stderr)
1258
1303
  print(f"\n== {cc_cmd} ==\n")
1259
- print(file_summary_table(funcs) if funcs
1304
+ print(file_summary_table(funcs, table_width()) if funcs
1260
1305
  else "(no functions found)")
1261
1306
  continue
1262
1307
  missing = sorted({n for p in pairs for n in p if n not in funcs})
@@ -1268,7 +1313,7 @@ def run_pairs(source, matrix, pair_specs, extra_flags, tmp):
1268
1313
  for old, new in pairs:
1269
1314
  print(side_by_side(funcs[old], funcs[new], old, new))
1270
1315
  print()
1271
- print(summary_table(pairs, funcs))
1316
+ print(summary_table(pairs, funcs, table_width()))
1272
1317
  if not ran_any:
1273
1318
  sys.exit("error: no usable compiler in the matrix")
1274
1319
  return 0
@@ -1316,7 +1361,7 @@ def run_inspect(source, matrix, fn_names, layout, extra_flags, tmp):
1316
1361
  print()
1317
1362
  print(listing(name, funcs[name]))
1318
1363
  print()
1319
- print(inspect_table(fn_names, funcs))
1364
+ print(inspect_table(fn_names, funcs, table_width()))
1320
1365
  return 0
1321
1366
 
1322
1367
 
@@ -1379,7 +1424,7 @@ def run_elf(elf, fn_names, filter_regex, objdump):
1379
1424
  print()
1380
1425
  print(listing(name, funcs[name]))
1381
1426
  print()
1382
- print(inspect_table(selected, funcs))
1427
+ print(inspect_table(selected, funcs, table_width()))
1383
1428
  return 0
1384
1429
 
1385
1430
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asmdiff"
7
- version = "0.1.6"
7
+ version = "0.1.7"
8
8
  description = "Compare per-function assembly between paired C implementations"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1189,11 +1189,72 @@ class TestFormatCalls(unittest.TestCase):
1189
1189
  def test_long_list_capped(self):
1190
1190
  calls = [f"fn{i}" for i in range(12)]
1191
1191
  out = asmdiff.format_calls(calls)
1192
- self.assertTrue(out.endswith("+4 more"))
1192
+ self.assertTrue(out.endswith("Total Calls:12"))
1193
1193
  self.assertIn("fn7", out)
1194
1194
  self.assertNotIn("fn8,", out)
1195
1195
 
1196
1196
 
1197
+ class TestTableMaxWidth(unittest.TestCase):
1198
+ """render_table(max_width=) fits rows to the terminal by trimming
1199
+ the last column only, whole callees at a time, replacing the tail
1200
+ with (or preserving) a Total Calls:N summary."""
1201
+
1202
+ def test_no_max_width_keeps_long_cells(self):
1203
+ rows = [("function", "calls"),
1204
+ ("f", "alpha, bravo, charlie, delta")]
1205
+ out = asmdiff.render_table(rows)
1206
+ self.assertIn("alpha, bravo, charlie, delta", out)
1207
+
1208
+ def test_trims_to_fit_with_total_summary(self):
1209
+ rows = [("function", "insns", "calls"),
1210
+ ("f", "5", "alpha, bravo, charlie, delta")]
1211
+ out = asmdiff.render_table(rows, max_width=37)
1212
+ self.assertIn("alpha, Total Calls:4", out)
1213
+ self.assertNotIn("bravo", out)
1214
+ self.assertTrue(all(len(l) <= 37 for l in out.splitlines()))
1215
+
1216
+ def test_fitting_cell_left_alone(self):
1217
+ rows = [("function", "calls"), ("f", "alpha, bravo")]
1218
+ out = asmdiff.render_table(rows, max_width=80)
1219
+ self.assertIn("alpha, bravo", out)
1220
+ self.assertNotIn("Total Calls", out)
1221
+
1222
+ def test_merges_existing_total_summary(self):
1223
+ rows = [("fn", "calls"), ("f", "a, b, c, Total Calls:12")]
1224
+ out = asmdiff.render_table(rows, max_width=21)
1225
+ self.assertIn("a, Total Calls:12", out)
1226
+ self.assertNotIn("b", out.splitlines()[1])
1227
+
1228
+ def test_single_callee_never_dropped(self):
1229
+ rows = [("fn", "calls"), ("f", "very_long_single_symbol_name")]
1230
+ out = asmdiff.render_table(rows, max_width=10)
1231
+ self.assertIn("very_long_single_symbol_name", out)
1232
+
1233
+ def test_dash_cell_untouched(self):
1234
+ rows = [("function_with_a_very_long_header", "calls"), ("f", "-")]
1235
+ out = asmdiff.render_table(rows, max_width=10)
1236
+ self.assertRegex(out.splitlines()[1], r"f\s+-")
1237
+
1238
+ def test_inspect_table_passes_max_width(self):
1239
+ funcs = {"f": ["call\talpha", "call\tbravo",
1240
+ "call\tcharlie", "call\tdelta", "ret"]}
1241
+ out = asmdiff.inspect_table(["f"], funcs, max_width=49)
1242
+ self.assertIn("alpha, Total Calls:4", out)
1243
+ self.assertNotIn("bravo", out)
1244
+
1245
+ def test_table_width_is_terminal_width_on_tty(self):
1246
+ with mock.patch.object(asmdiff.sys.stdout, "isatty",
1247
+ return_value=True), \
1248
+ mock.patch.object(asmdiff.shutil, "get_terminal_size",
1249
+ return_value=os.terminal_size((100, 24))):
1250
+ self.assertEqual(asmdiff.table_width(), 100)
1251
+
1252
+ def test_table_width_is_none_when_piped(self):
1253
+ with mock.patch.object(asmdiff.sys.stdout, "isatty",
1254
+ return_value=False):
1255
+ self.assertIsNone(asmdiff.table_width())
1256
+
1257
+
1197
1258
  class TestDiscoveredCompileCommands(unittest.TestCase):
1198
1259
  """compile_commands = true in a target / bare --compile-commands."""
1199
1260
 
@@ -1341,15 +1402,16 @@ class TestRendering(unittest.TestCase):
1341
1402
  lines = out.splitlines()
1342
1403
  self.assertIn("function", lines[0])
1343
1404
  self.assertIn("loop spans", lines[0])
1405
+ self.assertTrue(lines[0].endswith("calls"))
1344
1406
  self.assertRegex(lines[1], r"old_c\s+baseline\s+2\s+-\s+-")
1345
- self.assertRegex(lines[2], r"new_c\s+candidate\s+1\s+ldexpf\s+-")
1407
+ self.assertRegex(lines[2], r"new_c\s+candidate\s+1\s+-\s+ldexpf")
1346
1408
 
1347
1409
  def test_summary_table_loop_spans_column(self):
1348
1410
  funcs = {"a": [".L2:", "addl\t$1, %eax", "jne\t.L2"],
1349
1411
  "b": ["ret"]}
1350
1412
  out = asmdiff.summary_table([("a", "b")], funcs)
1351
1413
  lines = out.splitlines()
1352
- self.assertRegex(lines[1], r"a\s+baseline\s+2\s+-\s+\.L2:2")
1414
+ self.assertRegex(lines[1], r"a\s+baseline\s+2\s+\.L2:2\s+-")
1353
1415
  self.assertRegex(lines[2], r"b\s+candidate\s+1\s+-\s+-")
1354
1416
 
1355
1417
  def test_file_summary_totals_and_call_union(self):
@@ -1358,10 +1420,10 @@ class TestRendering(unittest.TestCase):
1358
1420
  "call\tmalloc", "call\tfree", "ret"]}
1359
1421
  out = asmdiff.file_summary_table(funcs)
1360
1422
  lines = out.splitlines()
1361
- self.assertRegex(lines[1], r"f\s+2\s+malloc\s+-")
1362
- self.assertRegex(lines[2], r"g\s+5\s+malloc, free\s+\.L2:2")
1423
+ self.assertRegex(lines[1], r"f\s+2\s+-\s+malloc")
1424
+ self.assertRegex(lines[2], r"g\s+5\s+\.L2:2\s+malloc, free")
1363
1425
  self.assertRegex(lines[3],
1364
- r"TOTAL \(2 functions\)\s+7\s+malloc, free\s+-")
1426
+ r"TOTAL \(2 functions\)\s+7\s+-\s+malloc, free")
1365
1427
 
1366
1428
 
1367
1429
  class TestInspectRendering(unittest.TestCase):
@@ -1379,13 +1441,13 @@ class TestInspectRendering(unittest.TestCase):
1379
1441
  lines = out.splitlines()
1380
1442
  self.assertIn("function", lines[0])
1381
1443
  self.assertEqual(len(lines), 2) # header + the one function
1382
- self.assertRegex(lines[1], r"new_const\s+2\s+ldexpf\s+-")
1444
+ self.assertRegex(lines[1], r"new_const\s+2\s+-\s+ldexpf")
1383
1445
  self.assertNotIn("TOTAL", out)
1384
1446
 
1385
1447
  def test_inspect_table_loop_spans(self):
1386
1448
  funcs = asmdiff.extract_functions(LOOP_ASM)
1387
1449
  out = asmdiff.inspect_table(["looper"], funcs)
1388
- self.assertRegex(out.splitlines()[1], r"looper\s+5\s+-\s+\.L2:3")
1450
+ self.assertRegex(out.splitlines()[1], r"looper\s+5\s+\.L2:3\s+-")
1389
1451
 
1390
1452
 
1391
1453
  class TestRunInspect(unittest.TestCase):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes