1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
|
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use mingling::macros::routeify;
use mingling::{
Grouped,
macros::{buffer, command, pack_err, r_println, renderer},
};
use crate::{
Next,
gd_res_structure::{TscnValue, parse},
res::gd_proj_dir::ResGodotProjectDirectory,
};
pack_err!(ErrorGodotProjectNotFound);
/// Build result
#[derive(Grouped)]
pub struct ResultCacheBuilt {
path: PathBuf,
len: usize,
}
#[command(node = "build-uid-index", routeify)]
pub fn build_uid_index(proj: &ResGodotProjectDirectory) -> Next {
let Some(ref dir) = proj.dir else {
return ErrorGodotProjectNotFound::default().into();
};
// 1. Scan uid → res:// paths
let map = scan_project(dir)?;
// 2. Write .godot/tscn2bsn.uid-idx.json
let cache_path = dir.join(".godot").join("tscn2bsn.uid-idx.json");
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&map)?;
std::fs::write(&cache_path, json)?;
ResultCacheBuilt {
path: cache_path,
len: map.len(),
}
.into()
}
/// Recursively collect all files in the project, skipping `.godot` / `.git`
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().into_owned();
if name == ".godot" || name == ".git" {
continue;
}
collect_files(&path, out)?;
} else if path.is_file() {
out.push(path);
}
}
Ok(())
}
/// Project relative path → `res://` form (Godot's standard in-project path)
fn to_res_path(project_dir: &Path, file: &Path) -> String {
let rel = file.strip_prefix(project_dir).unwrap_or(file);
let rel = rel.to_string_lossy().replace('\\', "/");
format!("res://{rel}")
}
/// Scan all uid sources, aggregate the mapping
fn scan_project(project_dir: &Path) -> io::Result<BTreeMap<String, String>> {
let mut files = Vec::new();
collect_files(project_dir, &mut files)?;
let mut map = BTreeMap::new();
for file in files {
let ext = file
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
// scenes/foo.glb.import → resource path res://scenes/foo.glb
"import" => {
if let Some(uid) = parse_import_uid(&file) {
let res_path = to_res_path(project_dir, &file);
insert_unique(
&mut map,
uid,
res_path.trim_end_matches(".import").to_string(),
);
}
}
"tscn" | "tres" => {
if let Some(uid) = parse_scene_uid(&file) {
insert_unique(&mut map, uid, to_res_path(project_dir, &file));
}
}
// foo.gd.uid → resource path res://foo.gd
"uid" => {
if let Some(uid) = parse_sidecar_uid(&file) {
let res_path = to_res_path(project_dir, &file);
insert_unique(&mut map, uid, res_path.trim_end_matches(".uid").to_string());
}
}
_ => {}
}
}
Ok(map)
}
fn insert_unique(map: &mut BTreeMap<String, String>, uid: String, res_path: String) {
match map.entry(uid.clone()) {
std::collections::btree_map::Entry::Vacant(v) => {
v.insert(res_path);
}
std::collections::btree_map::Entry::Occupied(o) => {
eprintln!("Warning: duplicate uid {uid}: {} vs {res_path}", o.get());
}
}
}
/// `.import` file: `uid="uid://..."` in the `[remap]` section (writer: editor_file_system.cpp L2693)
fn parse_import_uid(file: &Path) -> Option<String> {
let content = std::fs::read_to_string(file).ok()?;
let mut in_remap = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') {
in_remap = line == "[remap]";
continue;
}
if in_remap && let Some(v) = line.strip_prefix("uid=") {
let uid = v.trim().trim_matches('"');
if uid.starts_with("uid://") {
return Some(uid.to_string());
}
}
}
None
}
/// `.tscn` / `.tres`: reuse the tscn parser, extract the `uid` field from the header tag
fn parse_scene_uid(file: &Path) -> Option<String> {
let content = std::fs::read_to_string(file).ok()?;
let doc = parse(&content).ok()?;
for (key, value) in &doc.header.fields {
if key == "uid"
&& let TscnValue::Str(s) = value
{
return Some(s.clone());
}
}
None
}
/// `.uid` sidecar file: the first line is `uid://...` (writer: editor_file_system.cpp L939)
fn parse_sidecar_uid(file: &Path) -> Option<String> {
let content = std::fs::read_to_string(file).ok()?;
let first = content.lines().next()?.trim();
if first.starts_with("uid://") {
Some(first.to_string())
} else {
None
}
}
#[renderer(buffer)]
pub fn render_cache_built(r: ResultCacheBuilt) {
r_println!("OK: wrote {} ({} uids)", r.path.display(), r.len);
}
#[renderer(buffer)]
pub fn render_error_godot_project_not_found(_: ErrorGodotProjectNotFound) {
r_println!("Error: Godot project not found. ");
r_println!("");
r_println!(
"Please specify the project directory with `/P` or run this command in the project directory!"
)
}
|