Skip to main content

ferrocat_po/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![warn(missing_docs, rustdoc::broken_intra_doc_links)]
3//! Performance-first PO parsing and serialization.
4//!
5//! The crate exposes both owned and borrowed parsers for gettext PO files,
6//! a byte-oriented UTF-8 parser entry point, plus helpers for serialization
7//! and higher-level catalog update workflows.
8//!
9//! # Feature flags
10//!
11//! The default feature set is `full`, which currently enables the `catalog`
12//! workflow layer.
13//!
14//! - `catalog` exposes high-level catalog parsing, updates, combining,
15//!   conversion, audits, machine-translation metadata, plural handling, FCL
16//!   storage, and runtime artifact compilation. It also enables the
17//!   catalog-layer dependencies used for hashing, atomic file updates, serde
18//!   JSON output, ICU diagnostics, and CLDR plural data.
19//! - `serde` enables serde implementations for low-level PO document types and
20//!   is also enabled by `catalog` for catalog-layer JSON/report shapes.
21//! - `compile`, `mt`, and `plurals` are reserved subsystem aliases. Today they
22//!   imply `catalog`; they do not reduce or split the catalog API surface.
23//!
24//! Use `default-features = false` for the low-level PO parser, borrowed parser,
25//! serializer, string helpers, and lightweight `merge_catalog` helper without
26//! catalog-layer dependencies. Enabling `compile`, `mt`, or `plurals` currently
27//! has the same dependency effect as enabling `catalog`.
28//!
29//! # Examples
30//!
31//! ```rust
32//! use ferrocat_po::{PoFile, SerializeOptions, parse_po, stringify_po};
33//!
34//! let input = "msgid \"Hello\"\nmsgstr \"Hallo\"\n";
35//! let file = parse_po(input)?;
36//! assert_eq!(file.items[0].msgid, "Hello");
37//!
38//! let output = stringify_po(&file, &SerializeOptions::default());
39//! assert!(output.contains("msgid \"Hello\""));
40//! # Ok::<(), ferrocat_po::ParseError>(())
41//! ```
42//!
43//! ```rust
44//! use ferrocat_po::parse_po_bytes;
45//!
46//! let input = b"msgid \"Hello\"\nmsgstr \"Hallo\"\n";
47//! let file = parse_po_bytes(input)?;
48//! assert_eq!(file.items[0].msgstr[0], "Hallo");
49//! # Ok::<(), ferrocat_po::ParseError>(())
50//! ```
51//!
52//! ```rust
53//! use ferrocat_po::{
54//!     CompileCatalogArtifactOptions, CompileSelectedCatalogArtifactOptions,
55//!     CompiledCatalogIdIndex, ParseCatalogOptions, compile_catalog_artifact_selected,
56//!     parse_catalog,
57//! };
58//!
59//! let source = parse_catalog(
60//!     ParseCatalogOptions::new("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en").with_locale("en"),
61//! )?
62//! .into_normalized_view()?;
63//! let requested = parse_catalog(
64//!     ParseCatalogOptions::new("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "en").with_locale("de"),
65//! )?
66//! .into_normalized_view()?;
67//! let index = CompiledCatalogIdIndex::new(&[&requested, &source], ferrocat_po::CompiledKeyStrategy::FerrocatV1)?;
68//! let compiled_ids = index.iter().map(|(id, _)| id).collect::<Vec<_>>();
69//! let compiled = compile_catalog_artifact_selected(
70//!     &[&requested, &source],
71//!     &index,
72//!     &CompileSelectedCatalogArtifactOptions::new("de", "en", &compiled_ids),
73//! )?;
74//!
75//! assert_eq!(compiled.messages.len(), 1);
76//! # Ok::<(), Box<dyn std::error::Error>>(())
77//! ```
78//!
79//! ```rust
80//! use ferrocat_po::{
81//!     CatalogAuditOptions, ParseCatalogOptions, audit_catalogs, parse_catalog_for_review,
82//! };
83//!
84//! let source = parse_catalog_for_review(
85//!     ParseCatalogOptions::new("msgid \"Hello {name}\"\nmsgstr \"Hello {name}\"\n", "en")
86//!         .with_locale("en"),
87//! )?;
88//! let target = parse_catalog_for_review(
89//!     ParseCatalogOptions::new("msgid \"Hello {name}\"\nmsgstr \"Hallo\"\n", "en")
90//!         .with_locale("de"),
91//! )?;
92//! let report = audit_catalogs(&[&source, &target], &CatalogAuditOptions::new("en"))?;
93//!
94//! assert!(report.has_errors());
95//! # Ok::<(), Box<dyn std::error::Error>>(())
96//! ```
97
98#[cfg(feature = "catalog")]
99#[cfg_attr(docsrs, doc(cfg(feature = "catalog")))]
100mod api;
101mod borrowed;
102pub mod diagnostic_codes;
103mod line_state;
104mod merge;
105mod parse;
106mod scan;
107mod serialize;
108mod text;
109mod utf8;
110
111#[cfg(feature = "catalog")]
112#[cfg_attr(docsrs, doc(cfg(feature = "catalog")))]
113pub use api::{
114    AiProvenance, ApiError, COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION, CatalogAuditChecks,
115    CatalogAuditDiagnostic, CatalogAuditIcuOptions, CatalogAuditMessageRef, CatalogAuditOptions,
116    CatalogAuditReport, CatalogAuditSummary, CatalogCombineInput, CatalogCombineResult,
117    CatalogCombineSelection, CatalogCombineStats, CatalogConflictStrategy, CatalogConvertResult,
118    CatalogCoverageMessage, CatalogCoverageOptions, CatalogCoverageReport,
119    CatalogFileCombineResult, CatalogFileConvertResult, CatalogFileFormat, CatalogLocaleCoverage,
120    CatalogLocaleReview, CatalogMachineTranslationMessage, CatalogMachineTranslationReview,
121    CatalogMachineTranslationStatus, CatalogMergeSide, CatalogMessage, CatalogMessageKey,
122    CatalogMessageStatus, CatalogMode, CatalogOrigin, CatalogReviewOptions, CatalogReviewReport,
123    CatalogReviewSummary, CatalogReviewTranslation, CatalogSemantics, CatalogSourceChange,
124    CatalogSourceChangeKind, CatalogSourceChangeReport, CatalogStats, CatalogStorageFormat,
125    CatalogTranslationChange, CatalogTranslationChangeReport, CatalogUpdateInput,
126    CatalogUpdateResult, CombineCatalogFilesOptions, CombineCatalogOptions,
127    CompileCatalogArtifactIcuOptions, CompileCatalogArtifactOptions,
128    CompileCatalogArtifactReportOptions, CompileCatalogArtifactReportSelection,
129    CompileCatalogOptions, CompileSelectedCatalogArtifactOptions, CompiledCatalog,
130    CompiledCatalogArtifact, CompiledCatalogArtifactReport, CompiledCatalogDiagnostic,
131    CompiledCatalogIdDescription, CompiledCatalogIdIndex, CompiledCatalogMissingMessage,
132    CompiledCatalogProvenanceReport, CompiledCatalogPseudolocalizationOptions,
133    CompiledCatalogResolution, CompiledCatalogResolutionKind, CompiledCatalogTranslationKind,
134    CompiledCatalogUnavailableId, CompiledKeyStrategy, CompiledMessage, CompiledTranslation,
135    ConvertCatalogFileOptions, ConvertCatalogOptions, DescribeCompiledIdsReport, Diagnostic,
136    DiagnosticSeverity, EffectiveTranslation, EffectiveTranslationRef, ExtractedMessage,
137    ExtractedPluralMessage, ExtractedSingularMessage, IcuFormatterSupportPolicy,
138    IcuPseudolocalizationOptions, IcuSyntaxPolicy, MachineMetadata, MergeCatalogsThreeWayOptions,
139    NormalizedParsedCatalog, ObsoleteInfo, ObsoleteStrategy, OrderBy, ParseCatalogOptions,
140    ParsedCatalog, PlaceholderCommentMode, PluralEncoding, PluralSource, RenderOptions,
141    SourceExtractedMessage, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
142    WriteDurability, audit_catalogs, canonicalize_icu_with_policy, combine_catalog_files,
143    combine_catalogs, compile_catalog_artifact, compile_catalog_artifact_report,
144    compile_catalog_artifact_selected, compiled_key, compiled_key_with_policy, convert_catalog,
145    convert_catalog_file, machine_translation_hash, measure_catalog_coverage,
146    merge_catalogs_three_way, parse_catalog, parse_catalog_for_review,
147    pseudolocalize_compiled_catalog_artifact, review_catalogs, update_catalog, update_catalog_file,
148};
149pub use borrowed::{
150    BorrowedHeader, BorrowedMsgStr, BorrowedPoFile, BorrowedPoItem, parse_po_borrowed,
151};
152pub use diagnostic_codes::DiagnosticCode;
153pub use merge::{MergeMessageInput, merge_catalog};
154pub use parse::{parse_po, parse_po_bytes};
155pub use serialize::stringify_po;
156pub use text::{escape_string, extract_quoted, extract_quoted_cow, unescape_string};
157
158use core::{
159    fmt,
160    iter::FusedIterator,
161    ops::{Deref, DerefMut, Index},
162    slice,
163};
164
165#[cfg(feature = "serde")]
166use serde::{Deserialize, Serialize};
167
168use smallvec::{IntoIter as SmallVecIntoIter, SmallVec};
169
170/// Inline-capable vector for the small per-item collections (references, flags,
171/// comments, metadata) that hold a single element in the overwhelmingly common
172/// case, avoiding a heap allocation for the backing buffer.
173///
174/// The inline capacity and backing collection are private implementation
175/// details. Use this type by value in PO/catalog structures and read it through
176/// its slice view.
177#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
178#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
179#[cfg_attr(feature = "serde", serde(transparent))]
180pub struct PoVec<T>(SmallVec<[T; 1]>);
181
182impl<T> PoVec<T> {
183    /// Creates an empty vector.
184    #[must_use]
185    pub fn new() -> Self {
186        Self(SmallVec::new())
187    }
188
189    /// Creates an empty vector with room for at least `capacity` elements.
190    #[must_use]
191    pub fn with_capacity(capacity: usize) -> Self {
192        Self(SmallVec::with_capacity(capacity))
193    }
194
195    /// Returns the number of stored elements.
196    #[must_use]
197    pub fn len(&self) -> usize {
198        self.0.len()
199    }
200
201    /// Returns `true` when the vector contains no elements.
202    #[must_use]
203    pub fn is_empty(&self) -> bool {
204        self.0.is_empty()
205    }
206
207    /// Returns the values as a slice.
208    #[must_use]
209    pub fn as_slice(&self) -> &[T] {
210        self.0.as_slice()
211    }
212
213    /// Returns the values as a mutable slice.
214    #[must_use]
215    pub fn as_mut_slice(&mut self) -> &mut [T] {
216        self.0.as_mut_slice()
217    }
218
219    /// Returns an iterator over the values.
220    pub fn iter(&self) -> slice::Iter<'_, T> {
221        self.as_slice().iter()
222    }
223
224    /// Returns a mutable iterator over the values.
225    pub fn iter_mut(&mut self) -> slice::IterMut<'_, T> {
226        self.as_mut_slice().iter_mut()
227    }
228
229    /// Appends `value` to the end of the vector.
230    pub fn push(&mut self, value: T) {
231        self.0.push(value);
232    }
233
234    /// Removes all values.
235    pub fn clear(&mut self) {
236        self.0.clear();
237    }
238
239    /// Converts the collection into a standard [`Vec`].
240    #[must_use]
241    pub fn into_vec(self) -> Vec<T> {
242        self.0.into_vec()
243    }
244}
245
246impl<T> AsRef<[T]> for PoVec<T> {
247    fn as_ref(&self) -> &[T] {
248        self.as_slice()
249    }
250}
251
252impl<T> AsMut<[T]> for PoVec<T> {
253    fn as_mut(&mut self) -> &mut [T] {
254        self.as_mut_slice()
255    }
256}
257
258impl<T> Deref for PoVec<T> {
259    type Target = [T];
260
261    fn deref(&self) -> &Self::Target {
262        self.as_slice()
263    }
264}
265
266impl<T> DerefMut for PoVec<T> {
267    fn deref_mut(&mut self) -> &mut Self::Target {
268        self.as_mut_slice()
269    }
270}
271
272impl<T> Extend<T> for PoVec<T> {
273    fn extend<I>(&mut self, iter: I)
274    where
275        I: IntoIterator<Item = T>,
276    {
277        self.0.extend(iter);
278    }
279}
280
281impl<T> From<Vec<T>> for PoVec<T> {
282    fn from(value: Vec<T>) -> Self {
283        Self(SmallVec::from_vec(value))
284    }
285}
286
287impl<T, const N: usize> From<[T; N]> for PoVec<T> {
288    fn from(value: [T; N]) -> Self {
289        Self(value.into_iter().collect())
290    }
291}
292
293impl<T> FromIterator<T> for PoVec<T> {
294    fn from_iter<I>(iter: I) -> Self
295    where
296        I: IntoIterator<Item = T>,
297    {
298        Self(iter.into_iter().collect())
299    }
300}
301
302impl<T> From<PoVec<T>> for Vec<T> {
303    fn from(value: PoVec<T>) -> Self {
304        value.into_vec()
305    }
306}
307
308impl<T> IntoIterator for PoVec<T> {
309    type Item = T;
310    type IntoIter = PoVecIntoIter<T>;
311
312    fn into_iter(self) -> Self::IntoIter {
313        PoVecIntoIter {
314            inner: self.0.into_iter(),
315        }
316    }
317}
318
319impl<'a, T> IntoIterator for &'a PoVec<T> {
320    type Item = &'a T;
321    type IntoIter = slice::Iter<'a, T>;
322
323    fn into_iter(self) -> Self::IntoIter {
324        self.iter()
325    }
326}
327
328impl<'a, T> IntoIterator for &'a mut PoVec<T> {
329    type Item = &'a mut T;
330    type IntoIter = slice::IterMut<'a, T>;
331
332    fn into_iter(self) -> Self::IntoIter {
333        self.iter_mut()
334    }
335}
336
337impl<T, U> PartialEq<[U]> for PoVec<T>
338where
339    T: PartialEq<U>,
340{
341    fn eq(&self, other: &[U]) -> bool {
342        self.as_slice() == other
343    }
344}
345
346impl<T, U> PartialEq<&[U]> for PoVec<T>
347where
348    T: PartialEq<U>,
349{
350    fn eq(&self, other: &&[U]) -> bool {
351        self.as_slice() == *other
352    }
353}
354
355impl<T, U> PartialEq<Vec<U>> for PoVec<T>
356where
357    T: PartialEq<U>,
358{
359    fn eq(&self, other: &Vec<U>) -> bool {
360        self.as_slice() == other.as_slice()
361    }
362}
363
364/// Owning iterator returned by [`PoVec::into_iter`].
365pub struct PoVecIntoIter<T> {
366    inner: SmallVecIntoIter<[T; 1]>,
367}
368
369impl<T> Iterator for PoVecIntoIter<T> {
370    type Item = T;
371
372    fn next(&mut self) -> Option<Self::Item> {
373        self.inner.next()
374    }
375
376    fn size_hint(&self) -> (usize, Option<usize>) {
377        self.inner.size_hint()
378    }
379}
380
381impl<T> DoubleEndedIterator for PoVecIntoIter<T> {
382    fn next_back(&mut self) -> Option<Self::Item> {
383        self.inner.next_back()
384    }
385}
386
387impl<T> ExactSizeIterator for PoVecIntoIter<T> {}
388
389impl<T> FusedIterator for PoVecIntoIter<T> {}
390
391/// An owned PO document.
392#[derive(Debug, Clone, PartialEq, Eq, Default)]
393#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
394#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
395pub struct PoFile {
396    /// File-level translator comments that appear before the header block.
397    pub comments: Vec<String>,
398    /// File-level extracted comments that appear before the header block.
399    pub extracted_comments: Vec<String>,
400    /// Parsed header entries from the leading empty `msgid` block.
401    pub headers: Vec<Header>,
402    /// Regular catalog items in source order.
403    pub items: Vec<PoItem>,
404}
405
406/// A single header entry from the PO header block.
407#[derive(Debug, Clone, PartialEq, Eq, Default)]
408#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
409#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
410pub struct Header {
411    /// Header name such as `Language` or `Plural-Forms`.
412    pub key: String,
413    /// Header value without the trailing newline.
414    pub value: String,
415}
416
417/// A single gettext message entry.
418#[derive(Debug, Clone, PartialEq, Eq, Default)]
419#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
420#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
421pub struct PoItem {
422    /// Source message identifier.
423    pub msgid: String,
424    /// Optional gettext message context.
425    pub msgctxt: Option<String>,
426    /// Source references such as `src/app.rs:10`.
427    pub references: PoVec<String>,
428    /// Optional plural source identifier.
429    pub msgid_plural: Option<String>,
430    /// Translation payload for the message.
431    pub msgstr: MsgStr,
432    /// Translator comments attached to the item.
433    pub comments: PoVec<String>,
434    /// Extracted comments attached to the item.
435    pub extracted_comments: PoVec<String>,
436    /// Raw gettext flags such as `fuzzy`.
437    ///
438    /// The low-level PO parser and serializer preserve this field for faithful
439    /// PO round trips. Since Ferrocat 2.0, the high-level catalog layer drops
440    /// gettext flags, including `fuzzy`, when parsing or writing catalog data;
441    /// fuzzy/discard decisions are modeled by catalog-layer behavior instead
442    /// of being carried through this raw PO field.
443    pub flags: PoVec<String>,
444    /// Raw metadata lines that do not fit the dedicated fields.
445    pub metadata: PoVec<(String, String)>,
446    /// Whether the item is marked obsolete.
447    pub obsolete: bool,
448    /// Number of plural slots expected when the item is serialized.
449    pub nplurals: usize,
450}
451
452impl PoItem {
453    /// Creates an empty message entry with space for `nplurals` plural slots.
454    #[must_use]
455    pub fn new(nplurals: usize) -> Self {
456        Self {
457            nplurals,
458            ..Self::default()
459        }
460    }
461
462    pub(crate) fn clear_for_reuse(&mut self, nplurals: usize) {
463        self.msgid.clear();
464        self.msgctxt = None;
465        self.references.clear();
466        self.msgid_plural = None;
467        self.msgstr = MsgStr::None;
468        self.comments.clear();
469        self.extracted_comments.clear();
470        self.flags.clear();
471        self.metadata.clear();
472        self.obsolete = false;
473        self.nplurals = nplurals;
474    }
475}
476
477/// Message translation payload for a PO item.
478#[derive(Debug, Clone, PartialEq, Eq, Default)]
479#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
480#[cfg_attr(
481    feature = "serde",
482    serde(tag = "kind", content = "value", rename_all = "snake_case")
483)]
484pub enum MsgStr {
485    /// No translation values are present.
486    #[default]
487    None,
488    /// Single translation string.
489    Singular(String),
490    /// Plural translation strings indexed by plural slot.
491    Plural(Vec<String>),
492}
493
494impl MsgStr {
495    /// Creates a plural translation payload without normalizing by slot count.
496    ///
497    /// Use this when the plural shape matters even if the value vector has one
498    /// slot, such as gettext catalogs for one-form locales. `From<Vec<String>>`
499    /// normalizes empty vectors to [`MsgStr::None`] and single-value vectors to
500    /// [`MsgStr::Singular`].
501    #[must_use]
502    pub fn plural(values: Vec<String>) -> Self {
503        Self::Plural(values)
504    }
505
506    /// Returns `true` when no translation values are present.
507    #[must_use]
508    pub const fn is_empty(&self) -> bool {
509        matches!(self, Self::None)
510    }
511
512    /// Returns the number of translation values present.
513    #[must_use]
514    pub fn len(&self) -> usize {
515        match self {
516            Self::None => 0,
517            Self::Singular(_) => 1,
518            Self::Plural(values) => values.len(),
519        }
520    }
521
522    /// Returns the first translation value, if present.
523    #[must_use]
524    pub fn first(&self) -> Option<&str> {
525        match self {
526            Self::None => None,
527            Self::Singular(value) => Some(value.as_str()),
528            Self::Plural(values) => values.first().map(String::as_str),
529        }
530    }
531
532    /// Returns the translation at `index` without panicking.
533    #[must_use]
534    pub fn get(&self, index: usize) -> Option<&str> {
535        match self {
536            Self::Singular(value) if index == 0 => Some(value.as_str()),
537            Self::None | Self::Singular(_) => None,
538            Self::Plural(values) => values.get(index).map(String::as_str),
539        }
540    }
541
542    /// Iterates over all translation values in order.
543    #[must_use]
544    pub fn iter(&self) -> MsgStrIter<'_> {
545        match self {
546            Self::None => MsgStrIter::empty(),
547            Self::Singular(value) => MsgStrIter::single(value.as_str()),
548            Self::Plural(values) => MsgStrIter::many(values.iter()),
549        }
550    }
551
552    /// Converts the translation payload into an owned vector.
553    #[must_use]
554    pub fn into_vec(self) -> Vec<String> {
555        match self {
556            Self::None => Vec::new(),
557            Self::Singular(value) => vec![value],
558            Self::Plural(values) => values,
559        }
560    }
561}
562
563impl From<String> for MsgStr {
564    fn from(value: String) -> Self {
565        Self::Singular(value)
566    }
567}
568
569impl From<Vec<String>> for MsgStr {
570    fn from(values: Vec<String>) -> Self {
571        match values.len() {
572            0 => Self::None,
573            1 => Self::Singular(values.into_iter().next().expect("single msgstr value")),
574            _ => Self::Plural(values),
575        }
576    }
577}
578
579impl<'a> IntoIterator for &'a MsgStr {
580    type Item = &'a str;
581    type IntoIter = MsgStrIter<'a>;
582
583    fn into_iter(self) -> Self::IntoIter {
584        self.iter()
585    }
586}
587
588impl Index<usize> for MsgStr {
589    type Output = String;
590
591    fn index(&self, index: usize) -> &Self::Output {
592        match self {
593            Self::None => panic!("msgstr index out of bounds: no translations present"),
594            Self::Singular(value) if index == 0 => value,
595            Self::Singular(_) => panic!("msgstr index out of bounds: singular translation"),
596            Self::Plural(values) => &values[index],
597        }
598    }
599}
600
601/// Iterator over [`MsgStr`] values.
602pub struct MsgStrIter<'a> {
603    inner: MsgStrIterInner<'a>,
604}
605
606enum MsgStrIterInner<'a> {
607    Empty,
608    Single(Option<&'a str>),
609    Many(std::slice::Iter<'a, String>),
610}
611
612impl<'a> MsgStrIter<'a> {
613    const fn empty() -> Self {
614        Self {
615            inner: MsgStrIterInner::Empty,
616        }
617    }
618
619    const fn single(value: &'a str) -> Self {
620        Self {
621            inner: MsgStrIterInner::Single(Some(value)),
622        }
623    }
624
625    const fn many(iter: std::slice::Iter<'a, String>) -> Self {
626        Self {
627            inner: MsgStrIterInner::Many(iter),
628        }
629    }
630}
631
632impl<'a> Iterator for MsgStrIter<'a> {
633    type Item = &'a str;
634
635    fn next(&mut self) -> Option<Self::Item> {
636        match &mut self.inner {
637            MsgStrIterInner::Empty => None,
638            MsgStrIterInner::Single(value) => value.take(),
639            MsgStrIterInner::Many(iter) => iter.next().map(String::as_str),
640        }
641    }
642}
643
644/// Options controlling PO serialization.
645#[derive(Debug, Clone, PartialEq, Eq)]
646#[non_exhaustive]
647pub struct SerializeOptions {
648    /// Preferred soft line-wrap limit for long string literals.
649    pub fold_length: usize,
650    /// When `true`, one-line values stay compact instead of always expanding.
651    pub compact_multiline: bool,
652}
653
654impl Default for SerializeOptions {
655    fn default() -> Self {
656        Self {
657            fold_length: 80,
658            compact_multiline: true,
659        }
660    }
661}
662
663impl SerializeOptions {
664    /// Returns options that wrap string literals at the given soft limit.
665    #[must_use]
666    pub fn with_fold_length(mut self, fold_length: usize) -> Self {
667        self.fold_length = fold_length;
668        self
669    }
670
671    /// Returns options that keep one-line values compact when possible.
672    #[must_use]
673    pub fn with_compact_multiline(mut self, compact_multiline: bool) -> Self {
674        self.compact_multiline = compact_multiline;
675        self
676    }
677}
678
679/// One-based line/column context plus the byte offset for a parse error.
680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
681pub struct ParsePosition {
682    offset: usize,
683    line: usize,
684    column: usize,
685}
686
687impl ParsePosition {
688    /// Creates a new parse position.
689    ///
690    /// `offset` is zero-based and counts bytes from the parsed input after any
691    /// parser-specific pre-processing, while `line` and `column` are one-based.
692    #[must_use]
693    pub const fn new(offset: usize, line: usize, column: usize) -> Self {
694        Self {
695            offset,
696            line,
697            column,
698        }
699    }
700
701    /// Returns the zero-based byte offset in the parsed input.
702    #[must_use]
703    pub const fn offset(self) -> usize {
704        self.offset
705    }
706
707    /// Returns the one-based line number.
708    #[must_use]
709    pub const fn line(self) -> usize {
710        self.line
711    }
712
713    /// Returns the one-based column number.
714    #[must_use]
715    pub const fn column(self) -> usize {
716        self.column
717    }
718}
719
720/// Error returned when parsing or unescaping PO content fails.
721#[derive(Debug, Clone, PartialEq, Eq)]
722pub struct ParseError {
723    message: String,
724    position: Option<ParsePosition>,
725}
726
727impl ParseError {
728    /// Creates a new parse error with the provided message.
729    #[must_use]
730    pub fn new(message: impl Into<String>) -> Self {
731        Self {
732            message: message.into(),
733            position: None,
734        }
735    }
736
737    /// Creates a new parse error with source position metadata.
738    #[must_use]
739    pub fn with_position(message: impl Into<String>, position: ParsePosition) -> Self {
740        Self {
741            message: message.into(),
742            position: Some(position),
743        }
744    }
745
746    /// Returns the human-readable error message.
747    #[must_use]
748    pub fn message(&self) -> &str {
749        &self.message
750    }
751
752    /// Returns source position metadata when the parser could attach it.
753    #[must_use]
754    pub const fn position(&self) -> Option<ParsePosition> {
755        self.position
756    }
757
758    pub(crate) fn with_position_if_missing(mut self, position: ParsePosition) -> Self {
759        self.position.get_or_insert(position);
760        self
761    }
762}
763
764impl fmt::Display for ParseError {
765    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
766        f.write_str(&self.message)
767    }
768}
769
770impl std::error::Error for ParseError {}
771
772#[cfg(test)]
773mod tests {
774    use super::{MsgStr, ParseError, ParsePosition, PoVec, SerializeOptions};
775
776    #[cfg(feature = "serde")]
777    use super::{Header, PoFile, PoItem};
778
779    #[test]
780    fn parse_error_accessors_preserve_message_and_optional_position() {
781        let error = ParseError::new("invalid PO string");
782        assert_eq!(error.message(), "invalid PO string");
783        assert_eq!(error.position(), None);
784        assert_eq!(error.to_string(), "invalid PO string");
785
786        let position = ParsePosition::new(12, 2, 3);
787        let positioned = ParseError::with_position("invalid PO string", position);
788        assert_eq!(positioned.message(), "invalid PO string");
789        assert_eq!(positioned.position(), Some(position));
790        assert_eq!(positioned.position().map(ParsePosition::offset), Some(12));
791        assert_eq!(positioned.position().map(ParsePosition::line), Some(2));
792        assert_eq!(positioned.position().map(ParsePosition::column), Some(3));
793        assert_eq!(positioned.to_string(), "invalid PO string");
794    }
795
796    #[test]
797    fn msgstr_get_returns_none_for_empty_values() {
798        let msgstr = MsgStr::None;
799
800        assert_eq!(msgstr.get(0), None);
801    }
802
803    #[test]
804    fn msgstr_get_returns_singular_value_at_zero() {
805        let msgstr = MsgStr::from("Hallo".to_owned());
806
807        assert_eq!(msgstr.get(0), Some("Hallo"));
808        assert_eq!(msgstr.get(1), None);
809    }
810
811    #[test]
812    fn msgstr_get_returns_plural_values_by_index() {
813        let msgstr = MsgStr::from(vec!["eins".to_owned(), "viele".to_owned()]);
814
815        assert_eq!(msgstr.get(0), Some("eins"));
816        assert_eq!(msgstr.get(1), Some("viele"));
817        assert_eq!(msgstr.get(2), None);
818    }
819
820    #[test]
821    fn msgstr_plural_constructor_preserves_single_slot_shape() {
822        let values = vec!["translation".to_owned()];
823        let plural = MsgStr::plural(values.clone());
824
825        assert_eq!(plural, MsgStr::Plural(values.clone()));
826        assert_ne!(plural, MsgStr::from(values.clone()));
827        assert_eq!(plural.len(), 1);
828        assert_eq!(plural.first(), Some("translation"));
829        assert_eq!(plural.into_vec(), values);
830    }
831
832    #[test]
833    fn msgstr_helpers_cover_empty_singular_and_plural_shapes() {
834        let empty = MsgStr::from(Vec::<String>::new());
835        assert!(empty.is_empty());
836        assert_eq!(empty.len(), 0);
837        assert_eq!(empty.first(), None);
838        assert_eq!(empty.iter().count(), 0);
839        assert_eq!(empty.into_vec(), Vec::<String>::new());
840
841        let singular = MsgStr::from(vec!["Hallo".to_owned()]);
842        assert!(!singular.is_empty());
843        assert_eq!(singular.len(), 1);
844        assert_eq!(singular.first(), Some("Hallo"));
845        assert_eq!((&singular).into_iter().collect::<Vec<_>>(), vec!["Hallo"]);
846        assert_eq!(singular[0], "Hallo");
847        assert_eq!(singular.into_vec(), vec!["Hallo"]);
848
849        let plural = MsgStr::from(vec!["eins".to_owned(), "viele".to_owned()]);
850        assert_eq!(plural.len(), 2);
851        assert_eq!(plural.first(), Some("eins"));
852        assert_eq!(plural.iter().collect::<Vec<_>>(), vec!["eins", "viele"]);
853        assert_eq!(plural[1], "viele");
854        assert_eq!(plural.into_vec(), vec!["eins", "viele"]);
855    }
856
857    #[test]
858    fn povec_keeps_slice_iteration_and_vec_conversion_ergonomics() {
859        let mut values = PoVec::new();
860        assert!(values.is_empty());
861
862        values.push("src/app.rs:10".to_owned());
863        values.extend(["src/app.rs:20".to_owned()]);
864
865        assert_eq!(values.len(), 2);
866        assert_eq!(
867            values.as_slice(),
868            ["src/app.rs:10".to_owned(), "src/app.rs:20".to_owned()]
869        );
870        assert_eq!(
871            values.iter().map(String::as_str).collect::<Vec<_>>(),
872            ["src/app.rs:10", "src/app.rs:20"]
873        );
874
875        let from_vec = PoVec::from(vec!["fuzzy".to_owned()]);
876        assert_eq!(from_vec, vec!["fuzzy".to_owned()]);
877        assert_eq!(Vec::<String>::from(from_vec), vec!["fuzzy".to_owned()]);
878    }
879
880    #[test]
881    fn povec_trait_views_cover_mutable_slice_and_reference_iteration() {
882        let mut values = PoVec::with_capacity(2);
883        values.extend([1, 2]);
884
885        assert_eq!(AsRef::<[i32]>::as_ref(&values), [1, 2]);
886
887        AsMut::<[i32]>::as_mut(&mut values)[0] = 3;
888        values.as_mut_slice()[1] = 4;
889        for value in &mut values {
890            *value += 1;
891        }
892        values.iter_mut().for_each(|value| *value *= 2);
893
894        let as_slice: &[i32] = &values;
895        assert_eq!(as_slice, [8, 10]);
896
897        let as_mut_slice: &mut [i32] = &mut values;
898        as_mut_slice[0] += 1;
899
900        let expected = [9, 10];
901        assert!(values == expected[..]);
902        assert!(values == expected.as_slice());
903    }
904
905    #[test]
906    fn povec_owned_iterator_preserves_values_without_exposing_backing_type() {
907        let values = PoVec::from(["one".to_owned(), "other".to_owned()]);
908
909        assert_eq!(
910            values.into_iter().collect::<Vec<_>>(),
911            vec!["one".to_owned(), "other".to_owned()]
912        );
913    }
914
915    #[test]
916    fn povec_owned_iterator_supports_double_ended_size_hints() {
917        let mut iter = PoVec::from([1, 2, 3]).into_iter();
918
919        assert_eq!(iter.size_hint(), (3, Some(3)));
920        assert_eq!(iter.next_back(), Some(3));
921        assert_eq!(iter.size_hint(), (2, Some(2)));
922        assert_eq!(iter.next(), Some(1));
923        assert_eq!(iter.next_back(), Some(2));
924        assert_eq!(iter.next(), None);
925        assert_eq!(iter.next_back(), None);
926    }
927
928    #[test]
929    fn serialize_option_builders_set_fields() {
930        let options = SerializeOptions::default()
931            .with_fold_length(120)
932            .with_compact_multiline(false);
933
934        assert_eq!(options.fold_length, 120);
935        assert!(!options.compact_multiline);
936    }
937
938    #[cfg(feature = "serde")]
939    #[test]
940    fn po_file_serde_round_trips_owned_document_shape() {
941        let file = PoFile {
942            comments: vec!["translator note".to_owned()],
943            headers: vec![Header {
944                key: "Language".to_owned(),
945                value: "de".to_owned(),
946            }],
947            items: vec![PoItem {
948                msgid: "Hello".to_owned(),
949                msgstr: MsgStr::from("Hallo".to_owned()),
950                references: vec!["src/app.rs:10".to_owned()].into(),
951                nplurals: 1,
952                ..PoItem::default()
953            }],
954            ..PoFile::default()
955        };
956
957        let json = serde_json::to_value(&file).expect("PO file serialization must succeed");
958        assert_eq!(json["items"][0]["msgstr"]["kind"], "singular");
959        assert_eq!(json["items"][0]["msgstr"]["value"], "Hallo");
960
961        let roundtrip: PoFile =
962            serde_json::from_value(json).expect("PO file deserialization must succeed");
963        assert_eq!(roundtrip, file);
964    }
965}