Skip to main content

rstext/
po.rs

1use crate::{error::TextError, Result};
2use std::collections::HashMap;
3use std::io::{BufRead, Read};
4
5#[derive(Default)]
6pub struct Po {
7    header: Option<HashMap<String, String>>,
8    entities: Entities,
9    contexts: HashMap<String, Entities>,
10}
11
12type Entities = HashMap<String, String>;
13
14impl Po {
15    pub fn parse<R: Read>(reader: R) -> Result<Self> {
16        let mut reader = std::io::BufReader::new(reader);
17        let mut entities = HashMap::new();
18        let mut contexts: HashMap<String, Entities> = HashMap::new();
19        let mut line = String::new();
20
21        enum State {
22            None,
23            Context(String),
24            Msgid {
25                id: String,
26                ctx: Option<String>,
27            },
28            Entity {
29                msgid: String,
30                msgstr: String,
31                ctx: Option<String>,
32            },
33        }
34
35        let mut state = State::None;
36        loop {
37            line.clear();
38            let eof = reader.read_line(&mut line)?;
39            if eof == 0 {
40                match state {
41                    State::Msgid { .. } | State::Context(..) => return Err(TextError::FormatError),
42                    State::Entity { msgid, msgstr, ctx } => match ctx {
43                        Some(ctx) => {
44                            contexts.entry(ctx).or_default().insert(msgid, msgstr);
45                        }
46                        None => {
47                            entities.insert(msgid, msgstr);
48                        }
49                    },
50                    // eof
51                    _ => (),
52                }
53                break;
54            }
55
56            let is_empty_line = line.is_empty() || line.trim().is_empty();
57            let is_comment = line.starts_with("#");
58            if is_empty_line || is_comment {
59                continue;
60            }
61
62            if line.starts_with("msgctxt") {
63                // state is changed save privious entity
64                // we can't save state emidiately after creating in order to suppot multi lines
65                if let State::Entity { msgid, msgstr, ctx } = state {
66                    match ctx {
67                        Some(ctx) => {
68                            contexts.entry(ctx).or_default().insert(msgid, msgstr);
69                        }
70                        None => {
71                            entities.insert(msgid, msgstr);
72                        }
73                    }
74                }
75
76                let s: &str = line[7..].trim();
77                let context = unqoute(s).map(|s| s.to_owned())?;
78
79                state = State::Context(context);
80                continue;
81            }
82
83            if line.starts_with("msgid") {
84                let s: &str = line[5..].trim();
85                let id = unqoute(s).map(|s| s.to_owned())?;
86
87                // @todo: clean up
88                match state {
89                    // state is changed save privious entity
90                    // we can't save state emidiately after creating in order to suppot multi lines
91                    State::Entity { msgid, msgstr, ctx } => match ctx {
92                        Some(ctx) => {
93                            contexts.entry(ctx).or_default().insert(msgid, msgstr);
94                            state = State::Msgid { id, ctx: None };
95                        }
96                        None => {
97                            entities.insert(msgid, msgstr);
98                            state = State::Msgid { id, ctx: None };
99                        }
100                    },
101                    State::Context(ctx) => {
102                        state = State::Msgid { id, ctx: Some(ctx) };
103                    }
104                    _ => {
105                        state = State::Msgid { id, ctx: None };
106                    }
107                }
108
109                continue;
110            }
111
112            match state {
113                State::Msgid { id, ctx } if line.starts_with("msgstr") => {
114                    let s: &str = line[6..].trim();
115                    let msgstr = unqoute(s).map(|s| s.to_owned())?;
116
117                    state = State::Entity {
118                        msgid: id.clone(),
119                        ctx,
120                        msgstr,
121                    };
122                    continue;
123                }
124                // handle multiline entity
125                State::Entity { ref mut msgstr, .. } if unqoute(line.trim()).is_ok() => {
126                    let s = unqoute(line.trim()).unwrap();
127                    msgstr.push_str(&s);
128                    continue;
129                }
130                // format error
131                _ => return Err(TextError::FormatError),
132            }
133        }
134
135        let header = entities.get("").and_then(|s| Some(parse_header(s)));
136
137        Ok(Self {
138            entities,
139            contexts,
140            header,
141        })
142    }
143
144    pub fn get(&self, id: &str) -> Option<&str> {
145        self.entities.get(id).and_then(|s| Some(s.as_str()))
146    }
147
148    pub fn getc(&self, context: &str, id: &str) -> Option<&str> {
149        self.contexts
150            .get(context)
151            .and_then(|entities| entities.get(id))
152            .and_then(|s| Some(s.as_str()))
153    }
154
155    pub fn header(&self) -> Option<&HashMap<String, String>> {
156        self.header.as_ref()
157    }
158}
159
160fn parse_header(s: &str) -> HashMap<String, String> {
161    // @todo: there's a problem with saving strings with escape sequences
162    // that's why here `\\n`
163    s.split("\\n")
164        .map(|line| {
165            line.find(":")
166                .and_then(|pos| Some((&line[..pos], &line[pos + 1..])))
167        })
168        .flatten()
169        .map(|(key, value)| (key.to_owned(), value.to_owned()))
170        .collect()
171}
172
173fn unqoute<'a>(s: &'a str) -> Result<&'a str> {
174    if !s.starts_with("\"") || !s.ends_with("\"") {
175        return Err(TextError::FormatError);
176    }
177
178    Ok(&s[1..s.len() - 1])
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn parse_po_file() {
187        let file = "msgid \"ask_location_menu.next_button\"\n\
188                          msgstr \"Next\"\n";
189        let po = Po::parse(file.as_bytes()).unwrap();
190        assert_eq!(po.get("ask_location_menu.next_button"), Some("Next"));
191    }
192
193    #[test]
194    fn parse_po_file_messy() {
195        let file = "msgid     \"ask_location_menu.next_button\"   \n\
196                          msgstr    \"Next\"   \n   ";
197        let po = Po::parse(file.as_bytes()).unwrap();
198        assert_eq!(po.get("ask_location_menu.next_button"), Some("Next"));
199    }
200
201    #[test]
202    fn parse_po_file_emptylines() {
203        let file = r#"
204msgid "ask_location_menu.next_button"
205    
206msgstr "Next""#;
207        let po = Po::parse(file.as_bytes()).unwrap();
208        assert_eq!(po.get("ask_location_menu.next_button"), Some("Next"));
209    }
210
211    #[test]
212    fn parse_po_file_comments() {
213        let file = r#"
214#  translator-comments
215#. extracted-comments
216#: reference…
217#, flag…
218msgid "ask_location_menu.next_button"
219msgstr "Next""#;
220        let po = Po::parse(file.as_bytes()).unwrap();
221        assert_eq!(po.get("ask_location_menu.next_button"), Some("Next"));
222    }
223
224    #[test]
225    fn parse_po_file_empty() {
226        let file = "";
227        let po = Po::parse(file.as_bytes());
228        assert!(po.is_ok());
229    }
230
231    #[test]
232    fn parse_po_file_multi_entities() {
233        let file = "msgid \"id1\"\n\
234                          msgstr \"v1\"\n\
235                          msgid \"id2\"\n\
236                          msgstr \"v2\"\n";
237        let po = Po::parse(file.as_bytes()).unwrap();
238        assert_eq!(po.get("id1"), Some("v1"));
239        assert_eq!(po.get("id2"), Some("v2"));
240
241        let file = "msgid \"id1\"\n\
242                          msgstr \"v1\"\n\
243                          \n\
244                          msgid \"id2\"\n\
245                          msgstr \"v2\"\n";
246        let po = Po::parse(file.as_bytes()).unwrap();
247        assert_eq!(po.get("id1"), Some("v1"));
248        assert_eq!(po.get("id2"), Some("v2"));
249    }
250
251    #[test]
252    fn parse_po_file_multiline() {
253        let file = "msgid \"id\"\n\
254                          msgstr \"1\"\n\
255                          \"2\"\n\
256                          \"3\"\n";
257        let po = Po::parse(file.as_bytes()).unwrap();
258        assert_eq!(po.get("id"), Some("123"));
259    }
260
261    #[test]
262    fn parse_po_file_context() {
263        let file = "msgctxt \"default\"\n\
264                          msgid \"id\"\n\
265                          msgstr \"1\"\n";
266        let po = Po::parse(file.as_bytes()).unwrap();
267        assert_eq!(po.getc("default", "id"), Some("1"));
268        assert_eq!(po.get("id"), None);
269    }
270}