1#![warn(missing_docs, rustdoc::broken_intra_doc_links)]
2#[cfg(feature = "catalog")]
80mod api;
81mod borrowed;
82pub mod diagnostic_codes;
83mod line_state;
84mod merge;
85mod parse;
86mod scan;
87mod serialize;
88mod text;
89mod utf8;
90
91#[cfg(feature = "catalog")]
92pub use api::{
93 ApiError, COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION, CatalogAuditChecks, CatalogAuditDiagnostic,
94 CatalogAuditIcuOptions, CatalogAuditMessageRef, CatalogAuditOptions, CatalogAuditReport,
95 CatalogAuditSummary, CatalogCombineInput, CatalogCombineResult, CatalogCombineSelection,
96 CatalogCombineStats, CatalogConflictStrategy, CatalogCoverageMessage, CatalogCoverageOptions,
97 CatalogCoverageReport, CatalogFileCombineResult, CatalogFileFormat, CatalogLocaleCoverage,
98 CatalogLocaleReview, CatalogMachineTranslationMessage, CatalogMachineTranslationReview,
99 CatalogMachineTranslationStatus, CatalogMessage, CatalogMessageExtra, CatalogMessageKey,
100 CatalogMessageStatus, CatalogMode, CatalogOrigin, CatalogReviewOptions, CatalogReviewReport,
101 CatalogReviewSummary, CatalogReviewTranslation, CatalogSemantics, CatalogSourceChange,
102 CatalogSourceChangeKind, CatalogSourceChangeReport, CatalogStats, CatalogStorageFormat,
103 CatalogTranslationChange, CatalogTranslationChangeReport, CatalogUpdateInput,
104 CatalogUpdateResult, CombineCatalogFilesOptions, CombineCatalogOptions,
105 CompileCatalogArtifactIcuOptions, CompileCatalogArtifactOptions,
106 CompileCatalogArtifactReportOptions, CompileCatalogArtifactReportSelection,
107 CompileCatalogOptions, CompileSelectedCatalogArtifactOptions, CompiledCatalog,
108 CompiledCatalogArtifact, CompiledCatalogArtifactReport, CompiledCatalogDiagnostic,
109 CompiledCatalogIdDescription, CompiledCatalogIdIndex, CompiledCatalogMissingMessage,
110 CompiledCatalogProvenanceReport, CompiledCatalogResolution, CompiledCatalogResolutionKind,
111 CompiledCatalogTranslationKind, CompiledCatalogUnavailableId, CompiledKeyStrategy,
112 CompiledMessage, CompiledTranslation, DescribeCompiledIdsReport, Diagnostic,
113 DiagnosticSeverity, EffectiveTranslation, EffectiveTranslationRef, ExtractedMessage,
114 ExtractedPluralMessage, ExtractedSingularMessage, IcuFormatterSupportPolicy,
115 IcuPseudolocalizationOptions, IcuSyntaxPolicy, MachineTranslationMetadata, NdjsonCatalogReader,
116 NdjsonCatalogReaderOptions, NdjsonCatalogWriter, NdjsonCatalogWriterOptions,
117 NormalizedParsedCatalog, ObsoleteStrategy, OrderBy, ParseCatalogOptions, ParsedCatalog,
118 PlaceholderCommentMode, PluralEncoding, PluralSource, RenderOptions, SourceExtractedMessage,
119 TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions, audit_catalogs,
120 audit_catalogs_with_icu_options, catalog_coverage, catalog_review, combine_catalog_files,
121 combine_catalogs, compile_catalog_artifact, compile_catalog_artifact_report,
122 compile_catalog_artifact_selected, compile_catalog_artifact_selected_with_icu_options,
123 compile_catalog_artifact_with_icu_options, compiled_key, machine_translation_hash,
124 parse_catalog, pseudolocalize_compiled_catalog_artifact,
125 pseudolocalize_compiled_catalog_artifact_with_syntax_policy, update_catalog,
126 update_catalog_file,
127};
128pub use borrowed::{
129 BorrowedHeader, BorrowedMsgStr, BorrowedPoFile, BorrowedPoItem, parse_po_borrowed,
130};
131pub use merge::{ExtractedMessage as MergeExtractedMessage, merge_catalog};
132pub use parse::{parse_po, parse_po_bytes};
133pub use serialize::stringify_po;
134pub use text::{escape_string, extract_quoted, extract_quoted_cow, unescape_string};
135
136use core::{fmt, ops::Index};
137
138#[cfg(feature = "serde")]
139use serde::{Deserialize, Serialize};
140
141#[derive(Debug, Clone, PartialEq, Eq, Default)]
143#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
144#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
145pub struct PoFile {
146 pub comments: Vec<String>,
148 pub extracted_comments: Vec<String>,
150 pub headers: Vec<Header>,
152 pub items: Vec<PoItem>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Default)]
158#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
159#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
160pub struct Header {
161 pub key: String,
163 pub value: String,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Default)]
169#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
170#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
171pub struct PoItem {
172 pub msgid: String,
174 pub msgctxt: Option<String>,
176 pub references: Vec<String>,
178 pub msgid_plural: Option<String>,
180 pub msgstr: MsgStr,
182 pub comments: Vec<String>,
184 pub extracted_comments: Vec<String>,
186 pub flags: Vec<String>,
188 pub metadata: Vec<(String, String)>,
190 pub obsolete: bool,
192 pub nplurals: usize,
194}
195
196impl PoItem {
197 #[must_use]
199 pub fn new(nplurals: usize) -> Self {
200 Self {
201 nplurals,
202 ..Self::default()
203 }
204 }
205
206 pub(crate) fn clear_for_reuse(&mut self, nplurals: usize) {
207 self.msgid.clear();
208 self.msgctxt = None;
209 self.references.clear();
210 self.msgid_plural = None;
211 self.msgstr = MsgStr::None;
212 self.comments.clear();
213 self.extracted_comments.clear();
214 self.flags.clear();
215 self.metadata.clear();
216 self.obsolete = false;
217 self.nplurals = nplurals;
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Default)]
223#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
224#[cfg_attr(
225 feature = "serde",
226 serde(tag = "kind", content = "value", rename_all = "snake_case")
227)]
228pub enum MsgStr {
229 #[default]
231 None,
232 Singular(String),
234 Plural(Vec<String>),
236}
237
238impl MsgStr {
239 #[must_use]
241 pub const fn is_empty(&self) -> bool {
242 matches!(self, Self::None)
243 }
244
245 #[must_use]
247 pub fn len(&self) -> usize {
248 match self {
249 Self::None => 0,
250 Self::Singular(_) => 1,
251 Self::Plural(values) => values.len(),
252 }
253 }
254
255 #[must_use]
257 pub fn first(&self) -> Option<&String> {
258 match self {
259 Self::None => None,
260 Self::Singular(value) => Some(value),
261 Self::Plural(values) => values.first(),
262 }
263 }
264
265 #[must_use]
267 pub fn first_str(&self) -> Option<&str> {
268 self.first().map(String::as_str)
269 }
270
271 #[must_use]
273 pub fn get(&self, index: usize) -> Option<&str> {
274 match self {
275 Self::Singular(value) if index == 0 => Some(value.as_str()),
276 Self::None | Self::Singular(_) => None,
277 Self::Plural(values) => values.get(index).map(String::as_str),
278 }
279 }
280
281 #[must_use]
283 pub fn iter(&self) -> MsgStrIter<'_> {
284 match self {
285 Self::None => MsgStrIter::empty(),
286 Self::Singular(value) => MsgStrIter::single(value),
287 Self::Plural(values) => MsgStrIter::many(values.iter()),
288 }
289 }
290
291 #[must_use]
293 pub fn into_vec(self) -> Vec<String> {
294 match self {
295 Self::None => Vec::new(),
296 Self::Singular(value) => vec![value],
297 Self::Plural(values) => values,
298 }
299 }
300}
301
302impl From<String> for MsgStr {
303 fn from(value: String) -> Self {
304 Self::Singular(value)
305 }
306}
307
308impl From<Vec<String>> for MsgStr {
309 fn from(values: Vec<String>) -> Self {
310 match values.len() {
311 0 => Self::None,
312 1 => Self::Singular(values.into_iter().next().expect("single msgstr value")),
313 _ => Self::Plural(values),
314 }
315 }
316}
317
318impl<'a> IntoIterator for &'a MsgStr {
319 type Item = &'a String;
320 type IntoIter = MsgStrIter<'a>;
321
322 fn into_iter(self) -> Self::IntoIter {
323 self.iter()
324 }
325}
326
327impl Index<usize> for MsgStr {
328 type Output = String;
329
330 fn index(&self, index: usize) -> &Self::Output {
331 match self {
332 Self::None => panic!("msgstr index out of bounds: no translations present"),
333 Self::Singular(value) if index == 0 => value,
334 Self::Singular(_) => panic!("msgstr index out of bounds: singular translation"),
335 Self::Plural(values) => &values[index],
336 }
337 }
338}
339
340pub struct MsgStrIter<'a> {
342 inner: MsgStrIterInner<'a>,
343}
344
345enum MsgStrIterInner<'a> {
346 Empty,
347 Single(Option<&'a String>),
348 Many(std::slice::Iter<'a, String>),
349}
350
351impl<'a> MsgStrIter<'a> {
352 const fn empty() -> Self {
353 Self {
354 inner: MsgStrIterInner::Empty,
355 }
356 }
357
358 const fn single(value: &'a String) -> Self {
359 Self {
360 inner: MsgStrIterInner::Single(Some(value)),
361 }
362 }
363
364 const fn many(iter: std::slice::Iter<'a, String>) -> Self {
365 Self {
366 inner: MsgStrIterInner::Many(iter),
367 }
368 }
369}
370
371impl<'a> Iterator for MsgStrIter<'a> {
372 type Item = &'a String;
373
374 fn next(&mut self) -> Option<Self::Item> {
375 match &mut self.inner {
376 MsgStrIterInner::Empty => None,
377 MsgStrIterInner::Single(value) => value.take(),
378 MsgStrIterInner::Many(iter) => iter.next(),
379 }
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct SerializeOptions {
386 pub fold_length: usize,
388 pub compact_multiline: bool,
390}
391
392impl Default for SerializeOptions {
393 fn default() -> Self {
394 Self {
395 fold_length: 80,
396 compact_multiline: true,
397 }
398 }
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403pub struct ParsePosition {
404 offset: usize,
405 line: usize,
406 column: usize,
407}
408
409impl ParsePosition {
410 #[must_use]
415 pub const fn new(offset: usize, line: usize, column: usize) -> Self {
416 Self {
417 offset,
418 line,
419 column,
420 }
421 }
422
423 #[must_use]
425 pub const fn offset(self) -> usize {
426 self.offset
427 }
428
429 #[must_use]
431 pub const fn line(self) -> usize {
432 self.line
433 }
434
435 #[must_use]
437 pub const fn column(self) -> usize {
438 self.column
439 }
440}
441
442#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct ParseError {
445 message: String,
446 position: Option<ParsePosition>,
447}
448
449impl ParseError {
450 #[must_use]
452 pub fn new(message: impl Into<String>) -> Self {
453 Self {
454 message: message.into(),
455 position: None,
456 }
457 }
458
459 #[must_use]
461 pub fn with_position(message: impl Into<String>, position: ParsePosition) -> Self {
462 Self {
463 message: message.into(),
464 position: Some(position),
465 }
466 }
467
468 #[must_use]
470 pub fn message(&self) -> &str {
471 &self.message
472 }
473
474 #[must_use]
476 pub const fn position(&self) -> Option<ParsePosition> {
477 self.position
478 }
479
480 pub(crate) fn with_position_if_missing(mut self, position: ParsePosition) -> Self {
481 self.position.get_or_insert(position);
482 self
483 }
484}
485
486impl fmt::Display for ParseError {
487 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488 f.write_str(&self.message)
489 }
490}
491
492impl std::error::Error for ParseError {}
493
494#[cfg(test)]
495mod tests {
496 use super::{MsgStr, ParseError, ParsePosition};
497
498 #[cfg(feature = "serde")]
499 use super::{Header, PoFile, PoItem};
500
501 #[test]
502 fn parse_error_accessors_preserve_message_and_optional_position() {
503 let error = ParseError::new("invalid PO string");
504 assert_eq!(error.message(), "invalid PO string");
505 assert_eq!(error.position(), None);
506 assert_eq!(error.to_string(), "invalid PO string");
507
508 let position = ParsePosition::new(12, 2, 3);
509 let positioned = ParseError::with_position("invalid PO string", position);
510 assert_eq!(positioned.message(), "invalid PO string");
511 assert_eq!(positioned.position(), Some(position));
512 assert_eq!(positioned.position().map(ParsePosition::offset), Some(12));
513 assert_eq!(positioned.position().map(ParsePosition::line), Some(2));
514 assert_eq!(positioned.position().map(ParsePosition::column), Some(3));
515 assert_eq!(positioned.to_string(), "invalid PO string");
516 }
517
518 #[test]
519 fn msgstr_get_returns_none_for_empty_values() {
520 let msgstr = MsgStr::None;
521
522 assert_eq!(msgstr.get(0), None);
523 }
524
525 #[test]
526 fn msgstr_get_returns_singular_value_at_zero() {
527 let msgstr = MsgStr::from("Hallo".to_owned());
528
529 assert_eq!(msgstr.get(0), Some("Hallo"));
530 assert_eq!(msgstr.get(1), None);
531 }
532
533 #[test]
534 fn msgstr_get_returns_plural_values_by_index() {
535 let msgstr = MsgStr::from(vec!["eins".to_owned(), "viele".to_owned()]);
536
537 assert_eq!(msgstr.get(0), Some("eins"));
538 assert_eq!(msgstr.get(1), Some("viele"));
539 assert_eq!(msgstr.get(2), None);
540 }
541
542 #[test]
543 fn msgstr_helpers_cover_empty_singular_and_plural_shapes() {
544 let empty = MsgStr::from(Vec::<String>::new());
545 assert!(empty.is_empty());
546 assert_eq!(empty.len(), 0);
547 assert_eq!(empty.first(), None);
548 assert_eq!(empty.first_str(), None);
549 assert_eq!(empty.iter().count(), 0);
550 assert_eq!(empty.into_vec(), Vec::<String>::new());
551
552 let singular = MsgStr::from(vec!["Hallo".to_owned()]);
553 assert!(!singular.is_empty());
554 assert_eq!(singular.len(), 1);
555 assert_eq!(singular.first().map(String::as_str), Some("Hallo"));
556 assert_eq!(singular.first_str(), Some("Hallo"));
557 assert_eq!((&singular).into_iter().collect::<Vec<_>>(), vec!["Hallo"]);
558 assert_eq!(singular[0], "Hallo");
559 assert_eq!(singular.into_vec(), vec!["Hallo"]);
560
561 let plural = MsgStr::from(vec!["eins".to_owned(), "viele".to_owned()]);
562 assert_eq!(plural.len(), 2);
563 assert_eq!(plural.first_str(), Some("eins"));
564 assert_eq!(plural.iter().collect::<Vec<_>>(), vec!["eins", "viele"]);
565 assert_eq!(plural[1], "viele");
566 assert_eq!(plural.into_vec(), vec!["eins", "viele"]);
567 }
568
569 #[cfg(feature = "serde")]
570 #[test]
571 fn po_file_serde_round_trips_owned_document_shape() {
572 let file = PoFile {
573 comments: vec!["translator note".to_owned()],
574 headers: vec![Header {
575 key: "Language".to_owned(),
576 value: "de".to_owned(),
577 }],
578 items: vec![PoItem {
579 msgid: "Hello".to_owned(),
580 msgstr: MsgStr::from("Hallo".to_owned()),
581 references: vec!["src/app.rs:10".to_owned()],
582 nplurals: 1,
583 ..PoItem::default()
584 }],
585 ..PoFile::default()
586 };
587
588 let json = serde_json::to_value(&file).expect("PO file serialization must succeed");
589 assert_eq!(json["items"][0]["msgstr"]["kind"], "singular");
590 assert_eq!(json["items"][0]["msgstr"]["value"], "Hallo");
591
592 let roundtrip: PoFile =
593 serde_json::from_value(json).expect("PO file deserialization must succeed");
594 assert_eq!(roundtrip, file);
595 }
596}