Skip to main content

rpfm_extensions/diagnostics/
text.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//! Module with the structs and functions specific for `Text` diagnostics.
12
13use getset::{Getters, MutGetters};
14use serde_derive::{Serialize, Deserialize};
15
16use std::collections::{HashMap, HashSet};
17use std::{fmt, fmt::Display};
18
19use rpfm_lib::files::{RFile, RFileDecoded};
20use rpfm_lib::utils::*;
21
22use crate::dependencies::Dependencies;
23use crate::diagnostics::*;
24use crate::lua::LuaApi;
25use crate::lua::check::{check_script, LuaDefinitions, LuaIssueKind, LuaKeyReference};
26
27//-------------------------------------------------------------------------------//
28//                              Enums & Structs
29//-------------------------------------------------------------------------------//
30
31/// This struct contains the results of a Text diagnostic.
32#[derive(Debug, Clone, Default, Getters, MutGetters, Serialize, Deserialize)]
33#[getset(get = "pub", get_mut = "pub")]
34pub struct TextDiagnostic {
35    path: String,
36    pack: String,
37    results: Vec<TextDiagnosticReport>
38}
39
40/// This struct defines an individual Text diagnostic result.
41#[derive(Debug, Clone, Getters, MutGetters, Serialize, Deserialize)]
42#[getset(get = "pub", get_mut = "pub")]
43pub struct TextDiagnosticReport {
44    report_type: TextDiagnosticReportType,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub enum TextDiagnosticReportType {
49    InvalidKey((u64, u64), (u64, u64), String, String, String),
50
51    /// Lua code that can't be parsed. Contains the parser's message.
52    LuaSyntaxError((u64, u64), (u64, u64), String),
53
54    /// Method not documented for the type it's called on. Contains the type and the method.
55    UnknownMethod((u64, u64), (u64, u64), String, String),
56
57    /// Documented function called with the wrong amount of arguments. Contains the function,
58    /// the minimum and maximum (if any) amount of arguments it takes, and the amount passed.
59    WrongArgumentCount((u64, u64), (u64, u64), String, usize, Option<usize>, usize),
60
61    /// Listener for an event that doesn't exist. Contains the event.
62    UnknownEvent((u64, u64), (u64, u64), String),
63}
64
65//-------------------------------------------------------------------------------//
66//                             Implementations
67//-------------------------------------------------------------------------------//
68
69impl TextDiagnosticReport {
70    pub fn new(report_type: TextDiagnosticReportType) -> Self {
71        Self {
72            report_type
73        }
74    }
75}
76
77impl DiagnosticReport for TextDiagnosticReport {
78    fn message(&self) -> String {
79        match &self.report_type {
80            TextDiagnosticReportType::InvalidKey(_,_, table, column, key) => "Invalid Key: \"".to_string() + key + "\" is not in table \"" + table + "\", column \"" + column + "\".",
81            TextDiagnosticReportType::LuaSyntaxError(_,_, message) => format!("Syntax Error: {message}"),
82            TextDiagnosticReportType::UnknownMethod(_,_, owner, method) => format!("Unknown Method: \"{method}\" is not documented for \"{owner}\"."),
83            TextDiagnosticReportType::WrongArgumentCount(_,_, function, minimum, maximum, found) => {
84                let expected = match maximum {
85                    Some(maximum) if maximum == minimum => minimum.to_string(),
86                    Some(maximum) => format!("{minimum} to {maximum}"),
87                    None => format!("at least {minimum}"),
88                };
89
90                format!("Wrong Argument Count: \"{function}\" takes {expected} arguments, but {found} were passed.")
91            }
92            TextDiagnosticReportType::UnknownEvent(_,_, event) => format!("Unknown Event: \"{event}\" is not triggered by the game nor by any open script."),
93        }
94    }
95
96    fn level(&self) -> DiagnosticLevel {
97        match self.report_type {
98            TextDiagnosticReportType::InvalidKey(_,_,_,_,_) => DiagnosticLevel::Error,
99            TextDiagnosticReportType::LuaSyntaxError(_,_,_) => DiagnosticLevel::Error,
100            TextDiagnosticReportType::UnknownMethod(_,_,_,_) => DiagnosticLevel::Error,
101            TextDiagnosticReportType::WrongArgumentCount(_,_,_,_,_,_) => DiagnosticLevel::Warning,
102            TextDiagnosticReportType::UnknownEvent(_,_,_) => DiagnosticLevel::Warning,
103        }
104    }
105}
106
107impl TextDiagnosticReportType {
108
109    /// This function returns the range of text the report applies to.
110    ///
111    /// # Returns
112    ///
113    /// The range, as `((start line, start column), (end line, end column))`. All values are 0-based.
114    pub fn range(&self) -> ((u64, u64), (u64, u64)) {
115        match self {
116            Self::InvalidKey(start, end,_,_,_) |
117            Self::LuaSyntaxError(start, end,_) |
118            Self::UnknownMethod(start, end,_,_) |
119            Self::WrongArgumentCount(start, end,_,_,_,_) |
120            Self::UnknownEvent(start, end,_) => (*start, *end),
121        }
122    }
123}
124
125impl Display for TextDiagnosticReportType {
126    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
127        Display::fmt(match self {
128            Self::InvalidKey(_,_,_,_,_) => "InvalidKey",
129            Self::LuaSyntaxError(_,_,_) => "LuaSyntaxError",
130            Self::UnknownMethod(_,_,_,_) => "UnknownMethod",
131            Self::WrongArgumentCount(_,_,_,_,_,_) => "WrongArgumentCount",
132            Self::UnknownEvent(_,_,_) => "UnknownEvent",
133        }, f)
134    }
135}
136
137impl TextDiagnostic {
138    pub fn new(path: &str, pack: &str) -> Self {
139        Self {
140            path: path.to_owned(),
141            pack: pack.to_owned(),
142            results: vec![],
143        }
144    }
145
146    /// This function takes care of checking for Text-Related for errors.
147    #[allow(clippy::too_many_arguments)]
148    pub fn check(
149        pack_key: &str,
150        file: &RFile,
151        packs: &BTreeMap<String, Pack>,
152        dependencies: &Dependencies,
153        global_ignored_diagnostics: &[String],
154        ignored_fields: &[String],
155        ignored_diagnostics: &HashSet<String>,
156        ignored_diagnostics_for_fields: &HashMap<String, Vec<String>>,
157        lua_api: Option<&LuaApi>,
158        lua_definitions: &LuaDefinitions,
159    ) -> Option<DiagnosticType> {
160
161        if let Ok(RFileDecoded::Text(text)) = file.decoded() {
162            let mut diagnostic = Self::new(file.path_in_container_raw(), pack_key);
163
164            let text = text.contents();
165            let mut start_pos = 0;
166
167            // We're only interested in tables marked with "--@db".
168            while let Some(pos) = text[start_pos..].find("--@db ") {
169                if let Some(end_line) = text[start_pos + pos..].find('\n') {
170
171                    // We only support single-line comments.
172                    let table_data = text[start_pos + pos + 6..start_pos + pos + end_line].split(' ').collect::<Vec<_>>();
173
174                    // We expect table name and column.
175                    if table_data.len() >= 2 {
176                        let table_name = if table_data[0].ends_with("_tables") { table_data[0].to_owned() } else { table_data[0].to_owned() + "_tables" };
177                        let table_column = if table_data[1].ends_with("\r") {
178                            &table_data[1][..table_data[1].len() - 1]
179                        } else {
180                            table_data[1]
181                        };
182
183                        let index_to_check = if let Some(indexes) = table_data.get(2) {
184                            indexes.split(",")
185                                .filter_map(|x| x.parse::<usize>().ok())
186                                .collect()
187                        } else {
188                            vec![]
189                        };
190
191                        // We need to make sure we only check the next line for the start, or we may end up checking the wrong vars.
192                        let (next_line_start, next_line_end) = match text[start_pos + pos + 6..].find('\n') {
193                            Some(nls) => if text.as_bytes().get(start_pos + pos + 6 + nls + 1).is_some() {
194                                match text[start_pos + pos + 6 + nls + 1..].find('\n') {
195                                    Some(nle) => (start_pos + pos + 6 + nls + 1, start_pos + pos + 6 + nls + 1 + nle),
196                                    None => break,
197                                }
198                            } else {
199                                break;
200                            }
201
202                            None => break,
203                        };
204
205                        // Formats supported:
206                        // - Single line, single value:
207                        //      hb = "key"
208                        //
209                        // - Single line, single table:
210                        //      hb = { "a", "b" }
211                        //
212                        // - Single line, keyed table:
213                        //      hb = { "a" = "b", "c" = "d" }
214                        //
215                        // - Multiple lines, single table:
216                        //      hb = {
217                        //          "a",
218                        //          "b"
219                        //      }
220                        //
221                        // - Multiple lines, keyed table (support for key and value:
222                        //      hb = {
223                        //          "a" = "b"
224                        //          "c" = "d"
225                        //      }
226
227                        // Data to search are strings in commas between {}.
228                        let (keys, data_start, data_end) = {
229                            let mut vals = (vec![], 0, 0);
230
231                            if let Some(data_start) = text[next_line_start..next_line_end].find('{') {
232                                if let Some(data_end) = text[next_line_start + data_start..].find('}') {
233
234                                    // +1 to not include the { at the start.
235                                    let data_to_search = &text[next_line_start + data_start + 1..next_line_start + data_start + data_end];
236
237                                    // Multi-line table.
238                                    if data_to_search.contains('\n') || data_to_search.contains('\r') {
239
240                                        // Keyed table.
241                                        if data_to_search.contains('=') {
242                                            let data_split = data_to_search.split('\n')
243                                                .filter_map(|x| {
244                                                    let spl = x.split('=')
245                                                        .map(|y| y.split('\"').collect::<Vec<_>>());
246
247                                                    let mut keys = vec![];
248                                                    for (i, data) in spl.enumerate() {
249                                                        if index_to_check.contains(&i) && data.len() == 3 {
250                                                            keys.push(data[1].to_owned());
251                                                        }
252                                                    }
253
254                                                    if !keys.is_empty() {
255                                                        Some(keys)
256                                                    } else {
257                                                        None
258                                                    }
259                                                })
260                                                .flatten()
261                                                .collect::<Vec<_>>();
262
263                                            vals = (data_split, data_start, data_end)
264                                        }
265
266                                        // Non-keyed/single value table.
267                                        else {
268                                            let data_split = data_to_search.split('\n')
269                                                .filter_map(|x| {
270
271                                                    // On each line, we want the data between commas.
272                                                    let spl = x.split('\"').collect::<Vec<_>>();
273                                                    if spl.len() == 3 {
274                                                        Some(spl[1].to_owned())
275                                                    } else {
276                                                        None
277                                                    }
278                                                })
279                                                .collect::<Vec<_>>();
280
281                                            vals = (data_split, data_start, data_end)
282                                        }
283                                    }
284
285                                    // Single line keyed table.
286                                    else if data_to_search.contains('=') {
287                                        let data_split = data_to_search.split(',')
288                                            .filter_map(|x| {
289                                                let spl = x.split('=')
290                                                    .map(|y| y.split('\"').collect::<Vec<_>>());
291
292                                                let mut keys = vec![];
293                                                for (i, data) in spl.enumerate() {
294                                                    if index_to_check.contains(&i) && data.len() == 3 {
295                                                        keys.push(data[1].to_owned());
296                                                    }
297                                                }
298
299                                                if !keys.is_empty() {
300                                                    Some(keys)
301                                                } else {
302                                                    None
303                                                }
304                                            })
305                                            .flatten()
306                                            .collect::<Vec<_>>();
307
308                                        vals = (data_split, data_start, data_end)
309                                    }
310
311                                    // Single line non-keyed table.
312                                    else {
313                                        let data_split = data_to_search.split(',')
314                                            .filter_map(|x| {
315
316                                                // On each line, we want the data between commas.
317                                                let spl = x.split('\"').collect::<Vec<_>>();
318                                                if spl.len() == 3 {
319                                                    Some(spl[1].to_owned())
320                                                } else {
321                                                    None
322                                                }
323                                            })
324                                            .collect::<Vec<_>>();
325
326                                        vals = (data_split, data_start, data_end)
327                                    }
328                                }
329                            }
330
331                            // No { means it's single line, single value.
332                            else if let Some(data_start) = text[next_line_start..next_line_end].find('\"') {
333                                // +1 to skip the starting comma.
334                                if text.as_bytes().get(next_line_start + data_start + 1).is_some() {
335                                    if let Some(data_end) = text[next_line_start + data_start + 1..].find('\"') {
336                                       if text.as_bytes().get(next_line_start + data_start + 1 + data_end).is_some() {
337                                            let data_to_search = &text[next_line_start + data_start + 1..next_line_start + data_start + 1 + data_end];
338                                            vals = (vec![data_to_search.to_string()], data_start, data_end)
339                                        }
340                                    }
341                                }
342                            }
343
344                            vals
345                        };
346
347                        let mut not_found = HashMap::new();
348
349                        // Add the files from the dependencies, then the files from the pack, then reverse the list so we process first the pack ones.
350                        if let Ok(mut tables) = dependencies.db_data(&table_name, true, true) {
351                            for pack in packs.values() {
352                                tables.append(&mut pack.files_by_path(&ContainerPath::Folder("db/".to_string() + &table_name + "/"), true));
353                            }
354                            tables.reverse();
355
356                            // If there are no tables that match out name, ignore it.
357                            if tables.is_empty() {
358                                start_pos = next_line_start + data_start + data_end;
359                                continue;
360                            }
361
362                            for key in &keys {
363                                let key_to_check = key.trim();
364
365                                // Calculate the row, column_start and column_end of the data.
366                                let start_cursor = line_column_from_string_pos(text, (next_line_start + data_start + 1) as u64);
367                                let end_cursor = line_column_from_string_pos(text, (next_line_start + data_start + 1 + data_end) as u64);
368
369                                let mut found = false;
370                                for table in &tables {
371                                    if let Ok(RFileDecoded::DB(table)) = table.decoded() {
372                                        let definition = table.definition();
373                                        if let Some(column) = definition.column_position_by_name(table_column) {
374                                            for row in table.data().iter() {
375                                                if row[column].data_to_string() == *key_to_check {
376                                                    found = true;
377                                                    break;
378                                                }
379                                            }
380
381                                            if found {
382                                                break;
383                                            }
384                                        }
385                                    }
386                                }
387
388                                if !found {
389                                    not_found.insert(key_to_check, (start_cursor, end_cursor));
390                                }
391                            }
392
393                            for (key, (start, end)) in &not_found {
394                                if !Diagnostics::ignore_diagnostic(global_ignored_diagnostics, None, Some("InvalidKey"), ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields)  {
395                                    let result = TextDiagnosticReport::new(TextDiagnosticReportType::InvalidKey(*start, *end, table_name.to_string(), table_column.to_string(), key.to_string()));
396                                    diagnostic.results_mut().push(result);
397                                }
398                            }
399                        }
400
401                        start_pos = next_line_start + data_start + data_end;
402                    }
403                }
404            }
405
406            let is_ignored = |report_type: &TextDiagnosticReportType| Diagnostics::ignore_diagnostic(global_ignored_diagnostics, None, Some(&report_type.to_string()), ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields);
407            let result = check_script(text, lua_api, lua_definitions);
408            for issue in result.issues() {
409                let (start, end) = *issue.range();
410                let report_type = match issue.kind().clone() {
411                    LuaIssueKind::SyntaxError(message) => TextDiagnosticReportType::LuaSyntaxError(start, end, message),
412                    LuaIssueKind::UnknownMethod(owner, method) => TextDiagnosticReportType::UnknownMethod(start, end, owner, method),
413                    LuaIssueKind::WrongArgumentCount(function, minimum, maximum, found) => TextDiagnosticReportType::WrongArgumentCount(start, end, function, minimum, maximum, found),
414                    LuaIssueKind::UnknownEvent(event) => TextDiagnosticReportType::UnknownEvent(start, end, event),
415                };
416
417                if !is_ignored(&report_type) {
418                    diagnostic.results_mut().push(TextDiagnosticReport::new(report_type));
419                }
420            }
421
422            for report_type in Self::invalid_keys(result.key_references(), packs, dependencies) {
423                if !is_ignored(&report_type) {
424                    diagnostic.results_mut().push(TextDiagnosticReport::new(report_type));
425                }
426            }
427
428            if !diagnostic.results().is_empty() {
429                Some(DiagnosticType::Text(diagnostic))
430            } else { None }
431        } else { None }
432    }
433
434    /// This function returns the reports for the keys used by a script that are not in their DB tables.
435    ///
436    /// Keys are checked against the first key column of their table. Tables not found anywhere are skipped.
437    ///
438    /// # Arguments
439    ///
440    /// * `key_references` - Keys used by the script.
441    /// * `packs` - Open packs, whose tables are included in the check.
442    /// * `dependencies` - Dependencies cache, whose tables are included in the check.
443    ///
444    /// # Returns
445    ///
446    /// One [`TextDiagnosticReportType::InvalidKey`] per missing key.
447    fn invalid_keys(key_references: &[LuaKeyReference], packs: &BTreeMap<String, Pack>, dependencies: &Dependencies) -> Vec<TextDiagnosticReportType> {
448        let mut keys_by_table: HashMap<&str, Option<(String, HashSet<String>)>> = HashMap::new();
449        let mut reports = vec![];
450
451        for reference in key_references {
452            let table_keys = keys_by_table.entry(reference.table())
453                .or_insert_with(|| dependencies.db_key_values(Some(packs), reference.table()));
454
455            if let Some((column, keys)) = table_keys {
456                if !keys.contains(reference.key()) {
457                    let (start, end) = *reference.range();
458                    reports.push(TextDiagnosticReportType::InvalidKey(start, end, reference.table().to_owned(), column.to_owned(), reference.key().to_owned()));
459                }
460            }
461        }
462
463        reports
464    }
465}