Skip to main content

openstranded_s2mod_tool/
scanner.rs

1// openstranded-s2mod-tool — convert Stranded II mods to .s2mod format
2// Copyright (C) 2025  openstranded-s2mod-tool contributors
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! S2S reference scanning and classification.
18//!
19//! Scans inline scripts and standalone `.s2s` files for commands that reference
20//! other files (`dialogue`, `msgbox`, `button`, `addscript`, `extendscript`,
21//! `def_extend`, `def_override`), then classifies each referenced file for
22//! downstream conversion.
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26
27use crate::util::read_file_lossy;
28
29/// How another file references/loads a given .s2s file.
30#[derive(Debug, Clone, PartialEq)]
31pub enum S2sRefType {
32    /// `dialogue "startpage", "path"` — dialog page data
33    Dialogue { start_page: String },
34    /// `msgbox "Title", "path"` — plain text displayed in a message box
35    Msgbox { title: String },
36    /// `button id, "text", font, "path"` — script executed on button click
37    Button,
38    /// `addscript class, id, "path"` — event handler added to an entity
39    AddScript,
40    /// `extendscript class, id, "path"` — global event handler extension
41    ExtendScript,
42    /// `def_extend class, type_id, "path", "section"` — type extension
43    DefExtend,
44    /// `def_override class, type_id, "path", "section"` — type override
45    DefOverride,
46}
47
48/// A single reference from one file to another.
49#[derive(Debug, Clone)]
50pub struct S2sReference {
51    pub target: PathBuf,
52    pub ref_type: S2sRefType,
53    pub _section: Option<String>,
54}
55
56/// Map from .s2s file path → how it's referenced (file-level).
57pub type S2sRefMap = HashMap<PathBuf, Vec<S2sRefType>>;
58
59/// Section-level reference map: file path → section name → ref types for that section.
60/// Only populated for files whose references include a section name.
61pub type S2sSectionMap = HashMap<PathBuf, HashMap<String, Vec<S2sRefType>>>;
62
63/// Classify a .s2s file based on how it's referenced.
64///
65/// Returns what kind of data the file contains and how it should be converted.
66#[derive(Debug, Clone, PartialEq)]
67pub enum S2sClass {
68    /// Standard event-handler script (transpile s2s→lua)
69    Script,
70    /// Dialog page data (parse as dialogue, output Lua table)
71    Dialogue { start_page: String },
72    /// Plain text displayed via msgbox (copy as .txt)
73    Msgbox { title: String },
74    /// Unknown — try s2s2lua, fall back to copy-as-is
75    Unknown,
76}
77
78/// Scan script content for commands that load other .s2s files,
79/// and record the references found.
80///
81/// This scans BOTH inline scripts from .inf entries AND standalone
82/// .s2s file content, using lightweight text matching (no full parse).
83pub fn scan_references(
84    content: &str,
85    _source_path: &Path,
86    input_root: &Path,
87    refs: &mut Vec<S2sReference>,
88) {
89    // Normalise line endings and strip comments for cleaner matching
90    let text = content.replace("\r\n", "\n");
91
92    // Helper: extract a quoted string starting at position `start` after an opening `"`.
93    // Returns (content, end_pos) where end_pos is the position after the closing `"`.
94    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; // skip escaped char
103                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    // Helper: extract a non-string token (number, variable, or bare identifier)
115    // starting at position `start`. Returns (content, end_pos).
116    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        // Skip whitespace
122        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            // Variable, number, or negative number
131            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            // Single character token (e.g. `0` for class 0)
150            Some((text[s..s + 1].to_string(), s + 1))
151        }
152    };
153
154    // Helper: skip comma and optional whitespace
155    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    // Resolve a relative path referenced in a script to an absolute path.
173    //
174    // In Stranded II, all script paths are relative to the mod root (input_root),
175    // not to the source file's directory. This applies to both standalone .s2s
176    // files and embedded scripts inside .inf entries.
177    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        // Strip leading separator or "./" prefix
183        let cleaned = rel.trim_start_matches(['/', '\\', '.']);
184        let abs = input_root.join(cleaned);
185        // Normalise path separators
186        let normalised: PathBuf = abs.components().collect();
187        Some(normalised)
188    };
189
190    // ── Scan line by line for known commands ──
191    // We do a simple case-insensitive search for command names
192    let lower = text.to_lowercase();
193
194    // Pattern 1: dialogue "startpage", "path" [, "section"]
195    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        // After "dialogue ", should be a quoted string (the start page)
201        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                    // Optional section
205                    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    // Pattern 2: msgbox "Title", "path"
222    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    // Pattern 3: button id, "text", font, "path"
242    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        // Skip the first token (button ID)
248        if let Some((_, after_id)) = extract_token(&text, after_cmd)
249            && let Some(after_comma1) = skip_comma(&text, after_id) {
250                // Skip the button text string
251                if let Some((_, after_text)) = extract_quoted(&text, after_comma1)
252                    && let Some(after_comma2) = skip_comma(&text, after_text) {
253                        // Skip the font number
254                        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    // Pattern 4: addscript "class", id, "path" [, "section"]
271    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    // Pattern 5: extendscript class, id, "path"
298    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    // Pattern 6: def_extend "class", type_id, "path", "section"
320    // Pattern 7: def_override "class", type_id, "path", "section"
321    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
357/// Build a reference map: for each .s2s file, what commands reference it and how.
358///
359/// Scans both embedded scripts in .inf entries and standalone .s2s files.
360/// Also builds a section-level map for files referenced with section names.
361pub 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    // Scan embedded scripts from .inf entries
369    for (inf_path, entries) in inf_entries {
370        for entry in entries {
371            // Script content is stored in blocks named "script"
372            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    // Scan standalone .s2s files
383    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    // Build file-level map: target path → all RefTypes referencing it
390    let mut file_map: S2sRefMap = HashMap::new();
391    // Build section-level map: target path → section name → ref types
392    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 this reference has a section name, also record it in the section map
401        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
418/// After building the reference map, resolve references whose target files
419/// have a different extension than expected (e.g., referenced as `.s2s` in
420/// `msgbox`/`button` calls but stored as `.txt` on disk).
421///
422/// Updates `ref_map` and `section_map` in place so their keys point to the
423/// actual files. Returns the list of non-`.s2s` files that need processing,
424/// and emits warnings for references that could not be resolved at all.
425pub 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(); // old → new
431
432    let keys: Vec<PathBuf> = ref_map.keys().cloned().collect();
433    for key in &keys {
434        if key.exists() {
435            continue;
436        }
437        // Try swapping .s2s ↔ .txt
438        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    // Apply redirects
460    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    // Also include any referenced file that is not an .s2s file
470    // (e.g., .txt files referenced directly via their real extension).
471    // These need to be processed in step 3 alongside .s2s files.
472    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    // Prioritise: dialogue > mixed (both msgbox + script) > msgbox > script
491    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        // Dialogue takes highest priority
511        S2sClass::Dialogue { start_page }
512    } else if is_script && msgbox_ref.is_some() {
513        // Mixed file: referenced by both msgbox AND button/addscript/etc.
514        // Transpile as script (best-effort) rather than plain text.
515        S2sClass::Script
516    } else if let Some(title) = msgbox_ref {
517        // Pure msgbox: plain text
518        S2sClass::Msgbox { title }
519    } else if is_script {
520        S2sClass::Script
521    } else {
522        S2sClass::Unknown
523    }
524}