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
4use clap::Parser;
5use i_slint_compiler::diagnostics::{BuildDiagnostics, Spanned};
6use i_slint_compiler::parser::{syntax_nodes, SyntaxKind, SyntaxNode};
7use std::fmt::Write;
8
9type Messages = polib::catalog::Catalog;
10
11#[derive(clap::Parser)]
12#[command(author, version, about, long_about = None)]
13struct 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
49fn 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
87fn 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
97fn 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
177fn 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]
200fn 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