Skip to content

Commit eec4e0f

Browse files
ahaviliclaude
andcommitted
feat(l10n): support localized value lookup and wrapper caller discovery in usages command
The `l10n usages` command now resolves translated string values (e.g., "进场特效") to their l10n keys, and discovers code that references those keys through wrapper binding call sites — not just SwiftUI view nodes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5d5f874 commit eec4e0f

7 files changed

Lines changed: 328 additions & 14 deletions

File tree

grapha/src/app/index.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ pub(crate) fn load_graph_for_l10n(path: &Path) -> anyhow::Result<grapha_core::gr
3838
.context("no index found — run `grapha index` first")
3939
}
4040

41+
pub(crate) fn load_graph_for_l10n_usages(path: &Path) -> anyhow::Result<grapha_core::graph::Graph> {
42+
use grapha_core::graph::EdgeKind;
43+
let db_path = path.join(".grapha/grapha.db");
44+
let s = store::sqlite::SqliteStore::new(db_path);
45+
s.load_filtered(
46+
Some(&[
47+
EdgeKind::Contains,
48+
EdgeKind::TypeRef,
49+
EdgeKind::Calls,
50+
EdgeKind::Implements,
51+
EdgeKind::Uses,
52+
]),
53+
Some("l10n."),
54+
)
55+
.context("no index found — run `grapha index` first")
56+
}
57+
4158
fn store_file_path(format: &str, store_path: &Path) -> anyhow::Result<PathBuf> {
4259
match format {
4360
"json" => Ok(store_path.join("graph.json")),

grapha/src/app/query.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::{
99
SymbolCommands, assets, cache, changes, config, fields, localization, query, render, search,
1010
};
1111

12-
use super::index::{load_graph, load_graph_for_l10n, open_search_index};
12+
use super::index::{load_graph, load_graph_for_l10n, load_graph_for_l10n_usages, open_search_index};
1313

1414
fn query_cache_key(parts: &[&str]) -> String {
1515
parts.join("\0")
@@ -427,7 +427,7 @@ pub(crate) fn handle_l10n_command(
427427
}
428428

429429
let render_options = render_options.with_fields(resolve_field_set(&fields, &path));
430-
let graph = load_graph_for_l10n(&path)?;
430+
let graph = load_graph_for_l10n_usages(&path)?;
431431
let catalogs = localization::load_catalog_index(&path)?;
432432
let result = query::usages::query_usages(&graph, &catalogs, &key, table.as_deref());
433433

grapha/src/localization.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ pub struct LocalizationCatalogRecord {
3636
pub status: String,
3737
#[serde(skip_serializing_if = "Option::is_none")]
3838
pub comment: Option<String>,
39+
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40+
pub translations: BTreeMap<String, String>,
3941
}
4042

4143
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -123,6 +125,7 @@ pub struct LocalizationCatalogIndex {
123125
records: Vec<LocalizationCatalogRecord>,
124126
by_table_key: HashMap<(String, String), Vec<usize>>,
125127
by_key: HashMap<String, Vec<usize>>,
128+
by_value: HashMap<String, Vec<usize>>,
126129
}
127130

128131
impl LocalizationCatalogIndex {
@@ -136,6 +139,17 @@ impl LocalizationCatalogIndex {
136139
.entry(record.key.clone())
137140
.or_default()
138141
.push(index);
142+
if !record.source_value.is_empty() {
143+
self.by_value
144+
.entry(record.source_value.clone())
145+
.or_default()
146+
.push(index);
147+
}
148+
for value in record.translations.values() {
149+
if !value.is_empty() {
150+
self.by_value.entry(value.clone()).or_default().push(index);
151+
}
152+
}
139153
self.records.push(record);
140154
}
141155

@@ -167,6 +181,16 @@ impl LocalizationCatalogIndex {
167181
.cloned()
168182
.collect()
169183
}
184+
185+
pub fn records_for_value(&self, value: &str) -> Vec<LocalizationCatalogRecord> {
186+
self.by_value
187+
.get(value)
188+
.into_iter()
189+
.flatten()
190+
.filter_map(|index| self.records.get(*index))
191+
.cloned()
192+
.collect()
193+
}
170194
}
171195

172196
pub fn build_and_save_catalog_snapshot(
@@ -240,6 +264,23 @@ fn build_catalog_snapshot(
240264
let catalog_file = path_relative_to_root(&root, &catalog.path);
241265
let catalog_dir = path_to_snapshot_string(&path_relative_to_root(&root, &catalog.base_dir));
242266

267+
let mut translations_by_key: HashMap<String, BTreeMap<String, String>> = HashMap::new();
268+
for resource in &codec.resources {
269+
let lang = &resource.metadata.language;
270+
if lang == &source_language {
271+
continue;
272+
}
273+
for entry in &resource.entries {
274+
let value = translation_plain_string(&entry.value);
275+
if !value.is_empty() {
276+
translations_by_key
277+
.entry(entry.id.clone())
278+
.or_default()
279+
.insert(lang.clone(), value);
280+
}
281+
}
282+
}
283+
243284
for entry in &source_resource.entries {
244285
records.push(LocalizationCatalogRecord {
245286
table: table.clone(),
@@ -253,6 +294,9 @@ fn build_catalog_snapshot(
253294
.trim_matches('"')
254295
.to_string(),
255296
comment: entry.comment.clone(),
297+
translations: translations_by_key
298+
.remove(&entry.id)
299+
.unwrap_or_default(),
256300
});
257301
}
258302
}
@@ -1053,6 +1097,108 @@ mod tests {
10531097
);
10541098
}
10551099

1100+
#[test]
1101+
fn lookup_by_source_value() {
1102+
let index = LocalizationCatalogIndex::from_records(vec![LocalizationCatalogRecord {
1103+
table: "Localizable".to_string(),
1104+
key: "entrance_effect".to_string(),
1105+
catalog_file: "Localizable.xcstrings".to_string(),
1106+
catalog_dir: ".".to_string(),
1107+
source_language: "zh-Hans".to_string(),
1108+
source_value: "进场特效".to_string(),
1109+
status: "translated".to_string(),
1110+
comment: None,
1111+
translations: BTreeMap::new(),
1112+
}]);
1113+
1114+
assert!(index.records_for_key("进场特效").is_empty());
1115+
1116+
let records = index.records_for_value("进场特效");
1117+
assert_eq!(records.len(), 1);
1118+
assert_eq!(records[0].key, "entrance_effect");
1119+
}
1120+
1121+
#[test]
1122+
fn lookup_by_translation_value() {
1123+
let mut translations = BTreeMap::new();
1124+
translations.insert("zh-Hans".to_string(), "欢迎".to_string());
1125+
translations.insert("fr".to_string(), "Bienvenue".to_string());
1126+
1127+
let index = LocalizationCatalogIndex::from_records(vec![LocalizationCatalogRecord {
1128+
table: "Localizable".to_string(),
1129+
key: "welcome_title".to_string(),
1130+
catalog_file: "Localizable.xcstrings".to_string(),
1131+
catalog_dir: ".".to_string(),
1132+
source_language: "en".to_string(),
1133+
source_value: "Welcome".to_string(),
1134+
status: "translated".to_string(),
1135+
comment: None,
1136+
translations,
1137+
}]);
1138+
1139+
assert!(index.records_for_key("欢迎").is_empty());
1140+
1141+
let records = index.records_for_value("欢迎");
1142+
assert_eq!(records.len(), 1);
1143+
assert_eq!(records[0].key, "welcome_title");
1144+
1145+
let records = index.records_for_value("Bienvenue");
1146+
assert_eq!(records.len(), 1);
1147+
assert_eq!(records[0].key, "welcome_title");
1148+
1149+
let records = index.records_for_value("Welcome");
1150+
assert_eq!(records.len(), 1);
1151+
assert_eq!(records[0].key, "welcome_title");
1152+
}
1153+
1154+
#[test]
1155+
fn snapshot_stores_non_source_translations() {
1156+
let dir = tempfile::tempdir().unwrap();
1157+
let store_dir = dir.path().join(".grapha");
1158+
let file = dir.path().join("Localizable.xcstrings");
1159+
fs::write(
1160+
&file,
1161+
r#"{
1162+
"sourceLanguage" : "en",
1163+
"strings" : {
1164+
"welcome_title" : {
1165+
"localizations" : {
1166+
"en" : {
1167+
"stringUnit" : {
1168+
"state" : "translated",
1169+
"value" : "Welcome"
1170+
}
1171+
},
1172+
"zh-Hans" : {
1173+
"stringUnit" : {
1174+
"state" : "translated",
1175+
"value" : "欢迎"
1176+
}
1177+
}
1178+
}
1179+
}
1180+
},
1181+
"version" : "1.0"
1182+
}"#,
1183+
)
1184+
.unwrap();
1185+
1186+
build_and_save_catalog_snapshot(dir.path(), &store_dir).unwrap();
1187+
let index = load_catalog_index_from_store(&store_dir).unwrap();
1188+
1189+
let records = index.records_for_key("welcome_title");
1190+
assert_eq!(records.len(), 1);
1191+
assert_eq!(records[0].source_value, "Welcome");
1192+
assert_eq!(
1193+
records[0].translations.get("zh-Hans").map(String::as_str),
1194+
Some("欢迎")
1195+
);
1196+
1197+
let by_value = index.records_for_value("欢迎");
1198+
assert_eq!(by_value.len(), 1);
1199+
assert_eq!(by_value[0].key, "welcome_title");
1200+
}
1201+
10561202
#[test]
10571203
fn builds_and_loads_strings_catalogs() {
10581204
let dir = tempfile::tempdir().unwrap();
@@ -1223,6 +1369,7 @@ mod tests {
12231369
source_value: "Auth".to_string(),
12241370
status: "translated".to_string(),
12251371
comment: None,
1372+
translations: BTreeMap::new(),
12261373
},
12271374
LocalizationCatalogRecord {
12281375
table: "Localizable".to_string(),
@@ -1233,6 +1380,7 @@ mod tests {
12331380
source_value: "Profile".to_string(),
12341381
status: "translated".to_string(),
12351382
comment: None,
1383+
translations: BTreeMap::new(),
12361384
},
12371385
];
12381386

@@ -1256,6 +1404,7 @@ mod tests {
12561404
source_value: "A".to_string(),
12571405
status: "translated".to_string(),
12581406
comment: None,
1407+
translations: BTreeMap::new(),
12591408
},
12601409
LocalizationCatalogRecord {
12611410
table: "Localizable".to_string(),
@@ -1266,6 +1415,7 @@ mod tests {
12661415
source_value: "B".to_string(),
12671416
status: "translated".to_string(),
12681417
comment: None,
1418+
translations: BTreeMap::new(),
12691419
},
12701420
];
12711421

@@ -1370,6 +1520,7 @@ mod tests {
13701520
source_value: "Welcome".to_string(),
13711521
status: "translated".to_string(),
13721522
comment: None,
1523+
translations: BTreeMap::new(),
13731524
}]);
13741525

13751526
let resolution = resolve_usage(
@@ -1482,6 +1633,7 @@ mod tests {
14821633
source_value: "Share room".to_string(),
14831634
status: "translated".to_string(),
14841635
comment: None,
1636+
translations: BTreeMap::new(),
14851637
}]);
14861638

14871639
let resolution = resolve_usage(
@@ -1603,6 +1755,7 @@ mod tests {
16031755
source_value: "The ID you entered does not exist".to_string(),
16041756
status: "translated".to_string(),
16051757
comment: None,
1758+
translations: BTreeMap::new(),
16061759
},
16071760
LocalizationCatalogRecord {
16081761
table: "Localizable".to_string(),
@@ -1613,6 +1766,7 @@ mod tests {
16131766
source_value: "List is empty".to_string(),
16141767
status: "translated".to_string(),
16151768
comment: None,
1769+
translations: BTreeMap::new(),
16161770
},
16171771
]);
16181772

@@ -1675,6 +1829,7 @@ mod tests {
16751829
source_value: "Tournament".to_string(),
16761830
status: "translated".to_string(),
16771831
comment: None,
1832+
translations: BTreeMap::new(),
16781833
}]);
16791834

16801835
let resolution = resolve_usage(

grapha/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,9 +340,9 @@ enum L10nCommands {
340340
#[arg(long)]
341341
fields: Option<String>,
342342
},
343-
/// Find SwiftUI usage sites for a localization key
343+
/// Find SwiftUI usage sites for a localization key or translated value
344344
Usages {
345-
/// Localization key
345+
/// Localization key or translated string value
346346
key: String,
347347
/// Optional table/catalog name
348348
#[arg(long)]

grapha/src/query/localize.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ mod tests {
422422
use super::*;
423423
use crate::localization::LocalizationCatalogRecord;
424424
use grapha_core::graph::{Edge, EdgeKind, NodeKind, Span, Visibility};
425+
use std::collections::BTreeMap;
425426
use std::collections::HashMap as StdHashMap;
426427
use std::path::PathBuf;
427428

@@ -501,6 +502,7 @@ mod tests {
501502
source_value: "Welcome".to_string(),
502503
status: "translated".to_string(),
503504
comment: None,
505+
translations: BTreeMap::new(),
504506
});
505507

506508
let result = query_localize(&graph, &catalogs, "body").unwrap();
@@ -571,6 +573,7 @@ mod tests {
571573
source_value: "Welcome".to_string(),
572574
status: "translated".to_string(),
573575
comment: None,
576+
translations: BTreeMap::new(),
574577
});
575578

576579
let result = query_localize(&graph, &catalogs, "welcomeTitle").unwrap();
@@ -641,6 +644,7 @@ mod tests {
641644
source_value: "Welcome".to_string(),
642645
status: "translated".to_string(),
643646
comment: None,
647+
translations: BTreeMap::new(),
644648
});
645649

646650
let result = query_localize(&graph, &catalogs, "wrapper").unwrap();
@@ -797,6 +801,7 @@ mod tests {
797801
source_value: "Welcome".to_string(),
798802
status: "translated".to_string(),
799803
comment: None,
804+
translations: BTreeMap::new(),
800805
});
801806

802807
let result = query_localize(&graph, &catalogs, "welcomeTitle").unwrap();
@@ -846,6 +851,7 @@ mod tests {
846851
source_value: "Welcome".to_string(),
847852
status: "translated".to_string(),
848853
comment: None,
854+
translations: BTreeMap::new(),
849855
});
850856

851857
let result = query_localize(&graph, &catalogs, "welcomeTitle").unwrap();

0 commit comments

Comments
 (0)