From 3cfa51a4523f5d8ca7e96a77490a7a9a1808f1d4 Mon Sep 17 00:00:00 2001 From: fox0 <15684995+fox0@users.noreply.github.com> Date: Thu, 3 Apr 2025 20:19:07 +0700 Subject: [PATCH 1/3] xgettext: Rough draft v2 --- .gitignore | 4 +- Cargo.lock | 21 + Makefile | 20 + i18n/Cargo.toml | 11 + i18n/iconv.rs | 2 +- i18n/tests/i18n-tests.rs | 1 + i18n/tests/xgettext/mod.rs | 171 ++++++++ i18n/tests/xgettext/test_clap.pot | 36 ++ i18n/tests/xgettext/test_clap.rs | 38 ++ i18n/tests/xgettext/test_gettext.pot | 4 + i18n/tests/xgettext/test_gettext.rs | 9 + i18n/tests/xgettext/test_gettext_no_lines.pot | 3 + i18n/tests/xgettext/test_ngettext.pot | 6 + i18n/tests/xgettext/test_ngettext.rs | 11 + i18n/tests/xgettext/test_npgettext.pot | 7 + i18n/tests/xgettext/test_npgettext.rs | 13 + i18n/tests/xgettext/test_pgettext.pot | 5 + i18n/tests/xgettext/test_pgettext.rs | 11 + i18n/xgettext.rs | 380 ++++++++++++++++++ process/timeout.rs | 2 +- 20 files changed, 751 insertions(+), 4 deletions(-) create mode 100644 Makefile create mode 100644 i18n/tests/xgettext/mod.rs create mode 100644 i18n/tests/xgettext/test_clap.pot create mode 100644 i18n/tests/xgettext/test_clap.rs create mode 100644 i18n/tests/xgettext/test_gettext.pot create mode 100644 i18n/tests/xgettext/test_gettext.rs create mode 100644 i18n/tests/xgettext/test_gettext_no_lines.pot create mode 100644 i18n/tests/xgettext/test_ngettext.pot create mode 100644 i18n/tests/xgettext/test_ngettext.rs create mode 100644 i18n/tests/xgettext/test_npgettext.pot create mode 100644 i18n/tests/xgettext/test_npgettext.rs create mode 100644 i18n/tests/xgettext/test_pgettext.pot create mode 100644 i18n/tests/xgettext/test_pgettext.rs create mode 100644 i18n/xgettext.rs diff --git a/.gitignore b/.gitignore index cde3fc60..aeb5f28c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ - target/ - +locale/posixutils-rs.pot +locale/*/LC_MESSAGES/posixutils-rs.mo diff --git a/Cargo.lock b/Cargo.lock index 33330b4a..9429fc2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1512,8 +1512,13 @@ dependencies = [ "clap", "gettext-rs", "plib", + "pretty_assertions", + "proc-macro2", + "quote", "strum", "strum_macros", + "syn 2.0.100", + "tempfile", ] [[package]] @@ -1699,6 +1704,16 @@ dependencies = [ "zerocopy 0.8.24", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.31" @@ -2832,6 +2847,12 @@ dependencies = [ "bitflags 2.9.0", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9869cf17 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MIT + +PROJECT_NAME = posixutils-rs + +SRCS := $(shell find . -name '*.rs' -not -path './target/*' -not -path './*/tests/*') +POS := $(shell find locale -name '*.po') +MOS := $(POS:.po=.mo) + +locale: $(MOS) + +%.mo: %.po locale/${PROJECT_NAME}.pot + msgmerge $^ -o $< + msgfmt $< -o $@ + +locale/${PROJECT_NAME}.pot: $(SRCS) + cargo r --release --bin xgettext -- -n -p locale -d ${PROJECT_NAME} $^ + +clean: + rm -rf locale/${PROJECT_NAME}.pot + rm -rf $(MOS) diff --git a/i18n/Cargo.toml b/i18n/Cargo.toml index e4600c84..90e2d03c 100644 --- a/i18n/Cargo.toml +++ b/i18n/Cargo.toml @@ -15,6 +15,13 @@ bytemuck = { version = "1.17", features = ["derive"] } byteorder = "1.5" strum = "0.26" strum_macros = "0.26" +proc-macro2 = { version = "1", features = ["span-locations"] } +quote = "1" +syn = { version = "2", features = ["parsing", "full"] } + +[dev-dependencies] +tempfile = "3.10" +pretty_assertions = "1.4" [lints] workspace = true @@ -26,3 +33,7 @@ path = "./gencat.rs" [[bin]] name = "iconv" path = "./iconv.rs" + +[[bin]] +name = "xgettext" +path = "./xgettext.rs" diff --git a/i18n/iconv.rs b/i18n/iconv.rs index b1e3c3c1..69aaf71c 100644 --- a/i18n/iconv.rs +++ b/i18n/iconv.rs @@ -31,7 +31,7 @@ use strum_macros::{Display, EnumIter, EnumString}; mod iconv_lib; #[derive(Parser)] -#[command(version, about=gettext("iconv — codeset conversion"))] +#[command(version, about=gettext("iconv - codeset conversion"))] struct Args { #[arg(short = 'c', help=gettext("Omit invalid characters of the input file from the output"))] omit_invalid: bool, diff --git a/i18n/tests/i18n-tests.rs b/i18n/tests/i18n-tests.rs index 5e14bd44..537374e9 100644 --- a/i18n/tests/i18n-tests.rs +++ b/i18n/tests/i18n-tests.rs @@ -9,3 +9,4 @@ mod gencat; mod iconv; +mod xgettext; diff --git a/i18n/tests/xgettext/mod.rs b/i18n/tests/xgettext/mod.rs new file mode 100644 index 00000000..2afd8989 --- /dev/null +++ b/i18n/tests/xgettext/mod.rs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT + +use std::fs::{read_to_string, remove_file}; +use std::path::Path; + +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +use plib::testing::{run_test, TestPlan}; + +fn xgettext_test, P2: AsRef>( + args: &[&str], + output_file: P, + expected_output_file: P2, +) { + let str_args: Vec = args.iter().map(|s| String::from(*s)).collect(); + run_test(TestPlan { + cmd: String::from("xgettext"), + args: str_args, + stdin_data: "".into(), + expected_out: "".into(), + expected_err: "".into(), + expected_exit_code: 0, + }); + + let output = read_to_string(output_file).expect("Unable to open po-file"); + let expected_out = read_to_string(expected_output_file).unwrap(); + assert_eq!(output, expected_out); +} + +#[test] +fn test_xgettext_no_arg() { + run_test(TestPlan { + cmd: String::from("xgettext"), + args: vec![], + stdin_data: "".into(), + expected_out: "".into(), + expected_err: "xgettext: no input file given\n".into(), + expected_exit_code: 1, + }); +} + +#[test] +fn test_xgettext() { + let output_file = "messages.pot"; + xgettext_test( + &["tests/xgettext/test_gettext.rs"], + output_file, + "tests/xgettext/test_gettext_no_lines.pot", + ); + let _ = remove_file(output_file); +} + +#[test] +fn test_xgettext_domain() { + let output_file = "domain.pot"; + xgettext_test( + &["-d", "domain", "tests/xgettext/test_gettext.rs"], + output_file, + "tests/xgettext/test_gettext_no_lines.pot", + ); + let _ = remove_file(output_file); +} + +#[test] +fn test_xgettext_pathname() { + let temp_dir = tempdir().expect("Unable to create temporary directory"); + xgettext_test( + &[ + "-p", + &temp_dir.path().to_str().unwrap(), + "tests/xgettext/test_gettext.rs", + ], + temp_dir.path().join("messages.pot"), + "tests/xgettext/test_gettext_no_lines.pot", + ); +} + +#[test] +fn test_xgettext_domain_pathname() { + let temp_dir = tempdir().expect("Unable to create temporary directory"); + xgettext_test( + &[ + "-d", + "domain", + "-p", + &temp_dir.path().to_str().unwrap(), + "tests/xgettext/test_gettext.rs", + ], + temp_dir.path().join("domain.pot"), + "tests/xgettext/test_gettext_no_lines.pot", + ); +} + +#[test] +fn test_xgettext_pathname_lines() { + let temp_dir = tempdir().expect("Unable to create temporary directory"); + xgettext_test( + &[ + "-n", + "-p", + &temp_dir.path().to_str().unwrap(), + "tests/xgettext/test_gettext.rs", + ], + temp_dir.path().join("messages.pot"), + "tests/xgettext/test_gettext.pot", + ); +} + +#[test] +fn test_clap() { + let temp_dir = tempdir().expect("Unable to create temporary directory"); + xgettext_test( + &[ + "-n", + "-p", + &temp_dir.path().to_str().unwrap(), + "tests/xgettext/test_clap.rs", + ], + temp_dir.path().join("messages.pot"), + "tests/xgettext/test_clap.pot", + ); +} + +#[ignore] +#[test] +fn test_xgettext_ngettext() { + let temp_dir = tempdir().expect("Unable to create temporary directory"); + xgettext_test( + &[ + "-n", + "-p", + &temp_dir.path().to_str().unwrap(), + "tests/xgettext/test_ngettext.rs", + ], + temp_dir.path().join("messages.pot"), + "tests/xgettext/test_ngettext.pot", + ); +} + +#[ignore] +#[test] +fn test_xgettext_pgettext() { + let temp_dir = tempdir().expect("Unable to create temporary directory"); + xgettext_test( + &[ + "-n", + "-p", + &temp_dir.path().to_str().unwrap(), + "tests/xgettext/test_pgettext.rs", + ], + temp_dir.path().join("messages.pot"), + "tests/xgettext/test_pgettext.pot", + ); +} + +#[ignore] +#[test] +fn test_xgettext_npgettext() { + let temp_dir = tempdir().expect("Unable to create temporary directory"); + xgettext_test( + &[ + "-n", + "-p", + &temp_dir.path().to_str().unwrap(), + "tests/xgettext/test_npgettext.rs", + ], + temp_dir.path().join("messages.pot"), + "tests/xgettext/test_npgettext.pot", + ); +} diff --git a/i18n/tests/xgettext/test_clap.pot b/i18n/tests/xgettext/test_clap.pot new file mode 100644 index 00000000..3cd1cd3e --- /dev/null +++ b/i18n/tests/xgettext/test_clap.pot @@ -0,0 +1,36 @@ +#: tests/xgettext/test_clap.rs:29 +msgid "Arguments for the utility" +msgstr "" + +#: tests/xgettext/test_clap.rs:33 +msgid "Print help" +msgstr "" + +#: tests/xgettext/test_clap.rs:36 +msgid "Print version" +msgstr "" + +#: tests/xgettext/test_clap.rs:23 +msgid "The utility to be invoked" +msgstr "" + +#: tests/xgettext/test_clap.rs:19 +msgid "Write timing output to standard error in POSIX format" +msgstr "" + +#: tests/xgettext/test_clap.rs:10 +msgid "time - time a simple command or give resource usage" +msgstr "" + +#: tests/xgettext/test_clap.rs:11 +msgid "{about}\n" +"\n" +"Usage: {usage}\n" +"\n" +"Arguments:\n" +"{positionals}\n" +"\n" +"Options:\n" +"{options}" +msgstr "" + diff --git a/i18n/tests/xgettext/test_clap.rs b/i18n/tests/xgettext/test_clap.rs new file mode 100644 index 00000000..51ba97d6 --- /dev/null +++ b/i18n/tests/xgettext/test_clap.rs @@ -0,0 +1,38 @@ +use clap::Parser; + +use gettextrs::{ + bind_textdomain_codeset, bindtextdomain, gettext, setlocale, textdomain, LocaleCategory, +}; + +#[derive(Parser)] +#[command( + version, + about = gettext("time - time a simple command or give resource usage"), + help_template = gettext("{about}\n\nUsage: {usage}\n\nArguments:\n{positionals}\n\nOptions:\n{options}"), + disable_help_flag = true, + disable_version_flag = true, +)] +struct Args { + #[arg( + short, + long, + help = gettext("Write timing output to standard error in POSIX format") + )] + posix: bool, + + #[arg(help = gettext("The utility to be invoked"))] + utility: String, + + #[arg( + name = "ARGUMENT", + trailing_var_arg = true, + help = gettext("Arguments for the utility") + )] + arguments: Vec, + + #[arg(short, long, help = gettext("Print help"), action = clap::ArgAction::HelpLong)] + help: Option, + + #[arg(short = 'V', long, help = gettext("Print version"), action = clap::ArgAction::Version)] + version: Option, +} diff --git a/i18n/tests/xgettext/test_gettext.pot b/i18n/tests/xgettext/test_gettext.pot new file mode 100644 index 00000000..ca817d30 --- /dev/null +++ b/i18n/tests/xgettext/test_gettext.pot @@ -0,0 +1,4 @@ +#: tests/xgettext/test_gettext.rs:6 +msgid "Hello, world!" +msgstr "" + diff --git a/i18n/tests/xgettext/test_gettext.rs b/i18n/tests/xgettext/test_gettext.rs new file mode 100644 index 00000000..272cdef3 --- /dev/null +++ b/i18n/tests/xgettext/test_gettext.rs @@ -0,0 +1,9 @@ +use gettextrs::*; + +fn main() -> Result<(), Box> { + // `gettext()` simultaneously marks a string for translation and translates + // it at runtime. + println!("Translated: {}", gettext("Hello, world!")); + + Ok(()) +} diff --git a/i18n/tests/xgettext/test_gettext_no_lines.pot b/i18n/tests/xgettext/test_gettext_no_lines.pot new file mode 100644 index 00000000..6d8e0aa4 --- /dev/null +++ b/i18n/tests/xgettext/test_gettext_no_lines.pot @@ -0,0 +1,3 @@ +msgid "Hello, world!" +msgstr "" + diff --git a/i18n/tests/xgettext/test_ngettext.pot b/i18n/tests/xgettext/test_ngettext.pot new file mode 100644 index 00000000..e17460fa --- /dev/null +++ b/i18n/tests/xgettext/test_ngettext.pot @@ -0,0 +1,6 @@ +#: tests/xgettext/test_ngettext.rs:7 +#: tests/xgettext/test_ngettext.rs:8 +msgid "One thing" +msgid_plural "Multiple things" +msgstr[0] "" +msgstr[1] "" diff --git a/i18n/tests/xgettext/test_ngettext.rs b/i18n/tests/xgettext/test_ngettext.rs new file mode 100644 index 00000000..52bc9424 --- /dev/null +++ b/i18n/tests/xgettext/test_ngettext.rs @@ -0,0 +1,11 @@ +use gettextrs::*; + +fn main() -> Result<(), Box> { + // gettext supports plurals, i.e. you can have different messages depending + // on the number of items the message mentions. This even works for + // languages that have more than one plural form, like Russian or Czech. + println!("Singular: {}", ngettext("One thing", "Multiple things", 1)); + println!("Plural: {}", ngettext("One thing", "Multiple things", 2)); + + Ok(()) +} diff --git a/i18n/tests/xgettext/test_npgettext.pot b/i18n/tests/xgettext/test_npgettext.pot new file mode 100644 index 00000000..9f3b96ad --- /dev/null +++ b/i18n/tests/xgettext/test_npgettext.pot @@ -0,0 +1,7 @@ +#: tests/xgettext/test_npgettext.rs:10 +msgctxt "This is the context" +msgid "One thing" +msgid_plural "Multiple things" +msgstr[0] "" +msgstr[1] "" + diff --git a/i18n/tests/xgettext/test_npgettext.rs b/i18n/tests/xgettext/test_npgettext.rs new file mode 100644 index 00000000..5eea53f8 --- /dev/null +++ b/i18n/tests/xgettext/test_npgettext.rs @@ -0,0 +1,13 @@ +use gettextrs::*; + +fn main() -> Result<(), Box> { + // gettext de-duplicates strings, i.e. the same string used multiple times + // will have a single entry in the PO and MO files. However, the same words + // might have different meaning depending on the context. To distinguish + // between different contexts, gettext accepts an additional string: + println!( + "Plural with context: {}", + npgettext("This is the context", "One thing", "Multiple things", 2)); + + Ok(()) +} diff --git a/i18n/tests/xgettext/test_pgettext.pot b/i18n/tests/xgettext/test_pgettext.pot new file mode 100644 index 00000000..f4b18022 --- /dev/null +++ b/i18n/tests/xgettext/test_pgettext.pot @@ -0,0 +1,5 @@ +#: tests/xgettext/test_pgettext.rs:8 +msgctxt "This is the context" +msgid "Hello, world!" +msgstr "" + diff --git a/i18n/tests/xgettext/test_pgettext.rs b/i18n/tests/xgettext/test_pgettext.rs new file mode 100644 index 00000000..14000c88 --- /dev/null +++ b/i18n/tests/xgettext/test_pgettext.rs @@ -0,0 +1,11 @@ +use gettextrs::*; + +fn main() -> Result<(), Box> { + // gettext de-duplicates strings, i.e. the same string used multiple times + // will have a single entry in the PO and MO files. However, the same words + // might have different meaning depending on the context. To distinguish + // between different contexts, gettext accepts an additional string: + println!("With context: {}", pgettext("This is the context", "Hello, world!")); + + Ok(()) +} diff --git a/i18n/xgettext.rs b/i18n/xgettext.rs new file mode 100644 index 00000000..c2770c27 --- /dev/null +++ b/i18n/xgettext.rs @@ -0,0 +1,380 @@ +// +// Copyright (c) 2025 fox0 +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use std::collections::{HashMap, HashSet}; +use std::env::current_dir; +use std::ffi::OsStr; +use std::fs::{read_to_string, File}; +use std::io::Write; +use std::path::PathBuf; +use std::process::exit; + +use clap::Parser; +#[cfg(debug_assertions)] +use gettextrs::bindtextdomain; +use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; +use proc_macro2::{TokenStream, TokenTree}; +use quote::ToTokens; +use syn::{parse_file, parse_str, LitStr}; + +#[derive(Parser)] +#[command( + version, + about = gettext("xgettext - extract gettext call strings from C-language source files (DEVELOPMENT)"), + help_template = gettext("{about}\n\nUsage: {usage}\n\nArguments:\n{positionals}\n\nOptions:\n{options}"), + disable_help_flag = true, + disable_version_flag = true, +)] +struct Args { + #[arg( + short, + help = gettext("Extract all strings, not just those found in calls to gettext family functions. Only one dot-po file shall be created") + )] + all: bool, + + #[arg( + short, + help = gettext("Name the default output file DEFAULT_DOMAIN.po instead of messages.po") + )] + default_domain: Option, + + #[arg( + short, + help = gettext("\ + Join messages from C-language source files with existing dot-po files. For each dot-po file that xgettext writes messages to, \ + if the file does not exist, it shall be created. New messages shall be appended but any subsections with duplicate msgid values \ + except the first (including msgid values found in an existing dot-po file) shall either be commented out or omitted \ + in the resulting dot-po file; if omitted, a warning message may be written to standard error. Domain directives in the existing \ + dot-po files shall be ignored; the assumption is that all previous msgid values belong to the same domain. The behavior \ + is unspecified if an existing dot-po file was not created by xgettext or has been modified by another application.") + )] + join: bool, + + #[arg( + short = 'K', + help = gettext("\ + Specify an additional keyword to be looked for:\n\ + * If KEYWORD_SPEC is an empty string, this shall disable the use of default keywords for the gettext family of functions.\n\ + * If KEYWORD_SPEC is a C identifier, xgettext shall look for strings in the first argument of each call to the function or macro KEYWORD_SPEC.\n\ + * If KEYWORD_SPEC is of the form id:argnum then xgettext shall treat the argnum-th argument of a call to the function or macro id as the msgid argument, \ + where argnum 1 is the first argument.\n\ + * If KEYWORD_SPEC is of the form id:argnum1,argnum2 then xgettext shall treat strings in the argnum1-th argument \ + and in the argnum2-th argument of a call to the function or macro id as the msgid and msgid_plural arguments, respectively."), + default_value = "gettext" + )] + keyword_spec: Vec, + + #[arg( + short, + help = gettext("Add comment lines to the output file indicating pathnames and line numbers in the source files where each extracted string is encountered") + )] + numbers_lines: bool, + + #[arg( + short, + help = gettext("Create output files in the directory specified by pathname instead of in the current working directory") + )] + pathname: Option, + + #[arg( + short = 'X', + help = gettext("\ + Specify a file containing strings that shall not be extracted from the input files. \ + The format of EXCLUDE_FILE is identical to that of a dot-po file. However, \ + only statements containing msgid directives in EXCLUDE_FILE shall be used. All other statements shall be ignored.") + )] + exclude_file: Option, + + #[arg(short, long, help = gettext("Print help"), action = clap::ArgAction::HelpLong)] + help: Option, + + #[arg(short = 'V', long, help = gettext("Print version"), action = clap::ArgAction::Version)] + version: Option, + + #[arg( + name = "FILE", + trailing_var_arg = true, + help = gettext("A pathname of an input file containing C-language source code. If '-' is specified for an instance of file, the standard input shall be used.") + )] + files: Vec, +} + +#[derive(Debug)] +// #[cfg_attr(test, derive(Debug))] +pub struct Walker { + keywords: HashSet, + numbers_lines: bool, + /// msgid + messages: HashMap>, +} + +#[derive(Eq, PartialEq, PartialOrd, Ord)] +pub struct Line { + path: String, + line: usize, +} + +impl std::fmt::Display for Line { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.path, self.line)?; + Ok(()) + } +} + +// #[cfg(test)] +impl std::fmt::Debug for Line { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self)?; + Ok(()) + } +} + +impl Walker { + pub fn new(keyword_spec: Vec, numbers_lines: bool) -> Self { + assert!(!keyword_spec.is_empty()); + let mut keywords = HashSet::new(); + for value in keyword_spec { + keywords.insert(value); + } + + Self { + keywords, + numbers_lines, + messages: HashMap::new(), + } + } + + pub fn process_rust_file(&mut self, content: String, path: String) -> Result<(), syn::Error> { + let file = parse_file(&content)?; + self.walk(file.into_token_stream(), &path); + Ok(()) + } + + fn walk(&mut self, stream: TokenStream, path: &String) { + let mut iter = stream.into_iter().peekable(); + while let Some(token) = iter.next() { + match token { + TokenTree::Group(group) => { + // going into recursion + self.walk(group.stream(), path); + } + TokenTree::Ident(ident) => { + if self.keywords.contains(&ident.to_string()) { + if let Some(TokenTree::Group(group)) = iter.peek() { + if let Some(literal) = Self::extract(group.stream()) { + self.push(literal, path); + } + let _ = iter.next(); + } + } + } + _ => {} + } + } + } + + // literal string only + fn extract(stream: TokenStream) -> Option { + let mut iter = stream.into_iter().peekable(); + if let Some(TokenTree::Literal(literal)) = iter.next() { + let span = literal.span(); + let literal: Option = parse_str(&literal.to_string()).ok(); + if let Some(mut literal) = literal { + literal.set_span(span); + return Some(literal); + } + } + None + } + + fn push(&mut self, literal: LitStr, path: &String) { + let value = literal.value(); + if self.numbers_lines { + let path = path.clone(); + let lc = literal.span().start(); + let line = lc.line; + // let column = lc.column; + let line = Line { path, line }; + match self.messages.get_mut(&value) { + Some(v) => v.push(line), + None => { + self.messages.insert(value, vec![line]); + } + }; + } else { + match self.messages.get_mut(&value) { + Some(_) => {} + None => { + self.messages.insert(value, vec![]); + } + }; + } + } + + pub fn sort(&mut self) { + if !self.numbers_lines { + return; + } + for (_, lines) in &mut self.messages { + lines.sort(); + } + } + + fn escape(value: &String) -> String { + format!( + "\"{}\"", + value.replace("\"", "\\\"").replace("\n", "\\n\"\n\"") + ) + } +} + +impl std::fmt::Display for Walker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut vec: Vec<_> = self.messages.iter().collect(); + vec.sort_by(|a, b| a.0.cmp(b.0)); + for (str, lines) in vec { + debug_assert!( + (self.numbers_lines && !lines.is_empty()) + || (!self.numbers_lines && lines.is_empty()) + ); + for line in lines { + writeln!(f, "#: {}", line)?; + } + writeln!(f, "msgid {}", Self::escape(str))?; + writeln!(f, "msgstr \"\"")?; + writeln!(f, "")?; + } + Ok(()) + } +} + +fn main() -> Result<(), Box> { + setlocale(LocaleCategory::LcAll, ""); + textdomain("posixutils-rs")?; + #[cfg(debug_assertions)] + bindtextdomain("posixutils-rs", "locale")?; + bind_textdomain_codeset("posixutils-rs", "UTF-8")?; + + let args = Args::parse(); + + if args.files.is_empty() { + eprintln!("xgettext: {}", gettext("no input file given")); + exit(1); + } + + let mut walker = Walker::new(args.keyword_spec, args.numbers_lines); + + for path in args.files { + match path.extension().and_then(OsStr::to_str) { + Some("rs") => { + let content = read_to_string(&path)?; + let path = path.into_os_string().into_string().unwrap(); + walker.process_rust_file(content, path)?; + }, + _ => { + eprintln!("xgettext: {}", gettext("unsupported file type")); + exit(1); + }, + } + } + + walker.sort(); + + let output = args + .pathname + .unwrap_or_else(|| current_dir().unwrap()) + .join(format!( + "{}.pot", + args.default_domain + .unwrap_or_else(|| { "messages".to_string() }) + )); + + let mut output = File::create(output)?; + write!(output, "{}", walker)?; + + exit(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn test_process_rust_file() { + let code = String::from( + r#"fn main() { + assert_eq!("Hello, world!", gettext("Hello, world!")); +} +"#, + ); + let mut walker = Walker::new(vec!["gettext".into()], true); + walker + .process_rust_file(code, "test_process_rust_file.rs".to_string()) + .unwrap(); + assert_eq!(walker.messages.keys().len(), 1); + let lines = &walker.messages["Hello, world!"]; + assert_eq!(lines.len(), 1); + assert_eq!( + lines[0], + Line { + path: "test_process_rust_file.rs".into(), + line: 2 + } + ); + } + + #[test] + fn test_process_rust_file_format() { + let code = String::from( + r#"fn main() { + assert_eq!("Hello, world!", gettext("Hello, world!")); +} +"#, + ); + let mut walker = Walker::new(vec!["gettext".into()], true); + walker + .process_rust_file(code, "test_process_rust_file_format.rs".to_string()) + .unwrap(); + assert_eq!( + format!("{}", walker), + r#"#: test_process_rust_file_format.rs:2 +msgid "Hello, world!" +msgstr "" + +"# + ); + } + + #[test] + fn test_escape() { + let value = Walker::escape( + &"{about}\n\nUsage: {usage}\n\nArguments:\n{positionals}\n\nOptions:\n{options}".into(), + ); + assert_eq!( + value, + r#""{about}\n" +"\n" +"Usage: {usage}\n" +"\n" +"Arguments:\n" +"{positionals}\n" +"\n" +"Options:\n" +"{options}""# + ); + } + + #[test] + fn test_escape2() { + let value = Walker::escape(&"string \"string2\" string".into()); + assert_eq!(value, r#""string \"string2\" string""#); + } +} diff --git a/process/timeout.rs b/process/timeout.rs index 8faa211a..8b49b27c 100644 --- a/process/timeout.rs +++ b/process/timeout.rs @@ -30,7 +30,7 @@ static MONITORED_PID: AtomicI32 = AtomicI32::new(0); static TIMED_OUT: AtomicBool = AtomicBool::new(false); #[derive(Parser)] -#[command(version, about = gettext("timeout — execute a utility with a time limit"))] +#[command(version, about = gettext("timeout - execute a utility with a time limit"))] struct Args { #[arg(short = 'f', long, help=gettext("Only time out the utility itself, not its descendants."))] foreground: bool, From b88aa55938403e56cfb770b81105455f1386fbc5 Mon Sep 17 00:00:00 2001 From: fox0 <15684995+fox0@users.noreply.github.com> Date: Thu, 3 Apr 2025 20:22:17 +0700 Subject: [PATCH 2/3] use xgettext --- locale/bn/LC_MESSAGES/posixutils-rs.po | 0 locale/es/LC_MESSAGES/posixutils-rs.po | 0 locale/hi/LC_MESSAGES/posixutils-rs.po | 0 locale/ja/LC_MESSAGES/posixutils-rs.po | 0 locale/pt/LC_MESSAGES/posixutils-rs.po | 0 locale/ru/LC_MESSAGES/posixutils-rs.po | 0 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 locale/bn/LC_MESSAGES/posixutils-rs.po create mode 100644 locale/es/LC_MESSAGES/posixutils-rs.po create mode 100644 locale/hi/LC_MESSAGES/posixutils-rs.po create mode 100644 locale/ja/LC_MESSAGES/posixutils-rs.po create mode 100644 locale/pt/LC_MESSAGES/posixutils-rs.po create mode 100644 locale/ru/LC_MESSAGES/posixutils-rs.po diff --git a/locale/bn/LC_MESSAGES/posixutils-rs.po b/locale/bn/LC_MESSAGES/posixutils-rs.po new file mode 100644 index 00000000..e69de29b diff --git a/locale/es/LC_MESSAGES/posixutils-rs.po b/locale/es/LC_MESSAGES/posixutils-rs.po new file mode 100644 index 00000000..e69de29b diff --git a/locale/hi/LC_MESSAGES/posixutils-rs.po b/locale/hi/LC_MESSAGES/posixutils-rs.po new file mode 100644 index 00000000..e69de29b diff --git a/locale/ja/LC_MESSAGES/posixutils-rs.po b/locale/ja/LC_MESSAGES/posixutils-rs.po new file mode 100644 index 00000000..e69de29b diff --git a/locale/pt/LC_MESSAGES/posixutils-rs.po b/locale/pt/LC_MESSAGES/posixutils-rs.po new file mode 100644 index 00000000..e69de29b diff --git a/locale/ru/LC_MESSAGES/posixutils-rs.po b/locale/ru/LC_MESSAGES/posixutils-rs.po new file mode 100644 index 00000000..e69de29b From 1593c6fdb7019149728b41c121474bb946c8d2d4 Mon Sep 17 00:00:00 2001 From: fox0 <15684995+fox0@users.noreply.github.com> Date: Thu, 3 Apr 2025 21:53:07 +0700 Subject: [PATCH 3/3] xgettext: default_domain --- i18n/xgettext.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/i18n/xgettext.rs b/i18n/xgettext.rs index c2770c27..3ab9f64c 100644 --- a/i18n/xgettext.rs +++ b/i18n/xgettext.rs @@ -40,9 +40,10 @@ struct Args { #[arg( short, - help = gettext("Name the default output file DEFAULT_DOMAIN.po instead of messages.po") + help = gettext("Name the default output file DEFAULT_DOMAIN.po instead of messages.po"), + default_value = "messages" )] - default_domain: Option, + default_domain: String, #[arg( short, @@ -276,11 +277,11 @@ fn main() -> Result<(), Box> { let content = read_to_string(&path)?; let path = path.into_os_string().into_string().unwrap(); walker.process_rust_file(content, path)?; - }, - _ => { + } + _ => { eprintln!("xgettext: {}", gettext("unsupported file type")); exit(1); - }, + } } } @@ -289,11 +290,7 @@ fn main() -> Result<(), Box> { let output = args .pathname .unwrap_or_else(|| current_dir().unwrap()) - .join(format!( - "{}.pot", - args.default_domain - .unwrap_or_else(|| { "messages".to_string() }) - )); + .join(format!("{}.pot", args.default_domain)); let mut output = File::create(output)?; write!(output, "{}", walker)?;