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