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::*;
24
25//-------------------------------------------------------------------------------//
26//                              Enums & Structs
27//-------------------------------------------------------------------------------//
28
29/// This struct contains the results of a Text diagnostic.
30#[derive(Debug, Clone, Default, Getters, MutGetters, Serialize, Deserialize)]
31#[getset(get = "pub", get_mut = "pub")]
32pub struct TextDiagnostic {
33    path: String,
34    pack: String,
35    results: Vec<TextDiagnosticReport>
36}
37
38/// This struct defines an individual Text diagnostic result.
39#[derive(Debug, Clone, Getters, MutGetters, Serialize, Deserialize)]
40#[getset(get = "pub", get_mut = "pub")]
41pub struct TextDiagnosticReport {
42    report_type: TextDiagnosticReportType,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub enum TextDiagnosticReportType {
47    InvalidKey((u64, u64), (u64, u64), String, String, String),
48}
49
50//-------------------------------------------------------------------------------//
51//                             Implementations
52//-------------------------------------------------------------------------------//
53
54impl TextDiagnosticReport {
55    pub fn new(report_type: TextDiagnosticReportType) -> Self {
56        Self {
57            report_type
58        }
59    }
60}
61
62impl DiagnosticReport for TextDiagnosticReport {
63    fn message(&self) -> String {
64        match &self.report_type {
65            TextDiagnosticReportType::InvalidKey(_,_, table, column, key) => "Invalid Key: \"".to_string() + key + "\" is not in table \"" + table + "\", column \"" + column + "\".",
66        }
67    }
68
69    fn level(&self) -> DiagnosticLevel {
70        match self.report_type {
71            TextDiagnosticReportType::InvalidKey(_,_,_,_,_) => DiagnosticLevel::Error,
72        }
73    }
74}
75
76impl Display for TextDiagnosticReportType {
77    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
78        Display::fmt(match self {
79            Self::InvalidKey(_,_,_,_,_) => "InvalidKey",
80        }, f)
81    }
82}
83
84impl TextDiagnostic {
85    pub fn new(path: &str, pack: &str) -> Self {
86        Self {
87            path: path.to_owned(),
88            pack: pack.to_owned(),
89            results: vec![],
90        }
91    }
92
93    /// This function takes care of checking for Text-Related for errors.
94    #[allow(clippy::too_many_arguments)]
95    pub fn check(
96        pack_key: &str,
97        file: &RFile,
98        packs: &BTreeMap<String, Pack>,
99        dependencies: &Dependencies,
100        global_ignored_diagnostics: &[String],
101        ignored_fields: &[String],
102        ignored_diagnostics: &HashSet<String>,
103        ignored_diagnostics_for_fields: &HashMap<String, Vec<String>>,
104    ) -> Option<DiagnosticType> {
105
106        if let Ok(RFileDecoded::Text(text)) = file.decoded() {
107            let mut diagnostic = Self::new(file.path_in_container_raw(), pack_key);
108
109            let text = text.contents();
110            let mut start_pos = 0;
111
112            // We're only interested in tables marked with "--@db".
113            while let Some(pos) = text[start_pos..].find("--@db ") {
114                if let Some(end_line) = text[start_pos + pos..].find('\n') {
115
116                    // We only support single-line comments.
117                    let table_data = text[start_pos + pos + 6..start_pos + pos + end_line].split(' ').collect::<Vec<_>>();
118
119                    // We expect table name and column.
120                    if table_data.len() >= 2 {
121                        let table_name = if table_data[0].ends_with("_tables") { table_data[0].to_owned() } else { table_data[0].to_owned() + "_tables" };
122                        let table_column = if table_data[1].ends_with("\r") {
123                            &table_data[1][..table_data[1].len() - 1]
124                        } else {
125                            table_data[1]
126                        };
127
128                        let index_to_check = if let Some(indexes) = table_data.get(2) {
129                            indexes.split(",")
130                                .filter_map(|x| x.parse::<usize>().ok())
131                                .collect()
132                        } else {
133                            vec![]
134                        };
135
136                        // We need to make sure we only check the next line for the start, or we may end up checking the wrong vars.
137                        let (next_line_start, next_line_end) = match text[start_pos + pos + 6..].find('\n') {
138                            Some(nls) => if text.as_bytes().get(start_pos + pos + 6 + nls + 1).is_some() {
139                                match text[start_pos + pos + 6 + nls + 1..].find('\n') {
140                                    Some(nle) => (start_pos + pos + 6 + nls + 1, start_pos + pos + 6 + nls + 1 + nle),
141                                    None => break,
142                                }
143                            } else {
144                                break;
145                            }
146
147                            None => break,
148                        };
149
150                        // Formats supported:
151                        // - Single line, single value:
152                        //      hb = "key"
153                        //
154                        // - Single line, single table:
155                        //      hb = { "a", "b" }
156                        //
157                        // - Single line, keyed table:
158                        //      hb = { "a" = "b", "c" = "d" }
159                        //
160                        // - Multiple lines, single table:
161                        //      hb = {
162                        //          "a",
163                        //          "b"
164                        //      }
165                        //
166                        // - Multiple lines, keyed table (support for key and value:
167                        //      hb = {
168                        //          "a" = "b"
169                        //          "c" = "d"
170                        //      }
171
172                        // Data to search are strings in commas between {}.
173                        let (keys, data_start, data_end) = {
174                            let mut vals = (vec![], 0, 0);
175
176                            if let Some(data_start) = text[next_line_start..next_line_end].find('{') {
177                                if let Some(data_end) = text[next_line_start + data_start..].find('}') {
178
179                                    // +1 to not include the { at the start.
180                                    let data_to_search = &text[next_line_start + data_start + 1..next_line_start + data_start + data_end];
181
182                                    // Multi-line table.
183                                    if data_to_search.contains('\n') || data_to_search.contains('\r') {
184
185                                        // Keyed table.
186                                        if data_to_search.contains('=') {
187                                            let data_split = data_to_search.split('\n')
188                                                .filter_map(|x| {
189                                                    let spl = x.split('=')
190                                                        .map(|y| y.split('\"').collect::<Vec<_>>());
191
192                                                    let mut keys = vec![];
193                                                    for (i, data) in spl.enumerate() {
194                                                        if index_to_check.contains(&i) && data.len() == 3 {
195                                                            keys.push(data[1].to_owned());
196                                                        }
197                                                    }
198
199                                                    if !keys.is_empty() {
200                                                        Some(keys)
201                                                    } else {
202                                                        None
203                                                    }
204                                                })
205                                                .flatten()
206                                                .collect::<Vec<_>>();
207
208                                            vals = (data_split, data_start, data_end)
209                                        }
210
211                                        // Non-keyed/single value table.
212                                        else {
213                                            let data_split = data_to_search.split('\n')
214                                                .filter_map(|x| {
215
216                                                    // On each line, we want the data between commas.
217                                                    let spl = x.split('\"').collect::<Vec<_>>();
218                                                    if spl.len() == 3 {
219                                                        Some(spl[1].to_owned())
220                                                    } else {
221                                                        None
222                                                    }
223                                                })
224                                                .collect::<Vec<_>>();
225
226                                            vals = (data_split, data_start, data_end)
227                                        }
228                                    }
229
230                                    // Single line keyed table.
231                                    else if data_to_search.contains('=') {
232                                        let data_split = data_to_search.split(',')
233                                            .filter_map(|x| {
234                                                let spl = x.split('=')
235                                                    .map(|y| y.split('\"').collect::<Vec<_>>());
236
237                                                let mut keys = vec![];
238                                                for (i, data) in spl.enumerate() {
239                                                    if index_to_check.contains(&i) && data.len() == 3 {
240                                                        keys.push(data[1].to_owned());
241                                                    }
242                                                }
243
244                                                if !keys.is_empty() {
245                                                    Some(keys)
246                                                } else {
247                                                    None
248                                                }
249                                            })
250                                            .flatten()
251                                            .collect::<Vec<_>>();
252
253                                        vals = (data_split, data_start, data_end)
254                                    }
255
256                                    // Single line non-keyed table.
257                                    else {
258                                        let data_split = data_to_search.split(',')
259                                            .filter_map(|x| {
260
261                                                // On each line, we want the data between commas.
262                                                let spl = x.split('\"').collect::<Vec<_>>();
263                                                if spl.len() == 3 {
264                                                    Some(spl[1].to_owned())
265                                                } else {
266                                                    None
267                                                }
268                                            })
269                                            .collect::<Vec<_>>();
270
271                                        vals = (data_split, data_start, data_end)
272                                    }
273                                }
274                            }
275
276                            // No { means it's single line, single value.
277                            else if let Some(data_start) = text[next_line_start..next_line_end].find('\"') {
278                                // +1 to skip the starting comma.
279                                if text.as_bytes().get(next_line_start + data_start + 1).is_some() {
280                                    if let Some(data_end) = text[next_line_start + data_start + 1..].find('\"') {
281                                       if text.as_bytes().get(next_line_start + data_start + 1 + data_end).is_some() {
282                                            let data_to_search = &text[next_line_start + data_start + 1..next_line_start + data_start + 1 + data_end];
283                                            vals = (vec![data_to_search.to_string()], data_start, data_end)
284                                        }
285                                    }
286                                }
287                            }
288
289                            vals
290                        };
291
292                        let mut not_found = HashMap::new();
293
294                        // Add the files from the dependencies, then the files from the pack, then reverse the list so we process first the pack ones.
295                        if let Ok(mut tables) = dependencies.db_data(&table_name, true, true) {
296                            for pack in packs.values() {
297                                tables.append(&mut pack.files_by_path(&ContainerPath::Folder("db/".to_string() + &table_name + "/"), true));
298                            }
299                            tables.reverse();
300
301                            // If there are no tables that match out name, ignore it.
302                            if tables.is_empty() {
303                                start_pos = next_line_start + data_start + data_end;
304                                continue;
305                            }
306
307                            for key in &keys {
308                                let key_to_check = key.trim();
309
310                                // Calculate the row, column_start and column_end of the data.
311                                let start_cursor = line_column_from_string_pos(text, (next_line_start + data_start + 1) as u64);
312                                let end_cursor = line_column_from_string_pos(text, (next_line_start + data_start + 1 + data_end) as u64);
313
314                                let mut found = false;
315                                for table in &tables {
316                                    if let Ok(RFileDecoded::DB(table)) = table.decoded() {
317                                        let definition = table.definition();
318                                        if let Some(column) = definition.column_position_by_name(table_column) {
319                                            for row in table.data().iter() {
320                                                if row[column].data_to_string() == *key_to_check {
321                                                    found = true;
322                                                    break;
323                                                }
324                                            }
325
326                                            if found {
327                                                break;
328                                            }
329                                        }
330                                    }
331                                }
332
333                                if !found {
334                                    not_found.insert(key_to_check, (start_cursor, end_cursor));
335                                }
336                            }
337
338                            for (key, (start, end)) in &not_found {
339                                if !Diagnostics::ignore_diagnostic(global_ignored_diagnostics, None, Some("InvalidKey"), ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields)  {
340                                    let result = TextDiagnosticReport::new(TextDiagnosticReportType::InvalidKey(*start, *end, table_name.to_string(), table_column.to_string(), key.to_string()));
341                                    diagnostic.results_mut().push(result);
342                                }
343                            }
344                        }
345
346                        start_pos = next_line_start + data_start + data_end;
347                    }
348                }
349            }
350
351            if !diagnostic.results().is_empty() {
352                Some(DiagnosticType::Text(diagnostic))
353            } else { None }
354        } else { None }
355    }
356}