baobao_codegen_typescript/
code_file.rs

1//! CodeFile abstraction for structured TypeScript file generation.
2//!
3//! Provides a high-level API for generating TypeScript files with
4//! organized imports, body content, and exports sections.
5
6use std::any::Any;
7
8use baobao_codegen::builder::{CodeBuilder, CodeFragment, Indent, Renderable};
9
10use crate::ast::{Export, Import};
11
12/// A shebang line that appears at the very top of the file.
13///
14/// Used for executable scripts (e.g., `#!/usr/bin/env bun`).
15#[derive(Debug, Clone)]
16pub struct Shebang(String);
17
18impl Shebang {
19    /// Create a new shebang line.
20    pub fn new(shebang: impl Into<String>) -> Self {
21        Self(shebang.into())
22    }
23
24    /// Create a bun shebang (`#!/usr/bin/env bun`).
25    pub fn bun() -> Self {
26        Self::new("#!/usr/bin/env bun")
27    }
28}
29
30impl Renderable for Shebang {
31    fn to_fragments(&self) -> Vec<CodeFragment> {
32        vec![CodeFragment::Line(self.0.clone())]
33    }
34}
35
36/// A structured representation of a TypeScript file.
37///
38/// Organizes code into four sections: shebang, imports, body, and exports.
39/// Each section is rendered in order with appropriate blank lines.
40///
41/// # Example
42///
43/// ```ignore
44/// let file = CodeFile::new()
45///     .shebang(Shebang::bun())
46///     .import(Import::new("boune").named("defineCommand"))
47///     .add(command_schema)
48///     .export(Export::new().named("fooCommand"))
49///     .render();
50/// ```
51#[derive(Default)]
52pub struct CodeFile {
53    shebang: Option<Shebang>,
54    imports: Vec<Import>,
55    body: Vec<Vec<CodeFragment>>,
56    exports: Vec<Export>,
57}
58
59impl CodeFile {
60    /// Create a new empty CodeFile.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Set the shebang line (placed at the very top of the file).
66    pub fn shebang(mut self, shebang: Shebang) -> Self {
67        self.shebang = Some(shebang);
68        self
69    }
70
71    /// Add an import statement.
72    pub fn import(mut self, import: Import) -> Self {
73        self.imports.push(import);
74        self
75    }
76
77    /// Add imports from an iterator.
78    pub fn imports(mut self, imports: impl IntoIterator<Item = Import>) -> Self {
79        self.imports.extend(imports);
80        self
81    }
82
83    /// Add a body element (any Renderable).
84    ///
85    /// If the element is a `Shebang`, it will be placed at the top of the file.
86    #[allow(clippy::should_implement_trait)]
87    pub fn add<R: Renderable + Any>(mut self, node: R) -> Self {
88        if let Some(shebang) = (&node as &dyn Any).downcast_ref::<Shebang>() {
89            self.shebang = Some(shebang.clone());
90        } else {
91            self.body.push(node.to_fragments());
92        }
93        self
94    }
95
96    /// Add multiple body elements.
97    pub fn add_all<R: Renderable>(mut self, nodes: impl IntoIterator<Item = R>) -> Self {
98        for node in nodes {
99            self.body.push(node.to_fragments());
100        }
101        self
102    }
103
104    /// Add an export statement.
105    pub fn export(mut self, export: Export) -> Self {
106        self.exports.push(export);
107        self
108    }
109
110    /// Add exports from an iterator.
111    pub fn exports(mut self, exports: impl IntoIterator<Item = Export>) -> Self {
112        self.exports.extend(exports);
113        self
114    }
115
116    /// Render the file with TypeScript indentation (2 spaces).
117    pub fn render(&self) -> String {
118        self.render_with_indent(Indent::TYPESCRIPT)
119    }
120
121    /// Render the file with custom indentation.
122    pub fn render_with_indent(&self, indent: Indent) -> String {
123        let mut builder = CodeBuilder::new(indent);
124
125        // 1. Render shebang (must be first line)
126        if let Some(shebang) = &self.shebang {
127            builder.emit(shebang);
128        }
129
130        // 2. Blank line between shebang and imports
131        let has_content =
132            !self.imports.is_empty() || !self.body.is_empty() || !self.exports.is_empty();
133        if self.shebang.is_some() && has_content {
134            builder.push_blank();
135        }
136
137        // 3. Render imports
138        for import in &self.imports {
139            builder.emit(import);
140        }
141
142        // 4. Blank line between imports and body
143        if !self.imports.is_empty() && (!self.body.is_empty() || !self.exports.is_empty()) {
144            builder.push_blank();
145        }
146
147        // 5. Render body with blank lines between elements
148        for (i, fragments) in self.body.iter().enumerate() {
149            if i > 0 {
150                builder.push_blank();
151            }
152            for fragment in fragments {
153                builder.apply_fragment(fragment.clone());
154            }
155        }
156
157        // 6. Blank line before exports
158        if !self.body.is_empty() && !self.exports.is_empty() {
159            builder.push_blank();
160        }
161
162        // 7. Render exports
163        for export in &self.exports {
164            builder.emit(export);
165        }
166
167        builder.build()
168    }
169
170    /// Check if the file is empty.
171    pub fn is_empty(&self) -> bool {
172        self.imports.is_empty() && self.body.is_empty() && self.exports.is_empty()
173    }
174}
175
176/// A raw code fragment that implements Renderable.
177///
178/// Useful for adding raw code strings to CodeFile body.
179#[derive(Debug, Clone)]
180pub struct RawCode(String);
181
182impl RawCode {
183    /// Create a new raw code fragment.
184    pub fn new(code: impl Into<String>) -> Self {
185        Self(code.into())
186    }
187
188    /// Create a raw code fragment from multiple lines.
189    pub fn lines(lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
190        Self(
191            lines
192                .into_iter()
193                .map(Into::into)
194                .collect::<Vec<_>>()
195                .join("\n"),
196        )
197    }
198}
199
200impl Renderable for RawCode {
201    fn to_fragments(&self) -> Vec<CodeFragment> {
202        self.0
203            .lines()
204            .map(|line| CodeFragment::Line(line.to_string()))
205            .collect()
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_empty_file() {
215        let file = CodeFile::new();
216        assert!(file.is_empty());
217        assert_eq!(file.render(), "");
218    }
219
220    #[test]
221    fn test_imports_only() {
222        let file = CodeFile::new().import(Import::new("boune").named("defineCommand"));
223        let code = file.render();
224        assert!(code.contains("import { defineCommand } from \"boune\";"));
225    }
226
227    #[test]
228    fn test_raw_code_body() {
229        let file = CodeFile::new().add(RawCode::new("const x = 1;"));
230        let code = file.render();
231        assert_eq!(code, "const x = 1;\n");
232    }
233
234    #[test]
235    fn test_full_file() {
236        let file = CodeFile::new()
237            .import(Import::new("boune").named("defineCommand"))
238            .add(RawCode::new("const cmd = defineCommand({});"))
239            .export(Export::new().named("cmd"));
240
241        let code = file.render();
242        assert!(code.contains("import { defineCommand }"));
243        assert!(code.contains("const cmd = defineCommand"));
244        assert!(code.contains("export { cmd }"));
245    }
246
247    #[test]
248    fn test_blank_lines_between_body() {
249        let file = CodeFile::new()
250            .add(RawCode::new("const a = 1;"))
251            .add(RawCode::new("const b = 2;"));
252
253        let code = file.render();
254        assert!(code.contains("const a = 1;\n\nconst b = 2;"));
255    }
256
257    #[test]
258    fn test_shebang_at_top() {
259        let file = CodeFile::new()
260            .add(Shebang::bun())
261            .import(Import::new("./cli.ts").named("app"))
262            .add(RawCode::new("app.run();"));
263
264        let code = file.render();
265        assert!(code.starts_with("#!/usr/bin/env bun\n"));
266        assert!(code.contains("#!/usr/bin/env bun\n\nimport { app }"));
267    }
268
269    #[test]
270    fn test_shebang_only() {
271        let file = CodeFile::new().shebang(Shebang::bun());
272        let code = file.render();
273        assert_eq!(code, "#!/usr/bin/env bun\n");
274    }
275}