consync 0.1.1__tar.gz → 2.0.0__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 (71) hide show
  1. {consync-0.1.1 → consync-2.0.0}/PKG-INFO +1 -1
  2. {consync-0.1.1 → consync-2.0.0}/consync/__init__.py +1 -1
  3. {consync-0.1.1 → consync-2.0.0}/consync/config.py +2 -0
  4. {consync-0.1.1 → consync-2.0.0}/consync/models.py +3 -0
  5. {consync-0.1.1 → consync-2.0.0}/consync/parsers/__init__.py +1 -1
  6. consync-2.0.0/consync/parsers/c_struct_table.py +615 -0
  7. {consync-0.1.1 → consync-2.0.0}/consync/renderers/__init__.py +1 -0
  8. consync-2.0.0/consync/renderers/c_struct_table.py +207 -0
  9. {consync-0.1.1 → consync-2.0.0}/consync/sync.py +184 -13
  10. {consync-0.1.1 → consync-2.0.0}/pyproject.toml +1 -1
  11. consync-2.0.0/tests/test_c_struct_table.py +474 -0
  12. {consync-0.1.1 → consync-2.0.0}/.github/CODEOWNERS +0 -0
  13. {consync-0.1.1 → consync-2.0.0}/.github/copilot-instructions.md +0 -0
  14. {consync-0.1.1 → consync-2.0.0}/.github/dependabot.yml +0 -0
  15. {consync-0.1.1 → consync-2.0.0}/.github/workflows/ci.yml +0 -0
  16. {consync-0.1.1 → consync-2.0.0}/.github/workflows/codeql.yml +0 -0
  17. {consync-0.1.1 → consync-2.0.0}/.github/workflows/publish.yml +0 -0
  18. {consync-0.1.1 → consync-2.0.0}/.github/workflows/release.yml +0 -0
  19. {consync-0.1.1 → consync-2.0.0}/.gitignore +0 -0
  20. {consync-0.1.1 → consync-2.0.0}/CLAUDE.md +0 -0
  21. {consync-0.1.1 → consync-2.0.0}/CONTRIBUTING.md +0 -0
  22. {consync-0.1.1 → consync-2.0.0}/FAQ.md +0 -0
  23. {consync-0.1.1 → consync-2.0.0}/LICENSE +0 -0
  24. {consync-0.1.1 → consync-2.0.0}/README.md +0 -0
  25. {consync-0.1.1 → consync-2.0.0}/SECURITY.md +0 -0
  26. {consync-0.1.1 → consync-2.0.0}/TODO.md +0 -0
  27. {consync-0.1.1 → consync-2.0.0}/assets/demo.gif +0 -0
  28. {consync-0.1.1 → consync-2.0.0}/assets/demo.tape +0 -0
  29. {consync-0.1.1 → consync-2.0.0}/consync/backup.py +0 -0
  30. {consync-0.1.1 → consync-2.0.0}/consync/cli.py +0 -0
  31. {consync-0.1.1 → consync-2.0.0}/consync/hooks.py +0 -0
  32. {consync-0.1.1 → consync-2.0.0}/consync/lock.py +0 -0
  33. {consync-0.1.1 → consync-2.0.0}/consync/logging_config.py +0 -0
  34. {consync-0.1.1 → consync-2.0.0}/consync/parsers/c_header.py +0 -0
  35. {consync-0.1.1 → consync-2.0.0}/consync/parsers/csv_parser.py +0 -0
  36. {consync-0.1.1 → consync-2.0.0}/consync/parsers/json_parser.py +0 -0
  37. {consync-0.1.1 → consync-2.0.0}/consync/parsers/toml_parser.py +0 -0
  38. {consync-0.1.1 → consync-2.0.0}/consync/parsers/xlsx.py +0 -0
  39. {consync-0.1.1 → consync-2.0.0}/consync/precision.py +0 -0
  40. {consync-0.1.1 → consync-2.0.0}/consync/renderers/c_header.py +0 -0
  41. {consync-0.1.1 → consync-2.0.0}/consync/renderers/csharp.py +0 -0
  42. {consync-0.1.1 → consync-2.0.0}/consync/renderers/csv_renderer.py +0 -0
  43. {consync-0.1.1 → consync-2.0.0}/consync/renderers/json_renderer.py +0 -0
  44. {consync-0.1.1 → consync-2.0.0}/consync/renderers/python_const.py +0 -0
  45. {consync-0.1.1 → consync-2.0.0}/consync/renderers/rust_const.py +0 -0
  46. {consync-0.1.1 → consync-2.0.0}/consync/renderers/verilog.py +0 -0
  47. {consync-0.1.1 → consync-2.0.0}/consync/renderers/vhdl.py +0 -0
  48. {consync-0.1.1 → consync-2.0.0}/consync/state.py +0 -0
  49. {consync-0.1.1 → consync-2.0.0}/consync/validators.py +0 -0
  50. {consync-0.1.1 → consync-2.0.0}/consync/watcher.py +0 -0
  51. {consync-0.1.1 → consync-2.0.0}/examples/fpga/.consync.yaml +0 -0
  52. {consync-0.1.1 → consync-2.0.0}/examples/fpga/design_params.csv +0 -0
  53. {consync-0.1.1 → consync-2.0.0}/examples/hardware/.consync.yaml +0 -0
  54. {consync-0.1.1 → consync-2.0.0}/examples/hardware/constants.csv +0 -0
  55. {consync-0.1.1 → consync-2.0.0}/examples/multilang/.consync.yaml +0 -0
  56. {consync-0.1.1 → consync-2.0.0}/examples/multilang/constants.json +0 -0
  57. {consync-0.1.1 → consync-2.0.0}/npm/.npmrc +0 -0
  58. {consync-0.1.1 → consync-2.0.0}/npm/LICENSE +0 -0
  59. {consync-0.1.1 → consync-2.0.0}/npm/README.md +0 -0
  60. {consync-0.1.1 → consync-2.0.0}/npm/bin/consync.js +0 -0
  61. {consync-0.1.1 → consync-2.0.0}/npm/package.json +0 -0
  62. {consync-0.1.1 → consync-2.0.0}/npm/scripts/install.js +0 -0
  63. {consync-0.1.1 → consync-2.0.0}/tests/__init__.py +0 -0
  64. {consync-0.1.1 → consync-2.0.0}/tests/test_arrays.py +0 -0
  65. {consync-0.1.1 → consync-2.0.0}/tests/test_cli.py +0 -0
  66. {consync-0.1.1 → consync-2.0.0}/tests/test_embedded.py +0 -0
  67. {consync-0.1.1 → consync-2.0.0}/tests/test_parsers.py +0 -0
  68. {consync-0.1.1 → consync-2.0.0}/tests/test_precision.py +0 -0
  69. {consync-0.1.1 → consync-2.0.0}/tests/test_renderers.py +0 -0
  70. {consync-0.1.1 → consync-2.0.0}/tests/test_safety.py +0 -0
  71. {consync-0.1.1 → consync-2.0.0}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: consync
3
- Version: 0.1.1
3
+ Version: 2.0.0
4
4
  Summary: Bidirectional sync between spreadsheets and source code constants — with full decimal precision.
5
5
  Project-URL: Homepage, https://github.com/naveenkumarbaskaran/consync
6
6
  Project-URL: Repository, https://github.com/naveenkumarbaskaran/consync
@@ -1,6 +1,6 @@
1
1
  """consync — Bidirectional sync between spreadsheets and source code constants."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "2.0.0"
4
4
 
5
5
  from consync.models import Constant, SyncDirection
6
6
  from consync.config import load_config
@@ -116,6 +116,8 @@ def _parse_mapping(raw: dict[str, Any], config_dir: Path) -> MappingConfig:
116
116
  output_style=raw.get("output_style", "const"),
117
117
  static_const=raw.get("static_const", False),
118
118
  typed_ints=raw.get("typed_ints", True),
119
+ parser_options=raw.get("parser_options", {}),
120
+ renderer_options=raw.get("renderer_options", {}),
119
121
  validators=raw.get("validators", {}),
120
122
  )
121
123
 
@@ -90,6 +90,9 @@ class MappingConfig:
90
90
  output_style: str = "const" # "const" | "define" — #define vs const declaration
91
91
  static_const: bool = False # emit "static const" to avoid linker duplicates
92
92
  typed_ints: bool = True # use uint32_t/int32_t instead of plain int/double for integers
93
+ # Parser/renderer options (format-specific kwargs)
94
+ parser_options: dict = field(default_factory=dict) # passed to parser as **kwargs
95
+ renderer_options: dict = field(default_factory=dict) # passed to renderer as **kwargs
93
96
  # Validation hooks
94
97
  validators: dict = field(default_factory=dict) # {name: {min:, max:, type:, pattern:, ...}}
95
98
 
@@ -36,4 +36,4 @@ def list_parsers() -> list[str]:
36
36
 
37
37
 
38
38
  # Import all parser modules to trigger registration
39
- from consync.parsers import xlsx, csv_parser, json_parser, toml_parser, c_header # noqa: E402, F401
39
+ from consync.parsers import xlsx, csv_parser, json_parser, toml_parser, c_header, c_struct_table # noqa: E402, F401
@@ -0,0 +1,615 @@
1
+ """C struct table parser — reads struct array initializers from .c/.h files.
2
+
3
+ Handles complex embedded-style constant tables like:
4
+
5
+ static const MyStruct LUT[COUNT] = {
6
+ /* Label1 */ {{val1, val2}, val3, ...},
7
+ /* Label2 */ {{val1, val2}, val3, ...},
8
+ };
9
+
10
+ Features:
11
+ - Extracts row labels from leading comments (/* Label */)
12
+ - Parses nested brace initializers with field mapping
13
+ - Handles #if/#elif/#endif conditional blocks (extracts active variant)
14
+ - Supports C float suffixes (1.0F), hex literals (0xFF), unsigned (5u)
15
+ - Preserves computed expressions as string constants (e.g., "0.25F / NF_PSC_FREQUENCY")
16
+ - Separates literal numeric values from expression-based values
17
+
18
+ Configuration (in .consync.yaml):
19
+ source_format: c_struct_table
20
+ parser_options:
21
+ fields: [R_Phase, L_d, L_q, Psi, J, Imax, Tmax, NPpair]
22
+ variant: DPB # selects #elif block (matches RBFS_PscMotorParameter_DPB)
23
+ table_var: PSC_HWVarParLUT # variable name of the struct array
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from pathlib import Path
30
+
31
+ from consync.models import Constant
32
+ from consync.parsers import register
33
+
34
+
35
+ # Regex to match row labels in comments like /* BWA NI S4 */ or /* EMB 12V 35KN */
36
+ _ROW_LABEL_RE = re.compile(r"/\*\s*(.+?)\s*\*/")
37
+
38
+ # Regex to match a C numeric literal (int or float, with optional suffix)
39
+ _NUMERIC_LITERAL_RE = re.compile(
40
+ r"^[+-]?"
41
+ r"(?:0[xX][0-9a-fA-F]+|" # hex
42
+ r"\d+\.?\d*(?:[eE][+-]?\d+)?)" # decimal / float / scientific
43
+ r"[fFlLuU]*$" # C type suffixes
44
+ )
45
+
46
+ # Regex to detect #if / #elif lines with variant names
47
+ _PREPROC_IF_RE = re.compile(
48
+ r"^\s*#\s*(?:el)?if\s*\(.*?==\s*(\w+)\s*\)"
49
+ )
50
+ _PREPROC_ENDIF_RE = re.compile(r"^\s*#\s*endif")
51
+ _PREPROC_ELIF_RE = re.compile(r"^\s*#\s*elif")
52
+
53
+ # Matches the start of a static const struct array declaration
54
+ _TABLE_DECL_RE = re.compile(
55
+ r"^\s*(?:static\s+)?(?:const\s+)?\w+\s+(\w+)\s*\["
56
+ )
57
+
58
+
59
+ def _strip_c_suffix(token: str) -> str:
60
+ """Strip C numeric literal suffixes like F, f, L, u, U.
61
+
62
+ Does NOT strip from hex literals (0x...) since hex digits overlap with suffixes.
63
+ """
64
+ t = token.strip()
65
+ # Don't strip from hex — 'F' is a valid hex digit
66
+ if t.startswith(("0x", "0X", "+0x", "+0X", "-0x", "-0X")):
67
+ return t
68
+ return t.rstrip("fFlLuU")
69
+
70
+
71
+ def _is_numeric_literal(token: str) -> bool:
72
+ """Check if a token is a plain C numeric literal (not an expression)."""
73
+ stripped = _strip_c_suffix(token.strip())
74
+ return bool(_NUMERIC_LITERAL_RE.match(stripped))
75
+
76
+
77
+ def _parse_numeric(token: str) -> float | int:
78
+ """Parse a C numeric literal to Python number."""
79
+ stripped = _strip_c_suffix(token.strip())
80
+ if stripped.startswith(("0x", "0X")):
81
+ return int(stripped, 16)
82
+ if "." in stripped or "e" in stripped.lower():
83
+ return float(stripped)
84
+ return int(stripped)
85
+
86
+
87
+ def _tokenize_brace_values(text: str) -> list[str]:
88
+ """Tokenize a brace-enclosed initializer into top-level values.
89
+
90
+ Handles nested braces: {1.0, {2.0, 3.0}, 4.0} → ['1.0', '{2.0, 3.0}', '4.0']
91
+ """
92
+ tokens: list[str] = []
93
+ depth = 0
94
+ current = ""
95
+
96
+ for ch in text:
97
+ if ch == "{":
98
+ if depth > 0:
99
+ current += ch
100
+ depth += 1
101
+ elif ch == "}":
102
+ depth -= 1
103
+ if depth > 0:
104
+ current += ch
105
+ elif depth == 0 and current.strip():
106
+ tokens.append(current.strip())
107
+ current = ""
108
+ elif ch == "," and depth == 1:
109
+ if current.strip():
110
+ tokens.append(current.strip())
111
+ current = ""
112
+ else:
113
+ if depth >= 1:
114
+ current += ch
115
+
116
+ # Handle remaining (for cases without trailing comma)
117
+ if current.strip():
118
+ tokens.append(current.strip())
119
+
120
+ return tokens
121
+
122
+
123
+ def _flatten_values(text: str) -> list[str]:
124
+ """Recursively flatten nested brace initializers into a flat list of scalar values.
125
+
126
+ Input is the raw content INSIDE the outermost braces.
127
+ {1.0, {2.0, 3.0}, 4.0} → ['1.0', '2.0', '3.0', '4.0']
128
+ Scalars at any depth are collected. Sub-braces are recursed into.
129
+ """
130
+ # Tokenize at the current level (comma-separated, respecting brace depth)
131
+ tokens = _tokenize_at_level(text)
132
+ result: list[str] = []
133
+ for t in tokens:
134
+ t = t.strip()
135
+ if not t:
136
+ continue
137
+ if t.startswith("{"):
138
+ # Recurse into the brace content
139
+ inner = t[1:-1] if t.endswith("}") else t[1:]
140
+ result.extend(_flatten_values(inner))
141
+ else:
142
+ result.append(t)
143
+ return result
144
+
145
+
146
+ def _tokenize_at_level(text: str) -> list[str]:
147
+ """Split text by commas, respecting nested braces.
148
+
149
+ Returns tokens at the current level — brace groups are kept as single tokens.
150
+ """
151
+ tokens: list[str] = []
152
+ depth = 0
153
+ current = ""
154
+
155
+ for ch in text:
156
+ if ch == "{":
157
+ depth += 1
158
+ current += ch
159
+ elif ch == "}":
160
+ depth -= 1
161
+ current += ch
162
+ elif ch == "," and depth == 0:
163
+ if current.strip():
164
+ tokens.append(current.strip())
165
+ current = ""
166
+ else:
167
+ current += ch
168
+
169
+ if current.strip():
170
+ tokens.append(current.strip())
171
+
172
+ return tokens
173
+
174
+
175
+ def _extract_row_data(line: str) -> tuple[str, str] | None:
176
+ """Extract (label, brace_content) from a struct initializer row.
177
+
178
+ Returns None if line doesn't look like a struct row.
179
+ The brace_content is the inner content of the outermost balanced braces.
180
+ """
181
+ # Try to find a label comment
182
+ label_match = _ROW_LABEL_RE.search(line)
183
+ label = label_match.group(1).strip() if label_match else ""
184
+
185
+ # Find the first { that starts the struct data
186
+ brace_start = line.find("{")
187
+ if brace_start == -1:
188
+ return None
189
+
190
+ # Find the matching closing brace by counting depth
191
+ data_portion = line[brace_start:]
192
+ depth = 0
193
+ end_idx = -1
194
+ for i, ch in enumerate(data_portion):
195
+ if ch == "{":
196
+ depth += 1
197
+ elif ch == "}":
198
+ depth -= 1
199
+ if depth == 0:
200
+ end_idx = i
201
+ break
202
+
203
+ if end_idx == -1:
204
+ return None
205
+
206
+ # Extract the balanced content (strip outermost { })
207
+ inner_content = data_portion[1:end_idx]
208
+ return (label, inner_content)
209
+
210
+
211
+ def _auto_detect_table_var(text: str) -> str | None:
212
+ """Auto-detect the struct array variable name from the file.
213
+
214
+ Looks for patterns like:
215
+ static const MyType VarName[SIZE] = {
216
+ Returns the variable name or None.
217
+ """
218
+ for line in text.splitlines():
219
+ m = _TABLE_DECL_RE.match(line)
220
+ if m and "=" in line:
221
+ return m.group(1)
222
+ return None
223
+
224
+
225
+ # Regex for field header comment: /* field1 field2 field3 ... */
226
+ _FIELD_HEADER_RE = re.compile(
227
+ r"/\*[\s,]*((?:[A-Za-z_]\w*[\s,]+)+[A-Za-z_]\w*)\s*\*/"
228
+ )
229
+
230
+
231
+ def _auto_detect_fields(text: str) -> list[str] | None:
232
+ """Auto-detect field names from the column header comment.
233
+
234
+ Looks for a comment line like:
235
+ /* R_Phase L_d L_q Psi J Imax ... */
236
+ that appears just before the first data row (line with {{ ).
237
+
238
+ Returns list of field names or None.
239
+ """
240
+ lines = text.splitlines()
241
+ prev_comment = None
242
+
243
+ for line in lines:
244
+ stripped = line.strip()
245
+ # Look for comment lines that could be field headers
246
+ if stripped.startswith("/*") and stripped.endswith("*/") and "{{" not in stripped:
247
+ # Extract content between /* and */
248
+ inner = stripped[2:-2].strip()
249
+ # Must contain multiple words separated by whitespace/commas (field names)
250
+ # Filter: must have at least 3 words and no sentences (no spaces within words)
251
+ tokens = re.split(r"[\s,]+", inner)
252
+ tokens = [t for t in tokens if t and re.match(r"^[A-Za-z_]\w*$", t)]
253
+ if len(tokens) >= 3:
254
+ prev_comment = tokens
255
+ # When we hit a data row, the previous comment was the header
256
+ elif "{{" in stripped and prev_comment:
257
+ return prev_comment
258
+
259
+ return None
260
+
261
+
262
+ def _auto_detect_variants(text: str) -> list[str]:
263
+ """Find all preprocessor variant names for the primary #if chain in the file.
264
+
265
+ Groups by the macro being tested (e.g. RBFS_PscMotorParameter) and returns
266
+ the short variant names from the chain with the most alternatives.
267
+ This avoids picking up unrelated #if blocks (feature flags, etc.)
268
+
269
+ Returns list like ['BWA', 'EMB', 'DPB', 'IPB2'].
270
+ """
271
+ # Regex that captures both the macro name and the value it's compared against
272
+ _MACRO_VARIANT_RE = re.compile(
273
+ r"^\s*#\s*(?:el)?if\s*\(\s*(\w+)\s*==\s*(\w+)\s*\)"
274
+ )
275
+
276
+ # Group by macro name → list of (full_value, short_name)
277
+ macro_groups: dict[str, list[str]] = {}
278
+ for line in text.splitlines():
279
+ m = _MACRO_VARIANT_RE.match(line)
280
+ if m:
281
+ macro_name = m.group(1) # e.g. RBFS_PscMotorParameter
282
+ full_value = m.group(2) # e.g. RBFS_PscMotorParameter_DPB
283
+ short = full_value.rsplit("_", 1)[-1] if "_" in full_value else full_value
284
+ if macro_name not in macro_groups:
285
+ macro_groups[macro_name] = []
286
+ if short not in macro_groups[macro_name]:
287
+ macro_groups[macro_name].append(short)
288
+
289
+ if not macro_groups:
290
+ return []
291
+
292
+ # Return variants from the chain with the most alternatives (the product switch)
293
+ primary_chain = max(macro_groups.values(), key=len)
294
+ return primary_chain
295
+
296
+
297
+ def _find_variant_block(text: str, variant: str) -> str | None:
298
+ """Find the code block for a specific preprocessor variant.
299
+
300
+ Given variant="DPB", finds the block after #elif (...== RBFS_PscMotorParameter_DPB)
301
+ until the next #elif or #endif.
302
+ """
303
+ lines = text.splitlines()
304
+ in_target_block = False
305
+ block_lines: list[str] = []
306
+
307
+ for line in lines:
308
+ if in_target_block:
309
+ # Check if we've hit the next #elif or #endif
310
+ if _PREPROC_ELIF_RE.match(line) or _PREPROC_ENDIF_RE.match(line):
311
+ break
312
+ block_lines.append(line)
313
+ else:
314
+ # Check if this is our target #if/#elif
315
+ m = _PREPROC_IF_RE.match(line)
316
+ if m:
317
+ block_name = m.group(1)
318
+ # Match against the variant name (e.g., "DPB" matches "RBFS_PscMotorParameter_DPB")
319
+ if variant.upper() in block_name.upper():
320
+ in_target_block = True
321
+
322
+ return "\n".join(block_lines) if block_lines else None
323
+
324
+
325
+ def _find_table_block(text: str, table_var: str | None = None) -> str | None:
326
+ """Find the struct array declaration block.
327
+
328
+ If table_var is given, look for that specific variable name.
329
+ Otherwise find the first static const array declaration.
330
+ """
331
+ lines = text.splitlines()
332
+ in_table = False
333
+ brace_depth = 0
334
+ block_lines: list[str] = []
335
+
336
+ for line in lines:
337
+ if not in_table:
338
+ if table_var:
339
+ if table_var in line and "[" in line:
340
+ in_table = True
341
+ else:
342
+ m = _TABLE_DECL_RE.match(line)
343
+ if m:
344
+ in_table = True
345
+
346
+ if in_table:
347
+ # Find the opening brace
348
+ if "{" in line:
349
+ brace_depth += line.count("{") - line.count("}")
350
+ block_lines.append(line)
351
+ continue
352
+
353
+ if in_table:
354
+ block_lines.append(line)
355
+ brace_depth += line.count("{") - line.count("}")
356
+ if brace_depth <= 0:
357
+ break
358
+
359
+ return "\n".join(block_lines) if block_lines else None
360
+
361
+
362
+ @register("c_struct_table")
363
+ def parse_c_struct_table(
364
+ filepath: str | Path,
365
+ fields: list[str] | None = None,
366
+ variant: str | None = None,
367
+ table_var: str | None = None,
368
+ **kwargs,
369
+ ) -> list[Constant]:
370
+ """Parse constants from a C struct array initializer table.
371
+
372
+ Args:
373
+ filepath: Path to the .c/.h file.
374
+ fields: Ordered list of field names matching the struct layout.
375
+ If not provided, auto-detected from column header comments,
376
+ or falls back to field_0, field_1, etc.
377
+ variant: Preprocessor variant to extract (e.g., "DPB", "EMB", "BWA").
378
+ Use "all" to extract ALL variants (for multi-sheet output).
379
+ Matches against #if/#elif conditions.
380
+ If the file has #if blocks and no variant is given, uses the
381
+ first variant found.
382
+ table_var: Name of the struct array variable to find.
383
+ If not provided, auto-detected from the first
384
+ `static const ... NAME[...] = {` declaration.
385
+
386
+ Returns:
387
+ List of Constant objects with names like "RowLabel__FieldName".
388
+ When variant="all", each Constant has metadata["variant"] set.
389
+ """
390
+ filepath = Path(filepath)
391
+ if not filepath.exists():
392
+ raise FileNotFoundError(f"C struct table file not found: {filepath}")
393
+
394
+ text = filepath.read_text(encoding="utf-8")
395
+
396
+ # --- Auto-detection ---
397
+ # Auto-detect table_var if not provided
398
+ if not table_var:
399
+ table_var = _auto_detect_table_var(text)
400
+
401
+ # Handle "all" variant mode — parse every variant
402
+ if variant and variant.lower() == "all":
403
+ return _parse_all_variants(text, table_var, fields)
404
+
405
+ # Auto-detect variant if file has #if blocks and none specified
406
+ if not variant:
407
+ available_variants = _auto_detect_variants(text)
408
+ if available_variants:
409
+ variant = available_variants[0] # Default to first variant
410
+
411
+ # Strategy: First find the table block, THEN find the variant within it.
412
+ # This handles files where #if/#elif blocks are INSIDE the array declaration.
413
+ working_text = text
414
+
415
+ # Step 1: Find the table block (struct array declaration)
416
+ if table_var:
417
+ table_block = _find_table_block(working_text, table_var)
418
+ if table_block:
419
+ working_text = table_block
420
+
421
+ # Step 2: Within the table (or full text), find the variant block
422
+ if variant:
423
+ block = _find_variant_block(working_text, variant)
424
+ if block is None:
425
+ # Fallback: try searching in full text (variant block wraps the table)
426
+ block = _find_variant_block(text, variant)
427
+ if block is None:
428
+ raise ValueError(
429
+ f"Variant '{variant}' not found in {filepath}. "
430
+ f"Available variants: {_auto_detect_variants(text)}"
431
+ )
432
+ working_text = block
433
+
434
+ # If we still haven't narrowed it down, try finding table in the variant block
435
+ if table_var and variant:
436
+ inner_table = _find_table_block(working_text, table_var)
437
+ if inner_table:
438
+ working_text = inner_table
439
+
440
+ # Auto-detect fields from header comment (if not provided)
441
+ if not fields:
442
+ fields = _auto_detect_fields(working_text)
443
+ # If not found in variant block, try full table block or text
444
+ if not fields and table_var:
445
+ table_block = _find_table_block(text, table_var)
446
+ if table_block:
447
+ fields = _auto_detect_fields(table_block)
448
+ if not fields:
449
+ fields = _auto_detect_fields(text)
450
+
451
+ constants: list[Constant] = _parse_rows(working_text, fields)
452
+
453
+ # Tag each constant with the variant name
454
+ if variant:
455
+ for c in constants:
456
+ c.metadata["variant"] = variant
457
+
458
+ return constants
459
+
460
+
461
+ def _parse_all_variants(
462
+ text: str, table_var: str | None, fields: list[str] | None
463
+ ) -> list[Constant]:
464
+ """Parse ALL variants from the file. Each constant gets metadata['variant']."""
465
+ available_variants = _auto_detect_variants(text)
466
+ if not available_variants:
467
+ # No variants — just parse the whole file
468
+ working_text = text
469
+ if table_var:
470
+ table_block = _find_table_block(working_text, table_var)
471
+ if table_block:
472
+ working_text = table_block
473
+ if not fields:
474
+ fields = _auto_detect_fields(working_text) or _auto_detect_fields(text)
475
+ return _parse_rows(working_text, fields)
476
+
477
+ # Auto-detect fields once (from first variant or table header)
478
+ if not fields:
479
+ # Try finding in table block
480
+ if table_var:
481
+ table_block = _find_table_block(text, table_var)
482
+ if table_block:
483
+ fields = _auto_detect_fields(table_block)
484
+ if not fields:
485
+ fields = _auto_detect_fields(text)
486
+
487
+ all_constants: list[Constant] = []
488
+ for v in available_variants:
489
+ # Find variant block within the table
490
+ working_text = text
491
+ if table_var:
492
+ table_block = _find_table_block(working_text, table_var)
493
+ if table_block:
494
+ working_text = table_block
495
+
496
+ block = _find_variant_block(working_text, v)
497
+ if block is None:
498
+ block = _find_variant_block(text, v)
499
+ if block is None:
500
+ continue
501
+
502
+ constants = _parse_rows(block, fields)
503
+ # Tag each with variant
504
+ for c in constants:
505
+ c.metadata["variant"] = v
506
+ all_constants.extend(constants)
507
+
508
+ return all_constants
509
+
510
+
511
+ def _parse_rows(text: str, fields: list[str] | None) -> list[Constant]:
512
+ """Parse struct initializer rows from a block of text."""
513
+ constants: list[Constant] = []
514
+ row_counter = 0
515
+
516
+ for line in text.splitlines():
517
+ # Skip preprocessor directives, blank lines, and pure comments
518
+ stripped = line.strip()
519
+ if not stripped or stripped.startswith("#") or stripped.startswith("//"):
520
+ continue
521
+ if stripped.startswith("/*") and stripped.endswith("*/") and "{{" not in stripped:
522
+ continue
523
+ # Skip lines that are just opening/closing braces for the array
524
+ if stripped in ("{", "}", "};", "{}", "},"):
525
+ continue
526
+ # Skip table declaration line
527
+ if _TABLE_DECL_RE.match(stripped):
528
+ continue
529
+ # Skip #define lines (SPEEDPARAM macros etc.)
530
+ if stripped.startswith("#define"):
531
+ continue
532
+
533
+ row_data = _extract_row_data(line)
534
+ if row_data is None:
535
+ continue
536
+
537
+ label, brace_content = row_data
538
+
539
+ # Flatten all values from nested braces
540
+ flat_values = _flatten_values(brace_content)
541
+
542
+ if not flat_values:
543
+ continue
544
+
545
+ # Sanitize label for use as name prefix
546
+ name_prefix = _sanitize_label(label) if label else f"row_{row_counter}"
547
+ row_counter += 1
548
+
549
+ # Map values to field names
550
+ for i, raw_val in enumerate(flat_values):
551
+ field_name = fields[i] if fields and i < len(fields) else f"field_{i}"
552
+ const_name = f"{name_prefix}__{field_name}"
553
+
554
+ # Determine if value is a literal number or an expression
555
+ raw_val_trimmed = raw_val.strip()
556
+
557
+ # Handle boolean-like values (must check before macro detection)
558
+ if raw_val_trimmed.upper() in ("TRUE", "FALSE"):
559
+ constants.append(Constant(
560
+ name=const_name,
561
+ value=raw_val_trimmed,
562
+ unit="",
563
+ description=f"Row: {label}, Field: {field_name}",
564
+ metadata={"row_label": label, "field": field_name, "field_index": i,
565
+ "is_expression": False},
566
+ ))
567
+ continue
568
+
569
+ # Skip macro references like SPEEDPARAM, SPEEDPARAM_SIZEM1
570
+ if re.match(r"^[A-Z_][A-Z0-9_]*$", raw_val_trimmed) and not raw_val_trimmed.isdigit():
571
+ constants.append(Constant(
572
+ name=const_name,
573
+ value=raw_val_trimmed,
574
+ unit="",
575
+ description=f"Row: {label}, Field: {field_name} [macro]",
576
+ metadata={"row_label": label, "field": field_name, "field_index": i,
577
+ "is_expression": True, "raw": raw_val_trimmed},
578
+ ))
579
+ continue
580
+
581
+ if _is_numeric_literal(raw_val_trimmed):
582
+ value = _parse_numeric(raw_val_trimmed)
583
+ constants.append(Constant(
584
+ name=const_name,
585
+ value=value,
586
+ unit="",
587
+ description=f"Row: {label}, Field: {field_name}",
588
+ metadata={"row_label": label, "field": field_name, "field_index": i,
589
+ "is_expression": False, "raw": raw_val_trimmed},
590
+ ))
591
+ else:
592
+ # Expression-based value — store as string
593
+ constants.append(Constant(
594
+ name=const_name,
595
+ value=raw_val_trimmed,
596
+ unit="",
597
+ description=f"Row: {label}, Field: {field_name} [expression]",
598
+ metadata={"row_label": label, "field": field_name, "field_index": i,
599
+ "is_expression": True, "raw": raw_val_trimmed},
600
+ ))
601
+
602
+ return constants
603
+
604
+
605
+ def _sanitize_label(label: str) -> str:
606
+ """Convert a row label to a valid C-style identifier prefix.
607
+
608
+ "BWA NI S4" → "BWA_NI_S4"
609
+ "EMB 12V 35KN" → "EMB_12V_35KN"
610
+ """
611
+ # Replace non-alphanumeric chars with underscore
612
+ sanitized = re.sub(r"[^a-zA-Z0-9]+", "_", label.strip())
613
+ # Remove leading/trailing underscores
614
+ sanitized = sanitized.strip("_")
615
+ return sanitized
@@ -38,6 +38,7 @@ def list_renderers() -> list[str]:
38
38
  # Import all renderer modules to trigger registration
39
39
  from consync.renderers import ( # noqa: E402, F401
40
40
  c_header,
41
+ c_struct_table,
41
42
  csharp,
42
43
  csv_renderer,
43
44
  python_const,