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