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}