Skip to main content

translation/
translation_attributes.rs

1use core::ops::Range;
2
3use serde::{Deserialize, Serialize};
4
5use crate::translation_error::TranslationError;
6
7/// Marker for Translation.framework's `AttributeScopes.TranslationAttributes`.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
9pub struct TranslationAttributes;
10
11/// Marker for Translation.framework's `AttributeScopes.TranslationAttributes.EncodingConfiguration`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13pub struct TranslationAttributesEncodingConfiguration;
14
15/// Marker for Translation.framework's `AttributeScopes.TranslationAttributes.DecodingConfiguration`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
17pub struct TranslationAttributesDecodingConfiguration;
18
19impl TranslationAttributes {
20    #[must_use]
21    /// Returns the Translation attribute scope marker.
22    pub const fn translation() -> Self {
23        Self
24    }
25
26    #[must_use]
27    /// Returns the marker for Translation attribute encoding.
28    pub const fn encoding_configuration() -> TranslationAttributesEncodingConfiguration {
29        TranslationAttributesEncodingConfiguration
30    }
31
32    #[must_use]
33    /// Returns the marker for Translation attribute decoding.
34    pub const fn decoding_configuration() -> TranslationAttributesDecodingConfiguration {
35        TranslationAttributesDecodingConfiguration
36    }
37
38    #[must_use]
39    /// Creates a `SkipTranslationAttribute` value for a run.
40    pub const fn skips_translation(value: SkipTranslationAttributeValue) -> SkipTranslationAttribute {
41        SkipTranslationAttribute::new(value)
42    }
43}
44
45/// Rust value type for `SkipTranslationAttribute`.
46pub type SkipTranslationAttributeValue = bool;
47
48/// Mirrors `AttributeScopes.TranslationAttributes.SkipTranslationAttribute`.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
50#[serde(transparent)]
51pub struct SkipTranslationAttribute(SkipTranslationAttributeValue);
52
53impl SkipTranslationAttribute {
54    /// Swift attribute key name exposed by Translation.framework.
55    pub const NAME: &str = "Translation.DoNotTranslate";
56
57    #[must_use]
58    /// Creates a new skip-translation attribute value.
59    pub const fn new(value: SkipTranslationAttributeValue) -> Self {
60        Self(value)
61    }
62
63    #[must_use]
64    /// Returns the wrapped boolean value.
65    pub const fn value(self) -> SkipTranslationAttributeValue {
66        self.0
67    }
68
69    #[must_use]
70    /// Returns an enabled skip-translation marker.
71    pub const fn enabled() -> Self {
72        Self(true)
73    }
74
75    #[must_use]
76    /// Returns a disabled skip-translation marker.
77    pub const fn disabled() -> Self {
78        Self(false)
79    }
80}
81
82impl From<SkipTranslationAttributeValue> for SkipTranslationAttribute {
83    fn from(value: SkipTranslationAttributeValue) -> Self {
84        Self::new(value)
85    }
86}
87
88impl From<SkipTranslationAttribute> for SkipTranslationAttributeValue {
89    fn from(value: SkipTranslationAttribute) -> Self {
90        value.value()
91    }
92}
93
94/// Run-scoped Translation attributes stored on a `TranslationAttributedString`.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct TranslationAttributedRun {
98    start: usize,
99    end: usize,
100    value: SkipTranslationAttribute,
101}
102
103impl TranslationAttributedRun {
104    #[must_use]
105    /// Creates a run carrying a skip-translation attribute.
106    pub const fn new(start: usize, end: usize, value: SkipTranslationAttribute) -> Self {
107        Self { start, end, value }
108    }
109
110    #[must_use]
111    /// Returns the inclusive-exclusive character range start.
112    pub const fn start(&self) -> usize {
113        self.start
114    }
115
116    #[must_use]
117    /// Returns the inclusive-exclusive character range end.
118    pub const fn end(&self) -> usize {
119        self.end
120    }
121
122    #[must_use]
123    /// Returns the run range.
124    pub fn range(&self) -> Range<usize> {
125        self.start..self.end
126    }
127
128    #[must_use]
129    /// Returns the skip-translation value for the run.
130    pub const fn value(&self) -> SkipTranslationAttribute {
131        self.value
132    }
133}
134
135/// Rust-side attributed text wrapper for Translation.framework payloads.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
137#[serde(rename_all = "camelCase")]
138pub struct TranslationAttributedString {
139    text: String,
140    #[serde(default)]
141    skip_translation_runs: Vec<TranslationAttributedRun>,
142}
143
144impl TranslationAttributedString {
145    #[must_use]
146    /// Creates attributed text from plain text.
147    pub fn new(text: impl Into<String>) -> Self {
148        Self {
149            text: text.into(),
150            skip_translation_runs: Vec::new(),
151        }
152    }
153
154    #[must_use]
155    /// Returns the plain-text content.
156    pub fn text(&self) -> &str {
157        &self.text
158    }
159
160    #[must_use]
161    /// Returns the stored skip-translation runs.
162    pub fn skip_translation_runs(&self) -> &[TranslationAttributedRun] {
163        &self.skip_translation_runs
164    }
165
166    /// Replaces the plain-text content and clears any stale runs.
167    pub fn set_text(&mut self, text: impl Into<String>) {
168        self.text = text.into();
169        self.skip_translation_runs.clear();
170    }
171
172    /// Adds a skip-translation run using character offsets.
173    pub fn add_skip_translation_run(
174        &mut self,
175        range: Range<usize>,
176        value: impl Into<SkipTranslationAttribute>,
177    ) -> Result<(), TranslationError> {
178        self.validate_range(&range)?;
179        self.skip_translation_runs
180            .push(TranslationAttributedRun::new(range.start, range.end, value.into()));
181        self.skip_translation_runs.sort_by_key(TranslationAttributedRun::start);
182        Ok(())
183    }
184
185    /// Adds an enabled skip-translation run using character offsets.
186    pub fn add_skip_translation(
187        &mut self,
188        range: Range<usize>,
189    ) -> Result<(), TranslationError> {
190        self.add_skip_translation_run(range, SkipTranslationAttribute::enabled())
191    }
192
193    /// Returns a copy with an enabled skip-translation run.
194    pub fn with_skip_translation(
195        mut self,
196        range: Range<usize>,
197    ) -> Result<Self, TranslationError> {
198        self.add_skip_translation(range)?;
199        Ok(self)
200    }
201
202    /// Returns a copy with the provided skip-translation value.
203    pub fn with_skip_translation_value(
204        mut self,
205        range: Range<usize>,
206        value: impl Into<SkipTranslationAttribute>,
207    ) -> Result<Self, TranslationError> {
208        self.add_skip_translation_run(range, value)?;
209        Ok(self)
210    }
211
212    /// Adds an enabled skip-translation run for the first matching substring.
213    pub fn add_skip_translation_for_substring(
214        &mut self,
215        substring: &str,
216    ) -> Result<(), TranslationError> {
217        let range = self.substring_range(substring)?;
218        self.add_skip_translation(range)
219    }
220
221    /// Returns a copy with an enabled skip-translation run for the first matching substring.
222    pub fn with_skip_translation_for_substring(
223        mut self,
224        substring: &str,
225    ) -> Result<Self, TranslationError> {
226        self.add_skip_translation_for_substring(substring)?;
227        Ok(self)
228    }
229
230    fn validate_range(&self, range: &Range<usize>) -> Result<(), TranslationError> {
231        let character_len = self.text.chars().count();
232        if range.start > range.end || range.end > character_len {
233            return Err(TranslationError::InvalidArgument(format!(
234                "attributed text range {}..{} is outside 0..{}",
235                range.start, range.end, character_len
236            )));
237        }
238        Ok(())
239    }
240
241    fn substring_range(&self, substring: &str) -> Result<Range<usize>, TranslationError> {
242        if substring.is_empty() {
243            return Err(TranslationError::InvalidArgument(
244                "skip-translation substring must be non-empty".to_owned(),
245            ));
246        }
247        let start_byte = self.text.find(substring).ok_or_else(|| {
248            TranslationError::InvalidArgument(format!(
249                "substring '{substring}' was not found in attributed text"
250            ))
251        })?;
252        let end_byte = start_byte + substring.len();
253        Ok(self.text[..start_byte].chars().count()..self.text[..end_byte].chars().count())
254    }
255}
256
257impl From<String> for TranslationAttributedString {
258    fn from(text: String) -> Self {
259        Self::new(text)
260    }
261}
262
263impl From<&str> for TranslationAttributedString {
264    fn from(text: &str) -> Self {
265        Self::new(text)
266    }
267}