use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::catalogue;
use crate::layout::{self, ANCESTORS, Located};
use crate::library::{self, Id, Layout, Library, Manifest, Mark, Shape, Strength};
use crate::message;
const MAX_SOURCE_FILES: usize = 400;
const MAX_SOURCE_BYTES: u64 = 512 * 1024;
const MAX_SOURCE_DEPTH: usize = 6;
const MAX_SAMPLED: usize = 12;
const SKIPPED_DIRECTORIES: [&str; 11] = [
"node_modules",
".git",
"target",
"dist",
"build",
".next",
"out",
"out-test",
"coverage",
"vendor",
"lib",
];
const SOURCE_EXTENSIONS: [&str; 9] = [
"ts", "tsx", "js", "jsx", "mjs", "cjs", "vue", "svelte", "dart",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Class {
Manifest,
Config,
Layout,
Content,
CallSite,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Signal {
pub(crate) class: Class,
pub(crate) library: Id,
pub(crate) detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Identified {
pub(crate) library: Id,
pub(crate) version: Option<String>,
pub(crate) shape: Shape,
pub(crate) keys_are_source: bool,
pub(crate) evidence: Vec<Signal>,
pub(crate) files: Vec<Located>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum Requested {
#[default]
Detect,
Named(Id),
}
pub(crate) fn identify(inputs: &[PathBuf], requested: Requested) -> Result<Identified, String> {
let anchor = anchor_of(inputs)?;
let evidence = gather(&anchor, inputs);
let library = match requested {
Requested::Named(id) => id,
Requested::Detect => {
let decided = decide(&evidence)?;
check_version(&anchor, decided)?;
decided
}
};
let reading = layout::read(inputs, &anchor, library)?;
Ok(Identified {
library,
version: declared_version(&anchor, library),
shape: reading.shape,
keys_are_source: reading.keys_are_source,
evidence: evidence
.into_iter()
.filter(|signal| signal.library == library)
.collect(),
files: reading.files,
})
}
fn anchor_of(inputs: &[PathBuf]) -> Result<PathBuf, String> {
let first = inputs
.first()
.ok_or_else(|| "name the directory holding the catalogues".to_string())?;
let metadata =
std::fs::metadata(first).map_err(|error| format!("{}: {error}", first.display()))?;
let directory = if metadata.is_dir() {
first.clone()
} else {
first.parent().unwrap_or(Path::new(".")).to_path_buf()
};
std::fs::canonicalize(&directory).map_err(|error| format!("{}: {error}", directory.display()))
}
fn decide(evidence: &[Signal]) -> Result<Id, String> {
let decisive: Vec<Id> = library::all()
.iter()
.filter(|library| {
library
.signatures
.iter()
.filter(|signature| signature.strength == Strength::Decisive)
.any(|signature| carries(evidence, library.id, Class::Content, signature.mark))
})
.map(|library| library.id)
.collect();
if decisive.len() == 1 {
return Ok(decisive[0]);
}
let identified: Vec<Id> = library::all()
.iter()
.map(|library| library.id)
.filter(|id| classes_for(evidence, *id) >= 2)
.collect();
let candidates = if decisive.is_empty() {
identified
} else {
decisive
};
match candidates.as_slice() {
[only] => Ok(*only),
[] => Err(unidentified(evidence)),
several => Err(conflicted(evidence, several)),
}
}
fn classes_for(evidence: &[Signal], id: Id) -> usize {
let mut classes: Vec<Class> = Vec::new();
for signal in evidence.iter().filter(|signal| signal.library == id) {
if !classes.contains(&signal.class) {
classes.push(signal.class);
}
}
classes.len()
}
fn carries(evidence: &[Signal], id: Id, class: Class, mark: Mark) -> bool {
evidence.iter().any(|signal| {
signal.library == id && signal.class == class && signal.detail == mark.as_str()
})
}
fn unidentified(evidence: &[Signal]) -> String {
if evidence.is_empty() {
return "no i18n library could be identified here — nothing in the manifests, the \
config files, the layout, the catalogue syntax or the call sites points at one. \
Name it with --system <name>."
.to_string();
}
format!(
"no i18n library could be identified here. Found, but not enough to agree: {}. \
Two classes of evidence are needed. Name it with --system <name>.",
listed(evidence)
)
}
fn conflicted(evidence: &[Signal], several: &[Id]) -> String {
let each: Vec<String> = several
.iter()
.map(|id| {
let for_this: Vec<&Signal> = evidence
.iter()
.filter(|signal| signal.library == *id)
.collect();
format!(
"{} ({})",
id.as_str(),
for_this
.iter()
.map(|signal| describe(signal))
.collect::<Vec<_>>()
.join(", ")
)
})
.collect();
format!(
"{} libraries are identified here and only one can be right: {}. \
Name the one these catalogues belong to with --system <name>.",
several.len(),
each.join("; ")
)
}
fn listed(evidence: &[Signal]) -> String {
evidence
.iter()
.map(|signal| format!("{} {}", signal.library.as_str(), describe(signal)))
.collect::<Vec<_>>()
.join(", ")
}
fn describe(signal: &Signal) -> String {
let class = match signal.class {
Class::Manifest => "manifest",
Class::Config => "config",
Class::Layout => "layout",
Class::Content => "content",
Class::CallSite => "call site",
};
format!("{class}: {}", signal.detail)
}
fn gather(anchor: &Path, inputs: &[PathBuf]) -> Vec<Signal> {
let mut evidence = Vec::new();
evidence.extend(manifest_signals(anchor));
evidence.extend(config_signals(anchor));
evidence.extend(layout_signals(anchor, inputs));
evidence.extend(content_signals(anchor, inputs));
evidence.extend(call_signals(anchor));
evidence
}
fn shown(path: &Path, anchor: &Path) -> String {
for (up, ancestor) in anchor.ancestors().enumerate() {
if let Ok(rest) = path.strip_prefix(ancestor) {
let rest = rest
.components()
.map(|part| part.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
return format!("{}{rest}", "../".repeat(up));
}
}
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn manifest_signals(anchor: &Path) -> Vec<Signal> {
let mut signals = Vec::new();
for directory in anchor.ancestors().take(ANCESTORS) {
signals.extend(npm_signals(&directory.join("package.json"), anchor));
signals.extend(pubspec_signals(&directory.join("pubspec.yaml"), anchor));
}
signals
}
fn npm_signals(path: &Path, anchor: &Path) -> Vec<Signal> {
let Some(manifest) = read_json(path) else {
return Vec::new();
};
let at = shown(path, anchor);
let mut signals = Vec::new();
for section in ["dependencies", "devDependencies", "peerDependencies"] {
let Some(entries) = manifest.get(section).and_then(Value::as_object) else {
continue;
};
for package in entries.keys() {
for library in library::all() {
if library.package(package, Manifest::Npm).is_some() {
signals.push(Signal {
class: Class::Manifest,
library: library.id,
detail: format!("{package} in {at}"),
});
}
}
}
}
if manifest.get("l10n").is_some() {
signals.push(Signal {
class: Class::Manifest,
library: Id::VscodeL10n,
detail: format!("the l10n field in {at}"),
});
}
signals
}
fn pubspec_signals(path: &Path, anchor: &Path) -> Vec<Signal> {
let Some(text) = read_regular(path, MAX_MANIFEST_BYTES) else {
return Vec::new();
};
let at = shown(path, anchor);
let mut signals = Vec::new();
for (package, _) in pubspec_dependencies(&text) {
for library in library::all() {
if library.package(package, Manifest::Pubspec).is_some() {
signals.push(Signal {
class: Class::Manifest,
library: library.id,
detail: format!("{package} in {at}"),
});
}
}
}
signals
}
fn pubspec_dependencies(text: &str) -> Vec<(&str, &str)> {
let mut found = Vec::new();
let mut inside = false;
for line in text.lines() {
let trimmed = line.trim_end();
if !trimmed.starts_with(char::is_whitespace) && !trimmed.is_empty() {
inside = matches!(trimmed, "dependencies:" | "dev_dependencies:");
continue;
}
if !inside {
continue;
}
let Some((package, version)) = trimmed.trim().split_once(':') else {
continue;
};
found.push((package.trim(), version.trim()));
}
found
}
fn config_signals(anchor: &Path) -> Vec<Signal> {
let mut signals = Vec::new();
for directory in anchor.ancestors().take(ANCESTORS) {
let Ok(entries) = std::fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|kind| kind.is_file()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let Some((stem, extension)) = name.rsplit_once('.') else {
continue;
};
for library in library::all() {
if library
.configs
.iter()
.any(|config| config.matches(stem, extension))
{
signals.push(Signal {
class: Class::Config,
library: library.id,
detail: shown(&entry.path(), anchor),
});
}
}
}
}
signals
}
fn layout_signals(anchor: &Path, inputs: &[PathBuf]) -> Vec<Signal> {
let here = anchor
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
library::all()
.iter()
.flat_map(|library| {
library
.layouts
.iter()
.map(move |layout| (library.id, layout))
})
.filter(|(_, layout)| distinctive(layout, &here))
.filter(|(_, layout)| {
layout::read_layout(inputs, anchor, layout)
.ok()
.flatten()
.is_some()
})
.map(|(id, layout)| Signal {
class: Class::Layout,
library: id,
detail: layout::describe_shape(layout.shape),
})
.collect()
}
fn distinctive(layout: &Layout, here: &str) -> bool {
if !layout.directories.is_empty() {
return layout.directories.contains(&here);
}
!matches!(layout.shape, Shape::Shared { extension: "json" })
}
fn content_signals(anchor: &Path, inputs: &[PathBuf]) -> Vec<Signal> {
let marks = sampled_marks(anchor, inputs);
library::all()
.iter()
.flat_map(|library| {
library
.signatures
.iter()
.map(move |signature| (library.id, signature))
})
.filter(|(_, signature)| signature.strength != Strength::Weak)
.filter(|(_, signature)| marks.contains(&signature.mark))
.map(|(id, signature)| Signal {
class: Class::Content,
library: id,
detail: signature.mark.as_str().to_string(),
})
.collect()
}
fn sampled_marks(anchor: &Path, inputs: &[PathBuf]) -> Vec<Mark> {
let mut marks: Vec<Mark> = Vec::new();
for path in sample(anchor, inputs) {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(parsed) = catalogue::parse(&text) else {
continue;
};
let keys: Vec<&str> = parsed
.entries
.iter()
.map(|entry| entry.key.as_str())
.collect();
let from_messages = parsed
.entries
.iter()
.filter_map(|entry| entry.text.as_deref())
.flat_map(message::marks);
for mark in message::key_marks(&keys).into_iter().chain(from_messages) {
note(&mut marks, mark);
}
}
marks
}
fn note(marks: &mut Vec<Mark>, mark: Mark) {
if !marks.contains(&mark) {
marks.push(mark);
}
}
fn sample(anchor: &Path, inputs: &[PathBuf]) -> Vec<PathBuf> {
let mut found = Vec::new();
for input in inputs {
if input.is_file() {
found.push(input.clone());
}
}
found.extend(layout::catalogues_in(anchor));
if found.is_empty() {
for entry in std::fs::read_dir(anchor).into_iter().flatten().flatten() {
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
found.extend(layout::catalogues_in(&entry.path()));
}
}
}
found.sort();
found.dedup();
found.truncate(MAX_SAMPLED);
found
}
fn call_signals(anchor: &Path) -> Vec<Signal> {
let root = project_root(anchor);
let mut signals: Vec<Signal> = Vec::new();
let mut budget = MAX_SOURCE_FILES;
walk_source(&root, 0, &mut budget, &mut |path, text| {
for library in library::all() {
let already = signals
.iter()
.any(|signal| signal.library == library.id && signal.class == Class::CallSite);
if already {
continue;
}
if let Some(call) = library.calls.iter().find(|call| text.contains(**call)) {
signals.push(Signal {
class: Class::CallSite,
library: library.id,
detail: format!("{call} in {}", shown(path, anchor)),
});
}
}
});
signals
}
fn project_root(anchor: &Path) -> PathBuf {
anchor
.ancestors()
.take(ANCESTORS)
.find(|directory| {
directory.join("package.json").is_file() || directory.join("pubspec.yaml").is_file()
})
.unwrap_or(anchor)
.to_path_buf()
}
fn walk_source(
directory: &Path,
depth: usize,
budget: &mut usize,
visit: &mut impl FnMut(&Path, &str),
) {
if depth > MAX_SOURCE_DEPTH || *budget == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
let mut directories = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
if !name.starts_with('.') && !SKIPPED_DIRECTORIES.contains(&name.as_str()) {
directories.push(path);
}
continue;
}
let is_source = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| SOURCE_EXTENSIONS.contains(&extension));
if !is_source || *budget == 0 {
continue;
}
let Some(text) = read_regular(&path, MAX_SOURCE_BYTES) else {
continue;
};
*budget -= 1;
visit(&path, &text);
}
directories.sort();
for child in directories {
walk_source(&child, depth + 1, budget, visit);
}
}
fn read_regular(path: &Path, cap: u64) -> Option<String> {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() || metadata.len() > cap {
return None;
}
std::fs::read_to_string(path).ok()
}
const MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
fn read_json(path: &Path) -> Option<Value> {
let text = read_regular(path, MAX_MANIFEST_BYTES)?;
serde_json::from_str(&text).ok()
}
fn declared_version(anchor: &Path, library: Id) -> Option<String> {
let row = library.library();
anchor.ancestors().take(ANCESTORS).find_map(|directory| {
npm_version(&directory.join("package.json"), row)
.or_else(|| pubspec_version(&directory.join("pubspec.yaml"), row))
})
}
fn npm_version(path: &Path, row: &Library) -> Option<String> {
let manifest = read_json(path)?;
["dependencies", "devDependencies", "peerDependencies"]
.into_iter()
.filter_map(|section| manifest.get(section).and_then(Value::as_object))
.flatten()
.filter(|(package, _)| row.package(package, Manifest::Npm).is_some())
.find_map(|(_, spec)| spec.as_str().filter(|spec| !spec.is_empty()))
.map(str::to_string)
}
fn pubspec_version(path: &Path, row: &Library) -> Option<String> {
let text = read_regular(path, MAX_MANIFEST_BYTES)?;
pubspec_dependencies(&text)
.into_iter()
.filter(|(package, _)| row.package(package, Manifest::Pubspec).is_some())
.find(|(_, version)| !version.is_empty())
.map(|(_, version)| version.to_string())
}
fn check_version(anchor: &Path, library: Id) -> Result<(), String> {
let row = library.library();
for directory in anchor.ancestors().take(ANCESTORS) {
let Some(manifest) = read_json(&directory.join("package.json")) else {
continue;
};
for section in ["dependencies", "devDependencies", "peerDependencies"] {
let Some(entries) = manifest.get(section).and_then(Value::as_object) else {
continue;
};
for (package, spec) in entries {
let Some(known) = row.package(package, Manifest::Npm) else {
continue;
};
let Some(floor) = known.breaking_below else {
continue;
};
let Some(major) = spec.as_str().and_then(major_of) else {
continue;
};
if major < floor {
return Err(format!(
"{package} {} in {} writes its catalogues differently from {floor} and \
later, which is the model this reads. Auditing it as {} would be wrong.",
spec.as_str().unwrap_or_default(),
shown(&directory.join("package.json"), anchor),
library.as_str()
));
}
}
}
}
Ok(())
}
fn major_of(spec: &str) -> Option<u64> {
let spec = match spec.strip_prefix("npm:") {
Some(alias) => alias.rsplit_once('@').map(|(_, version)| version)?,
None => spec,
};
let digits: String = spec
.trim_start_matches(|c: char| !c.is_ascii_digit())
.chars()
.take_while(char::is_ascii_digit)
.collect();
digits.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn found(tree: &TempTree, at: &str) -> Identified {
identify(&[tree.path().join(at)], Requested::Detect).expect("identifies")
}
fn refusal(tree: &TempTree, at: &str) -> String {
identify(&[tree.path().join(at)], Requested::Detect).expect_err("a refusal")
}
fn names(identified: &Identified) -> Vec<String> {
identified
.files
.iter()
.map(|file| file.name.clone())
.collect()
}
fn classes(identified: &Identified) -> Vec<Class> {
let mut seen: Vec<Class> = Vec::new();
for signal in &identified.evidence {
if !seen.contains(&signal.class) {
seen.push(signal.class);
}
}
seen
}
#[test]
fn a_declared_library_is_identified_from_several_classes() {
let tree = TempTree::new("identify-i18next");
tree.write(
"package.json",
r#"{"dependencies":{"i18next":"^26.2.0","react-i18next":"^17.0.0"}}"#,
);
tree.write("src/app.tsx", "const { t } = useTranslation()\n");
tree.write("locales/en.json", r#"{"greeting":"Hello {{name}}"}"#);
tree.write("locales/es.json", r#"{"greeting":"Hola {{name}}"}"#);
let identified = found(&tree, "locales");
assert_eq!(identified.library, Id::I18next);
assert_eq!(identified.version.as_deref(), Some("^26.2.0"));
assert!(classes(&identified).contains(&Class::Manifest));
assert!(classes(&identified).contains(&Class::Content));
assert!(classes(&identified).contains(&Class::CallSite));
assert_eq!(names(&identified), ["en.json", "es.json"]);
}
#[test]
fn a_decisive_signature_identifies_with_nothing_else_present() {
let tree = TempTree::new("identify-arb");
tree.write(
"l10n/app_en.arb",
r#"{"@@locale":"en","greeting":"Hi {name}","@greeting":{"description":"x"}}"#,
);
tree.write(
"l10n/app_es.arb",
r#"{"@@locale":"es","greeting":"Hola {name}"}"#,
);
let identified = found(&tree, "l10n");
assert_eq!(identified.library, Id::FlutterArb);
assert!(
classes(&identified).contains(&Class::Content),
"the decisive class"
);
assert!(
!classes(&identified).contains(&Class::Manifest),
"there is no project here at all"
);
assert_eq!(
identified.files[0].locale.as_deref(),
Some("en"),
"app_en.arb"
);
}
#[test]
fn a_single_class_is_not_enough() {
let tree = TempTree::new("identify-thin");
tree.write("strings/en.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("strings/es.json", r#"{"a":"Hola {{name}}"}"#);
let error = refusal(&tree, "strings");
assert!(error.contains("not enough to agree"), "{error}");
assert!(error.contains("double-brace"), "{error}");
assert!(error.contains("--system"), "{error}");
}
#[test]
fn a_layout_and_its_syntax_are_enough_on_their_own() {
let tree = TempTree::new("identify-two-thin");
tree.write("locales/en.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("locales/es.json", r#"{"a":"Hola {{name}}"}"#);
let identified = found(&tree, "locales");
assert_eq!(identified.library, Id::I18next);
assert_eq!(classes(&identified), [Class::Layout, Class::Content]);
}
#[test]
fn nothing_at_all_is_refused_and_says_where_it_looked() {
let tree = TempTree::new("identify-nothing");
tree.write("strings/en.json", r#"{"a":"one"}"#);
tree.write("strings/es.json", r#"{"a":"uno"}"#);
let error = refusal(&tree, "strings");
assert!(error.contains("manifest"), "{error}");
assert!(error.contains("call sites"), "{error}");
}
#[test]
fn two_identified_libraries_are_refused_and_both_are_named() {
let tree = TempTree::new("identify-conflict");
tree.write(
"package.json",
r#"{"dependencies":{"i18next":"^26.0.0","next-intl":"^4.0.0"}}"#,
);
tree.write("src/a.tsx", "useTranslation()\nuseTranslations()\n");
tree.write("messages/en.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("messages/es.json", r#"{"a":"Hola {{name}}"}"#);
let error = refusal(&tree, "messages");
assert!(error.contains("i18next"), "{error}");
assert!(error.contains("next-intl"), "{error}");
assert!(error.contains("only one can be right"), "{error}");
}
#[test]
fn a_named_library_needs_no_evidence_at_all() {
let tree = TempTree::new("identify-named");
tree.write("l10n/bundle.l10n.json", r#"{"Save":"Save"}"#);
tree.write("l10n/bundle.l10n.de.json", r#"{"Save":"Speichern"}"#);
let identified = identify(
&[tree.path().join("l10n")],
Requested::Named(Id::VscodeL10n),
)
.expect("identifies");
assert_eq!(identified.library, Id::VscodeL10n);
assert!(identified.keys_are_source, "a bundle key is the English");
}
#[test]
fn a_named_library_overrules_the_evidence() {
let tree = TempTree::new("identify-override");
tree.write("package.json", r#"{"dependencies":{"i18next":"^26.0.0"}}"#);
tree.write("locales/en.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("locales/es.json", r#"{"a":"Hola {{name}}"}"#);
let identified = identify(
&[tree.path().join("locales")],
Requested::Named(Id::NextIntl),
)
.expect("identifies");
assert_eq!(identified.library, Id::NextIntl);
}
#[test]
fn the_split_manifest_layout_is_read_as_one_set() {
let tree = TempTree::new("identify-split");
tree.write("package.json", r#"{"name":"x","l10n":"./l10n"}"#);
tree.write("src/extension.ts", "vscode.l10n.t('Save')\n");
tree.write("package.nls.json", r#"{"command.title":"Save"}"#);
tree.write(
"src/i18n/package.nls.de.json",
r#"{"command.title":"Speichern"}"#,
);
tree.write(
"src/i18n/package.nls.ja.json",
r#"{"command.title":"保存"}"#,
);
let identified = found(&tree, "src/i18n");
assert_eq!(identified.library, Id::VscodeL10n);
assert_eq!(
identified.shape,
Shape::Fixed {
prefix: "package.nls",
extension: "json"
}
);
assert_eq!(
names(&identified),
[
"package.nls.json",
"package.nls.de.json",
"package.nls.ja.json"
]
);
assert!(!identified.keys_are_source, "an nls key is an identifier");
}
#[test]
fn the_bundle_layout_reads_its_own_prefix() {
let tree = TempTree::new("identify-bundle");
tree.write("package.json", r#"{"name":"x","l10n":"./l10n"}"#);
tree.write("l10n/bundle.l10n.json", r#"{"Save file":"Save file"}"#);
tree.write("l10n/bundle.l10n.pt-br.json", r#"{"Save file":"Salvar"}"#);
let identified = found(&tree, "l10n");
assert_eq!(identified.library, Id::VscodeL10n);
assert!(identified.keys_are_source);
assert_eq!(
identified.files[1].locale.as_deref(),
Some("pt-BR"),
"canonicalised from pt-br"
);
}
#[test]
fn the_namespaced_layout_reads_the_directory_as_the_locale() {
let tree = TempTree::new("identify-namespaced");
tree.write("package.json", r#"{"dependencies":{"i18next":"^26.0.0"}}"#);
tree.write("locales/en/common.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("locales/de/common.json", r#"{"a":"Hallo {{name}}"}"#);
let identified = found(&tree, "locales");
assert_eq!(identified.library, Id::I18next);
assert_eq!(names(&identified), ["de/common.json", "en/common.json"]);
assert_eq!(identified.files[0].locale.as_deref(), Some("de"));
}
#[test]
fn several_namespaces_are_refused_and_named() {
let tree = TempTree::new("identify-namespaces");
tree.write("package.json", r#"{"dependencies":{"i18next":"^26.0.0"}}"#);
tree.write("locales/en/common.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("locales/en/errors.json", "{}");
tree.write("locales/de/common.json", r#"{"a":"Hallo {{name}}"}"#);
tree.write("locales/de/errors.json", "{}");
let error = refusal(&tree, "locales");
assert!(error.contains("common.json"), "{error}");
assert!(error.contains("errors.json"), "{error}");
}
#[test]
fn a_manifest_several_directories_up_still_counts() {
let tree = TempTree::new("identify-monorepo");
tree.write("package.json", r#"{"dependencies":{"next-intl":"^4.0.0"}}"#);
tree.write(
"apps/web/messages/en.json",
r#"{"a":"{count, plural, other {#}}"}"#,
);
tree.write(
"apps/web/messages/es.json",
r#"{"a":"{count, plural, other {#}}"}"#,
);
let identified = found(&tree, "apps/web/messages");
assert_eq!(identified.library, Id::NextIntl);
let manifest = identified
.evidence
.iter()
.find(|signal| signal.class == Class::Manifest)
.expect("a manifest signal");
assert_eq!(manifest.detail, "next-intl in ../../../package.json");
}
#[test]
fn a_pubspec_declares_the_flutter_library() {
let tree = TempTree::new("identify-pubspec");
tree.write(
"pubspec.yaml",
"name: app\ndependencies:\n flutter_localizations:\n sdk: flutter\n intl: ^0.19.0\n",
);
tree.write("lib/l10n/app_en.arb", r#"{"greeting":"Hi {name}"}"#);
tree.write("lib/l10n/app_es.arb", r#"{"greeting":"Hola {name}"}"#);
let identified = found(&tree, "lib/l10n");
assert_eq!(identified.library, Id::FlutterArb);
assert!(classes(&identified).contains(&Class::Manifest));
assert_eq!(
identified.version.as_deref(),
Some("^0.19.0"),
"the manifest that identified it is the one quoted back"
);
}
#[test]
fn a_pubspec_package_with_no_version_does_not_invent_one() {
let tree = TempTree::new("identify-pubspec-sdk");
tree.write(
"pubspec.yaml",
"name: app\ndependencies:\n flutter_localizations:\n sdk: flutter\n",
);
tree.write("lib/l10n/app_en.arb", r#"{"greeting":"Hi {name}"}"#);
tree.write("lib/l10n/app_es.arb", r#"{"greeting":"Hola {name}"}"#);
let identified = found(&tree, "lib/l10n");
assert_eq!(identified.library, Id::FlutterArb);
assert_eq!(identified.version, None);
}
#[test]
fn a_breaking_major_is_refused_by_name() {
let tree = TempTree::new("identify-old");
tree.write("package.json", r#"{"dependencies":{"i18next":"^21.6.0"}}"#);
tree.write("src/a.ts", "useTranslation()\n");
tree.write("locales/en.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("locales/es.json", r#"{"a":"Hola {{name}}"}"#);
let error = refusal(&tree, "locales");
assert!(error.contains("21"), "{error}");
assert!(error.contains("differently"), "{error}");
}
#[test]
fn an_unfamiliar_major_is_read_and_reported() {
let tree = TempTree::new("identify-new");
tree.write("package.json", r#"{"dependencies":{"i18next":"^99.0.0"}}"#);
tree.write("src/a.ts", "useTranslation()\n");
tree.write("locales/en.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("locales/es.json", r#"{"a":"Hola {{name}}"}"#);
let identified = found(&tree, "locales");
assert_eq!(identified.version.as_deref(), Some("^99.0.0"));
}
#[test]
fn the_call_site_scan_skips_the_directories_nobody_means() {
let tree = TempTree::new("identify-skip");
tree.write("package.json", r#"{"name":"x"}"#);
tree.write("node_modules/next-intl/index.js", "useTranslations()\n");
tree.write("dist/bundle.js", "useTranslation()\n");
tree.write("strings/en.json", r#"{"a":"one"}"#);
tree.write("strings/es.json", r#"{"a":"uno"}"#);
let error = refusal(&tree, "strings");
assert!(!error.contains("next-intl"), "{error}");
assert!(!error.contains("i18next"), "{error}");
}
#[test]
fn a_config_votes_only_in_an_extension_that_library_writes() {
let tree = TempTree::new("identify-config-extension");
tree.write("l10n.json", "{}");
tree.write("l10n.ts", "export default {}\n");
tree.write("strings/en.json", r#"{"a":"one"}"#);
tree.write("strings/es.json", r#"{"a":"uno"}"#);
let error = refusal(&tree, "strings");
assert!(!error.contains("config: "), "{error}");
assert!(!error.contains("flutter-arb"), "{error}");
}
#[test]
fn the_extension_a_library_does_write_is_still_a_config() {
let tree = TempTree::new("identify-config-yaml");
tree.write("l10n.yaml", "arb-dir: lib/l10n\n");
tree.write("strings/en.json", r#"{"a":"one"}"#);
tree.write("strings/es.json", r#"{"a":"uno"}"#);
let error = refusal(&tree, "strings");
assert!(error.contains("config: ../l10n.yaml"), "{error}");
assert!(error.contains("flutter-arb"), "{error}");
}
#[test]
fn a_missing_input_is_refused_by_name() {
let tree = TempTree::new("identify-missing");
let error =
identify(&[tree.path().join("nope")], Requested::Detect).expect_err("a refusal");
assert!(error.contains("nope"), "{error}");
}
#[test]
fn no_reported_path_is_absolute() {
let tree = TempTree::new("identify-relative");
tree.write("package.json", r#"{"dependencies":{"i18next":"^26.0.0"}}"#);
tree.write("src/a.ts", "useTranslation()\n");
tree.write("locales/en.json", r#"{"a":"Hello {{name}}"}"#);
tree.write("locales/es.json", r#"{"a":"Hola {{name}}"}"#);
let identified = found(&tree, "locales");
for signal in &identified.evidence {
assert!(!signal.detail.contains("/private/"), "{}", signal.detail);
assert!(!signal.detail.starts_with('/'), "{}", signal.detail);
}
}
}