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 ¬_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}