Skip to main content

rpfm_server/state/
search.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Global search and replace operations.
12
13use anyhow::Result;
14use serde::Serialize;
15use serde_json::{Map, Value};
16
17use std::collections::{BTreeMap, HashSet};
18
19use rpfm_extensions::search::{GlobalSearch, Matches, MatchHolder, SearchSource};
20use rpfm_extensions::search::anim_fragment_battle::AnimFragmentBattleMatches;
21use rpfm_extensions::search::atlas::AtlasMatches;
22use rpfm_extensions::search::portrait_settings::PortraitSettingsMatches;
23use rpfm_extensions::search::rigid_model::RigidModelMatches;
24use rpfm_extensions::search::schema::SchemaMatches;
25use rpfm_extensions::search::table::TableMatches;
26use rpfm_extensions::search::text::TextMatches;
27use rpfm_extensions::search::unit_variant::UnitVariantMatches;
28use rpfm_extensions::search::unknown::UnknownMatches;
29
30use rpfm_ipc::api::ApiError;
31use rpfm_ipc::api::files::FileSource;
32use rpfm_ipc::api::search::{
33    DEFAULT_MATCHES_LIMIT, DEFAULT_SEARCH_FILE_TYPES, search_on_from_file_types, ListSearchMatches, ReplaceSearchMatches, RunSearch, SearchMatch, SearchMatchList,
34    SearchReplaced, SearchReport, SearchSummary,
35};
36use rpfm_ipc::helpers::RFileInfo;
37
38use rpfm_lib::files::{Container, ContainerPath};
39
40use super::{SessionState, loaded_schema};
41
42/// Common access to the matches of a file, whatever its type.
43trait FileMatches {
44
45    /// Returns the path of the file.
46    fn file_path(&self) -> &str;
47
48    /// Returns where the file is, or `None` for matches outside of files.
49    fn file_source(&self) -> Option<FileSource>;
50
51    /// Returns the amount of matches in the file.
52    fn match_count(&self) -> usize;
53
54    /// Returns a match, serialized.
55    fn match_details(&self, index: usize) -> Value;
56
57    /// Keeps only the matches at the provided indexes.
58    fn retain_matches(&mut self, indexes: &HashSet<usize>);
59}
60
61/// Implements [`FileMatches`] for match types with the same `path`, `source` and `matches` fields.
62///
63/// A macro, because the types share their shape but no trait.
64macro_rules! impl_file_matches {
65    ($($matches_type:ty),*) => {
66        $(
67            impl FileMatches for $matches_type {
68                fn file_path(&self) -> &str {
69                    self.path()
70                }
71
72                fn file_source(&self) -> Option<FileSource> {
73                    Some(file_source(self.source()))
74                }
75
76                fn match_count(&self) -> usize {
77                    self.matches().len()
78                }
79
80                fn match_details(&self, index: usize) -> Value {
81                    to_value(&self.matches()[index])
82                }
83
84                fn retain_matches(&mut self, indexes: &HashSet<usize>) {
85                    retain_indexes(self.matches_mut(), indexes);
86                }
87            }
88        )*
89    };
90}
91
92impl_file_matches!(AnimFragmentBattleMatches, AtlasMatches, PortraitSettingsMatches, RigidModelMatches, TableMatches, UnitVariantMatches, UnknownMatches);
93
94/// Text matches keep their text apart from their position, so the text is added to the details.
95impl FileMatches for TextMatches {
96    fn file_path(&self) -> &str {
97        self.path()
98    }
99
100    fn file_source(&self) -> Option<FileSource> {
101        Some(file_source(self.source()))
102    }
103
104    fn match_count(&self) -> usize {
105        self.matches().len()
106    }
107
108    fn match_details(&self, index: usize) -> Value {
109        let text_match = &self.matches()[index];
110        let mut details = to_value(text_match);
111        if let (Value::Object(details), Some(text)) = (&mut details, self.matches_strings().get(*text_match.text_index())) {
112            details.insert("text".to_owned(), Value::String(text.clone()));
113        }
114
115        details
116    }
117
118    fn retain_matches(&mut self, indexes: &HashSet<usize>) {
119        retain_indexes(self.matches_mut(), indexes);
120    }
121}
122
123/// Schema matches are about column names, not files, so they have no path nor source.
124impl FileMatches for SchemaMatches {
125    fn file_path(&self) -> &str {
126        ""
127    }
128
129    fn file_source(&self) -> Option<FileSource> {
130        None
131    }
132
133    fn match_count(&self) -> usize {
134        self.matches().len()
135    }
136
137    fn match_details(&self, index: usize) -> Value {
138        to_value(&self.matches()[index])
139    }
140
141    fn retain_matches(&mut self, indexes: &HashSet<usize>) {
142        retain_indexes(self.matches_mut(), indexes);
143    }
144}
145
146/// Position of a match in [`Matches`]: the type of its file, the index of the file in its type, and the index of the match in its file.
147#[derive(Debug, Clone, Copy)]
148struct MatchRef {
149    file_type: &'static str,
150    file_index: usize,
151    match_index: usize,
152}
153
154impl SessionState {
155
156    /// Replaces matches of a global search.
157    ///
158    /// # Arguments
159    ///
160    /// * `search` - The search the matches come from.
161    /// * `matches` - Matches to replace.
162    ///
163    /// # Returns
164    ///
165    /// The search, and the info of the edited files.
166    fn global_search_replace(&mut self, mut search: GlobalSearch, matches: &[MatchHolder]) -> Result<(GlobalSearch, Vec<RFileInfo>)> {
167        let schema = loaded_schema(&self.schema)?;
168        let edited_paths = search.replace(&self.game, schema, &mut self.packs, &mut self.dependencies, matches)?;
169        Ok((search, self.files_info_in_all_packs(&edited_paths)))
170    }
171
172    /// Searches text, keeping the matches for [`Self::list_search_matches`].
173    ///
174    /// # Returns
175    ///
176    /// A summary of the matches.
177    pub fn run_search(&mut self, request: &RunSearch) -> Result<SearchSummary> {
178        let mut search = GlobalSearch::default();
179        search.set_pattern(request.pattern.clone());
180        search.set_case_sensitive(request.case_sensitive);
181        search.set_use_regex(request.use_regex);
182        search.set_sources(request.sources.iter().map(search_source).collect());
183        search.set_game_key(self.game.key().to_owned());
184
185        let file_types = match request.file_types {
186            Some(ref file_types) => file_types.iter().map(String::as_str).collect::<Vec<_>>(),
187            None => DEFAULT_SEARCH_FILE_TYPES.to_vec(),
188        };
189        search.set_search_on(search_on_from_file_types(&file_types).map_err(ApiError::InvalidParams)?);
190
191        let schema = loaded_schema(&self.schema)?;
192        search.search(&self.game, schema, &mut self.packs, &mut self.dependencies, &[]);
193
194        let summary = summarize(search.matches());
195        self.search = Some(search);
196        Ok(summary)
197    }
198
199    /// Returns a page of the matches of the last search matching the request.
200    ///
201    /// # Errors
202    ///
203    /// Fails if there hasn't been a search yet.
204    pub fn list_search_matches(&self, request: &ListSearchMatches) -> Result<SearchMatchList> {
205        let matches = self.search.as_ref().ok_or(ApiError::SearchNotRun)?.matches();
206        let files = files_by_type(matches);
207
208        let mut page = vec![];
209        let mut total = 0;
210        let limit = request.limit.unwrap_or(DEFAULT_MATCHES_LIMIT);
211        for (id, match_ref) in match_refs(&files).into_iter().enumerate() {
212            let file = files[match_ref.file_type][match_ref.file_index];
213            let type_matches = request.file_types.as_ref().is_none_or(|file_types| file_types.iter().any(|file_type| file_type == match_ref.file_type));
214            if !type_matches || !file.file_path().starts_with(&request.path_prefix) {
215                continue;
216            }
217
218            if total >= request.offset && page.len() < limit {
219                page.push(SearchMatch {
220                    id,
221                    source: file.file_source(),
222                    path: file.file_path().to_owned(),
223                    file_type: match_ref.file_type.to_owned(),
224                    details: file.match_details(match_ref.match_index),
225                });
226            }
227
228            total += 1;
229        }
230
231        Ok(SearchMatchList { matches: page, total })
232    }
233
234    /// Replaces matches of the last search, and searches the edited files again.
235    ///
236    /// # Errors
237    ///
238    /// Fails if there hasn't been a search yet, if an ID doesn't exist, or if replacing fails.
239    pub fn replace_search_matches(&mut self, request: &ReplaceSearchMatches) -> Result<SearchReplaced> {
240        let mut search = self.search.take().ok_or(ApiError::SearchNotRun)?;
241        search.set_replace_text(request.replace_text.clone());
242
243        // The search replaces matches in any open pack with the path of their file, so only matches from open packs are passed to it.
244        let all_ids;
245        let ids = match request.matches {
246            Some(ref ids) => ids,
247            None => {
248                all_ids = (0..match_refs(&files_by_type(search.matches())).len()).collect::<Vec<_>>();
249                &all_ids
250            }
251        };
252
253        let result = match selected_match_holders(search.matches(), ids) {
254            Ok(holders) => self.global_search_replace(search.clone(), &holders),
255            Err(error) => Err(error.into()),
256        };
257
258        // Keep the previous search if replacing failed, so its matches can still be listed.
259        let (mut replaced, files_info) = match result {
260            Ok(result) => result,
261            Err(error) => {
262                self.search = Some(search);
263                return Err(error);
264            }
265        };
266
267        let edited = files_info.iter().map(|info| info.path().to_owned()).collect::<Vec<_>>();
268        let edited_paths = edited.iter().map(|path| ContainerPath::File(path.clone())).collect::<Vec<_>>();
269        let schema = loaded_schema(&self.schema)?;
270        replaced.search(&self.game, schema, &mut self.packs, &mut self.dependencies, &edited_paths);
271
272        let summary = summarize(replaced.matches());
273        self.search = Some(replaced);
274        Ok(SearchReplaced { edited, summary })
275    }
276
277    /// Returns the last search with all its matches, and the ID of the first match of each file.
278    ///
279    /// # Errors
280    ///
281    /// Fails if there is no search yet.
282    pub fn search_report(&self) -> Result<SearchReport> {
283        let search = self.search.as_ref().ok_or(ApiError::SearchNotRun)?;
284        Ok(SearchReport { search: search.clone(), first_ids: first_ids(&files_by_type(search.matches())) })
285    }
286
287    /// Returns the info of the files at the provided paths, from every open pack.
288    fn files_info_in_all_packs(&self, paths: &[ContainerPath]) -> Vec<RFileInfo> {
289        paths.iter()
290            .flat_map(|path| self.packs.values().flat_map(move |pack| pack.files_by_path(path, false)))
291            .map(RFileInfo::from)
292            .collect()
293    }
294}
295
296/// Returns the matches of each file, by type of file, in a fixed order.
297fn files_by_type(matches: &Matches) -> BTreeMap<&'static str, Vec<&dyn FileMatches>> {
298    fn as_dyn<T: FileMatches>(files: &[T]) -> Vec<&dyn FileMatches> {
299        files.iter().map(|file| file as &dyn FileMatches).collect()
300    }
301
302    BTreeMap::from([
303        ("Anim", as_dyn(matches.anim())),
304        ("AnimFragmentBattle", as_dyn(matches.anim_fragment_battle())),
305        ("AnimPack", as_dyn(matches.anim_pack())),
306        ("AnimsTable", as_dyn(matches.anims_table())),
307        ("Atlas", as_dyn(matches.atlas())),
308        ("Audio", as_dyn(matches.audio())),
309        ("BMD", as_dyn(matches.bmd())),
310        ("DB", as_dyn(matches.db())),
311        ("ESF", as_dyn(matches.esf())),
312        ("GroupFormations", as_dyn(matches.group_formations())),
313        ("Image", as_dyn(matches.image())),
314        ("Loc", as_dyn(matches.loc())),
315        ("MatchedCombat", as_dyn(matches.matched_combat())),
316        ("Pack", as_dyn(matches.pack())),
317        ("PortraitSettings", as_dyn(matches.portrait_settings())),
318        ("RigidModel", as_dyn(matches.rigid_model())),
319        ("Schema", vec![matches.schema() as &dyn FileMatches]),
320        ("SoundBank", as_dyn(matches.sound_bank())),
321        ("Text", as_dyn(matches.text())),
322        ("UIC", as_dyn(matches.uic())),
323        ("UnitVariant", as_dyn(matches.unit_variant())),
324        ("Unknown", as_dyn(matches.unknown())),
325        ("Video", as_dyn(matches.video())),
326    ])
327}
328
329/// Returns the position of every match, in the order their IDs follow.
330fn match_refs(files: &BTreeMap<&'static str, Vec<&dyn FileMatches>>) -> Vec<MatchRef> {
331    files.iter()
332        .flat_map(|(file_type, files)| files.iter().enumerate()
333            .flat_map(move |(file_index, file)| (0..file.match_count())
334                .map(move |match_index| MatchRef { file_type, file_index, match_index })))
335        .collect()
336}
337
338/// Returns the ID of the first match of each file, by type of file, numbering the matches like [`match_refs`].
339fn first_ids(files: &BTreeMap<&'static str, Vec<&dyn FileMatches>>) -> BTreeMap<String, Vec<usize>> {
340    let mut next_id = 0;
341    files.iter()
342        .map(|(file_type, files)| {
343            let ids = files.iter()
344                .map(|file| {
345                    let id = next_id;
346                    next_id += file.match_count();
347                    id
348                })
349                .collect();
350
351            ((*file_type).to_owned(), ids)
352        })
353        .collect()
354}
355
356/// Returns the matches with the provided IDs, grouped by file as the search expects them to replace them.
357///
358/// # Errors
359///
360/// Fails if any ID doesn't exist.
361fn selected_match_holders(matches: &Matches, ids: &[usize]) -> Result<Vec<MatchHolder>, ApiError> {
362    let refs = match_refs(&files_by_type(matches));
363    let mut selected: BTreeMap<(&str, usize), HashSet<usize>> = BTreeMap::new();
364    for id in ids {
365        let match_ref = refs.get(*id).ok_or_else(|| ApiError::InvalidParams(format!("There is no match with ID {id}.")))?;
366        selected.entry((match_ref.file_type, match_ref.file_index)).or_default().insert(match_ref.match_index);
367    }
368
369    let mut holders = vec![];
370    holders.extend(selected_files(matches.anim_fragment_battle(), "AnimFragmentBattle", &selected).map(MatchHolder::AnimFragmentBattle));
371    holders.extend(selected_files(matches.atlas(), "Atlas", &selected).map(MatchHolder::Atlas));
372    holders.extend(selected_files(matches.db(), "DB", &selected).map(MatchHolder::Db));
373    holders.extend(selected_files(matches.loc(), "Loc", &selected).map(MatchHolder::Loc));
374    holders.extend(selected_files(matches.portrait_settings(), "PortraitSettings", &selected).map(MatchHolder::PortraitSettings));
375    holders.extend(selected_files(matches.rigid_model(), "RigidModel", &selected).map(MatchHolder::RigidModel));
376    holders.extend(selected_files(matches.text(), "Text", &selected).map(MatchHolder::Text));
377    holders.extend(selected_files(matches.unit_variant(), "UnitVariant", &selected).map(MatchHolder::UnitVariant));
378    Ok(holders)
379}
380
381/// Returns copies of the files of a type in open packs with selected matches, keeping only those matches.
382fn selected_files<'a, T: FileMatches + Clone>(files: &'a [T], file_type: &'a str, selected: &'a BTreeMap<(&str, usize), HashSet<usize>>) -> impl Iterator<Item = T> + 'a {
383    files.iter().enumerate().filter_map(move |(file_index, file)| {
384        if !matches!(file.file_source(), Some(FileSource::Pack(_))) {
385            return None;
386        }
387
388        let indexes = selected.get(&(file_type, file_index))?;
389        let mut file = file.clone();
390        file.retain_matches(indexes);
391        Some(file)
392    })
393}
394
395/// Counts the matches of a search, by type of file.
396fn summarize(matches: &Matches) -> SearchSummary {
397    let mut summary = SearchSummary::default();
398    for (file_type, files) in files_by_type(matches) {
399        let count = files.iter().map(|file| file.match_count()).sum::<usize>();
400        if count > 0 {
401            summary.total += count;
402            summary.files += files.iter().filter(|file| file.match_count() > 0 && !file.file_path().is_empty()).count();
403            summary.by_type.insert(file_type.to_owned(), count);
404        }
405    }
406
407    summary
408}
409
410/// Returns the search source of a file source.
411fn search_source(source: &FileSource) -> SearchSource {
412    match source {
413        FileSource::Pack(pack_key) => SearchSource::Pack(pack_key.clone()),
414        FileSource::GameFiles => SearchSource::GameFiles,
415        FileSource::ParentFiles => SearchSource::ParentFiles,
416        FileSource::AssemblyKit => SearchSource::AssKitFiles,
417    }
418}
419
420/// Returns the file source of a search source.
421fn file_source(source: &SearchSource) -> FileSource {
422    match source {
423        SearchSource::Pack(pack_key) => FileSource::Pack(pack_key.clone()),
424        SearchSource::GameFiles => FileSource::GameFiles,
425        SearchSource::ParentFiles => FileSource::ParentFiles,
426        SearchSource::AssKitFiles => FileSource::AssemblyKit,
427    }
428}
429
430/// Keeps only the items at the provided indexes.
431fn retain_indexes<T>(items: &mut Vec<T>, indexes: &HashSet<usize>) {
432    let mut index = 0;
433    items.retain(|_| {
434        index += 1;
435        indexes.contains(&(index - 1))
436    });
437}
438
439/// Serializes a match, or returns an empty object if it can't be serialized.
440fn to_value(value: &impl Serialize) -> Value {
441    serde_json::to_value(value).unwrap_or_else(|_| Value::Object(Map::new()))
442}
443
444//-------------------------------------------------------------------------------//
445//                                   Tests
446//-------------------------------------------------------------------------------//
447
448#[cfg(test)]
449#[path = "search_test.rs"]
450mod search_test;