Skip to main content

cadmpeg_core/
text.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Source text that carries its own non-blank proof.
3
4#[cfg(feature = "schema")]
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// A character that is not whitespace.
10///
11/// The type exists so [`NonBlankString::prefixed`] is total: a prefix of this
12/// type makes the built string hold at least one non-whitespace character
13/// whatever the suffix renders to.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct NonWhitespaceChar(char);
16
17impl NonWhitespaceChar {
18    /// Admit an ASCII byte that is not whitespace.
19    #[must_use]
20    pub const fn from_ascii(byte: u8) -> Option<Self> {
21        if byte.is_ascii() && !matches!(byte, b'\t'..=b'\r' | b' ') {
22            // endian-exception: reconstructed-scalar
23            match char::from_u32(u32::from_be_bytes([0, 0, 0, byte])) {
24                Some(character) => Some(Self(character)),
25                None => None,
26            }
27        } else {
28            None
29        }
30    }
31
32    /// The lowercase hexadecimal digit naming the low four bits of `nibble`.
33    ///
34    /// Every hexadecimal digit is non-whitespace, and the mask makes the four
35    /// bits total over `u8`, so this constructor refuses nothing.
36    #[must_use]
37    pub fn hex_digit(nibble: u8) -> Self {
38        const DIGITS: [char; 16] = [
39            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
40        ];
41        Self(DIGITS[usize::from(nibble & 0x0f)])
42    }
43}
44
45impl std::fmt::Display for NonWhitespaceChar {
46    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        std::fmt::Display::fmt(&self.0, formatter)
48    }
49}
50
51/// A source string that holds at least one non-whitespace character.
52///
53/// A selection id, an external document identity and a native name are read
54/// back and compared as text. A run of spaces names nothing, so it is refused
55/// here rather than by each reader.
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
57#[serde(transparent)]
58pub struct NonBlankString(String);
59
60#[cfg(feature = "schema")]
61impl JsonSchema for NonBlankString {
62    fn schema_name() -> std::borrow::Cow<'static, str> {
63        "NonBlankString".into()
64    }
65
66    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
67        // Unicode White_Space matches char::is_whitespace; ECMAScript \s does not.
68        schemars::json_schema!({
69            "type": "string",
70            "pattern": r"[^\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]"
71        })
72    }
73}
74
75impl NonBlankString {
76    /// Constructs a source string that is not blank.
77    ///
78    /// Absent when the value holds no non-whitespace character, the empty
79    /// string included.
80    pub fn new(value: impl Into<String>) -> Option<Self> {
81        let value = value.into();
82        value
83            .chars()
84            .any(|character| !character.is_whitespace())
85            .then_some(Self(value))
86    }
87
88    /// Constructs a non-blank string from a leading character and a suffix.
89    ///
90    /// Total: the prefix is non-whitespace, so the result holds it whatever
91    /// the suffix renders to.
92    pub fn prefixed(prefix: NonWhitespaceChar, suffix: impl std::fmt::Display) -> Self {
93        Self(format!("{prefix}{suffix}"))
94    }
95
96    /// Append text while retaining the admitted non-whitespace character.
97    #[must_use]
98    pub fn with_suffix(&self, suffix: &str) -> Self {
99        let mut value = self.0.clone();
100        value.push_str(suffix);
101        Self(value)
102    }
103
104    /// Returns the source string.
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108
109    /// Consumes the value and returns the source string.
110    #[must_use]
111    pub fn into_string(self) -> String {
112        self.0
113    }
114}
115
116/// Builds a [`NonBlankString`] from a format literal whose first character is
117/// literal, non-whitespace text.
118///
119/// The template is always a literal, so the leading byte is the initializer of
120/// a `const` *item*: a template that starts with whitespace, with a non-ASCII
121/// byte or with a `{` placeholder fails `cargo check` with E0080, not only a
122/// codegen build. (An inline `const { … }` block is evaluated at codegen, so
123/// `check` would pass a whitespace literal.) Formatted arguments follow that
124/// literal prefix and cannot make the result blank.
125///
126/// ```compile_fail
127/// let _ = cadmpeg_core::nonblank_literal!(" {}", "name");
128/// ```
129///
130/// ```compile_fail
131/// let _ = cadmpeg_core::nonblank_literal!("\x0b");
132/// ```
133#[macro_export]
134macro_rules! nonblank_literal {
135    ($template:literal $(, $argument:expr)* $(,)?) => {{
136        const NONBLANK_LITERAL_LEADING: $crate::text::NonWhitespaceChar = {
137            let bytes = $template.as_bytes();
138            assert!(
139                !bytes.is_empty() && bytes[0] != b'{',
140                "a nonblank literal must start with literal ASCII text",
141            );
142            match $crate::text::NonWhitespaceChar::from_ascii(bytes[0]) {
143                Some(character) => character,
144                None => panic!("a nonblank literal starts with a non-whitespace ASCII character"),
145            }
146        };
147        let rendered = format!($template $(, $argument)*);
148        $crate::text::NonBlankString::prefixed(NONBLANK_LITERAL_LEADING, &rendered[1..])
149    }};
150}
151
152/// Lookup by the plain string the key spells.
153///
154/// `Ord` and `Hash` are derived over the same `String`, so a map keyed by this
155/// type answers `get`, `contains_key` and indexing with a `&str` and orders its
156/// keys exactly as a `String` key would.
157impl std::borrow::Borrow<str> for NonBlankString {
158    fn borrow(&self) -> &str {
159        self.as_str()
160    }
161}
162
163/// A source property the reader could not key.
164///
165/// A blank key names nothing, so the entry it carries cannot be asked for. A
166/// restated key names an entry that is already keyed, so the second value has
167/// nowhere to go. Either way the value is still source content, so the reader
168/// states the property it could not key instead of dropping it without a word.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum NamedEntryError {
171    /// The key holds no non-whitespace character.
172    Blank {
173        /// The record the reader named as the owner of the property set.
174        record: String,
175    },
176    /// The record states this key a second time.
177    Restated {
178        /// The record the reader named as the owner of the property set.
179        record: String,
180        /// The key the record states twice.
181        key: NonBlankString,
182    },
183}
184
185impl std::fmt::Display for NamedEntryError {
186    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        match self {
188            Self::Blank { record } => {
189                write!(formatter, "{record} states a property with a blank key")
190            }
191            Self::Restated { record, key } => write!(
192                formatter,
193                "{record} states the property {key} a second time"
194            ),
195        }
196    }
197}
198
199impl std::error::Error for NamedEntryError {}
200
201/// Keys a map by the entry names, and states every name it cannot key.
202///
203/// Returns the entries whose keys name something and are stated once, and one
204/// [`NamedEntryError`] per entry whose key is blank or restated, in the order
205/// the reader supplied. `record` names the owning record so the refused
206/// property can be reported against the record that states it; it is rendered
207/// only when a key is refused.
208///
209/// The first value a key states is the one that is kept, so a later restatement
210/// never overwrites what is already keyed. The blank key names its record and
211/// nothing else: the entries arrive as an iterator whose order this function
212/// does not know, so a position counted here would name nothing in the source.
213/// A reader that does know where in its source the property sits puts that in
214/// `record`, which it builds. A restated key names itself as well as the
215/// record, because the key is what the source states twice.
216///
217/// This is the route for a reader that keeps the properties it can key and
218/// reports the rest. A reader that refuses the whole set uses
219/// [`named_entries`].
220pub fn named_entries_reporting<V>(
221    record: impl std::fmt::Display,
222    entries: impl IntoIterator<Item = (String, V)>,
223) -> (BTreeMap<NonBlankString, V>, Vec<NamedEntryError>) {
224    let mut kept = BTreeMap::new();
225    let mut refused = Vec::new();
226    for (name, value) in entries {
227        match NonBlankString::new(name) {
228            Some(key) => match kept.entry(key) {
229                std::collections::btree_map::Entry::Vacant(slot) => {
230                    slot.insert(value);
231                }
232                std::collections::btree_map::Entry::Occupied(slot) => {
233                    refused.push(NamedEntryError::Restated {
234                        record: record.to_string(),
235                        key: slot.key().clone(),
236                    });
237                }
238            },
239            None => refused.push(NamedEntryError::Blank {
240                record: record.to_string(),
241            }),
242        }
243    }
244    (kept, refused)
245}
246
247/// Keys a map by the entry names, refusing a name that is blank or restated.
248///
249/// Codecs that read an open set of native names route the whole set through
250/// here, so the rule is stated once instead of at each reader.
251///
252/// # Errors
253///
254/// Names the record that states the first key this function cannot key, and
255/// for a restated key the key itself.
256pub fn named_entries<V>(
257    record: impl std::fmt::Display,
258    entries: impl IntoIterator<Item = (String, V)>,
259) -> Result<BTreeMap<NonBlankString, V>, NamedEntryError> {
260    let (kept, refused) = named_entries_reporting(record, entries);
261    match refused.into_iter().next() {
262        Some(error) => Err(error),
263        None => Ok(kept),
264    }
265}
266
267/// Builds a [`NonBlankString`] from a `&'static str` constant.
268///
269/// [`nonblank_literal!`](crate::nonblank_literal) needs a literal template,
270/// which a named constant is not. The proof is the same one: the leading byte
271/// is read in the initializer of a `const` *item*, so a constant that is empty
272/// or starts with whitespace or a non-ASCII byte fails `cargo check` with
273/// E0080. Use it where the key is a pinned constant, so the spelling stays in
274/// one place instead of being repeated as a literal beside the constant.
275///
276/// ```compile_fail
277/// const BLANK: &str = "\x0b";
278/// let _ = cadmpeg_core::nonblank_const!(BLANK);
279/// ```
280#[macro_export]
281macro_rules! nonblank_const {
282    ($constant:expr) => {{
283        const NONBLANK_CONST_LEADING: $crate::text::NonWhitespaceChar = {
284            let bytes = $constant.as_bytes();
285            assert!(
286                !bytes.is_empty(),
287                "a nonblank constant must hold at least one character",
288            );
289            match $crate::text::NonWhitespaceChar::from_ascii(bytes[0]) {
290                Some(character) => character,
291                None => panic!("a nonblank literal starts with a non-whitespace ASCII character"),
292            }
293        };
294        $crate::text::NonBlankString::prefixed(NONBLANK_CONST_LEADING, &$constant[1..])
295    }};
296}
297
298impl PartialEq<str> for NonBlankString {
299    fn eq(&self, other: &str) -> bool {
300        self.0 == other
301    }
302}
303
304impl PartialEq<&str> for NonBlankString {
305    fn eq(&self, other: &&str) -> bool {
306        self == *other
307    }
308}
309
310impl std::fmt::Display for NonBlankString {
311    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        formatter.write_str(self.as_str())
313    }
314}
315
316impl<'de> Deserialize<'de> for NonBlankString {
317    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
318    where
319        D: serde::Deserializer<'de>,
320    {
321        Self::new(String::deserialize(deserializer)?)
322            .ok_or_else(|| serde::de::Error::custom("source identity must not be blank"))
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    #![allow(clippy::unwrap_used)]
329
330    #[cfg(feature = "schema")]
331    use std::collections::BTreeMap;
332
333    use super::{
334        named_entries, named_entries_reporting, NamedEntryError, NonBlankString, NonWhitespaceChar,
335    };
336
337    #[test]
338    fn prefixes_preserve_nonblank_strings_and_wire_values() {
339        const HASH: NonWhitespaceChar = match NonWhitespaceChar::from_ascii(b'#') {
340            Some(character) => character,
341            None => panic!("the literal is an ASCII non-whitespace character"),
342        };
343        const PINNED: &str = "pinned";
344        for (prefix, suffix, expected) in [
345            (HASH, "42", "#42"),
346            (NonWhitespaceChar::hex_digit(0x0a), "", "a"),
347            (NonWhitespaceChar::hex_digit(0xf0), "", "0"),
348        ] {
349            let value = NonBlankString::prefixed(prefix, suffix);
350            assert_eq!(value.as_str(), expected);
351            assert_eq!(serde_json::to_value(&value).unwrap(), expected);
352        }
353        assert_eq!(crate::nonblank_literal!("#{}", 42).as_str(), "#42");
354        assert_eq!(crate::nonblank_const!(PINNED).as_str(), "pinned");
355    }
356
357    #[test]
358    fn ascii_character_admission_is_total_over_every_byte() {
359        for byte in u8::MIN..=u8::MAX {
360            let admitted = NonWhitespaceChar::from_ascii(byte);
361            assert_eq!(
362                admitted.is_some(),
363                byte.is_ascii() && !char::from(byte).is_whitespace()
364            );
365            if let Some(character) = admitted {
366                assert_eq!(character.to_string(), char::from(byte).to_string());
367            }
368        }
369    }
370
371    #[test]
372    fn every_admitted_prefix_produces_text_accepted_by_the_public_reader() {
373        for byte in u8::MIN..=u8::MAX {
374            let Some(prefix) = NonWhitespaceChar::from_ascii(byte) else {
375                continue;
376            };
377            let value = NonBlankString::prefixed(prefix, "\t\n\u{85}\u{3000}");
378            assert!(NonBlankString::new(value.as_str()).is_some(), "byte {byte}");
379            let wire = serde_json::to_string(&value).unwrap();
380            assert_eq!(
381                serde_json::from_str::<NonBlankString>(&wire).unwrap(),
382                value
383            );
384        }
385        assert!(NonWhitespaceChar::from_ascii(0x0b).is_none());
386    }
387
388    #[cfg(feature = "schema")]
389    #[test]
390    fn nonblank_schema_constrains_property_names_without_trimming_source_text() {
391        let schema = serde_json::to_value(schemars::schema_for!(NonBlankString)).unwrap();
392        assert_eq!(schema["type"], "string");
393        let pattern = schema["pattern"].as_str().unwrap();
394        let map =
395            serde_json::to_value(schemars::schema_for!(BTreeMap<NonBlankString, String>)).unwrap();
396        assert_eq!(map["additionalProperties"], false);
397        assert!(map["patternProperties"].get(pattern).is_some());
398        for text in [" \tname\n", "\u{feff}", "\0"] {
399            let value = NonBlankString::new(text).unwrap();
400            assert_eq!(serde_json::to_value(value).unwrap(), text);
401        }
402    }
403
404    #[test]
405    fn a_source_string_of_whitespace_alone_is_blank() {
406        assert!(NonBlankString::new("   ").is_none());
407        assert!(NonBlankString::new("").is_none());
408        assert!(NonBlankString::new("\t\n").is_none());
409        assert_eq!(
410            NonBlankString::new(" a ").map(|value| value.as_str().to_owned()),
411            Some(" a ".to_owned())
412        );
413    }
414
415    #[test]
416    fn a_blank_key_is_named_and_the_other_properties_survive() {
417        let entries = [
418            ("width".to_owned(), "10"),
419            ("   ".to_owned(), "dropped"),
420            ("depth".to_owned(), "4"),
421        ];
422
423        let (kept, refused) = named_entries_reporting("feature 7", entries.clone());
424        assert_eq!(refused.len(), 1);
425        assert!(
426            matches!(&refused[0], super::NamedEntryError::Blank { record } if record == "feature 7")
427        );
428        assert_eq!(
429            refused[0].to_string(),
430            "feature 7 states a property with a blank key"
431        );
432        assert_eq!(
433            kept.keys().map(NonBlankString::as_str).collect::<Vec<_>>(),
434            ["depth", "width"]
435        );
436
437        let error = named_entries("feature 7", entries).unwrap_err();
438        assert_eq!(error, refused[0]);
439    }
440
441    #[test]
442    fn a_restated_key_is_named_and_its_second_value_is_not_kept() {
443        let entries = [
444            ("k".to_owned(), "first"),
445            ("k".to_owned(), "second"),
446            ("depth".to_owned(), "4"),
447        ];
448
449        let (kept, refused) = named_entries_reporting("feature 7", entries.clone());
450        assert_eq!(kept.len(), 2);
451        assert_eq!(kept.get("k"), Some(&"first"));
452        assert_eq!(refused.len(), 1);
453        assert_eq!(
454            refused[0],
455            NamedEntryError::Restated {
456                record: "feature 7".to_owned(),
457                key: NonBlankString::new("k").unwrap(),
458            }
459        );
460        assert_eq!(
461            refused[0].to_string(),
462            "feature 7 states the property k a second time"
463        );
464
465        let error = named_entries("feature 7", entries).unwrap_err();
466        assert_eq!(error, refused[0]);
467    }
468
469    #[test]
470    fn keys_that_all_name_something_are_kept_whole() {
471        let entries = [("width".to_owned(), "10"), ("depth".to_owned(), "4")];
472
473        let kept = named_entries("feature 7", entries.clone()).unwrap();
474        assert_eq!(kept.len(), 2);
475        assert!(named_entries_reporting("feature 7", entries).1.is_empty());
476    }
477
478    #[test]
479    fn named_entries_keep_order_and_first_value() {
480        let entries = vec![
481            ("width".to_owned(), 1),
482            ("width".to_owned(), 2),
483            ("depth".to_owned(), 3),
484        ];
485        let (kept, refused) = named_entries_reporting("f", entries);
486        assert_eq!(kept.get("width"), Some(&1));
487        assert_eq!(
488            kept.keys().map(NonBlankString::as_str).collect::<Vec<_>>(),
489            ["depth", "width"]
490        );
491        assert_eq!(refused.len(), 1);
492        assert!(matches!(
493            named_entries("f", [(" ".to_owned(), 1)]).map_err(crate::CodecError::from),
494            Err(crate::CodecError::Malformed(_))
495        ));
496    }
497}