Skip to main content

formatjs_cli/
rust_extractor.rs

1use crate::extractor::{MessageDescriptor, flatten_message_descriptors, normalize_whitespace};
2use crate::id_generator::IdGenerator;
3use anyhow::{Result, bail};
4use proc_macro2::{LineColumn, Span};
5use serde_json::Value;
6use std::path::Path;
7use syn::parse::{Parse, ParseStream};
8use syn::spanned::Spanned;
9use syn::visit::{self, Visit};
10use syn::{Expr, Ident, LitStr, Macro, Token};
11
12const RUST_ID_INTERPOLATION_PATTERN: &str = "[sha512:contenthash:base64:10]";
13
14pub fn extract_messages_from_rust_source(
15    source_text: &str,
16    file_path: &Path,
17    extract_source_location: bool,
18    preserve_whitespace: bool,
19    flatten: bool,
20    throws: bool,
21) -> Result<Vec<MessageDescriptor>> {
22    let file = syn::parse_file(source_text)?;
23    let id_generator = IdGenerator::new(RUST_ID_INTERPOLATION_PATTERN)?;
24    let mut extractor = RustMessageExtractor {
25        source_text,
26        file_path,
27        extract_source_location,
28        preserve_whitespace,
29        throws,
30        id_generator,
31        messages: Vec::new(),
32        errors: Vec::new(),
33    };
34    extractor.visit_file(&file);
35    if let Some(error) = extractor.errors.into_iter().next() {
36        bail!(error);
37    }
38    flatten_message_descriptors(extractor.messages, source_text, file_path, flatten)
39}
40
41struct RustMessageExtractor<'a> {
42    source_text: &'a str,
43    file_path: &'a Path,
44    extract_source_location: bool,
45    preserve_whitespace: bool,
46    throws: bool,
47    id_generator: IdGenerator,
48    messages: Vec<MessageDescriptor>,
49    errors: Vec<String>,
50}
51
52impl RustMessageExtractor<'_> {
53    fn descriptor(&self, arguments: MessageArgs, span: Span) -> Result<MessageDescriptor> {
54        let (start, end) = if self.extract_source_location {
55            (
56                Some(line_column_to_offset(self.source_text, span.start())),
57                Some(line_column_to_offset(self.source_text, span.end())),
58            )
59        } else {
60            (None, None)
61        };
62        let normalized_default_message = normalize_whitespace(&arguments.default_message);
63        let description = arguments.description.map(Value::String);
64        let id = match arguments.id {
65            Some(id) => id,
66            None => {
67                self.id_generator
68                    .generate(Some(&normalized_default_message), &description, None)?
69            }
70        };
71        let default_message = if self.preserve_whitespace {
72            arguments.default_message
73        } else {
74            normalized_default_message
75        };
76        Ok(MessageDescriptor {
77            id: Some(id),
78            default_message: Some(default_message),
79            description,
80            file: self
81                .extract_source_location
82                .then(|| self.file_path.to_string_lossy().into_owned()),
83            start,
84            end,
85        })
86    }
87
88    fn error(&mut self, span: Span, message: impl AsRef<str>) {
89        if self.throws {
90            let start = span.start();
91            self.errors.push(format!(
92                "{}:{}:{}: {}",
93                self.file_path.display(),
94                start.line,
95                start.column + 1,
96                message.as_ref()
97            ));
98        }
99    }
100}
101
102impl<'ast> Visit<'ast> for RustMessageExtractor<'_> {
103    fn visit_macro(&mut self, node: &'ast Macro) {
104        let name = node.path.segments.last().map(|segment| &segment.ident);
105        if name.is_some_and(|name| name == "message_descriptor" || name == "format_message") {
106            let arguments = if name.is_some_and(|name| name == "format_message") {
107                syn::parse2::<FormatMessageArgs>(node.tokens.clone()).map(|args| args.message)
108            } else {
109                syn::parse2::<MessageArgs>(node.tokens.clone())
110            };
111            match arguments {
112                Ok(arguments) => match self.descriptor(arguments, node.span()) {
113                    Ok(descriptor) => self.messages.push(descriptor),
114                    Err(error) => self.error(node.span(), error.to_string()),
115                },
116                Err(error) => self.error(node.span(), error.to_string()),
117            }
118        }
119        visit::visit_macro(self, node);
120    }
121}
122
123struct FormatMessageArgs {
124    message: MessageArgs,
125}
126
127impl Parse for FormatMessageArgs {
128    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
129        input.parse::<Expr>()?;
130        input.parse::<Token![,]>()?;
131        let message = parse_message_args(input, true)?;
132        Ok(Self { message })
133    }
134}
135
136fn line_column_to_offset(source: &str, location: LineColumn) -> u32 {
137    let line_offset: usize = source
138        .split_inclusive('\n')
139        .take(location.line.saturating_sub(1))
140        .map(str::len)
141        .sum();
142    line_offset
143        .saturating_add(location.column)
144        .min(source.len()) as u32
145}
146
147struct MessageArgs {
148    id: Option<String>,
149    default_message: String,
150    description: Option<String>,
151}
152
153impl Parse for MessageArgs {
154    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
155        parse_message_args(input, false)
156    }
157}
158
159fn parse_message_args(input: ParseStream<'_>, allow_values: bool) -> syn::Result<MessageArgs> {
160    let mut id = None;
161    let mut default_message = None;
162    let mut description = None;
163    let mut values = false;
164    while !input.is_empty() {
165        let key: Ident = input.parse()?;
166        if input.peek(Token![:]) {
167            input.parse::<Token![:]>()?;
168        } else {
169            input.parse::<Token![=]>()?;
170        }
171        match key.to_string().as_str() {
172            "values" if allow_values && !values => {
173                input.parse::<Expr>()?;
174                values = true;
175            }
176            "values" if allow_values => {
177                return Err(syn::Error::new(key.span(), "duplicate message field"));
178            }
179            "values" => return Err(syn::Error::new(key.span(), "unknown message field")),
180            field => {
181                let value: LitStr = input.parse()?;
182                match field {
183                    "id" if id.is_none() => id = Some(value.value()),
184                    "default_message" if default_message.is_none() => {
185                        default_message = Some(value.value())
186                    }
187                    "description" if description.is_none() => description = Some(value.value()),
188                    "id" | "default_message" | "description" => {
189                        return Err(syn::Error::new(key.span(), "duplicate message field"));
190                    }
191                    _ => return Err(syn::Error::new(key.span(), "unknown message field")),
192                }
193            }
194        }
195        if input.peek(Token![,]) {
196            input.parse::<Token![,]>()?;
197        } else if !input.is_empty() {
198            return Err(input.error("expected comma"));
199        }
200    }
201
202    Ok(MessageArgs {
203        id,
204        default_message: default_message
205            .ok_or_else(|| syn::Error::new(Span::call_site(), "default_message is required"))?,
206        description,
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn extracts_message_descriptors() {
216        let source = r#"fn main() {
217            let descriptor = message_descriptor!(
218                default_message: "Hello, {name}!",
219                description: "Greeting"
220            );
221        }"#;
222        let messages = extract_messages_from_rust_source(
223            source,
224            Path::new("src/main.rs"),
225            false,
226            false,
227            false,
228            true,
229        )
230        .unwrap();
231        assert_eq!(messages.len(), 1);
232        assert_eq!(messages[0].id.as_deref(), Some("EG1xJTTqQy"));
233        assert_eq!(
234            messages[0].default_message.as_deref(),
235            Some("Hello, {name}!")
236        );
237        assert_eq!(
238            messages[0].description,
239            Some(Value::String("Greeting".to_owned()))
240        );
241    }
242
243    #[test]
244    fn preserves_explicit_id() {
245        let messages = extract_messages_from_rust_source(
246            r#"fn main() { message_descriptor!(id: "hello", default_message: "Hello"); }"#,
247            Path::new("src/main.rs"),
248            false,
249            false,
250            false,
251            true,
252        )
253        .unwrap();
254        assert_eq!(messages[0].id.as_deref(), Some("hello"));
255    }
256
257    #[test]
258    fn extracts_format_message_macros() {
259        let messages = extract_messages_from_rust_source(
260            r#"fn render(intl: &Intl, values: &Values<String>) {
261                format_message!(
262                    &intl,
263                    default_message: "Hello, {name}!",
264                    description: "Greeting",
265                    values: values,
266                );
267                formatjs_intl::format_message!(
268                    &intl,
269                    id: "approval.title",
270                    default_message: "Approve to continue",
271                );
272            }"#,
273            Path::new("src/main.rs"),
274            false,
275            false,
276            false,
277            true,
278        )
279        .unwrap();
280
281        assert_eq!(messages.len(), 2);
282        assert_eq!(messages[0].id.as_deref(), Some("EG1xJTTqQy"));
283        assert_eq!(messages[1].id.as_deref(), Some("approval.title"));
284    }
285
286    #[test]
287    fn reports_source_offsets() {
288        let source = "fn main() {\n    message_descriptor!(default_message: \"Hello\");\n}\n";
289        let messages = extract_messages_from_rust_source(
290            source,
291            Path::new("src/main.rs"),
292            true,
293            false,
294            false,
295            true,
296        )
297        .unwrap();
298        assert_eq!(messages[0].file.as_deref(), Some("src/main.rs"));
299        assert_eq!(messages[0].start, Some(16));
300        assert!(messages[0].end.unwrap() > messages[0].start.unwrap());
301    }
302}