#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NonWhitespaceChar(char);
impl NonWhitespaceChar {
#[must_use]
pub const fn from_ascii(byte: u8) -> Option<Self> {
if byte.is_ascii() && !matches!(byte, b'\t'..=b'\r' | b' ') {
match char::from_u32(u32::from_be_bytes([0, 0, 0, byte])) {
Some(character) => Some(Self(character)),
None => None,
}
} else {
None
}
}
#[must_use]
pub fn hex_digit(nibble: u8) -> Self {
const DIGITS: [char; 16] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
];
Self(DIGITS[usize::from(nibble & 0x0f)])
}
}
impl std::fmt::Display for NonWhitespaceChar {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, formatter)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct NonBlankString(String);
#[cfg(feature = "schema")]
impl JsonSchema for NonBlankString {
fn schema_name() -> std::borrow::Cow<'static, str> {
"NonBlankString".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"pattern": r"[^\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]"
})
}
}
impl NonBlankString {
pub fn new(value: impl Into<String>) -> Option<Self> {
let value = value.into();
value
.chars()
.any(|character| !character.is_whitespace())
.then_some(Self(value))
}
pub fn prefixed(prefix: NonWhitespaceChar, suffix: impl std::fmt::Display) -> Self {
Self(format!("{prefix}{suffix}"))
}
#[must_use]
pub fn with_suffix(&self, suffix: &str) -> Self {
let mut value = self.0.clone();
value.push_str(suffix);
Self(value)
}
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
#[macro_export]
macro_rules! nonblank_literal {
($template:literal $(, $argument:expr)* $(,)?) => {{
const NONBLANK_LITERAL_LEADING: $crate::text::NonWhitespaceChar = {
let bytes = $template.as_bytes();
assert!(
!bytes.is_empty() && bytes[0] != b'{',
"a nonblank literal must start with literal ASCII text",
);
match $crate::text::NonWhitespaceChar::from_ascii(bytes[0]) {
Some(character) => character,
None => panic!("a nonblank literal starts with a non-whitespace ASCII character"),
}
};
let rendered = format!($template $(, $argument)*);
$crate::text::NonBlankString::prefixed(NONBLANK_LITERAL_LEADING, &rendered[1..])
}};
}
impl std::borrow::Borrow<str> for NonBlankString {
fn borrow(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NamedEntryError {
Blank {
record: String,
},
Restated {
record: String,
key: NonBlankString,
},
}
impl std::fmt::Display for NamedEntryError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Blank { record } => {
write!(formatter, "{record} states a property with a blank key")
}
Self::Restated { record, key } => write!(
formatter,
"{record} states the property {key} a second time"
),
}
}
}
impl std::error::Error for NamedEntryError {}
pub fn named_entries_reporting<V>(
record: impl std::fmt::Display,
entries: impl IntoIterator<Item = (String, V)>,
) -> (BTreeMap<NonBlankString, V>, Vec<NamedEntryError>) {
let mut kept = BTreeMap::new();
let mut refused = Vec::new();
for (name, value) in entries {
match NonBlankString::new(name) {
Some(key) => match kept.entry(key) {
std::collections::btree_map::Entry::Vacant(slot) => {
slot.insert(value);
}
std::collections::btree_map::Entry::Occupied(slot) => {
refused.push(NamedEntryError::Restated {
record: record.to_string(),
key: slot.key().clone(),
});
}
},
None => refused.push(NamedEntryError::Blank {
record: record.to_string(),
}),
}
}
(kept, refused)
}
pub fn named_entries<V>(
record: impl std::fmt::Display,
entries: impl IntoIterator<Item = (String, V)>,
) -> Result<BTreeMap<NonBlankString, V>, NamedEntryError> {
let (kept, refused) = named_entries_reporting(record, entries);
match refused.into_iter().next() {
Some(error) => Err(error),
None => Ok(kept),
}
}
#[macro_export]
macro_rules! nonblank_const {
($constant:expr) => {{
const NONBLANK_CONST_LEADING: $crate::text::NonWhitespaceChar = {
let bytes = $constant.as_bytes();
assert!(
!bytes.is_empty(),
"a nonblank constant must hold at least one character",
);
match $crate::text::NonWhitespaceChar::from_ascii(bytes[0]) {
Some(character) => character,
None => panic!("a nonblank literal starts with a non-whitespace ASCII character"),
}
};
$crate::text::NonBlankString::prefixed(NONBLANK_CONST_LEADING, &$constant[1..])
}};
}
impl PartialEq<str> for NonBlankString {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for NonBlankString {
fn eq(&self, other: &&str) -> bool {
self == *other
}
}
impl std::fmt::Display for NonBlankString {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for NonBlankString {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Self::new(String::deserialize(deserializer)?)
.ok_or_else(|| serde::de::Error::custom("source identity must not be blank"))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
#[cfg(feature = "schema")]
use std::collections::BTreeMap;
use super::{
named_entries, named_entries_reporting, NamedEntryError, NonBlankString, NonWhitespaceChar,
};
#[test]
fn prefixes_preserve_nonblank_strings_and_wire_values() {
const HASH: NonWhitespaceChar = match NonWhitespaceChar::from_ascii(b'#') {
Some(character) => character,
None => panic!("the literal is an ASCII non-whitespace character"),
};
const PINNED: &str = "pinned";
for (prefix, suffix, expected) in [
(HASH, "42", "#42"),
(NonWhitespaceChar::hex_digit(0x0a), "", "a"),
(NonWhitespaceChar::hex_digit(0xf0), "", "0"),
] {
let value = NonBlankString::prefixed(prefix, suffix);
assert_eq!(value.as_str(), expected);
assert_eq!(serde_json::to_value(&value).unwrap(), expected);
}
assert_eq!(crate::nonblank_literal!("#{}", 42).as_str(), "#42");
assert_eq!(crate::nonblank_const!(PINNED).as_str(), "pinned");
}
#[test]
fn ascii_character_admission_is_total_over_every_byte() {
for byte in u8::MIN..=u8::MAX {
let admitted = NonWhitespaceChar::from_ascii(byte);
assert_eq!(
admitted.is_some(),
byte.is_ascii() && !char::from(byte).is_whitespace()
);
if let Some(character) = admitted {
assert_eq!(character.to_string(), char::from(byte).to_string());
}
}
}
#[test]
fn every_admitted_prefix_produces_text_accepted_by_the_public_reader() {
for byte in u8::MIN..=u8::MAX {
let Some(prefix) = NonWhitespaceChar::from_ascii(byte) else {
continue;
};
let value = NonBlankString::prefixed(prefix, "\t\n\u{85}\u{3000}");
assert!(NonBlankString::new(value.as_str()).is_some(), "byte {byte}");
let wire = serde_json::to_string(&value).unwrap();
assert_eq!(
serde_json::from_str::<NonBlankString>(&wire).unwrap(),
value
);
}
assert!(NonWhitespaceChar::from_ascii(0x0b).is_none());
}
#[cfg(feature = "schema")]
#[test]
fn nonblank_schema_constrains_property_names_without_trimming_source_text() {
let schema = serde_json::to_value(schemars::schema_for!(NonBlankString)).unwrap();
assert_eq!(schema["type"], "string");
let pattern = schema["pattern"].as_str().unwrap();
let map =
serde_json::to_value(schemars::schema_for!(BTreeMap<NonBlankString, String>)).unwrap();
assert_eq!(map["additionalProperties"], false);
assert!(map["patternProperties"].get(pattern).is_some());
for text in [" \tname\n", "\u{feff}", "\0"] {
let value = NonBlankString::new(text).unwrap();
assert_eq!(serde_json::to_value(value).unwrap(), text);
}
}
#[test]
fn a_source_string_of_whitespace_alone_is_blank() {
assert!(NonBlankString::new(" ").is_none());
assert!(NonBlankString::new("").is_none());
assert!(NonBlankString::new("\t\n").is_none());
assert_eq!(
NonBlankString::new(" a ").map(|value| value.as_str().to_owned()),
Some(" a ".to_owned())
);
}
#[test]
fn a_blank_key_is_named_and_the_other_properties_survive() {
let entries = [
("width".to_owned(), "10"),
(" ".to_owned(), "dropped"),
("depth".to_owned(), "4"),
];
let (kept, refused) = named_entries_reporting("feature 7", entries.clone());
assert_eq!(refused.len(), 1);
assert!(
matches!(&refused[0], super::NamedEntryError::Blank { record } if record == "feature 7")
);
assert_eq!(
refused[0].to_string(),
"feature 7 states a property with a blank key"
);
assert_eq!(
kept.keys().map(NonBlankString::as_str).collect::<Vec<_>>(),
["depth", "width"]
);
let error = named_entries("feature 7", entries).unwrap_err();
assert_eq!(error, refused[0]);
}
#[test]
fn a_restated_key_is_named_and_its_second_value_is_not_kept() {
let entries = [
("k".to_owned(), "first"),
("k".to_owned(), "second"),
("depth".to_owned(), "4"),
];
let (kept, refused) = named_entries_reporting("feature 7", entries.clone());
assert_eq!(kept.len(), 2);
assert_eq!(kept.get("k"), Some(&"first"));
assert_eq!(refused.len(), 1);
assert_eq!(
refused[0],
NamedEntryError::Restated {
record: "feature 7".to_owned(),
key: NonBlankString::new("k").unwrap(),
}
);
assert_eq!(
refused[0].to_string(),
"feature 7 states the property k a second time"
);
let error = named_entries("feature 7", entries).unwrap_err();
assert_eq!(error, refused[0]);
}
#[test]
fn keys_that_all_name_something_are_kept_whole() {
let entries = [("width".to_owned(), "10"), ("depth".to_owned(), "4")];
let kept = named_entries("feature 7", entries.clone()).unwrap();
assert_eq!(kept.len(), 2);
assert!(named_entries_reporting("feature 7", entries).1.is_empty());
}
#[test]
fn named_entries_keep_order_and_first_value() {
let entries = vec![
("width".to_owned(), 1),
("width".to_owned(), 2),
("depth".to_owned(), 3),
];
let (kept, refused) = named_entries_reporting("f", entries);
assert_eq!(kept.get("width"), Some(&1));
assert_eq!(
kept.keys().map(NonBlankString::as_str).collect::<Vec<_>>(),
["depth", "width"]
);
assert_eq!(refused.len(), 1);
assert!(matches!(
named_entries("f", [(" ".to_owned(), 1)]).map_err(crate::CodecError::from),
Err(crate::CodecError::Malformed(_))
));
}
}