1use getset::Getters;
26use rayon::prelude::*;
27use regex::Regex;
28
29use std::collections::{BTreeSet, HashMap};
30use std::fs;
31use std::path::Path;
32use std::sync::LazyLock;
33
34use rpfm_lib::error::{RLibError, Result};
35use rpfm_lib::files::ContainerPath;
36
37use crate::dependencies::Dependencies;
38
39use self::check::LuaDefinitions;
40
41pub mod check;
42pub mod harness;
43#[cfg(test)] mod tests;
44
45pub const ASSEMBLY_KIT_SCRIPT_DOCS_PATH: &str = "documentation/script/script";
47
48const SCRIPTING_DOC_FILE: &str = "scripting_doc.html";
50
51const BOOLEAN_RETURN_DOC_FIXES: [(&str, &str); 3] = [
53 ("CHARACTER_DETAILS_SCRIPT_INTERFACE", "has_trait"),
54 ("CHARACTER_SCRIPT_INTERFACE", "has_trait"),
55 ("FACTION_SCRIPT_INTERFACE", "was_confederated"),
56];
57
58const VANILLA_SCRIPTS_FOLDERS: [&str; 3] = ["script", "lua_scripts", "campaigns"];
61
62const VANILLA_EVENTS_SCRIPT: &str = "script/events.lua";
64
65static TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]*>").expect("valid regex"));
66static ENTITY_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);").expect("valid regex"));
67static INTERFACE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[A-Z][A-Z0-9_]*_SCRIPT_INTERFACE").expect("valid regex"));
68static IDENTIFIER_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").expect("valid regex"));
69static SIGNATURE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"(?s)<h3 class="function_name">(.*?)</h3>"#).expect("valid regex"));
70static PARAMETER_TABLE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"(?s)<table class="parameter_list">(.*?)</table>"#).expect("valid regex"));
71static TABLE_ROW_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<tr>(.*?)</tr>").expect("valid regex"));
72static TABLE_CELL_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<td>(.*?)</td>").expect("valid regex"));
73static DB_TABLE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<code>([a-z0-9_]+)</code>\s*(?:database\s+)?table\b").expect("valid regex"));
74static RETURNS_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<h4>Returns:</h4>\s*<ol>(.*?)</ol>").expect("valid regex"));
75static LIST_ITEM_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<li>(.*?)</li>").expect("valid regex"));
76static CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<code>(.*?)</code>").expect("valid regex"));
77static EVENT_ACCESSOR_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)Function Name: ([A-Za-z0-9_]+)</dd>\s*<dd>Interface: (.*?)</dd>(?:\s*<dd>Description: (.*?)</dd>)?").expect("valid regex"));
78static DESCRIPTION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"(?s)</dt>\s*<dd>(.*?)(?:<h4>|<p class="file_comment">|</dd>)"#).expect("valid regex"));
79static OPTIONAL_DEFAULT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<i>\s*optional, default value=(.*?)</i>").expect("valid regex"));
80static LINE_BREAK_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<br\s*/?>").expect("valid regex"));
81static INTERFACE_DESCRIPTION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<dd>Description: (.*?)</dd>").expect("valid regex"));
82static INTERFACE_PARAMETERS_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<dd>Parameters: (.*?)</dd>").expect("valid regex"));
83static INTERFACE_FUNCTION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"(?s)<dd>Function: <a name="[^"]*">([A-Za-z0-9_]+)</a></dd>(.*?)(?:<br>|$)"#).expect("valid regex"));
84static INTERFACE_RETURN_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<dd>Return: (.*?)</dd>").expect("valid regex"));
85static EVENT_TABLE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^([A-Za-z0-9_]+)\s*=\s*\{\s*\}").expect("valid regex"));
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
93pub enum LuaEnvironment {
94 Campaign,
95 Battle,
96 Frontend,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum LuaCallStyle {
102
103 Global,
105
106 Method,
108
109 Field,
111}
112
113#[derive(Clone, Debug, PartialEq, Eq)]
115pub enum LuaType {
116
117 Any,
119 Nil,
120 Boolean,
121 Number,
122 String,
123 Table,
124 Function,
125
126 Interface(String),
128
129 Object(String),
131}
132
133#[derive(Clone, Debug, PartialEq, Getters)]
135#[getset(get = "pub")]
136pub struct LuaParameter {
137
138 name: String,
140
141 lua_type: LuaType,
143
144 optional: bool,
146
147 variadic: bool,
149
150 db_table: Option<String>,
152
153 description: String,
155}
156
157#[derive(Clone, Debug, PartialEq, Getters)]
159#[getset(get = "pub")]
160pub struct LuaReturn {
161
162 lua_type: LuaType,
164
165 description: String,
167}
168
169#[derive(Clone, Debug, PartialEq, Getters)]
171#[getset(get = "pub")]
172pub struct LuaAccessor {
173
174 lua_type: LuaType,
176
177 description: String,
179}
180
181#[derive(Clone, Debug, PartialEq, Getters)]
183#[getset(get = "pub")]
184pub struct LuaFunction {
185
186 name: String,
188
189 call_style: LuaCallStyle,
191
192 signature: String,
194
195 description: String,
197
198 parameters: Option<Vec<LuaParameter>>,
200
201 returns: Vec<LuaReturn>,
203
204 environments: BTreeSet<LuaEnvironment>,
206}
207
208#[derive(Clone, Debug, Default, PartialEq, Getters)]
210#[getset(get = "pub")]
211pub struct LuaApi {
212
213 owners: HashMap<String, HashMap<String, LuaFunction>>,
216
217 events: HashMap<String, HashMap<String, LuaAccessor>>,
219
220 script_definitions: LuaDefinitions,
222}
223
224#[derive(Clone, Debug, PartialEq, Eq, Hash)]
226pub enum LuaHoverTarget {
227
228 Function(String, String),
230
231 Accessor(String, String),
233
234 Event(String),
236}
237
238impl LuaEnvironment {
243
244 pub fn docs_folder(&self) -> &'static str {
246 match self {
247 Self::Campaign => "campaign",
248 Self::Battle => "battle",
249 Self::Frontend => "frontend",
250 }
251 }
252}
253
254impl LuaType {
255
256 pub fn name(&self) -> &str {
258 match self {
259 Self::Any => "any",
260 Self::Nil => "nil",
261 Self::Boolean => "boolean",
262 Self::Number => "number",
263 Self::String => "string",
264 Self::Table => "table",
265 Self::Function => "function",
266 Self::Interface(name) | Self::Object(name) => name,
267 }
268 }
269
270 pub fn from_doc_name(doc_name: &str) -> Self {
280 let doc_name = doc_name.trim();
281 if let Some(interface) = INTERFACE_REGEX.find(doc_name) {
282 return Self::Interface(interface.as_str().to_owned());
283 }
284
285 let lower = doc_name.to_lowercase();
286 let first_word = lower.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
287 .find(|word| !word.is_empty())
288 .unwrap_or_default();
289
290 match first_word {
291 "" | "nil" | "void" | "none" => Self::Nil,
292 "bool" | "boolean" | "logical" => Self::Boolean,
293 "number" | "integer" | "int" | "int32" | "card16" | "card32" | "float" | "float32" | "positive" | "index" | "distance" | "proportion" => Self::Number,
294 "string" if lower.contains("table") => Self::Table,
295 "string" => Self::String,
296 "table" | "list" | "lists" | "lua" | "ca_std" | "sorted" | "accumulated" => Self::Table,
297 "function" | "iterator" => Self::Function,
298 "object" | "value" | "variable" | "userdata" | "address" | "any" => Self::Any,
299
300 _ if first_word.starts_with(|c: char| c.is_ascii_digit()) => Self::Number,
302 _ if lower.starts_with('(') => Self::Any,
303 _ => Self::Object(first_word.to_owned()),
304 }
305 }
306}
307
308impl LuaApi {
309
310 pub fn from_assembly_kit(docs_path: &Path) -> Result<Self> {
324 if !docs_path.is_dir() {
325 return Err(RLibError::AssemblyKitNotFound);
326 }
327
328 let mut api = Self::default();
329 for environment in [LuaEnvironment::Campaign, LuaEnvironment::Battle, LuaEnvironment::Frontend] {
330 let folder = docs_path.join(environment.docs_folder());
331 if !folder.is_dir() {
332 continue;
333 }
334
335 for entry in fs::read_dir(&folder)? {
336 let path = entry?.path();
337 if path.extension().is_some_and(|extension| extension == "html") {
338 let page = fs::read(&path)?;
339 api.add_page(&String::from_utf8_lossy(&page), environment);
340 }
341 }
342 }
343
344 let scripting_doc_path = docs_path.join(SCRIPTING_DOC_FILE);
345 if scripting_doc_path.is_file() {
346 let scripting_doc = fs::read(&scripting_doc_path)?;
347 api.add_scripting_doc(&String::from_utf8_lossy(&scripting_doc));
348 }
349
350 Ok(api)
351 }
352
353 pub fn add_vanilla_scripts(&mut self, dependencies: &Dependencies) {
362 let scripts = vanilla_scripts(dependencies);
363 for (path, source) in &scripts {
364 if path == VANILLA_EVENTS_SCRIPT {
365 self.add_events_script(source);
366 }
367 }
368
369 self.script_definitions = scripts.par_iter()
370 .filter(|(path, _)| path != VANILLA_EVENTS_SCRIPT)
371 .fold(LuaDefinitions::default, |mut definitions, (_, source)| {
372 definitions.add_script(source);
373 definitions
374 })
375 .reduce(LuaDefinitions::default, |mut definitions, other| {
376 definitions.extend(other);
377 definitions
378 });
379 }
380
381 pub fn hover_html(&self, target: &LuaHoverTarget) -> Option<String> {
391 match target {
392 LuaHoverTarget::Function(owner, name) => {
393 let function = self.function(owner, name)?;
394 let mut html = format!("<p><code>{}</code></p>", html_escape(function.signature()));
395 if !function.description().is_empty() {
396 html.push_str(&format!("<p>{}</p>", html_escape(function.description()).replace('\n', "<br/>")));
397 }
398
399 let parameters = function.parameters().as_deref().unwrap_or_default();
400 if !parameters.is_empty() {
401 html.push_str("<p><b>Parameters:</b></p><ul>");
402 for parameter in parameters {
403 html.push_str(&format!("<li><code>{}</code>: {}</li>", html_escape(parameter.name()), html_escape(parameter.description())));
404 }
405 html.push_str("</ul>");
406 }
407
408 if !function.returns().is_empty() {
409 html.push_str("<p><b>Returns:</b></p><ul>");
410 for returned in function.returns() {
411 html.push_str(&format!("<li><code>{}</code> {}</li>", html_escape(returned.lua_type().name()), html_escape(returned.description())));
412 }
413 html.push_str("</ul>");
414 }
415
416 Some(html)
417 }
418
419 LuaHoverTarget::Accessor(event, name) => {
420 let accessor = self.events.get(event)?.get(name)?;
421 let mut html = format!("<p><code>{}:{}()</code> → <code>{}</code></p>", html_escape(event), html_escape(name), html_escape(accessor.lua_type().name()));
422 if !accessor.description().is_empty() {
423 html.push_str(&format!("<p>{}</p>", html_escape(accessor.description())));
424 }
425
426 Some(html)
427 }
428
429 LuaHoverTarget::Event(event) => {
430 let accessors = self.events.get(event)?;
431 let mut html = format!("<p>Event <code>{}</code></p>", html_escape(event));
432 if !accessors.is_empty() {
433 let mut names = accessors.keys().collect::<Vec<_>>();
434 names.sort();
435
436 html.push_str("<p><b>Context:</b></p><ul>");
437 for name in names {
438 let accessor = &accessors[name];
439 html.push_str(&format!("<li><code>context:{}()</code> → <code>{}</code> {}</li>", html_escape(name), html_escape(accessor.lua_type().name()), html_escape(accessor.description())));
440 }
441 html.push_str("</ul>");
442 }
443
444 Some(html)
445 }
446 }
447 }
448
449 pub fn function(&self, owner: &str, name: &str) -> Option<&LuaFunction> {
460 self.owners.get(owner).and_then(|functions| functions.get(name))
461 }
462
463 pub(crate) fn add_page(&mut self, page: &str, environment: LuaEnvironment) {
472 for block in page.split(r#"<dl class="function">"#).skip(1) {
473 let Some((owner, mut function)) = parse_function_block(block) else {
474 continue;
475 };
476
477 let functions = self.owners.entry(owner).or_default();
478 match functions.get_mut(&function.name) {
479 Some(existing) => { existing.environments.insert(environment); },
480 None => {
481 function.environments.insert(environment);
482 functions.insert(function.name.to_owned(), function);
483 }
484 }
485 }
486
487 }
488
489 pub(crate) fn add_events_script(&mut self, source: &str) {
495 for captures in EVENT_TABLE_REGEX.captures_iter(source) {
496 self.events.entry(captures[1].to_owned()).or_default();
497 }
498 }
499
500 pub(crate) fn add_scripting_doc(&mut self, scripting_doc: &str) {
506 let events_start = scripting_doc.find(">Event Functions<");
507 let interfaces_start = scripting_doc.find(">Interface Functions<");
508
509 if let Some(events_start) = events_start {
510 let events_end = interfaces_start.filter(|end| *end > events_start).unwrap_or(scripting_doc.len());
511 for block in scripting_doc[events_start..events_end].split(r#"<h4><a name=""#).skip(1) {
512 let Some(event_name) = block.split('"').next() else {
513 continue;
514 };
515
516 let accessors = EVENT_ACCESSOR_REGEX.captures_iter(block)
517 .map(|captures| {
518 let accessor = LuaAccessor {
519 lua_type: LuaType::from_doc_name(&html_to_text(&captures[2])),
520 description: captures.get(3).map(|description| html_to_text(description.as_str())).unwrap_or_default(),
521 };
522
523 (captures[1].to_owned(), accessor)
524 })
525 .collect();
526
527 self.events.insert(event_name.to_owned(), accessors);
528 }
529 }
530
531 if let Some(interfaces_start) = interfaces_start {
532 for block in scripting_doc[interfaces_start..].split(r#"<h4><a name=""#).skip(1) {
533 let Some(interface_name) = block.split('"').next() else {
534 continue;
535 };
536
537 let functions = self.owners.entry(interface_name.to_owned()).or_default();
538 for captures in INTERFACE_FUNCTION_REGEX.captures_iter(block) {
539 let name = &captures[1];
540 let details = &captures[2];
541 let returns = INTERFACE_RETURN_REGEX.captures(details)
542 .map(|return_captures| parse_returns([LuaReturn {
543 lua_type: LuaType::from_doc_name(&html_to_text(&return_captures[1])),
544 description: String::new(),
545 }]))
546 .unwrap_or_default();
547
548 let parameters = INTERFACE_PARAMETERS_REGEX.captures(details).map(|parameters| html_to_text(¶meters[1])).unwrap_or_default();
550 let signature = if parameters.starts_with(&format!("{name}(")) {
551 format!("{interface_name}:{parameters}")
552 } else {
553 format!("{interface_name}:{name}({parameters})")
554 };
555
556 let function = LuaFunction {
557 name: name.to_owned(),
558 call_style: LuaCallStyle::Method,
559 signature,
560 description: INTERFACE_DESCRIPTION_REGEX.captures(details).map(|description| html_to_text(&description[1])).unwrap_or_default(),
561 parameters: None,
562 returns,
563 environments: BTreeSet::new(),
564 };
565
566 functions.insert(function.name.to_owned(), function);
567 }
568 }
569 }
570
571 for (owner, name) in BOOLEAN_RETURN_DOC_FIXES {
572 if let Some(function) = self.owners.get_mut(owner).and_then(|functions| functions.get_mut(name)) {
573 function.returns = vec![LuaReturn { lua_type: LuaType::Boolean, description: String::new() }];
574 }
575 }
576 }
577}
578
579pub fn vanilla_scripts(dependencies: &Dependencies) -> Vec<(String, String)> {
589 let folders = VANILLA_SCRIPTS_FOLDERS.map(|folder| ContainerPath::Folder(folder.to_owned()));
590 dependencies.files_by_path(&folders, true, false, false)
591 .into_par_iter()
592 .filter(|(path, _)| path.ends_with(".lua"))
593 .filter_map(|(path, file)| {
594
595 let mut file = file.clone();
597 file.load().ok()?;
598 Some((path, String::from_utf8_lossy(file.cached().ok()?).to_string()))
599 })
600 .collect()
601}
602
603fn parse_function_block(block: &str) -> Option<(String, LuaFunction)> {
617 if !block.contains(r#"name="function:"#) {
618 return None;
619 }
620
621 let signature = tidy_signature(&html_to_text(&SIGNATURE_REGEX.captures(block)?[1]));
622 let (head, arguments) = signature.split_once('(')?;
623 let arguments = arguments.rsplit_once(')').map(|(arguments, _)| arguments).unwrap_or(arguments).to_owned();
624
625 let head = head.trim();
626 let (owner, name, call_style) = match head.rfind([':', '.']) {
627 Some(position) if head[position..].starts_with(':') => (&head[..position], &head[position + 1..], LuaCallStyle::Method),
628 Some(position) => (&head[..position], &head[position + 1..], LuaCallStyle::Field),
629 None => ("", head, LuaCallStyle::Global),
630 };
631
632 if !IDENTIFIER_REGEX.is_match(name) || (!owner.is_empty() && !IDENTIFIER_REGEX.is_match(owner)) {
633 return None;
634 }
635
636 let owner = owner.to_owned();
637 let name = name.to_owned();
638
639 let mut parameters = parse_parameters(&arguments);
640
641 if let Some(table) = PARAMETER_TABLE_REGEX.captures(block) {
643 for row in TABLE_ROW_REGEX.captures_iter(&table[1]) {
644 let cells = TABLE_CELL_REGEX.captures_iter(&row[1]).map(|cell| cell[1].to_owned()).collect::<Vec<_>>();
645 let (Some(index), Some(description)) = (cells.first(), cells.get(2)) else {
646 continue;
647 };
648
649 let Some(parameter) = html_to_text(index).parse::<usize>().ok().and_then(|index| parameters.get_mut(index.checked_sub(1)?)) else {
650 continue;
651 };
652
653 match OPTIONAL_DEFAULT_REGEX.captures(description) {
654 Some(default) => {
655 parameter.optional = true;
656 let rest = html_to_text(&OPTIONAL_DEFAULT_REGEX.replace(description, ""));
657 parameter.description = format!("(optional, default: {}) {rest}", html_to_text(&default[1])).trim_end().to_owned();
658 }
659 None => parameter.description = html_to_text(description),
660 }
661
662 if let Some(table_name) = DB_TABLE_REGEX.captures(description) {
663 let table_name = &table_name[1];
664 parameter.db_table = Some(if table_name.ends_with("_tables") { table_name.to_owned() } else { format!("{table_name}_tables") });
665 }
666 }
667 }
668
669 let returns = RETURNS_REGEX.captures(block)
671 .map(|returns| parse_returns(LIST_ITEM_REGEX.captures_iter(&returns[1])
672 .map(|item| match CODE_REGEX.captures(&item[1]) {
673 Some(code) => LuaReturn {
674 lua_type: LuaType::from_doc_name(&html_to_text(&code[1])),
675 description: html_to_text(&item[1][code.get(0).map_or(0, |code| code.end())..]),
676 },
677 None => LuaReturn { lua_type: LuaType::Any, description: html_to_text(&item[1]) },
678 })))
679 .unwrap_or_default();
680
681 let description = DESCRIPTION_REGEX.captures(block)
682 .map(|description| LINE_BREAK_REGEX.split(&description[1])
683 .map(html_to_text)
684 .filter(|line| !line.is_empty())
685 .collect::<Vec<_>>()
686 .join("\n"))
687 .unwrap_or_default();
688
689 let function = LuaFunction {
690 name,
691 call_style,
692 signature,
693 description,
694 parameters: Some(parameters),
695 returns,
696 environments: BTreeSet::new(),
697 };
698
699 Some((owner, function))
700}
701
702fn parse_parameters(arguments: &str) -> Vec<LuaParameter> {
712 arguments.split(',')
713 .map(str::trim)
714 .filter(|argument| !argument.is_empty())
715 .map(|argument| {
716
717 let optional = argument.contains('[');
719 let argument = argument.trim_matches(|c: char| c == '[' || c == ']' || c.is_whitespace());
720 let (doc_type, name) = argument.split_once(char::is_whitespace).unwrap_or((argument, ""));
721 let variadic = doc_type == "...";
722
723 LuaParameter {
724 name: name.trim().to_owned(),
725 lua_type: if variadic { LuaType::Any } else { LuaType::from_doc_name(doc_type) },
726 optional,
727 variadic,
728 db_table: None,
729 description: String::new(),
730 }
731 })
732 .collect()
733}
734
735fn parse_returns(returns: impl IntoIterator<Item = LuaReturn>) -> Vec<LuaReturn> {
745 returns.into_iter().filter(|returned| returned.lua_type != LuaType::Nil).collect()
746}
747
748fn tidy_signature(signature: &str) -> String {
758 signature.replace(" ,", ",")
759 .replace("( ", "(")
760 .replace(" )", ")")
761 .replace("[ ", "[")
762 .replace(" ]", "]")
763}
764
765fn html_escape(text: &str) -> String {
775 text.replace('&', "&").replace('<', "<").replace('>', ">")
776}
777
778fn html_to_text(html: &str) -> String {
788 let text = TAG_REGEX.replace_all(html, " ");
789 let text = ENTITY_REGEX.replace_all(&text, |captures: ®ex::Captures| {
790 let entity = &captures[1];
791 let decoded = match entity {
792 "amp" => Some('&'),
793 "lt" => Some('<'),
794 "gt" => Some('>'),
795 "quot" => Some('"'),
796 "apos" => Some('\''),
797 "nbsp" | "emsp" | "ensp" | "thinsp" => Some(' '),
798 _ => entity.strip_prefix("#x").map_or_else(
799 || entity.strip_prefix('#').and_then(|code| code.parse::<u32>().ok()),
800 |code| u32::from_str_radix(code, 16).ok()
801 ).and_then(char::from_u32),
802 };
803
804 decoded.map_or_else(|| captures[0].to_owned(), String::from)
805 });
806
807 text.split_whitespace().collect::<Vec<_>>().join(" ")
808}