Skip to content

Commit 422703d

Browse files
committed
Refactor bindgen CLI and codegen with config-driven output path
Remove the --out CLI argument and derive the output directory from config.output_dir instead. Serialize IR types using explicit conditional checks instead of a grouped dict to preserve ordering. Extract post-generation formatters into their own module. Consolidate symbol resolution logic into a new SymbolResolver class and move type mapping methods onto it. Strip "src/" prefix from cursor paths during normalization.
1 parent 63ec471 commit 422703d

10 files changed

Lines changed: 843 additions & 813 deletions

File tree

tools/bindgen/cli.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
def main(argv=None) -> int:
1313
parser = argparse.ArgumentParser(prog="bindgen")
1414
parser.add_argument("--config", required=True, help="Path to config.yaml")
15-
parser.add_argument("--out", required=True, help="Output directory")
1615
parser.add_argument("--ir", help="Load IR from existing JSON file (skips parsing)")
1716
parser.add_argument("--dump-ir", help="Write IR JSON to path")
1817
parser.add_argument(
@@ -38,7 +37,7 @@ def main(argv=None) -> int:
3837
if args.dump_context:
3938
dump_context_json(module, cfg.mapping, Path(args.dump_context))
4039

41-
out_dir = Path(args.out)
40+
out_dir = Path(cfg.output_dir)
4241
out_dir.mkdir(parents=True, exist_ok=True)
4342
generate_bindings(module, cfg, out_dir, config_path)
4443

tools/bindgen/codegen/context.py

Lines changed: 238 additions & 162 deletions
Large diffs are not rendered by default.

tools/bindgen/codegen/generator.py

Lines changed: 76 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from __future__ import annotations
22

3-
import subprocess
43
from pathlib import Path
54
from typing import Any, Dict, List
65

76
from ..config import BindgenConfig
87
from ..ir.model import IRModule
9-
from .context import MappedFile, build_context
8+
from .context import MappedFile, TemplateContext, build_context
109
from .naming import NameTransformer
10+
from .post_formatters import run_post_formatters
1111

1212

1313
def _load_jinja():
@@ -20,123 +20,23 @@ def _load_jinja():
2020
return Environment, FileSystemLoader
2121

2222

23-
def _normalize_formatters(mapping_options: Dict[str, Any]) -> List[Dict[str, Any]]:
24-
"""Normalize formatter configs from mapping.options.formatters."""
25-
raw = mapping_options.get("formatters", [])
26-
if not isinstance(raw, list):
27-
print("[bindgen] warning: mapping.options.formatters must be a list, ignoring")
28-
return []
29-
30-
normalized: List[Dict[str, Any]] = []
31-
for index, item in enumerate(raw):
32-
if not isinstance(item, dict):
33-
print(f"[bindgen] warning: formatter[{index}] must be an object, skipping")
34-
continue
35-
36-
enabled = bool(item.get("enabled", True))
37-
if not enabled:
38-
continue
39-
40-
cmd = item.get("cmd")
41-
if not isinstance(cmd, list) or not cmd or not all(
42-
isinstance(token, str) and token for token in cmd
43-
):
44-
print(
45-
f"[bindgen] warning: formatter[{index}].cmd must be a non-empty string array, skipping"
46-
)
47-
continue
48-
49-
normalized.append(
50-
{
51-
"name": str(item.get("name", f"formatter[{index}]")),
52-
"cmd": cmd,
53-
"continue_on_error": bool(item.get("continue_on_error", True)),
54-
}
55-
)
56-
57-
return normalized
58-
59-
60-
def _expand_tokens(cmd: List[str], placeholders: Dict[str, str]) -> List[str]:
61-
expanded: List[str] = []
62-
for token in cmd:
63-
value = token
64-
for key, replacement in placeholders.items():
65-
value = value.replace(key, replacement)
66-
expanded.append(value)
67-
return expanded
68-
69-
7023
def _run_post_formatters(cfg: BindgenConfig, out_dir: Path, config_path: Path) -> None:
71-
"""Run post-generation formatter commands."""
72-
formatters = _normalize_formatters(cfg.mapping.options or {})
73-
if not formatters:
74-
return
75-
76-
placeholders = {
77-
"{out_dir}": str(out_dir.resolve()),
78-
"{config_dir}": str(config_path.resolve().parent),
79-
"{project_dir}": str(Path.cwd().resolve()),
80-
}
81-
run_cwd = str(config_path.resolve().parent)
82-
83-
for formatter in formatters:
84-
name = formatter["name"]
85-
command = _expand_tokens(formatter["cmd"], placeholders)
86-
continue_on_error = formatter["continue_on_error"]
87-
88-
print(f"[bindgen] formatter {name}: {' '.join(command)}")
89-
try:
90-
result = subprocess.run(
91-
command,
92-
cwd=run_cwd,
93-
capture_output=True,
94-
text=True,
95-
)
96-
except FileNotFoundError as exc:
97-
message = (
98-
f"[bindgen] formatter {name} failed: command not found: {command[0]}"
99-
)
100-
if continue_on_error:
101-
print(f"{message} (continuing)")
102-
continue
103-
raise RuntimeError(message) from exc
104-
except OSError as exc:
105-
message = f"[bindgen] formatter {name} failed: {exc}"
106-
if continue_on_error:
107-
print(f"{message} (continuing)")
108-
continue
109-
raise RuntimeError(message) from exc
110-
111-
if result.stdout.strip():
112-
print(result.stdout.rstrip())
113-
114-
if result.returncode != 0:
115-
stderr = result.stderr.strip()
116-
suffix = f": {stderr}" if stderr else ""
117-
message = (
118-
f"[bindgen] formatter {name} exited with {result.returncode}{suffix}"
119-
)
120-
if continue_on_error:
121-
print(f"{message} (continuing)")
122-
continue
123-
raise RuntimeError(message)
24+
"""Run post-generation formatter commands. Delegates to post_formatters module."""
25+
run_post_formatters(cfg, out_dir, config_path)
12426

12527

12628
def _build_file_context(
12729
file_path: str,
12830
mapped_file: MappedFile,
129-
global_context: Dict[str, Any],
31+
ctx: "TemplateContext",
13032
) -> Dict[str, Any]:
131-
"""Build context for a single file template."""
33+
"""Build context dict for a single file Jinja2 template."""
13234
from pathlib import PurePosixPath
13335

13436
def _iter_callable_bridges():
135-
for fn in mapped_file.functions:
136-
yield fn
37+
yield from mapped_file.functions
13738
for cls in mapped_file.classes:
138-
for method in cls.methods:
139-
yield method
39+
yield from cls.methods
14040

14141
callables = list(_iter_callable_bridges())
14242
uses_ui = any(
@@ -157,21 +57,21 @@ def _iter_callable_bridges():
15757
p = PurePosixPath(file_path)
15858

15959
return {
160-
# Global context
161-
"module": global_context["module"],
162-
"files": global_context["files"],
163-
"file_paths": global_context["file_paths"],
164-
"mapping": global_context["mapping"],
165-
"namer": global_context["namer"],
166-
"raw": global_context["raw"],
60+
# Global context (flattened for Jinja2)
61+
"module": ctx.module,
62+
"files": ctx.files,
63+
"file_paths": ctx.file_paths,
64+
"mapping": ctx.mapping,
65+
"namer": ctx.namer,
66+
"raw": ctx.raw,
16767
# All items (for cross-referencing)
168-
"all_items": global_context["items"],
169-
"all_types": global_context["types"],
170-
"all_enums": global_context["enums"],
171-
"all_functions": global_context["functions"],
172-
"all_classes": global_context["classes"],
173-
"all_constants": global_context["constants"],
174-
"all_aliases": global_context["aliases"],
68+
"all_items": ctx.items,
69+
"all_types": ctx.types,
70+
"all_enums": ctx.enums,
71+
"all_functions": ctx.functions,
72+
"all_classes": ctx.classes,
73+
"all_constants": ctx.constants,
74+
"all_aliases": ctx.aliases,
17575
# Current file items (mapped)
17676
"file": mapped_file,
17777
"file_path": file_path,
@@ -206,20 +106,32 @@ def _compute_output_path(
206106
template_name: str,
207107
out_dir: Path,
208108
namer: NameTransformer,
109+
strip_prefix: str = "",
209110
) -> Path:
210111
"""
211112
Compute output path from IR file path and template name.
212113
114+
If *strip_prefix* is given, the first occurrence of it in the IR file
115+
path is removed before computing the output sub-directory. This allows
116+
stripping an outer prefix such as ``Sources/CNativeAPI/src`` so that
117+
the output lands directly under *out_dir*.
118+
213119
Rules:
214-
- IR path: src/foundation/geometry.h
215-
- Template: file/dart.j2 -> out/src/foundation/geometry.dart
216-
- Template: file/rs.j2 -> out/src/foundation/geometry.rs
120+
- IR path: src/foundation/geometry.h, strip_prefix="src/"
121+
-> out/foundation/geometry.dart
122+
- Template: file/dart.j2 -> out/geometry.dart (stem "dart")
217123
218124
File name is transformed according to naming config.
219125
"""
220126
from pathlib import PurePosixPath
221127

222-
ir_path = PurePosixPath(ir_file_path)
128+
adjusted = ir_file_path
129+
if strip_prefix:
130+
idx = adjusted.find(strip_prefix)
131+
if idx >= 0:
132+
adjusted = adjusted[idx + len(strip_prefix):]
133+
134+
ir_path = PurePosixPath(adjusted)
223135
# Template stem becomes the new extension
224136
template_stem = Path(template_name).stem # e.g., "dart" from "dart.j2"
225137

@@ -262,31 +174,37 @@ def generate_bindings(
262174
_register_filters(env)
263175

264176
# Build context with preprocessed (mapped) data
265-
global_context = build_context(module, cfg.mapping)
266-
namer = global_context["namer"]
177+
ctx = build_context(module, cfg.mapping)
178+
namer = ctx.namer
267179

268180
# 1. Render global templates (template/*.j2)
269181
for template_path in config_template_root.glob("*.j2"):
270182
template = env.get_template(template_path.name)
271183
output_name = template_path.stem
272-
rendered = template.render(**global_context)
184+
rendered = template.render(**vars(ctx))
273185
(out_dir / output_name).write_text(rendered + "\n", encoding="utf-8")
274186

275187
# 2. Render per-file templates (template/file/*.j2)
276188
file_template_dir = config_template_root / "file"
277189
if file_template_dir.exists():
278190
file_templates = list(file_template_dir.glob("*.j2"))
279191

280-
for ir_file_path in global_context["file_paths"]:
281-
mapped_file = global_context["files"][ir_file_path]
192+
for ir_file_path in ctx.file_paths:
193+
mapped_file = ctx.files[ir_file_path]
282194
file_context = _build_file_context(
283-
ir_file_path, mapped_file, global_context
195+
ir_file_path, mapped_file, ctx
196+
)
197+
198+
# Resolve output path prefix to strip from IR file paths
199+
output_strip_prefix = (
200+
(cfg.mapping.options or {}).get("output_path_prefix", "")
284201
)
285202

286203
for template_path in file_templates:
287204
template = env.get_template(f"file/{template_path.name}")
288205
output_path = _compute_output_path(
289-
ir_file_path, template_path.name, out_dir, namer
206+
ir_file_path, template_path.name, out_dir, namer,
207+
strip_prefix=output_strip_prefix,
290208
)
291209

292210
# Create output directory if needed
@@ -300,62 +218,45 @@ def generate_bindings(
300218

301219

302220
def _register_filters(env) -> None:
303-
"""Register custom Jinja2 filters for naming conventions."""
304-
import re
305-
306-
def snake_case(s: str) -> str:
307-
"""Convert to snake_case."""
308-
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s)
309-
s = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s)
310-
return s.lower().replace("-", "_")
311-
312-
def camel_case(s: str) -> str:
313-
"""Convert to camelCase."""
314-
parts = re.split(r"[_\-\s]+", s)
315-
if not parts:
316-
return s
317-
return parts[0].lower() + "".join(p.title() for p in parts[1:])
318-
319-
def pascal_case(s: str) -> str:
320-
"""Convert to PascalCase."""
321-
parts = re.split(r"[_\-\s]+", s)
322-
return "".join(p.title() for p in parts)
323-
324-
def screaming_snake_case(s: str) -> str:
325-
"""Convert to SCREAMING_SNAKE_CASE."""
326-
return snake_case(s).upper()
327-
328-
def kebab_case(s: str) -> str:
329-
"""Convert to kebab-case."""
330-
return snake_case(s).replace("_", "-")
331-
332-
def strip_prefix(s: str, prefix: str) -> str:
221+
"""Register custom Jinja2 filters for naming conventions.
222+
223+
Delegates to naming.py to avoid duplicating naming logic.
224+
"""
225+
from .naming import (
226+
to_snake_case,
227+
to_camel_case,
228+
to_pascal_case,
229+
to_screaming_snake_case,
230+
to_kebab_case,
231+
)
232+
233+
def _strip_prefix(s: str, prefix: str) -> str:
333234
"""Strip prefix from string."""
334235
if s.startswith(prefix):
335236
return s[len(prefix) :]
336237
return s
337238

338-
def strip_suffix(s: str, suffix: str) -> str:
239+
def _strip_suffix(s: str, suffix: str) -> str:
339240
"""Strip suffix from string."""
340241
if s.endswith(suffix):
341242
return s[: -len(suffix)]
342243
return s
343244

344-
def add_prefix(s: str, prefix: str) -> str:
245+
def _add_prefix(s: str, prefix: str) -> str:
345246
"""Add prefix to string."""
346247
return prefix + s
347248

348-
def add_suffix(s: str, suffix: str) -> str:
249+
def _add_suffix(s: str, suffix: str) -> str:
349250
"""Add suffix to string."""
350251
return s + suffix
351252

352253
# Register naming filters
353-
env.filters["snake_case"] = snake_case
354-
env.filters["camel_case"] = camel_case
355-
env.filters["pascal_case"] = pascal_case
356-
env.filters["screaming_snake_case"] = screaming_snake_case
357-
env.filters["kebab_case"] = kebab_case
358-
env.filters["strip_prefix"] = strip_prefix
359-
env.filters["strip_suffix"] = strip_suffix
360-
env.filters["add_prefix"] = add_prefix
361-
env.filters["add_suffix"] = add_suffix
254+
env.filters["snake_case"] = to_snake_case
255+
env.filters["camel_case"] = to_camel_case
256+
env.filters["pascal_case"] = to_pascal_case
257+
env.filters["screaming_snake_case"] = to_screaming_snake_case
258+
env.filters["kebab_case"] = to_kebab_case
259+
env.filters["strip_prefix"] = _strip_prefix
260+
env.filters["strip_suffix"] = _strip_suffix
261+
env.filters["add_prefix"] = _add_prefix
262+
env.filters["add_suffix"] = _add_suffix

0 commit comments

Comments
 (0)