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