ryo_executor/engine/source_pipeline.rs
1//! Source Pipeline - Type-Safe Generator → Dumper Architecture
2//!
3//! This module defines the type-safe pipeline for source code generation and output.
4//! It ensures that `RegistryGenerator` and file dumping are always used together,
5//! preventing the bug where mod declarations are missing due to bypassing the generator.
6//!
7//! # Architecture Overview
8//!
9//! ```text
10//! ┌─────────────────────────┐
11//! │ SourceGenerator │
12//! │ (trait) │
13//! └───────────┬─────────────┘
14//! │
15//! ┌───────────┴─────────────┐
16//! │ RegistryGenerator │
17//! │ (impl SourceGenerator)│
18//! │ - Module structure │
19//! │ - Empty mod handling │
20//! │ - mod decl generation │
21//! └───────────┬─────────────┘
22//! │ GeneratedWorkspace
23//! │ (crate-relative paths)
24//! ▼
25//! ┌─────────────────────────┐
26//! │ SourceDumper<G> │
27//! │ G: SourceGenerator │
28//! │ - Path conversion │
29//! │ - crate-relative → │
30//! │ WorkspaceFilePath │
31//! └───────────┬─────────────┘
32//! │ HashMap<WorkspaceFilePath, String>
33//! ▼
34//! ┌─────────────────────────┐
35//! │ sync_files_and_rebuild │
36//! │ - ctx update │
37//! │ - graph rebuild │
38//! └─────────────────────────┘
39//! ```
40//!
41//! # Design Rationale
42//!
43//! ## Problem: Decoupled Generator and Dumper
44//!
45//! Previously, `FileDumper` and `RegistryGenerator` were independent:
46//!
47//! - `FileDumper`: Dumps from `ast_registry` directly (no empty module handling)
48//! - `RegistryGenerator`: Generates with empty module handling (but wasn't used!)
49//!
50//! This caused `CreateMod` to fail because:
51//! 1. `CreateMod` registers module in `symbol_registry` but NOT in `ast_registry`
52//! 2. `FileDumper` only looks at `ast_registry`, missing the new module
53//! 3. Result: No `mod xxx;` declaration in parent file
54//!
55//! ## Solution: Type-Enforced Pipeline
56//!
57//! By making `SourceDumper` generic over `SourceGenerator`:
58//!
59//! ```rust,ignore
60//! // The ONLY way to get file output is through the pipeline
61//! let dumper = SourceDumper::new(RegistryGenerator::multi_file());
62//! let files = dumper.dump_all(ctx);
63//! ```
64//!
65//! This ensures:
66//! 1. Generator is always invoked (empty modules handled correctly)
67//! 2. Path conversion is always applied
68//! 3. Cannot accidentally bypass the generator
69//!
70//! # Responsibilities
71//!
72//! | Component | Responsibility | Input | Output |
73//! |-----------|----------------|-------|--------|
74//! | `SourceGenerator` | Module structure, source generation | ASTRegistry + SymbolRegistry | GeneratedWorkspace |
75//! | `SourceDumper<G>` | Path conversion, file mapping | GeneratedWorkspace + ctx | HashMap<WorkspaceFilePath, String> |
76//!
77//! # Empty Module Handling
78//!
79//! `RegistryGenerator` handles empty modules by:
80//!
81//! 1. Scanning `symbol_registry` for `SymbolKind::Mod` entries
82//! 2. For modules without child items in `ast_registry`, creating `PureMod { content: None }`
83//! 3. Adding these to parent module's items for `mod xxx;` declaration output
84//!
85//! This happens in `RegistryGenerator::generate()`, specifically in:
86//! - `collect Mod symbols from SymbolRegistry` phase
87//! - `add_mod_symbols_to_tree()` function
88
89use ryo_analysis::{ASTRegistry, AnalysisContext};
90use ryo_symbol::{SymbolRegistry, WorkspaceFilePath};
91use std::collections::HashMap;
92
93use super::registry_generator::{GeneratedWorkspace, RegistryGenerator};
94use ryo_source::pure::ToSynError;
95
96// ============================================================================
97// SourceGenerator Trait
98// ============================================================================
99
100/// Trait for source code generators.
101///
102/// Implementors transform `ASTRegistry + SymbolRegistry` into `GeneratedWorkspace`.
103/// The key responsibility is determining module structure and handling edge cases
104/// like empty modules.
105pub trait SourceGenerator {
106 /// Generate source files from registries.
107 ///
108 /// # Returns
109 ///
110 /// `GeneratedWorkspace` containing crate-relative paths (e.g., `"src/lib.rs"`).
111 /// The caller is responsible for converting these to workspace-relative paths.
112 fn generate(
113 &self,
114 ast_registry: &ASTRegistry,
115 symbol_registry: &SymbolRegistry,
116 ) -> Result<GeneratedWorkspace, ToSynError>;
117}
118
119impl SourceGenerator for RegistryGenerator {
120 fn generate(
121 &self,
122 ast_registry: &ASTRegistry,
123 symbol_registry: &SymbolRegistry,
124 ) -> Result<GeneratedWorkspace, ToSynError> {
125 self.generate(ast_registry, symbol_registry)
126 }
127}
128
129// ============================================================================
130// SourceDumper
131// ============================================================================
132
133/// Type-safe source dumper that enforces generator usage.
134///
135/// This struct ensures that source generation always goes through a `SourceGenerator`,
136/// preventing bugs where mod declarations are missing.
137///
138/// # Type Safety
139///
140/// By requiring a `SourceGenerator` at construction time, we guarantee that:
141/// - Empty modules are handled correctly
142/// - Module hierarchy is properly derived
143/// - `mod xxx;` declarations are generated
144///
145/// # Example
146///
147/// ```rust,ignore
148/// use ryo_executor::engine::{SourceDumper, RegistryGenerator};
149///
150/// // Create dumper with generator (type-enforced)
151/// let dumper = SourceDumper::new(RegistryGenerator::multi_file());
152///
153/// // Dump all files (generator is automatically invoked)
154/// let files = dumper.dump_all(&ctx);
155/// ```
156pub struct SourceDumper<G: SourceGenerator> {
157 generator: G,
158}
159
160impl<G: SourceGenerator> SourceDumper<G> {
161 /// Create a new SourceDumper with the given generator.
162 pub fn new(generator: G) -> Self {
163 Self { generator }
164 }
165
166 /// Generate and dump all files from the analysis context.
167 ///
168 /// This method:
169 /// 1. Invokes the generator to create `GeneratedWorkspace`
170 /// 2. Converts crate-relative paths to `WorkspaceFilePath`
171 /// 3. Returns the final file map
172 ///
173 /// # Path Conversion
174 ///
175 /// Crate-relative paths (e.g., `"src/lib.rs"`) are converted to
176 /// workspace-relative paths (e.g., `"crates/core/src/lib.rs"`) by:
177 ///
178 /// 1. Finding existing files for each crate to determine crate_root
179 /// 2. Combining crate_root + crate-relative path
180 pub fn dump_all(
181 &self,
182 ctx: &AnalysisContext,
183 ) -> Result<HashMap<WorkspaceFilePath, String>, ToSynError> {
184 // Step 1: Generate via the generator (handles empty modules, etc.)
185 let workspace = self.generator.generate(&ctx.ast_registry, &ctx.registry)?;
186
187 // Step 2: Convert to WorkspaceFilePath
188 Ok(self.convert_to_workspace_paths(workspace, ctx))
189 }
190
191 /// Convert GeneratedWorkspace to HashMap<WorkspaceFilePath, String>.
192 ///
193 /// This handles the crate-relative → workspace-relative path conversion.
194 fn convert_to_workspace_paths(
195 &self,
196 workspace: GeneratedWorkspace,
197 ctx: &AnalysisContext,
198 ) -> HashMap<WorkspaceFilePath, String> {
199 let mut result = HashMap::new();
200
201 // Build crate_name → template WorkspaceFilePath mapping
202 let crate_templates = Self::get_crate_templates(ctx);
203
204 for (crate_name, generated_crate) in workspace.crates {
205 // Get template file for this crate (to derive workspace_root and crate_name)
206 let template = crate_templates.get(&crate_name);
207
208 for (crate_relative_path, generated_file) in generated_crate.files {
209 // Create WorkspaceFilePath using template's with_relative
210 let wfp = if let Some(template) = template {
211 // Use template to create new path with correct workspace_root/crate_name
212 template.with_relative(&crate_relative_path)
213 } else {
214 // Fallback: create from context (root crate case)
215 Self::create_workspace_file_path_fallback(&crate_relative_path, ctx)
216 };
217
218 result.insert(wfp, generated_file.source);
219 }
220 }
221
222 result
223 }
224
225 /// Get template WorkspaceFilePath for each crate.
226 ///
227 /// Uses the first file found for each crate as a template to derive
228 /// workspace_root and crate_name for new files.
229 fn get_crate_templates(ctx: &AnalysisContext) -> HashMap<String, WorkspaceFilePath> {
230 let mut templates = HashMap::new();
231
232 for wfp in ctx.files.keys() {
233 let crate_name = wfp.crate_name().as_str().to_string();
234 templates.entry(crate_name).or_insert_with(|| wfp.clone());
235 }
236
237 templates
238 }
239
240 /// Fallback: create WorkspaceFilePath when no template exists.
241 ///
242 /// This handles the case where a crate has no existing files (e.g., entirely new crate).
243 fn create_workspace_file_path_fallback(
244 crate_relative_path: &str,
245 ctx: &AnalysisContext,
246 ) -> WorkspaceFilePath {
247 // Use any existing file as template, just change the path.
248 //
249 // Panics if `ctx.files` is empty. This is treated as a corrupted
250 // internal state — `AnalysisContext` is always constructed with at
251 // least one file from the initial parse, so reaching this branch in
252 // production indicates the context was emptied between construction
253 // and the source-pipeline stage.
254 ctx.files
255 .keys()
256 .next()
257 .map(|any_file| any_file.with_relative(crate_relative_path))
258 .expect(
259 "AnalysisContext.files is unexpectedly empty in source pipeline; \
260 cannot synthesize a WorkspaceFilePath template",
261 )
262 }
263}
264
265// ============================================================================
266// Convenience Functions
267// ============================================================================
268
269/// Create a standard multi-file dumper.
270///
271/// This is the recommended way to create a dumper for most use cases.
272pub fn multi_file_dumper() -> SourceDumper<RegistryGenerator> {
273 SourceDumper::new(RegistryGenerator::multi_file())
274}
275
276/// Create a single-file dumper (all code in one file with nested mods).
277pub fn single_file_dumper() -> SourceDumper<RegistryGenerator> {
278 SourceDumper::new(RegistryGenerator::single_file())
279}
280
281// ============================================================================
282// Tests
283// ============================================================================
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use ryo_analysis::testing::ContextBuilder;
289
290 #[test]
291 fn test_source_generator_trait_implemented() {
292 // Verify RegistryGenerator implements SourceGenerator
293 let _: Box<dyn SourceGenerator> = Box::new(RegistryGenerator::multi_file());
294 }
295
296 #[test]
297 fn test_source_dumper_creation() {
298 let dumper = multi_file_dumper();
299 let ctx = ContextBuilder::new()
300 .with_file("src/lib.rs", "pub struct Foo;")
301 .build();
302
303 let files = dumper.dump_all(&ctx).unwrap();
304 assert!(!files.is_empty(), "Should generate at least one file");
305 }
306
307 #[test]
308 fn test_get_crate_templates() {
309 let ctx = ContextBuilder::new()
310 .with_file("src/lib.rs", "pub struct Foo;")
311 .build();
312
313 let templates = SourceDumper::<RegistryGenerator>::get_crate_templates(&ctx);
314
315 // Should have at least one template
316 assert!(
317 !templates.is_empty(),
318 "Should have at least one crate template"
319 );
320 }
321}