formatjs_cli 1.3.0

Command-line interface for FormatJS - A Rust-based CLI for internationalization
Documentation
use crate::extractor::{MessageDescriptor, flatten_message_descriptors, normalize_whitespace};
use crate::id_generator::IdGenerator;
use anyhow::{Result, bail};
use proc_macro2::{LineColumn, Span};
use serde_json::Value;
use std::path::Path;
use syn::parse::{Parse, ParseStream};
use syn::spanned::Spanned;
use syn::visit::{self, Visit};
use syn::{Expr, Ident, LitStr, Macro, Token};

const RUST_ID_INTERPOLATION_PATTERN: &str = "[sha512:contenthash:base64:10]";

pub fn extract_messages_from_rust_source(
    source_text: &str,
    file_path: &Path,
    extract_source_location: bool,
    preserve_whitespace: bool,
    flatten: bool,
    throws: bool,
) -> Result<Vec<MessageDescriptor>> {
    let file = syn::parse_file(source_text)?;
    let id_generator = IdGenerator::new(RUST_ID_INTERPOLATION_PATTERN)?;
    let mut extractor = RustMessageExtractor {
        source_text,
        file_path,
        extract_source_location,
        preserve_whitespace,
        throws,
        id_generator,
        messages: Vec::new(),
        errors: Vec::new(),
    };
    extractor.visit_file(&file);
    if let Some(error) = extractor.errors.into_iter().next() {
        bail!(error);
    }
    flatten_message_descriptors(extractor.messages, source_text, file_path, flatten)
}

struct RustMessageExtractor<'a> {
    source_text: &'a str,
    file_path: &'a Path,
    extract_source_location: bool,
    preserve_whitespace: bool,
    throws: bool,
    id_generator: IdGenerator,
    messages: Vec<MessageDescriptor>,
    errors: Vec<String>,
}

impl RustMessageExtractor<'_> {
    fn descriptor(&self, arguments: MessageArgs, span: Span) -> Result<MessageDescriptor> {
        let (start, end) = if self.extract_source_location {
            (
                Some(line_column_to_offset(self.source_text, span.start())),
                Some(line_column_to_offset(self.source_text, span.end())),
            )
        } else {
            (None, None)
        };
        let normalized_default_message = normalize_whitespace(&arguments.default_message);
        let description = arguments.description.map(Value::String);
        let id = match arguments.id {
            Some(id) => id,
            None => {
                self.id_generator
                    .generate(Some(&normalized_default_message), &description, None)?
            }
        };
        let default_message = if self.preserve_whitespace {
            arguments.default_message
        } else {
            normalized_default_message
        };
        Ok(MessageDescriptor {
            id: Some(id),
            default_message: Some(default_message),
            description,
            file: self
                .extract_source_location
                .then(|| self.file_path.to_string_lossy().into_owned()),
            start,
            end,
        })
    }

    fn error(&mut self, span: Span, message: impl AsRef<str>) {
        if self.throws {
            let start = span.start();
            self.errors.push(format!(
                "{}:{}:{}: {}",
                self.file_path.display(),
                start.line,
                start.column + 1,
                message.as_ref()
            ));
        }
    }
}

impl<'ast> Visit<'ast> for RustMessageExtractor<'_> {
    fn visit_macro(&mut self, node: &'ast Macro) {
        let name = node.path.segments.last().map(|segment| &segment.ident);
        if name.is_some_and(|name| name == "message_descriptor" || name == "format_message") {
            let arguments = if name.is_some_and(|name| name == "format_message") {
                syn::parse2::<FormatMessageArgs>(node.tokens.clone()).map(|args| args.message)
            } else {
                syn::parse2::<MessageArgs>(node.tokens.clone())
            };
            match arguments {
                Ok(arguments) => match self.descriptor(arguments, node.span()) {
                    Ok(descriptor) => self.messages.push(descriptor),
                    Err(error) => self.error(node.span(), error.to_string()),
                },
                Err(error) => self.error(node.span(), error.to_string()),
            }
        }
        visit::visit_macro(self, node);
    }
}

struct FormatMessageArgs {
    message: MessageArgs,
}

impl Parse for FormatMessageArgs {
    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
        input.parse::<Expr>()?;
        input.parse::<Token![,]>()?;
        let message = parse_message_args(input, true)?;
        Ok(Self { message })
    }
}

fn line_column_to_offset(source: &str, location: LineColumn) -> u32 {
    let line_offset: usize = source
        .split_inclusive('\n')
        .take(location.line.saturating_sub(1))
        .map(str::len)
        .sum();
    line_offset
        .saturating_add(location.column)
        .min(source.len()) as u32
}

struct MessageArgs {
    id: Option<String>,
    default_message: String,
    description: Option<String>,
}

impl Parse for MessageArgs {
    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
        parse_message_args(input, false)
    }
}

fn parse_message_args(input: ParseStream<'_>, allow_values: bool) -> syn::Result<MessageArgs> {
    let mut id = None;
    let mut default_message = None;
    let mut description = None;
    let mut values = false;
    while !input.is_empty() {
        let key: Ident = input.parse()?;
        if input.peek(Token![:]) {
            input.parse::<Token![:]>()?;
        } else {
            input.parse::<Token![=]>()?;
        }
        match key.to_string().as_str() {
            "values" if allow_values && !values => {
                input.parse::<Expr>()?;
                values = true;
            }
            "values" if allow_values => {
                return Err(syn::Error::new(key.span(), "duplicate message field"));
            }
            "values" => return Err(syn::Error::new(key.span(), "unknown message field")),
            field => {
                let value: LitStr = input.parse()?;
                match field {
                    "id" if id.is_none() => id = Some(value.value()),
                    "default_message" if default_message.is_none() => {
                        default_message = Some(value.value())
                    }
                    "description" if description.is_none() => description = Some(value.value()),
                    "id" | "default_message" | "description" => {
                        return Err(syn::Error::new(key.span(), "duplicate message field"));
                    }
                    _ => return Err(syn::Error::new(key.span(), "unknown message field")),
                }
            }
        }
        if input.peek(Token![,]) {
            input.parse::<Token![,]>()?;
        } else if !input.is_empty() {
            return Err(input.error("expected comma"));
        }
    }

    Ok(MessageArgs {
        id,
        default_message: default_message
            .ok_or_else(|| syn::Error::new(Span::call_site(), "default_message is required"))?,
        description,
    })
}

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

    #[test]
    fn extracts_message_descriptors() {
        let source = r#"fn main() {
            let descriptor = message_descriptor!(
                default_message: "Hello, {name}!",
                description: "Greeting"
            );
        }"#;
        let messages = extract_messages_from_rust_source(
            source,
            Path::new("src/main.rs"),
            false,
            false,
            false,
            true,
        )
        .unwrap();
        assert_eq!(messages.len(), 1);
        assert_eq!(messages[0].id.as_deref(), Some("EG1xJTTqQy"));
        assert_eq!(
            messages[0].default_message.as_deref(),
            Some("Hello, {name}!")
        );
        assert_eq!(
            messages[0].description,
            Some(Value::String("Greeting".to_owned()))
        );
    }

    #[test]
    fn preserves_explicit_id() {
        let messages = extract_messages_from_rust_source(
            r#"fn main() { message_descriptor!(id: "hello", default_message: "Hello"); }"#,
            Path::new("src/main.rs"),
            false,
            false,
            false,
            true,
        )
        .unwrap();
        assert_eq!(messages[0].id.as_deref(), Some("hello"));
    }

    #[test]
    fn extracts_format_message_macros() {
        let messages = extract_messages_from_rust_source(
            r#"fn render(intl: &Intl, values: &Values<String>) {
                format_message!(
                    &intl,
                    default_message: "Hello, {name}!",
                    description: "Greeting",
                    values: values,
                );
                formatjs_intl::format_message!(
                    &intl,
                    id: "approval.title",
                    default_message: "Approve to continue",
                );
            }"#,
            Path::new("src/main.rs"),
            false,
            false,
            false,
            true,
        )
        .unwrap();

        assert_eq!(messages.len(), 2);
        assert_eq!(messages[0].id.as_deref(), Some("EG1xJTTqQy"));
        assert_eq!(messages[1].id.as_deref(), Some("approval.title"));
    }

    #[test]
    fn reports_source_offsets() {
        let source = "fn main() {\n    message_descriptor!(default_message: \"Hello\");\n}\n";
        let messages = extract_messages_from_rust_source(
            source,
            Path::new("src/main.rs"),
            true,
            false,
            false,
            true,
        )
        .unwrap();
        assert_eq!(messages[0].file.as_deref(), Some("src/main.rs"));
        assert_eq!(messages[0].start, Some(16));
        assert!(messages[0].end.unwrap() > messages[0].start.unwrap());
    }
}