sigil-stitch 0.3.1

Type-safe, import-aware, width-aware code generation for multiple languages
Documentation
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
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
use crate::code_block::CodeBlock;
use crate::code_renderer::CodeRenderer;
use crate::error::SigilStitchError;
use crate::import::ImportGroup;
use crate::import_collector;
use crate::lang::CodeLang;
use crate::spec::fun_spec::FunSpec;
use crate::spec::import_spec::ImportSpec;
use crate::spec::modifiers::DeclarationContext;
use crate::spec::type_spec::TypeSpec;
use crate::type_name::TypeName;

/// A member of a file.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum FileMember {
    /// A CodeBlock (e.g., module-level statements, class declarations).
    Code(CodeBlock),
    /// Raw content string (escape hatch, no import tracking).
    RawContent(String),
    /// Raw content string with associated types for import tracking.
    ///
    /// Content is emitted verbatim; types are walked for import collection only.
    /// The caller is responsible for ensuring type names in the raw content match
    /// what the import resolver will emit.
    RawContentWithImports {
        /// The raw content to emit verbatim.
        content: String,
        /// Types to register for import collection.
        types: Vec<TypeName>,
    },
    /// A type declaration (struct, class, interface, trait, enum).
    Type(TypeSpec),
    /// A top-level function.
    Fun(FunSpec),
}

/// A complete source file with automatic import management.
///
/// `FileSpec` is the top-level orchestrator that combines code blocks, type
/// declarations, and functions into a rendered source file. It drives the
/// three-pass rendering pipeline:
///
/// 1. **Materialize** -- Specs (`TypeSpec`, `FunSpec`) emit `CodeBlock`s
/// 2. **Collect imports** -- Walk all blocks, extract `ImportRef` from `%T` types
/// 3. **Render** -- Emit import header + body with resolved names and pretty printing
///
/// # Examples
///
/// ```
/// use sigil_stitch::prelude::*;
/// use sigil_stitch::lang::typescript::TypeScript;
///
/// let user = TypeName::importable_type("./models", "User");
///
/// let mut cb = CodeBlock::builder();
/// cb.add_statement("const u: %T = getUser()", (user,));
/// let body = cb.build().unwrap();
///
/// let file = FileSpec::builder("user.ts")
///     .add_code(body)
///     .build().unwrap();
///
/// let output = file.render(80).unwrap();
/// // output contains: import type { User } from './models'
/// // output contains: const u: User = getUser();
/// ```
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct FileSpec {
    filename: String,
    header: Option<CodeBlock>,
    members: Vec<FileMember>,
    explicit_imports: Vec<ImportSpec>,
    #[serde(skip)]
    lang: Option<Box<dyn CodeLang>>,
}

impl FileSpec {
    /// Create a builder that auto-detects the language from the filename extension.
    ///
    /// If the extension is not recognized, [`build()`](FileSpecBuilder::build) will
    /// return an error. Use [`builder_with`](FileSpec::builder_with) for explicit
    /// language control or unsupported extensions.
    pub fn builder(filename: &str) -> FileSpecBuilder {
        let ext = filename.rsplit('.').next().unwrap_or("");
        let lang = crate::lang::lang_from_extension(ext);
        FileSpecBuilder {
            filename: filename.to_string(),
            header: None,
            members: Vec::new(),
            explicit_imports: Vec::new(),
            lang,
        }
    }

    /// Create a builder with a specific language configuration.
    pub fn builder_with(filename: &str, lang: impl CodeLang) -> FileSpecBuilder {
        FileSpecBuilder {
            filename: filename.to_string(),
            header: None,
            members: Vec::new(),
            explicit_imports: Vec::new(),
            lang: Some(Box::new(lang)),
        }
    }

    /// Get the filename.
    pub fn filename(&self) -> &str {
        &self.filename
    }

    /// Render the file to a string using the three-pass algorithm.
    ///
    /// `width` controls the target line width for pretty-printing.
    pub fn render(&self, width: usize) -> Result<String, SigilStitchError> {
        let lang: &dyn CodeLang = self
            .lang
            .as_deref()
            .expect("FileSpec requires a language — use builder_with() to set one");

        // Phase 0: Materialize specs into CodeBlocks.
        enum Materialized {
            Blocks(Vec<CodeBlock>),
            Raw(String),
            RawWithImports {
                content: String,
                types: Vec<TypeName>,
            },
        }

        let mut materialized: Vec<Materialized> = Vec::with_capacity(self.members.len());
        for m in &self.members {
            materialized.push(match m {
                FileMember::Code(b) => Materialized::Blocks(vec![b.clone()]),
                FileMember::RawContent(s) => Materialized::Raw(s.clone()),
                FileMember::RawContentWithImports { content, types } => {
                    Materialized::RawWithImports {
                        content: content.clone(),
                        types: types.clone(),
                    }
                }
                FileMember::Type(spec) => Materialized::Blocks(spec.emit(lang)?),
                FileMember::Fun(spec) => {
                    Materialized::Blocks(vec![spec.emit(lang, DeclarationContext::TopLevel)?])
                }
            });
        }

        // Pass 1: Collect imports from all CodeBlock members.
        let mut import_refs = Vec::new();

        if let Some(header) = &self.header {
            import_refs.extend(import_collector::collect_imports(header));
        }

        for mat in &materialized {
            match mat {
                Materialized::Blocks(blocks) => {
                    for block in blocks {
                        import_refs.extend(import_collector::collect_imports(block));
                    }
                }
                Materialized::RawWithImports { types, .. } => {
                    for ty in types {
                        ty.collect_imports(&mut import_refs);
                    }
                }
                Materialized::Raw(_) => {}
            }
        }

        // Import Resolution: Dedup, conflict detection, alias assignment.
        // Convert explicit ImportSpec entries to ImportEntry and merge.
        let explicit_entries: Vec<_> = self
            .explicit_imports
            .iter()
            .cloned()
            .map(|spec| spec.into_entry())
            .collect();
        let imports = ImportGroup::resolve_with_explicit(&import_refs, explicit_entries);

        // Pass 2: Render with resolved names.
        let mut output = String::new();

        // Render header block if present (e.g., license comment, Go package declaration).
        if let Some(header) = &self.header {
            let mut renderer = CodeRenderer::new(lang, &imports, width);
            let header_output = renderer.render(header)?;
            if !header_output.is_empty() {
                output.push_str(&header_output);
                if !header_output.ends_with('\n') {
                    output.push('\n');
                }
                output.push('\n');
            }
        }

        // Render import header.
        let import_header = lang.render_imports(&imports);
        if !import_header.is_empty() {
            output.push_str(&import_header);
            output.push_str("\n\n");
        }

        // Render materialized members.
        for (i, mat) in materialized.iter().enumerate() {
            if i > 0 {
                output.push('\n');
            }
            match mat {
                Materialized::Blocks(blocks) => {
                    for (j, block) in blocks.iter().enumerate() {
                        if j > 0 {
                            output.push('\n');
                        }
                        let mut renderer = CodeRenderer::new(lang, &imports, width);
                        let member_output = renderer.render(block)?;
                        output.push_str(&member_output);
                        if !member_output.ends_with('\n') {
                            output.push('\n');
                        }
                    }
                }
                Materialized::Raw(content) => {
                    output.push_str(content);
                    if !content.ends_with('\n') {
                        output.push('\n');
                    }
                }
                Materialized::RawWithImports { content, .. } => {
                    output.push_str(content);
                    if !content.ends_with('\n') {
                        output.push('\n');
                    }
                }
            }
        }

        Ok(output)
    }
}

/// Builder for [`FileSpec`].
///
/// Use [`FileSpec::builder()`] to create. Add members with `add_code()`,
/// `add_type()`, `add_function()`, or `add_raw()`, then call `build()`.
#[derive(Debug)]
pub struct FileSpecBuilder {
    filename: String,
    header: Option<CodeBlock>,
    members: Vec<FileMember>,
    explicit_imports: Vec<ImportSpec>,
    lang: Option<Box<dyn CodeLang>>,
}

impl FileSpecBuilder {
    /// Set a file header (e.g., license comment, package declaration).
    pub fn header(mut self, block: CodeBlock) -> Self {
        self.header = Some(block);
        self
    }

    /// Add a CodeBlock member.
    pub fn add_code(mut self, block: CodeBlock) -> Self {
        self.members.push(FileMember::Code(block));
        self
    }

    /// Add raw content (no import tracking).
    pub fn add_raw(mut self, content: &str) -> Self {
        self.members
            .push(FileMember::RawContent(content.to_string()));
        self
    }

    /// Add raw content with associated types for import tracking.
    ///
    /// The content is emitted verbatim (no substitution). The types are walked
    /// during import collection so the correct import statements are generated.
    pub fn add_raw_with_imports(mut self, content: &str, types: Vec<TypeName>) -> Self {
        self.members.push(FileMember::RawContentWithImports {
            content: content.to_string(),
            types,
        });
        self
    }

    /// Add a generic member.
    pub fn add_member(mut self, member: FileMember) -> Self {
        self.members.push(member);
        self
    }

    /// Add a type declaration (struct, class, interface, trait, enum).
    pub fn add_type(mut self, spec: TypeSpec) -> Self {
        self.members.push(FileMember::Type(spec));
        self
    }

    /// Add a top-level function.
    pub fn add_function(mut self, spec: FunSpec) -> Self {
        self.members.push(FileMember::Fun(spec));
        self
    }

    /// Set the language configuration.
    pub fn lang(mut self, lang: impl CodeLang) -> Self {
        self.lang = Some(Box::new(lang));
        self
    }

    /// Add an explicit import (forced, aliased, side-effect, or wildcard).
    pub fn add_import(mut self, spec: ImportSpec) -> Self {
        self.explicit_imports.push(spec);
        self
    }

    /// Build the FileSpec.
    ///
    /// # Errors
    ///
    /// Returns [`SigilStitchError::EmptyName`] if `filename` is empty.
    /// Returns an error if no language was detected or configured.
    pub fn build(self) -> Result<FileSpec, SigilStitchError> {
        snafu::ensure!(
            !self.filename.is_empty(),
            crate::error::EmptyNameSnafu {
                builder: "FileSpecBuilder",
            }
        );
        let lang = self.lang.ok_or_else(|| {
            let ext = self.filename.rsplit('.').next().unwrap_or("");
            SigilStitchError::Render {
                context: "FileSpecBuilder::build()".to_string(),
                message: format!(
                    "unrecognized file extension '.{ext}' in filename '{}'; \
                     use FileSpec::builder_with() to specify the language explicitly",
                    self.filename
                ),
            }
        })?;
        Ok(FileSpec {
            filename: self.filename,
            header: self.header,
            members: self.members,
            explicit_imports: self.explicit_imports,
            lang: Some(lang),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use crate::type_name::TypeName;

    #[test]
    fn test_empty_file() {
        let file = FileSpec::builder("empty.ts").build().unwrap();
        let output = file.render(80).unwrap();
        assert!(output.is_empty() || output.trim().is_empty());
    }

    #[test]
    fn test_simple_file_with_import() {
        let user = TypeName::importable_type("./models", "User");

        let mut b = CodeBlock::builder();
        b.add_statement("const u: %T = getUser()", (user,));
        let block = b.build().unwrap();

        let file = FileSpec::builder("user.ts")
            .add_code(block)
            .build()
            .unwrap();

        let output = file.render(80).unwrap();
        assert!(output.contains("import type { User } from './models'"));
        assert!(output.contains("const u: User = getUser();"));
    }

    #[test]
    fn test_conflicting_imports() {
        let user1 = TypeName::importable_type("./models", "User");
        let user2 = TypeName::importable_type("./other", "User");

        let mut b = CodeBlock::builder();
        b.add_statement("const u1: %T = get1()", (user1,));
        b.add_statement("const u2: %T = get2()", (user2,));
        let block = b.build().unwrap();

        let file = FileSpec::builder("user.ts")
            .add_code(block)
            .build()
            .unwrap();

        let output = file.render(80).unwrap();
        // First wins simple name.
        assert!(output.contains("const u1: User = get1();"));
        // Second gets alias.
        assert!(output.contains("const u2: OtherUser = get2();"));
        assert!(output.contains("User as OtherUser"));
    }

    #[test]
    fn test_raw_content_no_import_tracking() {
        let file = FileSpec::builder("raw.ts")
            .add_raw("// This is raw content\nexport const VERSION = '1.0.0';\n")
            .build()
            .unwrap();

        let output = file.render(80).unwrap();
        assert!(output.contains("// This is raw content"));
        assert!(output.contains("export const VERSION = '1.0.0';"));
        // No import header.
        assert!(!output.contains("import"));
    }

    #[test]
    fn test_mixed_code_and_raw() {
        let user = TypeName::importable_type("./models", "User");

        let mut b = CodeBlock::builder();
        b.add_statement("const u: %T = getUser()", (user,));
        let block = b.build().unwrap();

        let file = FileSpec::builder("mixed.ts")
            .add_raw("// Generated file, do not edit.\n")
            .add_code(block)
            .build()
            .unwrap();

        let output = file.render(80).unwrap();
        assert!(output.contains("import type { User }"));
        assert!(output.contains("// Generated file"));
        assert!(output.contains("const u: User = getUser();"));
    }

    #[test]
    fn test_file_with_header() {
        let mut header_builder = CodeBlock::builder();
        header_builder.add("// License: MIT", ());
        let header = header_builder.build().unwrap();

        let mut b = CodeBlock::builder();
        b.add_statement("const x = 1", ());
        let block = b.build().unwrap();

        let file = FileSpec::builder("test.ts")
            .header(header)
            .add_code(block)
            .build()
            .unwrap();

        let output = file.render(80).unwrap();
        assert!(output.starts_with("// License: MIT"));
        assert!(output.contains("const x = 1;"));
    }

    #[test]
    fn test_dedup_same_import() {
        let user1 = TypeName::importable_type("./models", "User");
        let user2 = TypeName::importable_type("./models", "User");

        let mut b = CodeBlock::builder();
        b.add_statement("const u1: %T = get1()", (user1,));
        b.add_statement("const u2: %T = get2()", (user2,));
        let block = b.build().unwrap();

        let file = FileSpec::builder("user.ts")
            .add_code(block)
            .build()
            .unwrap();

        let output = file.render(80).unwrap();
        // Should appear only once.
        let import_count = output.matches("import type { User }").count();
        assert_eq!(import_count, 1);
    }

    #[test]
    fn test_build_empty_filename_errors() {
        let result = FileSpec::builder("").build();
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("'name' must not be empty")
        );
    }

    #[test]
    fn test_aliased_type_in_codeblock() {
        let user = TypeName::importable("./models", "User").with_alias("UserModel");

        let mut b = CodeBlock::builder();
        b.add_statement("const u: %T = getUser()", (user,));
        let block = b.build().unwrap();

        let file = FileSpec::builder("user.ts")
            .add_code(block)
            .build()
            .unwrap();

        let output = file.render(80).unwrap();
        // Import should use the alias.
        assert!(
            output.contains("User as UserModel"),
            "Expected aliased import, got:\n{output}"
        );
        // Code should reference the alias name.
        assert!(
            output.contains("const u: UserModel = getUser();"),
            "Expected alias in code, got:\n{output}"
        );
    }

    #[test]
    fn test_aliased_type_with_auto_alias_conflict() {
        // Two types named "User" from different modules.
        // First one has a preferred alias; second should still get auto-aliased.
        let user1 = TypeName::importable_type("./models", "User").with_alias("ModelUser");
        let user2 = TypeName::importable_type("./other", "User");

        let mut b = CodeBlock::builder();
        b.add_statement("const u1: %T = get1()", (user1,));
        b.add_statement("const u2: %T = get2()", (user2,));
        let block = b.build().unwrap();

        let file = FileSpec::builder("user.ts")
            .add_code(block)
            .build()
            .unwrap();

        let output = file.render(80).unwrap();
        // First uses its preferred alias.
        assert!(
            output.contains("const u1: ModelUser = get1();"),
            "Expected preferred alias, got:\n{output}"
        );
        // Second gets auto-aliased since "User" is claimed.
        assert!(
            output.contains("const u2: OtherUser = get2();"),
            "Expected auto-alias for second, got:\n{output}"
        );
    }
}