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;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum FileMember {
Code(CodeBlock),
RawContent(String),
RawContentWithImports {
content: String,
types: Vec<TypeName>,
},
Type(TypeSpec),
Fun(FunSpec),
}
#[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 {
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,
}
}
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)),
}
}
pub fn filename(&self) -> &str {
&self.filename
}
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");
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)?])
}
});
}
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(_) => {}
}
}
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);
let mut output = String::new();
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');
}
}
let import_header = lang.render_imports(&imports);
if !import_header.is_empty() {
output.push_str(&import_header);
output.push_str("\n\n");
}
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)
}
}
#[derive(Debug)]
pub struct FileSpecBuilder {
filename: String,
header: Option<CodeBlock>,
members: Vec<FileMember>,
explicit_imports: Vec<ImportSpec>,
lang: Option<Box<dyn CodeLang>>,
}
impl FileSpecBuilder {
pub fn header(mut self, block: CodeBlock) -> Self {
self.header = Some(block);
self
}
pub fn add_code(mut self, block: CodeBlock) -> Self {
self.members.push(FileMember::Code(block));
self
}
pub fn add_raw(mut self, content: &str) -> Self {
self.members
.push(FileMember::RawContent(content.to_string()));
self
}
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
}
pub fn add_member(mut self, member: FileMember) -> Self {
self.members.push(member);
self
}
pub fn add_type(mut self, spec: TypeSpec) -> Self {
self.members.push(FileMember::Type(spec));
self
}
pub fn add_function(mut self, spec: FunSpec) -> Self {
self.members.push(FileMember::Fun(spec));
self
}
pub fn lang(mut self, lang: impl CodeLang) -> Self {
self.lang = Some(Box::new(lang));
self
}
pub fn add_import(mut self, spec: ImportSpec) -> Self {
self.explicit_imports.push(spec);
self
}
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();
assert!(output.contains("const u1: User = get1();"));
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';"));
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();
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();
assert!(
output.contains("User as UserModel"),
"Expected aliased import, got:\n{output}"
);
assert!(
output.contains("const u: UserModel = getUser();"),
"Expected alias in code, got:\n{output}"
);
}
#[test]
fn test_aliased_type_with_auto_alias_conflict() {
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();
assert!(
output.contains("const u1: ModelUser = get1();"),
"Expected preferred alias, got:\n{output}"
);
assert!(
output.contains("const u2: OtherUser = get2();"),
"Expected auto-alias for second, got:\n{output}"
);
}
}