Skip to main content

rpfm_server/state/
diagnostics.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//! Diagnostics and Lua script operations.
12
13use anyhow::{anyhow, Result};
14
15use rpfm_extensions::diagnostics::{DiagnosticLevel as CheckLevel, DiagnosticReport, Diagnostics, DiagnosticType};
16use rpfm_extensions::lua::check::{check_script, LuaDefinitions};
17use rpfm_extensions::lua::harness::{run_tests, LuaScripts, LuaTestOptions, LuaTestReport};
18
19use rpfm_ipc::api::{ApiError, Done};
20use rpfm_ipc::api::diagnostics::{DEFAULT_DIAGNOSTICS_LIMIT, DiagnosticLevel, DiagnosticList, DiagnosticResult, DiagnosticsSummary, IgnoreDiagnostics, ListDiagnostics, RunDiagnostics};
21
22use rpfm_lib::files::{Container, ContainerPath};
23
24use rpfm_telemetry::info;
25
26use rpfm_ipc::settings::Settings;
27
28use super::{SessionState, cached_lua_api};
29
30/// Results of a diagnostics check.
31pub(super) struct DiagnosticsResults {
32
33    /// The results as the check returns them, to check only some paths again later.
34    diagnostics: Diagnostics,
35
36    /// The results flattened, one per problem, for listing.
37    results: Vec<DiagnosticResult>,
38}
39
40/// A hover of a Lua script: its start line and column, end line and column (all 0-based), and its docs as rich text.
41pub type LuaHover = (u64, u64, u64, u64, String);
42
43impl SessionState {
44
45    /// Checks the open packs for problems.
46    ///
47    /// Does nothing if there is no schema.
48    ///
49    /// # Arguments
50    ///
51    /// * `diagnostics` - Results of a previous check, or a new one with the diagnostics to ignore.
52    /// * `paths_to_check` - Paths to recheck, keeping the previous results for the rest. If empty, everything is checked.
53    /// * `check_ak_only_refs` - If references to tables only in the Assembly Kit are checked.
54    /// * `settings` - Settings, to find the game's install folder and Assembly Kit.
55    /// * `on_progress` - Called with the steps done and their total as the check advances. If it returns `false`, the check stops early.
56    ///
57    /// # Returns
58    ///
59    /// The updated diagnostics, or `None` if the check was stopped.
60    pub fn check_diagnostics(&mut self, mut diagnostics: Diagnostics, paths_to_check: &[ContainerPath], check_ak_only_refs: bool, settings: &Settings, on_progress: &(dyn Fn(usize, usize) -> bool + Sync)) -> Option<Diagnostics> {
61        if let Some(ref schema) = self.schema {
62            let game_path = settings.path_buf(self.game.key());
63            let lua_api = cached_lua_api(&mut self.lua_api_cache, &self.game, settings, &self.dependencies);
64            if !diagnostics.check(&mut self.packs, &mut self.dependencies, schema, &self.game, &game_path, paths_to_check, check_ak_only_refs, lua_api, on_progress) {
65                info!("Checking diagnostics: stopped.");
66                return None;
67            }
68        }
69
70        info!("Checking diagnostics: done.");
71        Some(diagnostics)
72    }
73
74    /// Checks the open packs for problems, keeping the results for [`Self::list_diagnostics`].
75    ///
76    /// # Arguments
77    ///
78    /// * `request` - What to check.
79    /// * `settings` - Settings, to find the game's install folder and Assembly Kit.
80    /// * `on_progress` - Called with the steps done and their total as the check advances. If it returns `false`, the check stops early.
81    ///
82    /// # Returns
83    ///
84    /// A summary of the results, or `None` if the check was stopped, keeping the results of the last check.
85    pub fn run_diagnostics(&mut self, request: &RunDiagnostics, settings: &Settings, on_progress: &(dyn Fn(usize, usize) -> bool + Sync)) -> Option<DiagnosticsSummary> {
86        let paths = request.paths.iter()
87            .map(|path| if self.packs.values().any(|pack| pack.has_file(path)) {
88                ContainerPath::File(path.to_owned())
89            } else {
90                ContainerPath::Folder(path.trim_end_matches('/').to_owned())
91            })
92            .collect::<Vec<_>>();
93
94        // Partial checks work on a copy of the last results, so a stopped check leaves them untouched.
95        let mut diagnostics = match self.diagnostics {
96            Some(ref previous) if !paths.is_empty() => previous.diagnostics.clone(),
97            _ => Diagnostics::default(),
98        };
99
100        diagnostics.diagnostics_ignored_mut().clone_from(&request.ignored_types);
101        let diagnostics = self.check_diagnostics(diagnostics, &paths, request.check_assembly_kit_only_references, settings, on_progress)?;
102        let results = flatten_diagnostics(&diagnostics);
103        let summary = summarize(&results);
104
105        self.diagnostics = Some(DiagnosticsResults { diagnostics, results });
106        Some(summary)
107    }
108
109    /// Returns a page of the results of the last diagnostics check matching the request.
110    ///
111    /// # Errors
112    ///
113    /// Fails if there are no results yet.
114    pub fn list_diagnostics(&self, request: &ListDiagnostics) -> Result<DiagnosticList> {
115        let results = &self.diagnostics.as_ref().ok_or(ApiError::DiagnosticsNotRun)?.results;
116        let matching = results.iter()
117            .filter(|result| request.levels.as_ref().is_none_or(|levels| levels.contains(&result.level)))
118            .filter(|result| request.report_types.as_ref().is_none_or(|types| types.contains(&result.report_type)))
119            .filter(|result| request.pack.as_ref().is_none_or(|pack| *pack == result.pack))
120            .filter(|result| result.path.starts_with(&request.path_prefix));
121
122        let mut page = vec![];
123        let mut total = 0;
124        let limit = request.limit.unwrap_or(DEFAULT_DIAGNOSTICS_LIMIT);
125        for result in matching {
126            if total >= request.offset && page.len() < limit {
127                page.push(result.clone());
128            }
129
130            total += 1;
131        }
132
133        Ok(DiagnosticList { results: page, total })
134    }
135
136    /// Returns the results of the last diagnostics check, as the check returns them.
137    ///
138    /// # Errors
139    ///
140    /// Fails if there are no results yet.
141    pub fn diagnostics_report(&self) -> Result<Diagnostics> {
142        Ok(self.diagnostics.as_ref().ok_or(ApiError::DiagnosticsNotRun)?.diagnostics.clone())
143    }
144
145    /// Makes the next diagnostics checks of a pack skip some results, saving the rule in the pack's settings.
146    ///
147    /// # Errors
148    ///
149    /// Fails if the pack isn't open, or if any value has characters the rule format uses as separators.
150    pub fn ignore_diagnostics(&mut self, request: &IgnoreDiagnostics) -> Result<Done> {
151        let values = std::iter::once(&request.path).chain(&request.columns).chain(&request.report_types);
152        if let Some(value) = values.clone().find(|value| value.contains([';', '\n'])) {
153            return Err(ApiError::InvalidParams(format!("Values can't contain ';' or line breaks: {value}")).into());
154        }
155
156        if let Some(value) = request.columns.iter().chain(&request.report_types).find(|value| value.contains(',')) {
157            return Err(ApiError::InvalidParams(format!("Columns and report types can't contain ',': {value}")).into());
158        }
159
160        let line = format!("\n{};{};{}", request.path, request.columns.join(","), request.report_types.join(","));
161        self.add_pack_ignored_diagnostics_line(&request.pack, line)?;
162        Ok(Done {})
163    }
164
165    /// Returns the hovers of a Lua script, with the docs of the game's Lua API. Empty if the API is not available.
166    pub fn lua_hovers(&mut self, source: &str, settings: &Settings) -> Vec<LuaHover> {
167        let Some(lua_api) = cached_lua_api(&mut self.lua_api_cache, &self.game, settings, &self.dependencies) else { return vec![] };
168
169        check_script(source, Some(lua_api), &LuaDefinitions::default()).hovers().iter()
170            .filter_map(|hover| {
171                let ((start_line, start_column), (end_line, end_column)) = *hover.range();
172                lua_api.hover_html(hover.target()).map(|html| (start_line, start_column, end_line, end_column, html))
173            })
174            .collect()
175    }
176
177    /// Runs Lua tests against the scripts of the game and the open packs, outside of the game.
178    ///
179    /// # Arguments
180    ///
181    /// * `test_source` - Code of the Lua test file.
182    /// * `campaign` - Campaign whose vanilla scripts to load. If `None`, only the script libraries and the mods are loaded.
183    /// * `settings` - Settings, to find the game's Assembly Kit.
184    ///
185    /// # Returns
186    ///
187    /// The results of the tests.
188    ///
189    /// # Errors
190    ///
191    /// Fails if the game's Lua API is not available, or if the tests can't be loaded.
192    pub fn lua_run_tests(&mut self, test_source: &str, campaign: Option<String>, settings: &Settings) -> Result<LuaTestReport> {
193        let lua_api = cached_lua_api(&mut self.lua_api_cache, &self.game, settings, &self.dependencies)
194            .ok_or_else(|| anyhow!("The Lua API of the game is not available. Lua tests need the game's Assembly Kit to be installed."))?;
195
196        let scripts = LuaScripts::from_game_and_packs(&self.dependencies, self.packs.values());
197        let key_values = |table_name| self.dependencies.db_key_values(Some(&self.packs), table_name)
198            .map(|(_, keys)| keys.into_iter().collect::<Vec<_>>())
199            .unwrap_or_default();
200
201        let mut options = LuaTestOptions::default();
202        options.set_campaign(campaign);
203        options.set_faction_keys(key_values("factions_tables"));
204        options.set_region_keys(key_values("regions_tables"));
205
206        Ok(run_tests(lua_api, &scripts, test_source, &options)?)
207    }
208}
209
210/// Flattens the results of a check into one result per problem.
211fn flatten_diagnostics(diagnostics: &Diagnostics) -> Vec<DiagnosticResult> {
212    diagnostics.results().iter()
213        .flat_map(|diagnostic| match diagnostic {
214            DiagnosticType::DB(diagnostic) => results_of(diagnostic.pack(), diagnostic.path(), "db", diagnostic.results(), |report| report.report_type().to_string(), |report| (cells(report.cells_affected()), report.column_names().clone())),
215            DiagnosticType::Loc(diagnostic) => results_of(diagnostic.pack(), diagnostic.path(), "loc", diagnostic.results(), |report| report.report_type().to_string(), |report| (cells(report.cells_affected()), report.column_names().clone())),
216            DiagnosticType::AnimFragmentBattle(diagnostic) => results_of(diagnostic.pack(), diagnostic.path(), "anim_fragment_battle", diagnostic.results(), |report| report.report_type().to_string(), no_cells),
217            DiagnosticType::Config(diagnostic) => results_of("", "", "config", diagnostic.results(), |report| report.report_type().to_string(), no_cells),
218            DiagnosticType::Dependency(diagnostic) => results_of(diagnostic.pack(), diagnostic.path(), "dependency", diagnostic.results(), |report| report.report_type().to_string(), |report| (cells(report.cells_affected()), vec![])),
219            DiagnosticType::GroupFormations(diagnostic) => results_of(diagnostic.pack(), diagnostic.path(), "group_formations", diagnostic.results(), |report| report.report_type().to_string(), no_cells),
220            DiagnosticType::Pack(diagnostic) => results_of(diagnostic.pack(), "", "pack", diagnostic.results(), |report| report.report_type().to_string(), no_cells),
221            DiagnosticType::PortraitSettings(diagnostic) => results_of(diagnostic.pack(), diagnostic.path(), "portrait_settings", diagnostic.results(), |report| report.report_type().to_string(), no_cells),
222            DiagnosticType::Text(diagnostic) => results_of(diagnostic.pack(), diagnostic.path(), "text", diagnostic.results(), |report| report.report_type().to_string(), no_cells),
223        })
224        .collect()
225}
226
227/// Builds the results of the reports of a checked file.
228///
229/// # Arguments
230///
231/// * `pack` - Key of the pack of the file.
232/// * `path` - Path of the file.
233/// * `checked` - What was checked, like `db` or `pack`.
234/// * `reports` - Problems found.
235/// * `report_type` - Returns the type of a problem.
236/// * `cells` - Returns the table cells and columns of a problem.
237fn results_of<R: DiagnosticReport>(pack: &str, path: &str, checked: &str, reports: &[R], report_type: impl Fn(&R) -> String, cells: impl Fn(&R) -> (Vec<[i32; 2]>, Vec<String>)) -> Vec<DiagnosticResult> {
238    reports.iter()
239        .map(|report| {
240            let (cells, columns) = cells(report);
241            DiagnosticResult {
242                pack: pack.to_owned(),
243                path: path.to_owned(),
244                checked: checked.to_owned(),
245                report_type: report_type(report),
246                level: match report.level() {
247                    CheckLevel::Info => DiagnosticLevel::Info,
248                    CheckLevel::Warning => DiagnosticLevel::Warning,
249                    CheckLevel::Error => DiagnosticLevel::Error,
250                },
251                message: report.message(),
252                cells,
253                columns,
254            }
255        })
256        .collect()
257}
258
259/// Returns no cells nor columns, for reports that aren't about table cells.
260fn no_cells<R>(_: &R) -> (Vec<[i32; 2]>, Vec<String>) {
261    (vec![], vec![])
262}
263
264/// Converts `(row, column)` cells to `[row, column]` arrays.
265fn cells(cells: &[(i32, i32)]) -> Vec<[i32; 2]> {
266    cells.iter().map(|(row, column)| [*row, *column]).collect()
267}
268
269/// Counts results by level and by type.
270fn summarize(results: &[DiagnosticResult]) -> DiagnosticsSummary {
271    let mut summary = DiagnosticsSummary { total: results.len(), ..DiagnosticsSummary::default() };
272    for result in results {
273        *summary.by_level.entry(result.level).or_default() += 1;
274        *summary.by_type.entry(result.report_type.clone()).or_default() += 1;
275    }
276
277    summary
278}
279
280//-------------------------------------------------------------------------------//
281//                                   Tests
282//-------------------------------------------------------------------------------//
283
284#[cfg(test)]
285#[path = "diagnostics_test.rs"]
286mod diagnostics_test;