1#[cfg(feature = "schema")]
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct NonWhitespaceChar(char);
16
17impl NonWhitespaceChar {
18 #[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 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 #[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#[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 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 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 pub fn prefixed(prefix: NonWhitespaceChar, suffix: impl std::fmt::Display) -> Self {
93 Self(format!("{prefix}{suffix}"))
94 }
95
96 #[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 pub fn as_str(&self) -> &str {
106 &self.0
107 }
108
109 #[must_use]
111 pub fn into_string(self) -> String {
112 self.0
113 }
114}
115
116#[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
152impl std::borrow::Borrow<str> for NonBlankString {
158 fn borrow(&self) -> &str {
159 self.as_str()
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum NamedEntryError {
171 Blank {
173 record: String,
175 },
176 Restated {
178 record: String,
180 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
201pub 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
247pub 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#[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}