1use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26
27use crate::util::read_file_lossy;
28
29#[derive(Debug, Clone, PartialEq)]
31pub enum S2sRefType {
32 Dialogue { start_page: String },
34 Msgbox { title: String },
36 Button,
38 AddScript,
40 ExtendScript,
42 DefExtend,
44 DefOverride,
46}
47
48#[derive(Debug, Clone)]
50pub struct S2sReference {
51 pub target: PathBuf,
52 pub ref_type: S2sRefType,
53 pub _section: Option<String>,
54}
55
56pub type S2sRefMap = HashMap<PathBuf, Vec<S2sRefType>>;
58
59pub type S2sSectionMap = HashMap<PathBuf, HashMap<String, Vec<S2sRefType>>>;
62
63#[derive(Debug, Clone, PartialEq)]
67pub enum S2sClass {
68 Script,
70 Dialogue { start_page: String },
72 Msgbox { title: String },
74 Unknown,
76}
77
78pub fn scan_references(
84 content: &str,
85 _source_path: &Path,
86 input_root: &Path,
87 refs: &mut Vec<S2sReference>,
88) {
89 let text = content.replace("\r\n", "\n");
91
92 let extract_quoted = |text: &str, start: usize| -> Option<(String, usize)> {
95 let bytes = text.as_bytes();
96 if start >= bytes.len() || bytes[start] != b'"' {
97 return None;
98 }
99 let mut i = start + 1;
100 while i < bytes.len() {
101 if bytes[i] == b'\\' {
102 i += 2; continue;
104 }
105 if bytes[i] == b'"' {
106 let s = text[start + 1..i].to_string();
107 return Some((s, i + 1));
108 }
109 i += 1;
110 }
111 None
112 };
113
114 let extract_token = |text: &str, start: usize| -> Option<(String, usize)> {
117 let bytes = text.as_bytes();
118 if start >= bytes.len() {
119 return None;
120 }
121 let mut s = start;
123 while s < bytes.len() && (bytes[s] == b' ' || bytes[s] == b'\t') {
124 s += 1;
125 }
126 if s >= bytes.len() {
127 return None;
128 }
129 if bytes[s] == b'$' || bytes[s].is_ascii_digit() || bytes[s] == b'-' {
130 let mut e = s + 1;
132 while e < bytes.len()
133 && (bytes[e].is_ascii_alphanumeric() || bytes[e] == b'_' || bytes[e] == b'.')
134 {
135 e += 1;
136 }
137 let tok = text[s..e].to_string();
138 Some((tok, e))
139 } else if bytes[s].is_ascii_alphabetic() {
140 let mut e = s + 1;
141 while e < bytes.len()
142 && (bytes[e].is_ascii_alphanumeric() || bytes[e] == b'_')
143 {
144 e += 1;
145 }
146 let tok = text[s..e].to_string();
147 Some((tok, e))
148 } else {
149 Some((text[s..s + 1].to_string(), s + 1))
151 }
152 };
153
154 let skip_comma = |text: &str, pos: usize| -> Option<usize> {
156 let bytes = text.as_bytes();
157 let mut i = pos;
158 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
159 i += 1;
160 }
161 if i < bytes.len() && bytes[i] == b',' {
162 i += 1;
163 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
164 i += 1;
165 }
166 Some(i)
167 } else {
168 None
169 }
170 };
171
172 let resolve_path = |rel: &str| -> Option<PathBuf> {
178 let p = Path::new(rel);
179 if p.is_absolute() {
180 return Some(p.to_path_buf());
181 }
182 let cleaned = rel.trim_start_matches(['/', '\\', '.']);
184 let abs = input_root.join(cleaned);
185 let normalised: PathBuf = abs.components().collect();
187 Some(normalised)
188 };
189
190 let lower = text.to_lowercase();
193
194 let mut search_pos = 0;
196 while let Some(pos) = lower[search_pos..].find("dialogue ") {
197 let abs_pos = search_pos + pos;
198 let after_cmd = abs_pos + "dialogue ".len();
199
200 if let Some((start_page, after_page)) = extract_quoted(&text, after_cmd)
202 && let Some(after_comma1) = skip_comma(&text, after_page)
203 && let Some((path, after_path)) = extract_quoted(&text, after_comma1) {
204 let section = skip_comma(&text, after_path)
206 .and_then(|p| extract_quoted(&text, p))
207 .map(|(s, _)| s);
208
209 if let Some(target) = resolve_path(&path) {
210 refs.push(S2sReference {
211 target,
212 ref_type: S2sRefType::Dialogue { start_page },
213 _section: section,
214 });
215 }
216 }
217
218 search_pos = abs_pos + 1;
219 }
220
221 let mut search_pos = 0;
223 while let Some(pos) = lower[search_pos..].find("msgbox ") {
224 let abs_pos = search_pos + pos;
225 let after_cmd = abs_pos + "msgbox ".len();
226
227 if let Some((_title, after_title)) = extract_quoted(&text, after_cmd)
228 && let Some(after_comma) = skip_comma(&text, after_title)
229 && let Some((path, _)) = extract_quoted(&text, after_comma)
230 && let Some(target) = resolve_path(&path) {
231 refs.push(S2sReference {
232 target,
233 ref_type: S2sRefType::Msgbox { title: _title },
234 _section: None,
235 });
236 }
237
238 search_pos = abs_pos + 1;
239 }
240
241 let mut search_pos = 0;
243 while let Some(pos) = lower[search_pos..].find("button ") {
244 let abs_pos = search_pos + pos;
245 let after_cmd = abs_pos + "button ".len();
246
247 if let Some((_, after_id)) = extract_token(&text, after_cmd)
249 && let Some(after_comma1) = skip_comma(&text, after_id) {
250 if let Some((_, after_text)) = extract_quoted(&text, after_comma1)
252 && let Some(after_comma2) = skip_comma(&text, after_text) {
253 if let Some((_, after_font)) = extract_token(&text, after_comma2)
255 && let Some(after_comma3) = skip_comma(&text, after_font)
256 && let Some((path, _)) = extract_quoted(&text, after_comma3)
257 && let Some(target) = resolve_path(&path) {
258 refs.push(S2sReference {
259 target,
260 ref_type: S2sRefType::Button,
261 _section: None,
262 });
263 }
264 }
265 }
266
267 search_pos = abs_pos + 1;
268 }
269
270 let mut search_pos = 0;
272 while let Some(pos) = lower[search_pos..].find("addscript ") {
273 let abs_pos = search_pos + pos;
274 let after_cmd = abs_pos + "addscript ".len();
275
276 if let Some((_class, after_class)) = extract_quoted(&text, after_cmd)
277 && let Some(after_comma1) = skip_comma(&text, after_class)
278 && let Some((_, after_id)) = extract_token(&text, after_comma1)
279 && let Some(after_comma2) = skip_comma(&text, after_id)
280 && let Some((path, after_path)) = extract_quoted(&text, after_comma2) {
281 let section = skip_comma(&text, after_path)
282 .and_then(|p| extract_quoted(&text, p))
283 .map(|(s, _)| s);
284
285 if let Some(target) = resolve_path(&path) {
286 refs.push(S2sReference {
287 target,
288 ref_type: S2sRefType::AddScript,
289 _section: section,
290 });
291 }
292 }
293
294 search_pos = abs_pos + 1;
295 }
296
297 let mut search_pos = 0;
299 while let Some(pos) = lower[search_pos..].find("extendscript ") {
300 let abs_pos = search_pos + pos;
301 let after_cmd = abs_pos + "extendscript ".len();
302
303 if let Some((_, after_class)) = extract_token(&text, after_cmd)
304 && let Some(after_comma1) = skip_comma(&text, after_class)
305 && let Some((_, after_id)) = extract_token(&text, after_comma1)
306 && let Some(after_comma2) = skip_comma(&text, after_id)
307 && let Some((path, _)) = extract_quoted(&text, after_comma2)
308 && let Some(target) = resolve_path(&path) {
309 refs.push(S2sReference {
310 target,
311 ref_type: S2sRefType::ExtendScript,
312 _section: None,
313 });
314 }
315
316 search_pos = abs_pos + 1;
317 }
318
319 for keyword in &["def_extend ", "def_override "] {
322 let mut search_pos = 0;
323 while let Some(pos) = lower[search_pos..].find(keyword) {
324 let abs_pos = search_pos + pos;
325 let after_cmd = abs_pos + keyword.len();
326 let ref_type = if *keyword == "def_extend " {
327 S2sRefType::DefExtend
328 } else {
329 S2sRefType::DefOverride
330 };
331
332 if let Some((_class, after_class)) = extract_quoted(&text, after_cmd)
333 && let Some(after_comma1) = skip_comma(&text, after_class)
334 && let Some((_, after_id)) = extract_token(&text, after_comma1)
335 && let Some(after_comma2) = skip_comma(&text, after_id)
336 && let Some((path, after_path)) =
337 extract_quoted(&text, after_comma2)
338 {
339 let section = skip_comma(&text, after_path)
340 .and_then(|p| extract_quoted(&text, p))
341 .map(|(s, _)| s);
342
343 if let Some(target) = resolve_path(&path) {
344 refs.push(S2sReference {
345 target,
346 ref_type: ref_type.clone(),
347 _section: section,
348 });
349 }
350 }
351
352 search_pos = abs_pos + 1;
353 }
354 }
355}
356
357pub fn build_reference_map(
362 inf_entries: &std::collections::HashMap<PathBuf, Vec<inf2ron::InfEntry>>,
363 s2s_files: &[PathBuf],
364 input_root: &Path,
365) -> (S2sRefMap, S2sSectionMap) {
366 let mut all_refs: Vec<S2sReference> = Vec::new();
367
368 for (inf_path, entries) in inf_entries {
370 for entry in entries {
371 if let Some(script_blocks) = entry.blocks.get("script") {
373 for block in script_blocks {
374 if let inf2ron::BlockContent::Text(content) = &block.content {
375 scan_references(content, inf_path, input_root, &mut all_refs);
376 }
377 }
378 }
379 }
380 }
381
382 for script_path in s2s_files {
384 if let Ok(content) = read_file_lossy(script_path) {
385 scan_references(&content, script_path, input_root, &mut all_refs);
386 }
387 }
388
389 let mut file_map: S2sRefMap = HashMap::new();
391 let mut section_map: S2sSectionMap = HashMap::new();
393
394 for r in &all_refs {
395 file_map
396 .entry(r.target.clone())
397 .or_default()
398 .push(r.ref_type.clone());
399
400 if let Some(ref section_name) = r._section {
402 section_map
403 .entry(r.target.clone())
404 .or_default()
405 .entry(section_name.clone())
406 .or_default()
407 .push(r.ref_type.clone());
408 }
409 }
410
411 if !all_refs.is_empty() {
412 eprintln!(" {} cross-references found", all_refs.len());
413 }
414
415 (file_map, section_map)
416}
417
418pub fn resolve_missing_script_refs(
426 ref_map: &mut S2sRefMap,
427 section_map: &mut S2sSectionMap,
428) -> Vec<PathBuf> {
429 let mut extra_files: Vec<PathBuf> = Vec::new();
430 let mut redirects: Vec<(PathBuf, PathBuf)> = Vec::new(); let keys: Vec<PathBuf> = ref_map.keys().cloned().collect();
433 for key in &keys {
434 if key.exists() {
435 continue;
436 }
437 if let Some(ext) = key.extension().and_then(|s| s.to_str()) {
439 let alt_ext = match ext {
440 "s2s" => "txt",
441 "txt" => "s2s",
442 _ => {
443 eprintln!(" Warning: referenced script not found: {:?}", key);
444 continue;
445 }
446 };
447 let alt = key.with_extension(alt_ext);
448 if alt.exists() {
449 redirects.push((key.clone(), alt.clone()));
450 extra_files.push(alt);
451 } else {
452 eprintln!(" Warning: referenced script not found: {:?}", key);
453 }
454 } else {
455 eprintln!(" Warning: referenced script has no extension: {:?}", key);
456 }
457 }
458
459 for (old_key, new_key) in &redirects {
461 if let Some(refs) = ref_map.remove(old_key) {
462 ref_map.entry(new_key.clone()).or_insert(refs);
463 }
464 if let Some(sections) = section_map.remove(old_key) {
465 section_map.entry(new_key.clone()).or_insert(sections);
466 }
467 }
468
469 for key in ref_map.keys() {
473 let is_s2s = key.extension().is_some_and(|e| e == "s2s");
474 if !is_s2s && key.exists() {
475 extra_files.push(key.clone());
476 }
477 }
478
479 extra_files.sort();
480 extra_files.dedup();
481 extra_files
482}
483
484pub fn classify_s2s(path: &Path, ref_map: &S2sRefMap) -> S2sClass {
485 let refs = match ref_map.get(path) {
486 Some(r) => r,
487 None => return S2sClass::Unknown,
488 };
489
490 let mut dialogue_ref = None;
492 let mut msgbox_ref = None;
493 let mut is_script = false;
494
495 for r in refs {
496 match r {
497 S2sRefType::Dialogue { start_page } => {
498 dialogue_ref = Some(start_page.clone());
499 }
500 S2sRefType::Msgbox { title } => {
501 msgbox_ref = Some(title.clone());
502 }
503 _ => {
504 is_script = true;
505 }
506 }
507 }
508
509 if let Some(start_page) = dialogue_ref {
510 S2sClass::Dialogue { start_page }
512 } else if is_script && msgbox_ref.is_some() {
513 S2sClass::Script
516 } else if let Some(title) = msgbox_ref {
517 S2sClass::Msgbox { title }
519 } else if is_script {
520 S2sClass::Script
521 } else {
522 S2sClass::Unknown
523 }
524}