1 | // Copyright © SixtyFPS GmbH <info@slint.dev> |
2 | // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-1.1 OR LicenseRef-Slint-commercial |
3 | |
4 | use clap::Parser; |
5 | use i_slint_compiler::diagnostics::{BuildDiagnostics, Spanned}; |
6 | use i_slint_compiler::parser::{syntax_nodes, SyntaxKind, SyntaxNode}; |
7 | use std::fmt::Write; |
8 | |
9 | type Messages = polib::catalog::Catalog; |
10 | |
11 | #[derive (clap::Parser)] |
12 | #[command(author, version, about, long_about = None)] |
13 | struct Cli { |
14 | #[arg(name = "path to .slint file(s)" , action)] |
15 | paths: Vec<std::path::PathBuf>, |
16 | |
17 | #[arg(long = "default-domain" , short = 'd' )] |
18 | domain: Option<String>, |
19 | |
20 | #[arg( |
21 | name = "file" , |
22 | short = 'o' , |
23 | help = "Write output to specified file (instead of messages.po)." |
24 | )] |
25 | output: Option<std::path::PathBuf>, |
26 | |
27 | //#[arg(long = "omit-header", help = r#"Don’t write header with ‘msgid ""’ entry"#)] |
28 | //omit_header: bool, |
29 | // |
30 | //#[arg(long = "copyright-holder", help = "Set the copyright holder in the output")] |
31 | //copyright_holder: Option<String>, |
32 | // |
33 | #[arg(long = "package-name" , help = "Set the package name in the header of the output" )] |
34 | package_name: Option<String>, |
35 | |
36 | #[arg(long = "package-version" , help = "Set the package version in the header of the output" )] |
37 | package_version: Option<String>, |
38 | // |
39 | // #[arg( |
40 | // long = "msgid-bugs-address", |
41 | // help = "Set the reporting address for msgid bugs. This is the email address or URL to which the translators shall report bugs in the untranslated strings" |
42 | // )] |
43 | // msgid_bugs_address: Option<String>, |
44 | #[arg(long = "join-existing" , short = 'j' )] |
45 | /// Join messages with existing file |
46 | join_existing: bool, |
47 | } |
48 | |
49 | fn main() -> std::io::Result<()> { |
50 | let args = Cli::parse(); |
51 | |
52 | let output = args.output.unwrap_or_else(|| { |
53 | format!(" {}.po" , args.domain.as_ref().map(String::as_str).unwrap_or("messages" )).into() |
54 | }); |
55 | |
56 | let mut messages = if args.join_existing { |
57 | polib::po_file::parse(&output) |
58 | .map_err(|x| std::io::Error::new(std::io::ErrorKind::Other, x))? |
59 | } else { |
60 | let package = args.package_name.as_ref().map(|x| x.as_ref()).unwrap_or("PACKAGE" ); |
61 | let version = args.package_version.as_ref().map(|x| x.as_ref()).unwrap_or("VERSION" ); |
62 | let metadata = polib::metadata::CatalogMetadata { |
63 | project_id_version: format!(" {package} {version}" ,), |
64 | pot_creation_date: chrono::Utc::now().format("%Y-%m-%d %H:%M%z" ).to_string(), |
65 | po_revision_date: "YEAR-MO-DA HO:MI+ZONE" .into(), |
66 | last_translator: "FULL NAME <EMAIL@ADDRESS>" .into(), |
67 | language_team: "LANGUAGE <LL@li.org>" .into(), |
68 | mime_version: "1.0" .into(), |
69 | content_type: "text/plain; charset=UTF-8" .into(), |
70 | content_transfer_encoding: "8bit" .into(), |
71 | language: String::new(), |
72 | plural_rules: Default::default(), |
73 | // "Report-Msgid-Bugs-To: {address}" addess = output_details.bugs_address |
74 | }; |
75 | |
76 | Messages::new(metadata) |
77 | }; |
78 | |
79 | for path in args.paths { |
80 | process_file(path, &mut messages)? |
81 | } |
82 | |
83 | polib::po_file::write(&messages, &output)?; |
84 | Ok(()) |
85 | } |
86 | |
87 | fn process_file(path: std::path::PathBuf, messages: &mut Messages) -> std::io::Result<()> { |
88 | let mut diag: BuildDiagnostics = BuildDiagnostics::default(); |
89 | let syntax_node: SyntaxNode = i_slint_compiler::parser::parse_file(path, &mut diag).ok_or_else(|| { |
90 | std::io::Error::new(kind:std::io::ErrorKind::Other, error:diag.to_string_vec().join(sep:", " )) |
91 | })?; |
92 | visit_node(syntax_node, results:messages, current_context:None); |
93 | |
94 | Ok(()) |
95 | } |
96 | |
97 | fn visit_node(node: SyntaxNode, results: &mut Messages, current_context: Option<String>) { |
98 | for n in node.children() { |
99 | if n.kind() == SyntaxKind::AtTr { |
100 | if let Some(msgid) = n |
101 | .child_text(SyntaxKind::StringLiteral) |
102 | .and_then(|s| i_slint_compiler::literals::unescape_string(&s)) |
103 | { |
104 | let tr = syntax_nodes::AtTr::from(n.clone()); |
105 | let msgctxt = tr |
106 | .TrContext() |
107 | .and_then(|n| n.child_text(SyntaxKind::StringLiteral)) |
108 | .and_then(|s| i_slint_compiler::literals::unescape_string(&s)) |
109 | .or_else(|| current_context.clone()); |
110 | let plural = tr |
111 | .TrPlural() |
112 | .and_then(|n| n.child_text(SyntaxKind::StringLiteral)) |
113 | .and_then(|s| i_slint_compiler::literals::unescape_string(&s)); |
114 | |
115 | let update = |msg: &mut dyn polib::message::MessageMutView| { |
116 | let span = node.span(); |
117 | if span.is_valid() { |
118 | let (line, _) = node.source_file.line_column(span.offset); |
119 | if line > 0 { |
120 | let source = msg.source_mut(); |
121 | let path = node.source_file.path().to_string_lossy(); |
122 | if source.is_empty() { |
123 | *source = format!(" {path}: {line}" ); |
124 | } else { |
125 | write!(source, " {path}: {line}" ).unwrap(); |
126 | } |
127 | } |
128 | } |
129 | |
130 | let comment = msg.comments_mut(); |
131 | if comment.is_empty() { |
132 | if let Some(c) = tr |
133 | .child_token(SyntaxKind::StringLiteral) |
134 | .and_then(get_comments_before_line) |
135 | .or_else(|| tr.first_token().and_then(get_comments_before_line)) |
136 | { |
137 | *comment = c; |
138 | } |
139 | } |
140 | }; |
141 | |
142 | if let Some(mut x) = |
143 | results.find_message_mut(msgctxt.as_deref(), &msgid, plural.as_deref()) |
144 | { |
145 | update(&mut x) |
146 | } else { |
147 | let mut builder = if let Some(plural) = plural { |
148 | let mut builder = polib::message::Message::build_plural(); |
149 | builder.with_msgid_plural(plural); |
150 | // Workaround for #4238 : poedit doesn't add the plural by default. |
151 | builder.with_msgstr_plural(vec![String::new(), String::new()]); |
152 | builder |
153 | } else { |
154 | polib::message::Message::build_singular() |
155 | }; |
156 | builder.with_msgid(msgid); |
157 | if let Some(msgctxt) = msgctxt { |
158 | builder.with_msgctxt(msgctxt); |
159 | } |
160 | let mut msg = builder.done(); |
161 | update(&mut msg); |
162 | results.append_or_update(msg); |
163 | } |
164 | } |
165 | } |
166 | let current_context = syntax_nodes::Component::new(n.clone()) |
167 | .and_then(|x| { |
168 | x.DeclaredIdentifier() |
169 | .child_text(SyntaxKind::Identifier) |
170 | .map(|t| i_slint_compiler::parser::normalize_identifier(&t)) |
171 | }) |
172 | .or_else(|| current_context.clone()); |
173 | visit_node(n, results, current_context); |
174 | } |
175 | } |
176 | |
177 | fn get_comments_before_line(token: i_slint_compiler::parser::SyntaxToken) -> Option<String> { |
178 | let mut token = token.prev_token()?; |
179 | loop { |
180 | if token.kind() == SyntaxKind::Whitespace { |
181 | let mut lines = token.text().lines(); |
182 | lines.next(); |
183 | if lines.next().is_some() { |
184 | // One \n |
185 | if lines.next().is_some() { |
186 | return None; // two \n or more |
187 | } |
188 | token = token.prev_token()?; |
189 | if token.kind() == SyntaxKind::Comment && token.text().starts_with("//" ) { |
190 | return Some(token.text().trim_start_matches('/' ).trim().into()); |
191 | } |
192 | return None; |
193 | } |
194 | } |
195 | token = token.prev_token()?; |
196 | } |
197 | } |
198 | |
199 | #[test ] |
200 | fn extract_messages() { |
201 | use itertools::Itertools; |
202 | |
203 | #[derive (PartialEq, Debug)] |
204 | pub struct M<'a> { |
205 | pub msgid: &'a str, |
206 | pub msgctx: &'a str, |
207 | pub plural: &'a str, |
208 | pub comments: &'a str, |
209 | pub locations: String, |
210 | } |
211 | |
212 | impl M<'static> { |
213 | pub fn new( |
214 | msgid: &'static str, |
215 | plural: &'static str, |
216 | msgctx: &'static str, |
217 | comments: &'static str, |
218 | locations: &'static [usize], |
219 | ) -> Self { |
220 | let locations = locations.iter().map(|l| format!("test.slint: {l}" ,)).join(" " ); |
221 | Self { msgid, msgctx, plural, comments, locations } |
222 | } |
223 | } |
224 | |
225 | let source = r##"export component Foo { |
226 | // comment 1 |
227 | x: @tr("Message 1"); |
228 | // comment does not count |
229 | |
230 | // comment 2 |
231 | y: @tr("ctx" => "Message 2"); |
232 | // comment does not count |
233 | |
234 | z: @tr("Message 3" | "Messages 3" % x); |
235 | |
236 | // comment 4 |
237 | a: @tr("ctx4" => "Message 4" | "Messages 4" % x); |
238 | |
239 | //recursive |
240 | rec: @tr("rec1 {}", @tr("rec2")); |
241 | |
242 | nl: @tr("rw\nctx" => "r\nw"); |
243 | |
244 | // comment does not count : xgettext takes the comment next to the string |
245 | xx: @tr( |
246 | //multi line |
247 | "multi-line\nsecond line" |
248 | ); |
249 | |
250 | // comment 5 |
251 | d: @tr("dup1"); |
252 | d: @tr("ctx" => "dup1"); |
253 | d: @tr("dup1"); |
254 | // comment 6 |
255 | d: @tr("ctx" => "dup1"); |
256 | |
257 | // two-line-comment |
258 | // macro and string on different line |
259 | x: @tr( |
260 | "x" |
261 | ); |
262 | } |
263 | global Xx_x { |
264 | property <string> moo: @tr("Global"); |
265 | } |
266 | }"## ; |
267 | |
268 | let r = [ |
269 | M::new("Message 1" , "" , "Foo" , "comment 1" , &[3]), |
270 | M::new("Message 2" , "" , "ctx" , "comment 2" , &[7]), |
271 | M::new("Message 3" , "Messages 3" , "Foo" , "" , &[10]), |
272 | M::new("Message 4" , "Messages 4" , "ctx4" , "comment 4" , &[13]), |
273 | M::new("rec1 {}" , "" , "Foo" , "recursive" , &[16]), |
274 | M::new("rec2" , "" , "Foo" , "recursive" , &[16]), |
275 | M::new("r \nw" , "" , "rw \nctx" , "" , &[18]), |
276 | M::new("multi-line \nsecond line" , "" , "Foo" , "multi line" , &[21]), |
277 | M::new("dup1" , "" , "Foo" , "comment 5" , &[27, 29]), |
278 | M::new("dup1" , "" , "ctx" , "comment 6" , &[28, 31]), |
279 | M::new("x" , "" , "Foo" , "macro and string on different line" , &[35]), |
280 | M::new("Global" , "" , "Xx-x" , "" , &[40]), |
281 | ]; |
282 | |
283 | let mut diag = BuildDiagnostics::default(); |
284 | let syntax_node = i_slint_compiler::parser::parse( |
285 | source.into(), |
286 | Some(std::path::Path::new("test.slint" )), |
287 | None, |
288 | &mut diag, |
289 | ); |
290 | |
291 | let mut messages = polib::catalog::Catalog::new(Default::default()); |
292 | visit_node(syntax_node, &mut messages, None); |
293 | |
294 | for (a, b) in r.iter().zip(messages.messages()) { |
295 | assert_eq!( |
296 | *a, |
297 | M { |
298 | msgid: b.msgid(), |
299 | msgctx: b.msgctxt(), |
300 | plural: b.msgid_plural().unwrap_or_default(), |
301 | comments: b.comments(), |
302 | locations: b.source().into() |
303 | } |
304 | ); |
305 | } |
306 | assert_eq!(r.len(), messages.count()); |
307 | } |
308 | |