1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
//! Extension trait and supporting types for alef.
use crate::core::backend::GeneratedFile;
use crate::core::config::Language;
use crate::core::ir::ApiSurface;
use crate::core::template_env::TemplateEnv;
use anyhow::{Context as _, Result};
use std::any::Any;
use std::path::Path;
/// Opaque per-extension configuration.
pub struct ExtensionConfig {
pub inner: Option<Box<dyn Any + Send + Sync>>,
pub raw: Option<toml::Value>,
}
impl ExtensionConfig {
/// Construct an empty config.
pub fn empty() -> Self {
Self { inner: None, raw: None }
}
/// Construct from a raw TOML value.
pub fn from_raw(raw: toml::Value) -> Self {
Self {
inner: None,
raw: Some(raw),
}
}
/// Construct with typed inner config and raw TOML value.
pub fn with_typed<T: Any + Send + Sync>(typed: T, raw: Option<toml::Value>) -> Self {
Self {
inner: Some(Box::new(typed)),
raw,
}
}
/// Downcast the typed inner config to `T`.
pub fn downcast<T: Any>(&self) -> Option<&T> {
self.inner.as_ref().and_then(|b| b.downcast_ref::<T>())
}
}
impl std::fmt::Debug for ExtensionConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExtensionConfig")
.field("has_inner", &self.inner.is_some())
.field("has_raw", &self.raw.is_some())
.finish()
}
}
/// Extension point for alef's code generation pipeline.
///
/// All three methods have default no-op implementations; override only what
/// you need.
pub trait Extension: Send + Sync {
/// Stable, unique slug for this extension. Used as the TOML config key.
fn name(&self) -> &str;
/// Parse this extension's TOML section.
///
/// Default: returns [`ExtensionConfig::empty`].
fn parse_config(&self, raw: Option<&toml::Value>) -> Result<ExtensionConfig> {
let _ = raw;
Ok(ExtensionConfig::empty())
}
/// Augment the API surface after extraction and before generation.
///
/// Default: no-op.
fn augment_surface(&self, _api: &mut ApiSurface, _cfg: &ExtensionConfig) -> Result<()> {
Ok(())
}
/// Emit extra files for one language.
///
/// Default: returns an empty list.
fn emit_for_language(
&self,
_api: &ApiSurface,
_cfg: &ExtensionConfig,
_language: Language,
_env: &TemplateEnv,
) -> Result<Vec<GeneratedFile>> {
Ok(vec![])
}
/// Transform the complete file list for one language after backend generation
/// and after [`Extension::emit_for_language`] has appended any extension-owned files.
///
/// Use this hook to rewrite, filter, or replace backend-generated files —
/// for example to patch a generated type definition or inject content into
/// an existing file. The `files` slice includes both backend-generated files
/// and any files previously appended by other extensions for this language.
///
/// Default: no-op.
fn transform_emitted_files(
&self,
_api: &ApiSurface,
_cfg: &ExtensionConfig,
_language: Language,
_files: &mut Vec<GeneratedFile>,
_env: &TemplateEnv,
) -> Result<()> {
Ok(())
}
/// Transform the scaffold-pass file list for one language before it is written.
///
/// Runs during scaffold generation, once per resolved language, over the
/// complete set of scaffold files (package manifests, entry wrappers, build
/// files). Unlike [`Extension::transform_emitted_files`] — which only sees
/// `generate_bindings` output — this hook can rewrite files the scaffold stage
/// owns, such as a package `main`/entry wrapper or a dependency manifest
/// (`package.json`, `composer.json`, `pubspec.yaml`). Use it to wire an
/// extension-emitted entry module into the package's real resolution path or
/// to add runtime dependencies. The `files` slice contains every scaffold file
/// for the current generation, so match by path; rewrites must be idempotent.
///
/// Like the other transform hooks this does not feed the generation-inputs
/// hash, so additions never affect `alef verify`.
///
/// Default: no-op.
fn transform_scaffold_files(
&self,
_api: &ApiSurface,
_cfg: &ExtensionConfig,
_language: Language,
_files: &mut Vec<GeneratedFile>,
_env: &TemplateEnv,
) -> Result<()> {
Ok(())
}
/// Contribute raw lines to the package's public-API init file for one
/// language (e.g. Python's `__init__.py`).
///
/// Called during public-API generation, once per resolved language, after
/// the backend produced the package init file. Returned lines are appended
/// verbatim to that file with exact-line de-duplication, so the extension
/// owns all language semantics and idempotency (import statements, `__all__`
/// merges, etc.). Core only appends; it never rewrites existing lines.
///
/// This hook does not feed alef's generation-inputs hash, so additions never
/// affect `alef verify`.
///
/// Default: returns an empty list.
fn public_api_additions(
&self,
_api: &ApiSurface,
_cfg: &ExtensionConfig,
_language: Language,
) -> Result<Vec<String>> {
Ok(Vec::new())
}
/// Emit e2e test files for one language.
///
/// Called during `alef e2e generate` after the built-in generators run,
/// once per resolved language. Extensions use this to own domain-specific
/// e2e generation (e.g. HTTP integration tests) without modifying alef core.
/// Returned files are merged into the same collection alef writes and
/// orphan-sweeps.
///
/// Default: returns an empty list.
fn emit_e2e(
&self,
_groups: &[crate::e2e::fixture::FixtureGroup],
_e2e_config: &crate::core::config::E2eConfig,
_config: &crate::core::config::ResolvedCrateConfig,
_language: &str,
_type_defs: &[crate::core::ir::TypeDef],
_enums: &[crate::core::ir::EnumDef],
) -> Result<Vec<GeneratedFile>> {
Ok(vec![])
}
/// Render the source body for one fixture-driven documentation snippet.
///
/// Core owns path selection, frontmatter, and Markdown wrapping. Extensions
/// return `None` for fixtures they do not own and a non-empty target-language
/// body for fixtures they do own.
fn render_e2e_snippet(
&self,
_fixture: &crate::e2e::fixture::Fixture,
_e2e_config: &crate::core::config::E2eConfig,
_config: &crate::core::config::ResolvedCrateConfig,
_language: &str,
_type_defs: &[crate::core::ir::TypeDef],
_enums: &[crate::core::ir::EnumDef],
) -> Result<Option<String>> {
Ok(None)
}
}
/// Read the `[extensions.<name>]` section from `alef.toml` at `config_path`.
///
/// Returns `None` when the file has no `[extensions]` table or no key matching
/// `name`. Returns the raw [`toml::Value`] of that sub-table when present.
///
/// Called by the extract and generation pipeline stages so every extension
/// receives its own TOML section rather than `None`. Backwards compatible:
/// consumers that have no `[extensions.<name>]` block continue to receive
/// `None` in [`Extension::parse_config`].
pub fn read_extension_config(config_path: &Path, name: &str) -> Result<Option<toml::Value>> {
let content = std::fs::read_to_string(config_path).with_context(|| {
format!(
"failed to read alef.toml for extension config ({})",
config_path.display()
)
})?;
let doc: toml::Value = toml::from_str(&content).with_context(|| {
format!(
"failed to parse alef.toml for extension config ({})",
config_path.display()
)
})?;
let raw = doc.get("extensions").and_then(|ext| ext.get(name)).cloned();
Ok(raw)
}
#[cfg(test)]
mod tests {
use super::*;
struct NoopExtension;
impl Extension for NoopExtension {
fn name(&self) -> &str {
"noop"
}
}
#[test]
fn extension_config_empty_round_trip() {
let cfg = ExtensionConfig::empty();
assert!(cfg.inner.is_none());
assert!(cfg.raw.is_none());
assert!(cfg.downcast::<u32>().is_none());
}
#[test]
fn extension_config_with_typed_downcasts() {
let cfg = ExtensionConfig::with_typed(42u32, None);
assert_eq!(cfg.downcast::<u32>(), Some(&42u32));
assert!(cfg.downcast::<String>().is_none());
}
#[test]
fn noop_extension_parse_config_returns_empty_for_none() {
let ext = NoopExtension;
let cfg = ext.parse_config(None).expect("parse_config failed");
assert!(cfg.inner.is_none());
assert!(cfg.raw.is_none());
}
#[test]
fn noop_extension_parse_config_accepts_raw_value() {
let ext = NoopExtension;
let raw = toml::Value::String("some_value".to_string());
let cfg = ext.parse_config(Some(&raw)).expect("parse_config failed");
assert!(cfg.inner.is_none());
}
#[test]
fn read_extension_config_missing_section_returns_none() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("alef.toml");
std::fs::write(&path, "[workspace]\nalef_version = \"0.1.0\"\n").unwrap();
let result = read_extension_config(&path, "my_ext").expect("read failed");
assert!(result.is_none());
}
#[test]
fn read_extension_config_present_section_returns_value() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("alef.toml");
std::fs::write(
&path,
"[workspace]\nalef_version = \"0.1.0\"\n\n[extensions.my_ext]\nfoo = \"bar\"\n",
)
.unwrap();
let result = read_extension_config(&path, "my_ext").expect("read failed");
let val = result.expect("expected Some");
assert_eq!(val.get("foo").and_then(|v| v.as_str()), Some("bar"));
}
#[test]
fn read_extension_config_unknown_extension_returns_none() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("alef.toml");
std::fs::write(
&path,
"[workspace]\nalef_version = \"0.1.0\"\n\n[extensions.other_ext]\nfoo = \"bar\"\n",
)
.unwrap();
let result = read_extension_config(&path, "my_ext").expect("read failed");
assert!(result.is_none());
}
#[test]
fn transform_emitted_files_default_is_noop() {
use crate::core::backend::GeneratedFile;
let ext = NoopExtension;
let api = ApiSurface::default();
let cfg = ExtensionConfig::empty();
let env = crate::core::template_env::TemplateEnv::new();
let mut files = vec![GeneratedFile {
path: std::path::PathBuf::from("test.rs"),
content: "fn main() {}".to_string(),
generated_header: false,
}];
ext.transform_emitted_files(&api, &cfg, Language::Python, &mut files, &env)
.expect("transform failed");
assert_eq!(files.len(), 1);
assert_eq!(files[0].content, "fn main() {}");
}
#[test]
fn transform_scaffold_files_default_is_noop() {
use crate::core::backend::GeneratedFile;
let ext = NoopExtension;
let api = ApiSurface::default();
let cfg = ExtensionConfig::empty();
let env = crate::core::template_env::TemplateEnv::new();
let mut files = vec![GeneratedFile {
path: std::path::PathBuf::from("package.json"),
content: "{}".to_string(),
generated_header: false,
}];
ext.transform_scaffold_files(&api, &cfg, Language::Node, &mut files, &env)
.expect("transform failed");
assert_eq!(files.len(), 1);
assert_eq!(files[0].content, "{}");
}
}