Skip to main content

tiger_lib/
fileset.rs

1//! Track all the files (vanilla and mods) that are relevant to the current validation.
2
3use std::borrow::ToOwned;
4use std::cmp::Ordering;
5use std::ffi::OsStr;
6use std::fmt::{Display, Formatter};
7use std::path::{Path, PathBuf};
8use std::string::ToString;
9use std::sync::RwLock;
10
11use anyhow::{Result, bail};
12use rayon::prelude::*;
13use walkdir::WalkDir;
14
15use crate::block::Block;
16use crate::everything::{Everything, FilesError};
17use crate::game::Game;
18use crate::helpers::TigerHashSet;
19use crate::item::Item;
20#[cfg(any(feature = "vic3", feature = "eu5"))]
21use crate::mod_metadata::ModMetadata;
22#[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
23use crate::modfile::ModFile;
24use crate::parse::ParserMemory;
25use crate::pathtable::{PathTable, PathTableIndex};
26use crate::report::{
27    ErrorKey, Severity, add_loaded_dlc_root, add_loaded_mod_root, err, fatal, report,
28};
29use crate::token::Token;
30use crate::util::fix_slashes_for_target_platform;
31
32/// Note that ordering of these enum values matters.
33/// Files later in the order will override files of the same name before them,
34/// and the warnings about duplicates take that into account.
35// TODO: verify the relative order of `Clausewitz` and `Jomini`
36#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum FileKind {
38    /// `Internal` is for parsing tiger's own data. The user should not see warnings from this.
39    Internal,
40    /// `Clausewitz` and `Jomini` are directories bundled with the base game.
41    Clausewitz,
42    Jomini,
43    /// The base game files.
44    Vanilla,
45    /// Downloadable content present on the user's system.
46    Dlc(u8),
47    /// Other mods loaded as directed by the config file. 0-based indexing.
48    LoadedMod(u8),
49    /// The mod under scrutiny. Usually, warnings are not emitted unless they touch `Mod` files.
50    Mod,
51}
52
53impl FileKind {
54    pub fn counts_as_vanilla(&self) -> bool {
55        match self {
56            FileKind::Clausewitz | FileKind::Jomini | FileKind::Vanilla | FileKind::Dlc(_) => true,
57            FileKind::Internal | FileKind::LoadedMod(_) | FileKind::Mod => false,
58        }
59    }
60}
61
62/// The top level directories used by EU5.
63/// The other games only have the `NoStage` stage, which doesn't correspond to a directory prefix.
64///
65/// Note that ordering of these enum values matters for the same reason as [`FileKind`].
66#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)]
67pub enum FileStage {
68    #[cfg(feature = "eu5")]
69    LoadingScreen,
70    #[cfg(feature = "eu5")]
71    MainMenu,
72    #[cfg(feature = "eu5")]
73    InGame,
74    NoStage,
75}
76
77impl FileStage {
78    fn with_dir(self, path: &Path) -> PathBuf {
79        let toplevel: Option<&'static str> = match self {
80            #[cfg(feature = "eu5")]
81            FileStage::LoadingScreen => Some("loading_screen"),
82            #[cfg(feature = "eu5")]
83            FileStage::MainMenu => Some("main_menu"),
84            #[cfg(feature = "eu5")]
85            FileStage::InGame => Some("in_game"),
86            FileStage::NoStage => None,
87        };
88        // TODO: could try using Cow here. Might be that the caller has to clone anyway though.
89        let mut p = path.to_owned();
90        if let Some(toplevel) = toplevel {
91            p.push(toplevel);
92        }
93        p
94    }
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct FileEntry {
99    /// Pathname components below the mod directory or the vanilla game dir
100    /// Must not be empty.
101    path: PathBuf,
102    /// The top-level directories use by EU5. This prefix is not included in `path`.
103    stage: FileStage,
104    /// Whether it's a vanilla or mod file
105    kind: FileKind,
106    /// Index into the `PathTable`. Used to initialize `Loc`, which doesn't carry a copy of the pathbuf.
107    /// A `FileEntry` might not have this index, because `FileEntry` needs to be usable before the (ordered)
108    /// path table is created.
109    idx: Option<PathTableIndex>,
110    /// The full filesystem path of this entry. Not used for ordering or equality.
111    fullpath: PathBuf,
112}
113
114impl FileEntry {
115    pub fn new(path: PathBuf, stage: FileStage, kind: FileKind, fullpath: PathBuf) -> Self {
116        assert!(path.file_name().is_some());
117        Self { path, stage, kind, idx: None, fullpath }
118    }
119
120    pub fn stage(&self) -> FileStage {
121        self.stage
122    }
123
124    pub fn kind(&self) -> FileKind {
125        self.kind
126    }
127
128    pub fn path(&self) -> &Path {
129        &self.path
130    }
131
132    pub fn fullpath(&self) -> &Path {
133        &self.fullpath
134    }
135
136    /// Convenience function
137    /// Won't panic because `FileEntry` with empty filename is not allowed.
138    #[allow(clippy::missing_panics_doc)]
139    pub fn filename(&self) -> &OsStr {
140        self.path.file_name().unwrap()
141    }
142
143    fn store_in_pathtable(&mut self) {
144        assert!(self.idx.is_none());
145        self.idx = Some(PathTable::store(self.path.clone(), self.fullpath.clone()));
146    }
147
148    pub fn path_idx(&self) -> Option<PathTableIndex> {
149        self.idx
150    }
151}
152
153impl Display for FileEntry {
154    fn fmt(&self, fmt: &mut Formatter) -> Result<(), std::fmt::Error> {
155        write!(fmt, "{}", self.path.display())
156    }
157}
158
159impl PartialOrd for FileEntry {
160    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
161        Some(self.cmp(other))
162    }
163}
164
165impl Ord for FileEntry {
166    fn cmp(&self, other: &Self) -> Ordering {
167        // Compare idx if available (for speed), otherwise compare the paths.
168        // TODO: the unnecessary unwrap can be fixed after msrv is high enough to use multiple `let` in one if.
169        #[allow(clippy::unnecessary_unwrap)]
170        let ord = if self.idx.is_some() && other.idx.is_some() {
171            self.idx.unwrap().cmp(&other.idx.unwrap())
172        } else {
173            self.path.cmp(&other.path)
174        };
175
176        // EU5 support: [`FileStage`] takes precedence over [`FileKind`]
177        // For the other games, this should compile down to nothing.
178        let ord = if ord == Ordering::Equal { self.stage.cmp(&other.stage) } else { ord };
179
180        // For same paths, the later [`FileKind`] wins.
181        if ord == Ordering::Equal { self.kind.cmp(&other.kind) } else { ord }
182    }
183}
184
185/// A trait for a submodule that can process files.
186pub trait FileHandler<T: Send>: Sync + Send {
187    /// The `FileHandler` can read settings it needs from the ck3-tiger config.
188    fn config(&mut self, _config: &Block) {}
189
190    /// Which files this handler is interested in.
191    /// This is a directory prefix of files it wants to handle,
192    /// relative to the mod or vanilla root.
193    fn subpath(&self) -> PathBuf;
194
195    /// This is called for each matching file, in arbitrary order.
196    /// If a `T` is returned, it will be passed to `handle_file` later.
197    /// Since `load_file` is executed multi-threaded while `handle_file`
198    /// is single-threaded, try to do the heavy work in this function.
199    fn load_file(&self, entry: &FileEntry, parser: &ParserMemory) -> Option<T>;
200
201    /// This is called for each matching file in turn, in lexical order.
202    /// That's the order in which the CK3 game engine loads them too.
203    fn handle_file(&mut self, entry: &FileEntry, loaded: T);
204
205    /// This is called after all files have been handled.
206    /// The `FileHandler` can generate indexes, perform full-data checks, etc.
207    fn finalize(&mut self) {}
208}
209
210#[derive(Clone, Debug)]
211pub struct LoadedMod {
212    /// The `FileKind` to use for file entries from this mod.
213    kind: FileKind,
214
215    /// The tag used for this mod in error messages.
216    #[allow(dead_code)]
217    label: String,
218
219    /// The location of this mod in the filesystem.
220    root: PathBuf,
221
222    /// A list of directories that should not be read from vanilla or previous mods.
223    replace_paths: Vec<PathBuf>,
224}
225
226impl LoadedMod {
227    fn new_main_mod(root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
228        Self { kind: FileKind::Mod, label: "MOD".to_string(), root, replace_paths }
229    }
230
231    fn new(kind: FileKind, label: String, root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
232        Self { kind, label, root, replace_paths }
233    }
234
235    pub fn root(&self) -> &Path {
236        &self.root
237    }
238
239    pub fn kind(&self) -> FileKind {
240        self.kind
241    }
242
243    pub fn should_replace(&self, path: &Path) -> bool {
244        self.replace_paths.iter().any(|p| p == path)
245    }
246}
247
248#[derive(Debug)]
249pub struct Fileset {
250    /// The CK3 game directory.
251    vanilla_root: Option<PathBuf>,
252
253    /// Extra CK3 directory loaded before vanilla.
254    #[cfg(feature = "jomini")]
255    clausewitz_root: Option<PathBuf>,
256
257    /// Extra CK3 directory loaded before vanilla.
258    #[cfg(feature = "jomini")]
259    jomini_root: Option<PathBuf>,
260
261    /// The mod being analyzed.
262    the_mod: LoadedMod,
263
264    /// Other mods to be loaded before `mod`, in order.
265    pub loaded_mods: Vec<LoadedMod>,
266
267    /// DLC directories to be loaded after vanilla, in order.
268    loaded_dlcs: Vec<LoadedMod>,
269
270    /// The ck3-tiger config.
271    config: Option<Block>,
272
273    /// The CK3 and mod files in arbitrary order (will be empty after `finalize`).
274    files: Vec<FileEntry>,
275
276    /// The CK3 and mod files in the order the game would load them.
277    ordered_files: Vec<FileEntry>,
278
279    /// Filename Tokens for the files in `ordered_files`.
280    /// Used for [`Fileset::iter_keys()`].
281    filename_tokens: Vec<Token>,
282
283    /// All filenames from `ordered_files`, for quick lookup.
284    filenames: TigerHashSet<PathBuf>,
285
286    /// All directories that have been looked up, for quick lookup.
287    directories: RwLock<TigerHashSet<PathBuf>>,
288
289    /// Filenames that have been looked up during validation. Used to filter the --unused output.
290    used: RwLock<TigerHashSet<String>>,
291}
292
293impl Fileset {
294    pub fn new(vanilla_dir: Option<&Path>, mod_root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
295        let vanilla_root = if Game::is_jomini() {
296            vanilla_dir.map(|dir| dir.join("game"))
297        } else {
298            vanilla_dir.map(ToOwned::to_owned)
299        };
300        #[cfg(feature = "jomini")]
301        let clausewitz_root = vanilla_dir.map(|dir| dir.join("clausewitz"));
302        #[cfg(feature = "jomini")]
303        let jomini_root = vanilla_dir.map(|dir| dir.join("jomini"));
304
305        Fileset {
306            vanilla_root,
307            #[cfg(feature = "jomini")]
308            clausewitz_root,
309            #[cfg(feature = "jomini")]
310            jomini_root,
311            the_mod: LoadedMod::new_main_mod(mod_root, replace_paths),
312            loaded_mods: Vec::new(),
313            loaded_dlcs: Vec::new(),
314            config: None,
315            files: Vec::new(),
316            ordered_files: Vec::new(),
317            filename_tokens: Vec::new(),
318            filenames: TigerHashSet::default(),
319            directories: RwLock::new(TigerHashSet::default()),
320            used: RwLock::new(TigerHashSet::default()),
321        }
322    }
323
324    pub fn config(
325        &mut self,
326        config: Block,
327        #[allow(unused_variables)] workshop_dir: Option<&Path>,
328        #[allow(unused_variables)] paradox_dir: Option<&Path>,
329    ) -> Result<()> {
330        let config_path = config.loc.fullpath();
331        for block in config.get_field_blocks("load_mod") {
332            let mod_idx;
333            if let Ok(idx) = u8::try_from(self.loaded_mods.len()) {
334                mod_idx = idx;
335            } else {
336                bail!("too many loaded mods, cannot process more");
337            }
338
339            let default_label = || format!("MOD{mod_idx}");
340            let label =
341                block.get_field_value("label").map_or_else(default_label, ToString::to_string);
342
343            if Game::is_ck3() || Game::is_imperator() || Game::is_hoi4() {
344                #[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
345                if let Some(path) = get_modfile(&label, config_path, block, paradox_dir) {
346                    let modfile = ModFile::read(&path)?;
347                    eprintln!(
348                        "Loading secondary mod {label} from: {}{}",
349                        modfile.modpath().display(),
350                        modfile
351                            .display_name()
352                            .map_or_else(String::new, |name| format!(" \"{name}\"")),
353                    );
354                    let kind = FileKind::LoadedMod(mod_idx);
355                    let loaded_mod = LoadedMod::new(
356                        kind,
357                        label.clone(),
358                        modfile.modpath().clone(),
359                        modfile.replace_paths(),
360                    );
361                    add_loaded_mod_root(label);
362                    self.loaded_mods.push(loaded_mod);
363                } else {
364                    bail!(
365                        "could not load secondary mod from config; missing valid `modfile` or `workshop_id` field"
366                    );
367                }
368            } else if Game::is_vic3() || Game::is_eu5() {
369                #[cfg(any(feature = "vic3", feature = "eu5"))]
370                if let Some(pathdir) = get_mod(&label, config_path, block, workshop_dir) {
371                    match ModMetadata::read(&pathdir) {
372                        Ok(metadata) => {
373                            eprintln!(
374                                "Loading secondary mod {label} from: {}{}",
375                                pathdir.display(),
376                                metadata
377                                    .display_name()
378                                    .map_or_else(String::new, |name| format!(" \"{name}\"")),
379                            );
380                            let kind = FileKind::LoadedMod(mod_idx);
381                            let loaded_mod = LoadedMod::new(
382                                kind,
383                                label.clone(),
384                                pathdir,
385                                metadata.replace_paths(),
386                            );
387                            add_loaded_mod_root(label);
388                            self.loaded_mods.push(loaded_mod);
389                        }
390                        Err(e) => {
391                            eprintln!(
392                                "could not load secondary mod {label} from: {}",
393                                pathdir.display()
394                            );
395                            eprintln!("  because: {e}");
396                        }
397                    }
398                } else {
399                    bail!(
400                        "could not load secondary mod from config; missing valid `mod` or `workshop_id` field"
401                    );
402                }
403            }
404        }
405        self.config = Some(config);
406        Ok(())
407    }
408
409    fn should_replace(&self, path: &Path, kind: FileKind) -> bool {
410        if kind == FileKind::Mod {
411            return false;
412        }
413        if kind < FileKind::Mod && self.the_mod.should_replace(path) {
414            return true;
415        }
416        for loaded_mod in &self.loaded_mods {
417            if kind < loaded_mod.kind && loaded_mod.should_replace(path) {
418                return true;
419            }
420        }
421        false
422    }
423
424    fn scan(
425        &mut self,
426        path: &Path,
427        stage: FileStage,
428        kind: FileKind,
429    ) -> Result<(), walkdir::Error> {
430        for entry in WalkDir::new(path) {
431            let entry = entry?;
432            if entry.depth() == 0 || !entry.file_type().is_file() {
433                continue;
434            }
435            // unwrap is safe here because WalkDir gives us paths with this prefix.
436            let inner_path = entry.path().strip_prefix(path).unwrap();
437            if inner_path.starts_with(".git") {
438                continue;
439            }
440            let inner_dir = inner_path.parent().unwrap_or_else(|| Path::new(""));
441            if self.should_replace(inner_dir, kind) {
442                continue;
443            }
444            self.files.push(FileEntry::new(
445                inner_path.to_path_buf(),
446                stage,
447                kind,
448                entry.path().to_path_buf(),
449            ));
450        }
451        Ok(())
452    }
453
454    #[allow(clippy::nonminimal_bool)] // The expressions as written are clearer
455    fn scan_stage(&mut self, stage: FileStage) -> Result<(), FilesError> {
456        #[cfg(feature = "jomini")]
457        if let Some(path) = &self.clausewitz_root {
458            let path = stage.with_dir(path);
459            if !(Game::is_eu5() && !path.exists()) {
460                self.scan(&path, stage, FileKind::Clausewitz)
461                    .map_err(|e| FilesError::VanillaUnreadable { path: path.clone(), source: e })?;
462            }
463        }
464        #[cfg(feature = "jomini")]
465        if let Some(path) = &self.jomini_root {
466            let path = stage.with_dir(path);
467            if !(Game::is_eu5() && !path.exists()) {
468                self.scan(&path, stage, FileKind::Jomini)
469                    .map_err(|e| FilesError::VanillaUnreadable { path: path.clone(), source: e })?;
470            }
471        }
472        if let Some(path) = &self.vanilla_root {
473            let path = stage.with_dir(path);
474            if !(Game::is_eu5() && !path.exists()) {
475                self.scan(&path, stage, FileKind::Vanilla)
476                    .map_err(|e| FilesError::VanillaUnreadable { path: path.clone(), source: e })?;
477            }
478            #[cfg(feature = "hoi4")]
479            if Game::is_hoi4() {
480                self.load_dlcs(&path.join("integrated_dlc"))?;
481            }
482            // We don't know yet how EU5 will do DLCs
483            if !Game::is_eu5() {
484                self.load_dlcs(&path.join("dlc"))?;
485            }
486        }
487        // loaded_mods is cloned here for the borrow checker
488        for loaded_mod in &self.loaded_mods.clone() {
489            let path = stage.with_dir(loaded_mod.root());
490            if !(Game::is_eu5() && !path.exists()) {
491                self.scan(&path, stage, loaded_mod.kind())
492                    .map_err(|e| FilesError::ModUnreadable { path: path.clone(), source: e })?;
493            }
494        }
495        let path = stage.with_dir(self.the_mod.root());
496        if !(Game::is_eu5() && !path.exists()) {
497            self.scan(&path, stage, FileKind::Mod)
498                .map_err(|e| FilesError::ModUnreadable { path: path.clone(), source: e })?;
499        }
500        Ok(())
501    }
502
503    pub fn scan_all(&mut self) -> Result<(), FilesError> {
504        if Game::is_eu5() {
505            #[cfg(feature = "eu5")]
506            self.scan_stage(FileStage::LoadingScreen)?;
507            #[cfg(feature = "eu5")]
508            self.scan_stage(FileStage::MainMenu)?;
509            #[cfg(feature = "eu5")]
510            self.scan_stage(FileStage::InGame)?;
511        } else {
512            self.scan_stage(FileStage::NoStage)?;
513        }
514        Ok(())
515    }
516
517    pub fn load_dlcs(&mut self, dlc_root: &Path) -> Result<(), FilesError> {
518        for entry in WalkDir::new(dlc_root).max_depth(1).sort_by_file_name().into_iter().flatten() {
519            if entry.depth() == 1 && entry.file_type().is_dir() {
520                let label = entry.file_name().to_string_lossy().to_string();
521                let idx =
522                    u8::try_from(self.loaded_dlcs.len()).expect("more than 256 DLCs installed");
523                let dlc = LoadedMod::new(
524                    FileKind::Dlc(idx),
525                    label.clone(),
526                    entry.path().to_path_buf(),
527                    Vec::new(),
528                );
529                // TODO: figure out how to handle this in EU5
530                self.scan(dlc.root(), FileStage::NoStage, dlc.kind()).map_err(|e| {
531                    FilesError::VanillaUnreadable { path: dlc.root().to_path_buf(), source: e }
532                })?;
533                self.loaded_dlcs.push(dlc);
534                add_loaded_dlc_root(label);
535            }
536        }
537        Ok(())
538    }
539
540    pub fn finalize(&mut self) {
541        // This sorts by pathname but where pathnames are equal it places `Mod` entries after `Vanilla` entries
542        // and `LoadedMod` entries between them in order
543        self.files.sort();
544
545        // When there are identical paths, only keep the last entry of them.
546        for entry in self.files.drain(..) {
547            if let Some(prev) = self.ordered_files.last_mut() {
548                if entry.path == prev.path {
549                    *prev = entry;
550                } else {
551                    self.ordered_files.push(entry);
552                }
553            } else {
554                self.ordered_files.push(entry);
555            }
556        }
557
558        for entry in &mut self.ordered_files {
559            let token = Token::new(&entry.filename().to_string_lossy(), (&*entry).into());
560            self.filename_tokens.push(token);
561            entry.store_in_pathtable();
562            self.filenames.insert(entry.path.clone());
563        }
564    }
565
566    pub fn get_files_under<'a>(&'a self, subpath: &'a Path) -> &'a [FileEntry] {
567        let start = self.ordered_files.partition_point(|entry| entry.path < subpath);
568        let end = start
569            + self.ordered_files[start..].partition_point(|entry| entry.path.starts_with(subpath));
570        &self.ordered_files[start..end]
571    }
572
573    pub fn filter_map_under<F, T>(&self, subpath: &Path, f: F) -> Vec<T>
574    where
575        F: Fn(&FileEntry) -> Option<T> + Sync + Send,
576        T: Send,
577    {
578        self.get_files_under(subpath).par_iter().filter_map(f).collect()
579    }
580
581    pub fn handle<T: Send, H: FileHandler<T>>(&self, handler: &mut H, parser: &ParserMemory) {
582        if let Some(config) = &self.config {
583            handler.config(config);
584        }
585        let subpath = handler.subpath();
586        let entries = self.filter_map_under(&subpath, |entry| {
587            handler.load_file(entry, parser).map(|loaded| (entry.clone(), loaded))
588        });
589        for (entry, loaded) in entries {
590            handler.handle_file(&entry, loaded);
591        }
592        handler.finalize();
593    }
594
595    pub fn mark_used(&self, file: &str) {
596        let file = file.strip_prefix('/').unwrap_or(file);
597        self.used.write().unwrap().insert(file.to_string());
598    }
599
600    pub fn exists(&self, key: &str) -> bool {
601        let key = key.strip_prefix('/').unwrap_or(key);
602        let filepath = if Game::is_hoi4() && key.contains('\\') {
603            PathBuf::from(key.replace('\\', "/"))
604        } else {
605            PathBuf::from(key)
606        };
607        self.filenames.contains(&filepath)
608    }
609
610    pub fn iter_keys(&self) -> impl Iterator<Item = &Token> {
611        self.filename_tokens.iter()
612    }
613
614    pub fn entry_exists(&self, key: &str) -> bool {
615        // file exists
616        if self.exists(key) {
617            return true;
618        }
619
620        // directory lookup - check if there are any files within the directory
621        let dir = key.strip_prefix('/').unwrap_or(key);
622        let dirpath = Path::new(dir);
623
624        if self.directories.read().unwrap().contains(dirpath) {
625            return true;
626        }
627
628        match self.ordered_files.binary_search_by_key(&dirpath, |fe| fe.path.as_path()) {
629            // should be handled in `exists` already; something must be wrong
630            Ok(_) => unreachable!(),
631            Err(idx) => {
632                // there exists a file in the given directory
633                if self.ordered_files[idx].path.starts_with(dirpath) {
634                    self.directories.write().unwrap().insert(dirpath.to_path_buf());
635                    return true;
636                }
637            }
638        }
639        false
640    }
641
642    pub fn verify_entry_exists(&self, entry: &str, token: &Token, max_sev: Severity) {
643        self.mark_used(&entry.replace("//", "/"));
644        if !self.entry_exists(entry) {
645            let msg = format!("file or directory {entry} does not exist");
646            report(ErrorKey::MissingFile, Item::File.severity().at_most(max_sev))
647                .msg(msg)
648                .loc(token)
649                .push();
650        }
651    }
652
653    #[cfg(feature = "ck3")] // vic3 happens not to use
654    pub fn verify_exists(&self, file: &Token) {
655        self.mark_used(&file.as_str().replace("//", "/"));
656        if !self.exists(file.as_str()) {
657            let msg = "referenced file does not exist";
658            report(ErrorKey::MissingFile, Item::File.severity()).msg(msg).loc(file).push();
659        }
660    }
661
662    pub fn verify_exists_implied(&self, file: &str, t: &Token, max_sev: Severity) {
663        self.mark_used(&file.replace("//", "/"));
664        if !self.exists(file) {
665            let msg = format!("file {file} does not exist");
666            report(ErrorKey::MissingFile, Item::File.severity().at_most(max_sev))
667                .msg(msg)
668                .loc(t)
669                .push();
670        }
671    }
672
673    pub fn verify_exists_implied_crashes(&self, file: &str, t: &Token) {
674        self.mark_used(&file.replace("//", "/"));
675        if !self.exists(file) {
676            let msg = format!("file {file} does not exist");
677            fatal(ErrorKey::Crash).msg(msg).loc(t).push();
678        }
679    }
680
681    pub fn validate(&self, _data: &Everything) {
682        let common_dirs = match Game::game() {
683            #[cfg(feature = "ck3")]
684            Game::Ck3 => crate::ck3::tables::misc::COMMON_DIRS,
685            #[cfg(feature = "vic3")]
686            Game::Vic3 => crate::vic3::tables::misc::COMMON_DIRS,
687            #[cfg(feature = "imperator")]
688            Game::Imperator => crate::imperator::tables::misc::COMMON_DIRS,
689            #[cfg(feature = "eu5")]
690            Game::Eu5 => crate::eu5::tables::misc::COMMON_DIRS,
691            #[cfg(feature = "hoi4")]
692            Game::Hoi4 => crate::hoi4::tables::misc::COMMON_DIRS,
693        };
694        let common_subdirs_ok = match Game::game() {
695            #[cfg(feature = "ck3")]
696            Game::Ck3 => crate::ck3::tables::misc::COMMON_SUBDIRS_OK,
697            #[cfg(feature = "vic3")]
698            Game::Vic3 => crate::vic3::tables::misc::COMMON_SUBDIRS_OK,
699            #[cfg(feature = "imperator")]
700            Game::Imperator => crate::imperator::tables::misc::COMMON_SUBDIRS_OK,
701            #[cfg(feature = "eu5")]
702            Game::Eu5 => crate::eu5::tables::misc::COMMON_SUBDIRS_OK,
703            #[cfg(feature = "hoi4")]
704            Game::Hoi4 => crate::hoi4::tables::misc::COMMON_SUBDIRS_OK,
705        };
706        // Check the files in directories in common/ to make sure they are in known directories
707        let mut warned: Vec<&Path> = Vec::new();
708        'outer: for entry in &self.ordered_files {
709            if !entry.path.to_string_lossy().ends_with(".txt") {
710                continue;
711            }
712            if entry.path == OsStr::new("common/achievement_groups.txt") {
713                continue;
714            }
715            #[cfg(feature = "hoi4")]
716            if Game::is_hoi4() {
717                for valid in crate::hoi4::tables::misc::COMMON_FILES {
718                    if <&str as AsRef<Path>>::as_ref(valid) == entry.path {
719                        continue 'outer;
720                    }
721                }
722            }
723            let dirname = entry.path.parent().unwrap();
724            if warned.contains(&dirname) {
725                continue;
726            }
727            if !entry.path.starts_with("common") {
728                // Check if the modder forgot the common/ part
729                let joined = Path::new("common").join(&entry.path);
730                for valid in common_dirs {
731                    if joined.starts_with(valid) {
732                        let msg = format!("file in unexpected directory {}", dirname.display());
733                        let info = format!("did you mean common/{} ?", dirname.display());
734                        err(ErrorKey::Filename).msg(msg).info(info).loc(entry).push();
735                        warned.push(dirname);
736                        continue 'outer;
737                    }
738                }
739                continue;
740            }
741
742            for valid in common_subdirs_ok {
743                if entry.path.starts_with(valid) {
744                    continue 'outer;
745                }
746            }
747
748            for valid in common_dirs {
749                if <&str as AsRef<Path>>::as_ref(valid) == dirname {
750                    continue 'outer;
751                }
752            }
753
754            if entry.path.starts_with("common/scripted_values") {
755                let msg = "file should be in common/script_values/";
756                err(ErrorKey::Filename).msg(msg).loc(entry).push();
757            } else if (Game::is_ck3() || Game::is_imperator())
758                && entry.path.starts_with("common/on_actions")
759            {
760                let msg = "file should be in common/on_action/";
761                err(ErrorKey::Filename).msg(msg).loc(entry).push();
762            } else if (Game::is_vic3() || Game::is_hoi4())
763                && entry.path.starts_with("common/on_action")
764            {
765                let msg = "file should be in common/on_actions/";
766                err(ErrorKey::Filename).msg(msg).loc(entry).push();
767            } else if Game::is_vic3() && entry.path.starts_with("common/modifiers") {
768                let msg = "file should be in common/static_modifiers since 1.7";
769                err(ErrorKey::Filename).msg(msg).loc(entry).push();
770            } else if Game::is_ck3() && entry.path.starts_with("common/vassal_contracts") {
771                let msg = "common/vassal_contracts was replaced with common/subject_contracts/contracts/ in 1.16";
772                err(ErrorKey::Filename).msg(msg).loc(entry).push();
773            } else {
774                let msg = format!("file in unexpected directory `{}`", dirname.display());
775                err(ErrorKey::Filename).msg(msg).loc(entry).push();
776            }
777            warned.push(dirname);
778        }
779    }
780
781    pub fn check_unused_dds(&self, _data: &Everything) {
782        let mut vec = Vec::new();
783        for entry in &self.ordered_files {
784            let pathname = entry.path.to_string_lossy();
785            if entry.path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("dds"))
786                && !entry.path.starts_with("gfx/interface/illustrations/loading_screens")
787                && !self.used.read().unwrap().contains(pathname.as_ref())
788            {
789                vec.push(entry);
790            }
791        }
792        for entry in vec {
793            report(ErrorKey::UnusedFile, Severity::Untidy)
794                .msg("Unused DDS files")
795                .abbreviated(entry)
796                .push();
797        }
798    }
799}
800
801#[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
802fn get_modfile(
803    label: &String,
804    config_path: &Path,
805    block: &Block,
806    paradox_dir: Option<&Path>,
807) -> Option<PathBuf> {
808    let mut path: Option<PathBuf> = None;
809    if let Some(modfile) = block.get_field_value("modfile") {
810        let modfile_path = fix_slashes_for_target_platform(
811            config_path
812                .parent()
813                .unwrap() // SAFETY: known to be for a file in a directory
814                .join(modfile.as_str()),
815        );
816        if modfile_path.exists() {
817            path = Some(modfile_path);
818        } else {
819            eprintln!("Could not find mod {label} at: {}", modfile_path.display());
820        }
821    }
822    if path.is_none() {
823        if let Some(workshop_id) = block.get_field_value("workshop_id") {
824            match paradox_dir {
825                Some(p) => {
826                    path = Some(fix_slashes_for_target_platform(
827                        p.join(format!("mod/ugc_{workshop_id}.mod")),
828                    ));
829                }
830                None => eprintln!("workshop_id defined, but could not find paradox directory"),
831            }
832        }
833    }
834    path
835}
836
837#[cfg(any(feature = "vic3", feature = "eu5"))]
838fn get_mod(
839    label: &String,
840    config_path: &Path,
841    block: &Block,
842    workshop_dir: Option<&Path>,
843) -> Option<PathBuf> {
844    let mut path: Option<PathBuf> = None;
845    if let Some(modfile) = block.get_field_value("mod") {
846        let mod_path = fix_slashes_for_target_platform(
847            config_path
848                .parent()
849                .unwrap() // SAFETY: known to be for a file in a directory
850                .join(modfile.as_str()),
851        );
852        if mod_path.exists() {
853            path = Some(mod_path);
854        } else {
855            eprintln!("Could not find mod {label} at: {}", mod_path.display());
856        }
857    }
858    if path.is_none() {
859        if let Some(workshop_id) = block.get_field_value("workshop_id") {
860            match workshop_dir {
861                Some(w) => {
862                    path = Some(fix_slashes_for_target_platform(w.join(workshop_id.as_str())));
863                }
864                None => eprintln!("workshop_id defined, but could not find workshop"),
865            }
866        }
867    }
868    path
869}