use crate::issue::Issue;
use crate::java;
use serde_json::Value;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, LazyLock};
pub trait MessageResolver {
fn resolve(&self, issue: &Issue) -> String;
}
impl<F: Fn(&Issue) -> String> MessageResolver for F {
fn resolve(&self, issue: &Issue) -> String {
self(issue)
}
}
#[derive(Clone, Debug, Default)]
pub struct Messages {
templates: HashMap<String, String>,
parent: Option<Arc<Messages>>,
}
static ENGLISH: LazyLock<Messages> = LazyLock::new(|| {
Messages::from_properties(include_str!("messages/en.properties"))
.expect("the English catalogue is well formed")
});
static JAPANESE: LazyLock<Messages> = LazyLock::new(|| {
Messages::from_properties(include_str!("messages/ja.properties"))
.expect("the Japanese catalogue is well formed")
.falling_back_to(&ENGLISH)
});
impl Messages {
pub fn english() -> &'static Messages {
&ENGLISH
}
pub fn japanese() -> &'static Messages {
&JAPANESE
}
pub fn empty() -> Self {
Self::default()
}
pub fn from_properties(text: &str) -> Result<Self, PropertiesError> {
let pairs = java::load_properties(text).map_err(|e| PropertiesError {
line: e.line,
reason: e.reason,
})?;
let templates = pairs
.into_iter()
.map(|(key, template)| match key.strip_prefix("raoh.") {
Some(bare) => (bare.to_owned(), template),
None => (key, template),
})
.collect();
Ok(Self {
templates,
parent: None,
})
}
pub fn falling_back_to(self, parent: &Messages) -> Self {
let parent = match self.parent {
Some(own) => Arc::new((*own).clone().falling_back_to(parent)),
None => Arc::new(parent.clone()),
};
Self {
templates: self.templates,
parent: Some(parent),
}
}
pub fn with_overrides<K, T>(&self, overrides: impl IntoIterator<Item = (K, T)>) -> Self
where
K: Into<String>,
T: Into<String>,
{
Self {
templates: overrides
.into_iter()
.map(|(k, t)| (k.into(), t.into()))
.collect(),
parent: Some(Arc::new(self.clone())),
}
}
fn layers(&self) -> impl Iterator<Item = &Messages> {
std::iter::successors(Some(self), |layer| layer.parent.as_deref())
}
pub fn templates(&self) -> impl Iterator<Item = (&str, &str)> {
let mut seen: HashMap<&str, &str> = HashMap::new();
for layer in self.layers() {
for (key, template) in &layer.templates {
seen.entry(key.as_str()).or_insert(template.as_str());
}
}
seen.into_iter()
}
pub fn template(&self, key: &str) -> Option<&str> {
self.layers()
.find_map(|layer| layer.templates.get(key))
.map(String::as_str)
}
}
impl MessageResolver for Messages {
fn resolve(&self, issue: &Issue) -> String {
self.layers()
.find_map(|layer| {
[issue.message_key(), issue.code()]
.into_iter()
.filter_map(|key| layer.templates.get(key))
.find_map(|template| fill(template, issue.meta()))
})
.unwrap_or_else(|| format!("validation failed: {}", issue.code()))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PropertiesError {
line: usize,
reason: &'static str,
}
impl PropertiesError {
pub fn line(&self) -> usize {
self.line
}
}
impl fmt::Display for PropertiesError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}: {}", self.line, self.reason)
}
}
impl std::error::Error for PropertiesError {}
fn fill(template: &str, meta: &BTreeMap<String, Value>) -> Option<String> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
match after.find('}').map(|close| (close, &after[..close])) {
Some((close, name)) if is_placeholder_name(name) => {
out.push_str(&display(meta.get(name)?));
rest = &after[close + 1..];
}
_ => {
out.push('{');
rest = after;
}
}
}
out.push_str(rest);
Some(out)
}
fn is_placeholder_name(name: &str) -> bool {
let mut chars = name.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
}
pub(crate) fn display(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Number(n) if n.is_f64() => n
.as_f64()
.map_or_else(|| n.to_string(), java::double_to_string),
Value::Array(items) => {
let items: Vec<String> = items.iter().map(display).collect();
format!("[{}]", items.join(", "))
}
Value::Object(entries) => {
let entries: Vec<String> = entries
.iter()
.map(|(k, v)| format!("{k}={}", display(v)))
.collect();
format!("{{{}}}", entries.join(", "))
}
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{codes, message_keys};
#[test]
fn the_message_key_is_looked_up_before_the_code() {
let issue = Issue::new(codes::OUT_OF_RANGE)
.with_message_key(message_keys::OUT_OF_RANGE_POSITIVE)
.with_meta("min", 1);
assert_eq!(
Messages::japanese().resolve(&issue),
"正の値で入力してください"
);
}
#[test]
fn a_template_missing_a_meta_entry_falls_back_to_the_code() {
let messages = Messages::empty().with_overrides([
("out_of_range.minimum", "at least {min}"),
("out_of_range", "out of range"),
]);
let issue =
Issue::new(codes::OUT_OF_RANGE).with_message_key(message_keys::OUT_OF_RANGE_MINIMUM);
assert_eq!(messages.resolve(&issue), "out of range");
}
#[test]
fn a_refined_key_falls_back_to_its_code_in_a_java_catalogue() {
let java = Messages::from_properties("raoh.invalid_format=bad form").unwrap();
let issue =
Issue::new(codes::INVALID_FORMAT).with_message_key(message_keys::INVALID_FORMAT_EMAIL);
assert_eq!(java.resolve(&issue), "bad form");
}
#[test]
fn lists_and_fractions_are_written_as_java_writes_them() {
let issue = Issue::new(codes::NOT_ALLOWED).with_meta("allowed", vec!["a", "b"]);
assert_eq!(Messages::english().resolve(&issue), "must be one of [a, b]");
let issue = Issue::new(codes::OUT_OF_RANGE)
.with_message_key(message_keys::OUT_OF_RANGE_MINIMUM)
.with_meta("min", 1e7);
assert_eq!(
Messages::english().resolve(&issue),
"must be at least 1.0E7"
);
}
#[test]
fn escaped_catalogues_read_as_java_reads_them() {
let messages =
Messages::from_properties("raoh.required=\\u5fc5\\u9808\nraoh.blank : empty").unwrap();
assert_eq!(messages.template("required"), Some("必須"));
assert_eq!(messages.template("blank"), Some("empty"));
assert_eq!(Messages::from_properties("x=\\u12").unwrap_err().line(), 1);
}
#[test]
fn a_partial_translation_wins_over_a_refined_key_beneath_it() {
let french = Messages::from_properties("raoh.invalid_format=format invalide")
.unwrap()
.falling_back_to(Messages::english());
let email =
Issue::new(codes::INVALID_FORMAT).with_message_key(message_keys::INVALID_FORMAT_EMAIL);
assert_eq!(french.resolve(&email), "format invalide");
let overridden = Messages::english().with_overrides([("invalid_format", "bad form")]);
assert_eq!(overridden.resolve(&email), "bad form");
}
#[test]
fn an_unfillable_template_gives_way_to_the_same_key_beneath_it() {
let partial = Messages::english().with_overrides([("too_short", "{least}+ characters")]);
let issue = Issue::new(codes::TOO_SHORT).with_meta("min", 3);
assert_eq!(partial.resolve(&issue), "must be at least 3 characters");
}
#[test]
fn falling_back_goes_beneath_every_layer_there_is() {
let top = Messages::from_properties("raoh.blank=top")
.unwrap()
.falling_back_to(&Messages::from_properties("raoh.required=middle").unwrap())
.falling_back_to(Messages::english());
assert_eq!(top.resolve(&Issue::new(codes::BLANK)), "top");
assert_eq!(top.resolve(&Issue::new(codes::REQUIRED)), "middle");
assert_eq!(
top.resolve(&Issue::new(codes::TOO_BIG).with_meta("max", 2)),
"must have at most 2 elements"
);
}
#[test]
fn a_closure_is_a_resolver() {
let upper = |issue: &Issue| issue.code().to_uppercase();
assert_eq!(upper.resolve(&Issue::new("blank")), "BLANK");
}
#[test]
fn the_catalogues_cover_every_code_and_message_key() {
let english = Messages::from_properties(include_str!("messages/en.properties")).unwrap();
let japanese = Messages::from_properties(include_str!("messages/ja.properties")).unwrap();
for key in codes::ALL.iter().chain(message_keys::ALL) {
assert!(
english.template(key).is_some(),
"no English template for {key}"
);
assert!(
japanese.template(key).is_some(),
"no Japanese template for {key}"
);
}
let mut english_keys: Vec<&String> = english.templates.keys().collect();
let mut japanese_keys: Vec<&String> = japanese.templates.keys().collect();
english_keys.sort();
japanese_keys.sort();
assert_eq!(english_keys, japanese_keys);
}
}