linguini-codegen-ts 0.1.0-alpha.1

TypeScript code generator for Linguini localization projects
mod decl;
mod emit;
mod expr;
mod formatters;
mod names;
mod project;
mod tree;

use std::fmt;

use linguini_cldr::built_in_plural_rules;
use linguini_ir::IrModule;

use self::emit::{
    emit_enums, emit_forms, emit_imports, emit_index, emit_local_functions, emit_messages,
    emit_shared, emit_type_aliases,
};
use super::plural::generate_plural_function;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeScriptOptions {
    pub locale: String,
    pub plural_function: String,
    pub plural_import: Option<String>,
    pub plural_source: Option<String>,
    pub included_messages: Vec<String>,
}

impl Default for TypeScriptOptions {
    fn default() -> Self {
        Self {
            locale: "ru".to_owned(),
            plural_function: "plural".to_owned(),
            plural_import: Some("./plurals".to_owned()),
            plural_source: None,
            included_messages: Vec::new(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeScriptLocaleModule {
    pub locale: String,
    pub module: IrModule,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeScriptProjectOptions {
    pub declaration: bool,
    pub tree_shaking: bool,
    pub included_messages: Vec<String>,
    pub base_locale: Option<String>,
}

impl Default for TypeScriptProjectOptions {
    fn default() -> Self {
        Self {
            declaration: true,
            tree_shaking: false,
            included_messages: Vec::new(),
            base_locale: None,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeScriptGeneratedFile {
    pub path: String,
    pub contents: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeScriptCodegenError {
    message: String,
}

impl TypeScriptCodegenError {
    fn missing_plural_rules(locale: &str) -> Self {
        Self {
            message: format!("missing built-in CLDR plural rules for configured locale `{locale}`"),
        }
    }
}

impl fmt::Display for TypeScriptCodegenError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for TypeScriptCodegenError {}

pub fn generate_typescript_project_files(
    schema: &IrModule,
    locales: &[TypeScriptLocaleModule],
    options: &TypeScriptProjectOptions,
) -> Result<Vec<TypeScriptGeneratedFile>, TypeScriptCodegenError> {
    let mut files = vec![TypeScriptGeneratedFile {
        path: "shared.ts".to_owned(),
        contents: generate_shared_module(),
    }];

    if options.declaration {
        files.push(TypeScriptGeneratedFile {
            path: "shared.d.ts".to_owned(),
            contents: decl::generate_shared_declaration(),
        });
    }

    for locale in locales {
        let locale_options = project_locale_options(&locale.locale, options)?;
        let visible_schema = visible_schema(schema, &locale_options);
        files.push(TypeScriptGeneratedFile {
            path: format!("locales/{}.ts", locale.locale),
            contents: generate_typescript_module(&visible_schema, &locale.module, &locale_options),
        });
        if options.declaration {
            files.push(TypeScriptGeneratedFile {
                path: format!("locales/{}.d.ts", locale.locale),
                contents: decl::generate_locale_declaration(&visible_schema),
            });
        }
    }

    files.push(TypeScriptGeneratedFile {
        path: "index.ts".to_owned(),
        contents: project::generate_project_index(locales, options.base_locale.as_deref()),
    });
    if options.declaration {
        files.push(TypeScriptGeneratedFile {
            path: "index.d.ts".to_owned(),
            contents: project::generate_project_index_declaration(
                locales,
                options.base_locale.as_deref(),
            ),
        });
    }

    Ok(files)
}

pub fn generate_typescript_files(
    schema: &IrModule,
    locale: &IrModule,
    options: &TypeScriptOptions,
) -> Vec<TypeScriptGeneratedFile> {
    vec![
        TypeScriptGeneratedFile {
            path: "shared.ts".to_owned(),
            contents: generate_shared_module(),
        },
        TypeScriptGeneratedFile {
            path: "shared.d.ts".to_owned(),
            contents: decl::generate_shared_declaration(),
        },
        TypeScriptGeneratedFile {
            path: format!("locales/{}.ts", options.locale),
            contents: generate_typescript_module(schema, locale, options),
        },
        TypeScriptGeneratedFile {
            path: format!("locales/{}.d.ts", options.locale),
            contents: decl::generate_locale_declaration(schema),
        },
        TypeScriptGeneratedFile {
            path: "index.ts".to_owned(),
            contents: generate_index_module(options),
        },
        TypeScriptGeneratedFile {
            path: "index.d.ts".to_owned(),
            contents: decl::generate_index_declaration(options),
        },
    ]
}

pub fn generate_typescript_module(
    schema: &IrModule,
    locale: &IrModule,
    options: &TypeScriptOptions,
) -> String {
    let schema = visible_schema(schema, options);
    let mut output = String::new();
    emit_imports(locale, options, &mut output);
    emit::emit_plural_helpers(options, &mut output);
    emit_enums(&schema, &mut output);
    emit_type_aliases(&schema, &mut output);
    emit_forms(locale, options, &mut output);
    emit_local_functions(locale, options, &mut output);
    let exports = emit_messages(&schema, locale, options, &mut output);
    emit_locale_default(&exports, &mut output);
    output
}

fn project_locale_options(
    locale: &str,
    project_options: &TypeScriptProjectOptions,
) -> Result<TypeScriptOptions, TypeScriptCodegenError> {
    let plural_function = plural_function_name(locale);
    let plural_rules = built_in_plural_rules(locale)
        .ok_or_else(|| TypeScriptCodegenError::missing_plural_rules(locale))?;
    Ok(TypeScriptOptions {
        locale: locale.to_owned(),
        plural_function: plural_function.clone(),
        plural_import: None,
        plural_source: Some(generate_plural_function(&plural_function, &plural_rules)),
        included_messages: if project_options.tree_shaking {
            project_options.included_messages.clone()
        } else {
            Vec::new()
        },
    })
}

fn visible_schema(schema: &IrModule, options: &TypeScriptOptions) -> IrModule {
    if options.included_messages.is_empty() {
        return schema.clone();
    }

    let mut visible = schema.clone();
    visible.messages.retain(|message| {
        options.included_messages.iter().any(|selected| {
            selected == &message.name
                || message
                    .name
                    .strip_prefix(selected)
                    .is_some_and(|rest| rest.starts_with('.'))
        })
    });
    visible
}

fn plural_function_name(locale: &str) -> String {
    format!("plural{}", pascal_identifier(locale))
}

fn pascal_identifier(value: &str) -> String {
    value
        .split(|character: char| !character.is_ascii_alphanumeric())
        .filter(|part| !part.is_empty())
        .map(|part| {
            let mut chars = part.chars();
            let Some(first) = chars.next() else {
                return String::new();
            };
            let mut output = String::new();
            output.push(first.to_ascii_uppercase());
            output.extend(chars.map(|character| character.to_ascii_lowercase()));
            output
        })
        .collect::<String>()
}

fn generate_shared_module() -> String {
    let mut output = String::new();
    emit_shared(&mut output);
    output
}

fn generate_index_module(options: &TypeScriptOptions) -> String {
    let mut output = String::new();
    emit_index(options, &mut output);
    output
}

fn emit_locale_default(exports: &emit::ModuleExports, output: &mut String) {
    output.push_str("const lgl = {\n");
    for name in exports.top_level.iter().chain(exports.groups.iter()) {
        output.push_str(&format!("  {name},\n"));
    }
    output.push_str("} as const;\n\n");
    output.push_str("export default lgl;\n");
}