use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Id {
I18next,
NextIntl,
VscodeL10n,
FlutterArb,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Manifest {
Npm,
Pubspec,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Package {
pub(crate) name: &'static str,
pub(crate) manifest: Manifest,
pub(crate) breaking_below: Option<u64>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Config {
pub(crate) stem: &'static str,
pub(crate) extensions: &'static [&'static str],
}
impl Config {
pub(crate) fn matches(&self, stem: &str, extension: &str) -> bool {
self.stem == stem && self.extensions.contains(&extension)
}
}
const JS_CONFIG: &[&str] = &["js", "cjs", "mjs", "ts", "mts", "cts", "json"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "shape")]
pub(crate) enum Shape {
Shared { extension: &'static str },
Fixed {
prefix: &'static str,
extension: &'static str,
},
Namespaced { extension: &'static str },
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Layout {
pub(crate) shape: Shape,
pub(crate) directories: &'static [&'static str],
pub(crate) keys_are_source: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Interpolation {
DoubleBrace,
SingleBrace,
Positional,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Grammar {
pub(crate) interpolation: Interpolation,
pub(crate) icu: bool,
pub(crate) nesting: bool,
pub(crate) trims: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Plurals {
KeySuffix,
Icu,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Metadata {
DoubleAt,
Arb,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Signature {
pub(crate) mark: Mark,
pub(crate) strength: Strength,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Strength {
Decisive,
Strong,
Weak,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Mark {
DoubleBrace,
IcuArgument,
Positional,
SingleBrace,
DollarT,
PluralKeySuffix,
ArbMetadata,
SentenceKeys,
}
impl Mark {
pub(crate) fn as_str(self) -> &'static str {
match self {
Mark::DoubleBrace => "double-brace",
Mark::IcuArgument => "icu-argument",
Mark::Positional => "positional",
Mark::SingleBrace => "single-brace",
Mark::DollarT => "dollar-t",
Mark::PluralKeySuffix => "plural-key-suffix",
Mark::ArbMetadata => "arb-metadata",
Mark::SentenceKeys => "sentence-keys",
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Library {
pub(crate) id: Id,
pub(crate) packages: &'static [Package],
pub(crate) configs: &'static [Config],
pub(crate) calls: &'static [&'static str],
pub(crate) layouts: &'static [Layout],
pub(crate) signatures: &'static [Signature],
pub(crate) grammar: Grammar,
pub(crate) plurals: Plurals,
pub(crate) metadata: Metadata,
}
impl Id {
pub(crate) fn as_str(self) -> &'static str {
match self {
Id::I18next => "i18next",
Id::NextIntl => "next-intl",
Id::VscodeL10n => "vscode-l10n",
Id::FlutterArb => "flutter-arb",
}
}
pub(crate) fn parse(text: &str) -> Option<Self> {
ALL.iter()
.map(|library| library.id)
.find(|id| id.as_str() == text)
}
pub(crate) fn library(self) -> &'static Library {
ALL.iter()
.find(|library| library.id == self)
.expect("every id has a row")
}
}
pub(crate) fn names() -> Vec<&'static str> {
ALL.iter().map(|library| library.id.as_str()).collect()
}
pub(crate) fn all() -> &'static [Library] {
ALL
}
const ALL: &[Library] = &[
Library {
id: Id::I18next,
packages: &[
Package {
name: "i18next",
manifest: Manifest::Npm,
breaking_below: Some(23),
},
Package {
name: "react-i18next",
manifest: Manifest::Npm,
breaking_below: None,
},
Package {
name: "i18next-vue",
manifest: Manifest::Npm,
breaking_below: None,
},
],
configs: &[
Config {
stem: "i18next-parser.config",
extensions: JS_CONFIG,
},
Config {
stem: "next-i18next.config",
extensions: JS_CONFIG,
},
],
calls: &["useTranslation(", "i18next.t("],
layouts: &[
Layout {
shape: Shape::Namespaced { extension: "json" },
directories: &[],
keys_are_source: false,
},
Layout {
shape: Shape::Shared { extension: "json" },
directories: &["locales", "translations", "lang"],
keys_are_source: false,
},
],
signatures: &[
Signature {
mark: Mark::DoubleBrace,
strength: Strength::Strong,
},
Signature {
mark: Mark::DollarT,
strength: Strength::Strong,
},
Signature {
mark: Mark::PluralKeySuffix,
strength: Strength::Strong,
},
],
grammar: Grammar {
interpolation: Interpolation::DoubleBrace,
icu: false,
nesting: true,
trims: true,
},
plurals: Plurals::KeySuffix,
metadata: Metadata::DoubleAt,
},
Library {
id: Id::NextIntl,
packages: &[Package {
name: "next-intl",
manifest: Manifest::Npm,
breaking_below: None,
}],
configs: &[Config {
stem: "next-intl.config",
extensions: JS_CONFIG,
}],
calls: &["useTranslations(", "getTranslations(", "next-intl"],
layouts: &[Layout {
shape: Shape::Shared { extension: "json" },
directories: &["messages"],
keys_are_source: false,
}],
signatures: &[
Signature {
mark: Mark::IcuArgument,
strength: Strength::Strong,
},
Signature {
mark: Mark::SingleBrace,
strength: Strength::Weak,
},
],
grammar: Grammar {
interpolation: Interpolation::SingleBrace,
icu: true,
nesting: false,
trims: true,
},
plurals: Plurals::Icu,
metadata: Metadata::DoubleAt,
},
Library {
id: Id::VscodeL10n,
packages: &[Package {
name: "@vscode/l10n",
manifest: Manifest::Npm,
breaking_below: None,
}],
configs: &[],
calls: &["vscode.l10n.t(", "l10n.t("],
layouts: &[
Layout {
shape: Shape::Fixed {
prefix: "bundle.l10n",
extension: "json",
},
directories: &[],
keys_are_source: true,
},
Layout {
shape: Shape::Fixed {
prefix: "package.nls",
extension: "json",
},
directories: &[],
keys_are_source: false,
},
],
signatures: &[
Signature {
mark: Mark::Positional,
strength: Strength::Weak,
},
Signature {
mark: Mark::SentenceKeys,
strength: Strength::Strong,
},
],
grammar: Grammar {
interpolation: Interpolation::Positional,
icu: false,
nesting: false,
trims: false,
},
plurals: Plurals::None,
metadata: Metadata::DoubleAt,
},
Library {
id: Id::FlutterArb,
packages: &[
Package {
name: "flutter_localizations",
manifest: Manifest::Pubspec,
breaking_below: None,
},
Package {
name: "intl",
manifest: Manifest::Pubspec,
breaking_below: None,
},
],
configs: &[Config {
stem: "l10n",
extensions: &["yaml", "yml"],
}],
calls: &["AppLocalizations.of("],
layouts: &[Layout {
shape: Shape::Shared { extension: "arb" },
directories: &[],
keys_are_source: false,
}],
signatures: &[
Signature {
mark: Mark::ArbMetadata,
strength: Strength::Decisive,
},
Signature {
mark: Mark::IcuArgument,
strength: Strength::Weak,
},
],
grammar: Grammar {
interpolation: Interpolation::SingleBrace,
icu: true,
nesting: false,
trims: true,
},
plurals: Plurals::Icu,
metadata: Metadata::Arb,
},
];
impl Library {
pub(crate) fn is_metadata(&self, key: &str) -> bool {
let head = key.split('.').next().unwrap_or(key);
match self.metadata {
Metadata::DoubleAt => head.starts_with("@@"),
Metadata::Arb => head.starts_with('@'),
}
}
pub(crate) fn package(&self, name: &str, manifest: Manifest) -> Option<&'static Package> {
self.packages
.iter()
.find(|package| package.name == name && package.manifest == manifest)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_library_round_trips_through_its_name() {
for library in all() {
let name = library.id.as_str();
assert_eq!(Id::parse(name), Some(library.id), "{name}");
assert_eq!(Id::parse(name).expect("an id").library().id, library.id);
}
assert_eq!(Id::parse("gettext"), None);
}
#[test]
fn the_four_that_must_work_are_all_here() {
let ids: Vec<&str> = names();
for expected in ["i18next", "next-intl", "vscode-l10n", "flutter-arb"] {
assert!(ids.contains(&expected), "{expected} is missing");
}
}
#[test]
fn every_library_can_reach_an_identification() {
for library in all() {
let voting = library
.signatures
.iter()
.any(|s| s.strength != Strength::Weak);
let classes = usize::from(!library.packages.is_empty())
+ usize::from(!library.configs.is_empty())
+ usize::from(!library.layouts.is_empty())
+ usize::from(voting)
+ usize::from(!library.calls.is_empty());
assert!(classes >= 2, "{:?} can never be identified", library.id);
}
}
#[test]
fn every_id_variant_has_a_row() {
let every = [Id::I18next, Id::NextIntl, Id::VscodeL10n, Id::FlutterArb];
for id in every {
match id {
Id::I18next | Id::NextIntl | Id::VscodeL10n | Id::FlutterArb => {}
}
assert_eq!(id.library().id, id, "{id:?} has no row");
}
assert_eq!(all().len(), every.len(), "a row nothing names");
}
#[test]
fn every_mark_spells_itself_the_way_serde_does() {
for mark in [
Mark::DoubleBrace,
Mark::IcuArgument,
Mark::Positional,
Mark::SingleBrace,
Mark::DollarT,
Mark::PluralKeySuffix,
Mark::ArbMetadata,
Mark::SentenceKeys,
] {
match mark {
Mark::DoubleBrace
| Mark::IcuArgument
| Mark::Positional
| Mark::SingleBrace
| Mark::DollarT
| Mark::PluralKeySuffix
| Mark::ArbMetadata
| Mark::SentenceKeys => {}
}
assert_eq!(
serde_json::to_value(mark).expect("a mark serializes"),
serde_json::Value::String(mark.as_str().to_string()),
"{mark:?}"
);
}
}
#[test]
fn no_two_libraries_claim_the_same_decisive_signature() {
let mut seen: Vec<Mark> = Vec::new();
for library in all() {
for signature in library
.signatures
.iter()
.filter(|s| s.strength == Strength::Decisive)
{
assert!(
!seen.contains(&signature.mark),
"{:?} is claimed twice",
signature.mark
);
seen.push(signature.mark);
}
}
}
#[test]
fn arb_metadata_takes_its_children_with_it() {
let arb = Id::FlutterArb.library();
assert!(arb.is_metadata("@@locale"));
assert!(arb.is_metadata("@greeting"));
assert!(arb.is_metadata("@greeting.description"));
assert!(!arb.is_metadata("greeting"));
let other = Id::I18next.library();
assert!(other.is_metadata("@@locale"));
assert!(!other.is_metadata("@greeting"), "somebody else's naming");
}
#[test]
fn the_two_vscode_layouts_disagree_about_what_a_key_is() {
let vscode = Id::VscodeL10n.library();
let bundle = vscode
.layouts
.iter()
.find(|layout| {
matches!(
layout.shape,
Shape::Fixed {
prefix: "bundle.l10n",
..
}
)
})
.expect("the bundle layout");
let nls = vscode
.layouts
.iter()
.find(|layout| {
matches!(
layout.shape,
Shape::Fixed {
prefix: "package.nls",
..
}
)
})
.expect("the manifest layout");
assert!(bundle.keys_are_source);
assert!(!nls.keys_are_source);
}
#[test]
fn only_a_known_break_carries_a_version_floor() {
let i18next = Id::I18next.library();
assert_eq!(
i18next
.package("i18next", Manifest::Npm)
.expect("the package")
.breaking_below,
Some(23)
);
assert_eq!(
i18next
.package("react-i18next", Manifest::Npm)
.expect("the package")
.breaking_below,
None
);
}
}