1use std::fmt;
35use std::path::Path;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Argument {
40 pub tag: String,
42 pub by_reference: bool,
44 pub array: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Declaration {
51 pub name: String,
52 pub tag: String,
54 pub arguments: Vec<Argument>,
55 pub variadic: bool,
57 pub implemented_by: Option<String>,
62 pub shape_known: bool,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Divergence {
71 MissingFromInclude { native: String },
74 NotRegistered { native: String },
77 Arity {
79 native: String,
80 include: usize,
81 plugin: usize,
82 },
83 ReturnTag {
85 native: String,
86 include: String,
87 plugin: String,
88 },
89 CallbackNotForwarded { callback: String },
92 Template { problem: String },
95 ArgumentShape {
97 native: String,
98 position: usize,
99 include: Argument,
100 plugin: Argument,
101 },
102}
103
104impl fmt::Display for Divergence {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 fn describe(argument: &Argument) -> String {
107 let mut text = String::new();
108 if argument.by_reference {
109 text.push('&');
110 }
111 if !argument.tag.is_empty() {
112 text.push_str(&argument.tag);
113 text.push(':');
114 }
115 text.push_str("arg");
116 if argument.array {
117 text.push_str("[]");
118 }
119 text
120 }
121
122 match self {
123 Self::MissingFromInclude { native } => write!(
124 f,
125 "{native} is registered but missing from the include; a script calling it will not compile"
126 ),
127 Self::NotRegistered { native } => write!(
128 f,
129 "{native} is declared in the include but not registered; a script calling it fails at runtime"
130 ),
131 Self::Arity {
132 native,
133 include,
134 plugin,
135 } => write!(
136 f,
137 "{native} takes {plugin} argument(s), the include declares {include}"
138 ),
139 Self::ReturnTag {
140 native,
141 include,
142 plugin,
143 } => {
144 let shown = |tag: &str| {
145 if tag.is_empty() {
146 "untagged".into()
147 } else {
148 format!("{tag}:")
149 }
150 };
151 write!(
152 f,
153 "{native} returns {}, the include declares {}",
154 shown(plugin),
155 shown(include)
156 )
157 }
158 Self::CallbackNotForwarded { callback } => write!(
159 f,
160 "{callback} is called by the plugin but the include does not forward it; \
161 a script implementing it as `public` would never be called"
162 ),
163 Self::Template { problem } => write!(f, "{problem}"),
164 Self::ArgumentShape {
165 native,
166 position,
167 include,
168 plugin,
169 } => write!(
170 f,
171 "{native} argument {position} is {}, the include declares {}",
172 describe(plugin),
173 describe(include)
174 ),
175 }
176 }
177}
178
179fn without_comments(source: &str) -> String {
182 let mut out = String::with_capacity(source.len());
183 let mut chars = source.chars().peekable();
184 let mut in_block = false;
185
186 while let Some(c) = chars.next() {
187 if in_block {
188 if c == '*' && chars.peek() == Some(&'/') {
189 chars.next();
190 in_block = false;
191 }
192 continue;
193 }
194 match (c, chars.peek()) {
195 ('/', Some('/')) => {
196 for c in chars.by_ref() {
197 if c == '\n' {
198 out.push('\n');
199 break;
200 }
201 }
202 }
203 ('/', Some('*')) => {
204 chars.next();
205 in_block = true;
206 }
207 _ => out.push(c),
208 }
209 }
210 out
211}
212
213fn split_arguments(list: &str) -> Vec<String> {
216 let mut parts = Vec::new();
217 let mut depth = 0i32;
218 let mut current = String::new();
219
220 for c in list.chars() {
221 match c {
222 '{' | '(' | '[' => {
223 depth += 1;
224 current.push(c);
225 }
226 '}' | ')' | ']' => {
227 depth -= 1;
228 current.push(c);
229 }
230 ',' if depth == 0 => parts.push(std::mem::take(&mut current)),
231 _ => current.push(c),
232 }
233 }
234 if !current.trim().is_empty() {
235 parts.push(current);
236 }
237 parts
238}
239
240fn parse_argument(text: &str) -> Option<Argument> {
242 let text = text.split('=').next().unwrap_or(text).trim();
244 let text = text.trim_start_matches("const").trim();
245 if text.is_empty() || text.contains("...") {
247 return None;
248 }
249
250 let by_reference = text.starts_with('&');
251 let text = text.trim_start_matches('&').trim();
252
253 let (tag, rest) = match text.split_once(':') {
254 Some((tag, rest)) if !tag.starts_with('{') => (tag.trim().to_lowercase(), rest),
256 _ => (String::new(), text),
257 };
258
259 Some(Argument {
260 tag,
261 by_reference,
262 array: rest.contains('['),
263 })
264}
265
266#[must_use]
271pub fn parse(source: &str) -> Vec<Declaration> {
272 parse_declarations(source, "native ")
273}
274
275fn parse_declarations(source: &str, keyword: &str) -> Vec<Declaration> {
277 let cleaned = without_comments(source);
278 let mut declarations = Vec::new();
279 let mut rest = cleaned.as_str();
280
281 while let Some(start) = rest.find(keyword) {
282 rest = &rest[start + keyword.len()..];
283 let Some(open) = rest.find('(') else { break };
284 let Some(close) = rest.find(')') else { break };
285 if close < open {
286 continue;
287 }
288
289 let head = rest[..open].trim();
290 let (tag, name) = match head.rsplit_once(':') {
291 Some((tag, name)) => (tag.trim().to_lowercase(), name.trim()),
292 None => (String::new(), head),
293 };
294 if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
295 rest = &rest[close..];
296 continue;
297 }
298
299 let tail = &rest[close + 1..];
302 let tail = &tail[..tail.find(';').unwrap_or(0)];
303 let tail_len = tail.len();
304 let implemented_by = tail
305 .split_once('=')
306 .map(|(_, target)| target.trim().to_string())
307 .filter(|target| {
308 !target.is_empty() && target.chars().all(|c| c.is_alphanumeric() || c == '_')
309 });
310
311 let list = &rest[open + 1..close];
312 let variadic = list.contains("...");
313 let arguments = split_arguments(list)
314 .iter()
315 .filter_map(|argument| parse_argument(argument))
316 .collect();
317
318 declarations.push(Declaration {
319 name: name.to_string(),
320 tag,
321 arguments,
322 variadic,
323 implemented_by,
324 shape_known: true,
325 });
326 rest = &rest[close + 1 + tail_len..];
327 }
328 declarations
329}
330
331#[must_use]
337pub fn parse_forwards(source: &str) -> Vec<Declaration> {
338 parse_declarations(source, "forward ")
339}
340
341#[must_use]
347pub fn compare(include_source: &str) -> Vec<Divergence> {
348 compare_with(include_source, &crate::plugin::native_decls())
349}
350
351#[must_use]
357pub fn render(plugin_name: &str, decls: &[&str]) -> String {
358 crate::plugin::render_include(plugin_name, decls)
359}
360
361#[must_use]
369pub fn registered(decls: &[&str]) -> Vec<Declaration> {
370 decls
371 .iter()
372 .filter_map(|decl| {
373 let (source, shape_known) = match decl.trim_start().strip_prefix("//") {
374 Some(rest) => (rest.trim_start(), false),
375 None => (*decl, true),
376 };
377 let mut parsed = parse(source).into_iter().next()?;
378 parsed.shape_known = shape_known;
379 Some(parsed)
380 })
381 .collect()
382}
383
384#[must_use]
389pub fn compare_with(include_source: &str, decls: &[&str]) -> Vec<Divergence> {
390 let rendered = if include_source.contains("{{") {
394 match Template::new(include_source, decls).render() {
395 Ok(rendered) => rendered,
396 Err(errors) => {
397 return errors
398 .into_iter()
399 .map(|e| Divergence::Template {
400 problem: e.to_string(),
401 })
402 .collect();
403 }
404 }
405 } else {
406 include_source.to_string()
407 };
408
409 let mut findings = compare_declarations(&parse(&rendered), ®istered(decls));
410 findings.extend(missing_forwards(
411 &rendered,
412 &crate::runtime::Runtime::try_get()
413 .map(|rt| rt.callback_decls().to_vec())
414 .unwrap_or_default(),
415 ));
416 findings
417}
418
419#[must_use]
425pub fn missing_forwards(include_source: &str, callbacks: &[&str]) -> Vec<Divergence> {
426 let forwarded = parse_forwards(include_source);
427 let mut missing: Vec<String> = callbacks
428 .iter()
429 .filter_map(|declared| {
430 let name = declared
431 .trim()
432 .split('(')
433 .next()
434 .unwrap_or("")
435 .trim()
436 .to_string();
437 (!name.is_empty() && !forwarded.iter().any(|f| f.name == name)).then_some(name)
438 })
439 .collect();
440 missing.sort();
441
442 missing
443 .into_iter()
444 .map(|callback| Divergence::CallbackNotForwarded { callback })
445 .collect()
446}
447
448pub fn compare_file_with(
453 path: impl AsRef<Path>,
454 decls: &[&str],
455) -> std::io::Result<Vec<Divergence>> {
456 Ok(compare_with(&std::fs::read_to_string(path)?, decls))
457}
458
459pub fn compare_file(path: impl AsRef<Path>) -> std::io::Result<Vec<Divergence>> {
464 Ok(compare(&std::fs::read_to_string(path)?))
465}
466
467#[must_use]
470pub fn compare_declarations(
471 declared: &[Declaration],
472 registered: &[Declaration],
473) -> Vec<Divergence> {
474 fn implementing(declaration: &Declaration) -> &str {
477 declaration
478 .implemented_by
479 .as_deref()
480 .unwrap_or(&declaration.name)
481 }
482
483 let mut findings = Vec::new();
484
485 let mut missing: Vec<&Declaration> = registered
486 .iter()
487 .filter(|r| !declared.iter().any(|d| implementing(d) == r.name))
488 .collect();
489 missing.sort_by(|a, b| a.name.cmp(&b.name));
490 findings.extend(missing.into_iter().map(|r| Divergence::MissingFromInclude {
491 native: r.name.clone(),
492 }));
493
494 let mut extra: Vec<&Declaration> = declared
495 .iter()
496 .filter(|d| !registered.iter().any(|r| r.name == implementing(d)))
497 .collect();
498 extra.sort_by(|a, b| a.name.cmp(&b.name));
499 findings.extend(extra.into_iter().map(|d| Divergence::NotRegistered {
500 native: d.name.clone(),
501 }));
502
503 let mut shared: Vec<(&Declaration, &Declaration)> = declared
504 .iter()
505 .filter_map(|d| {
506 registered
507 .iter()
508 .find(|r| r.name == implementing(d))
509 .map(|r| (d, r))
510 })
511 .collect();
512 shared.sort_by(|a, b| a.0.name.cmp(&b.0.name));
513
514 for (include, plugin) in shared {
515 if !plugin.shape_known {
518 continue;
519 }
520 if include.tag != plugin.tag {
521 findings.push(Divergence::ReturnTag {
522 native: include.name.clone(),
523 include: include.tag.clone(),
524 plugin: plugin.tag.clone(),
525 });
526 }
527
528 if !include.variadic && include.arguments.len() != plugin.arguments.len() {
531 findings.push(Divergence::Arity {
532 native: include.name.clone(),
533 include: include.arguments.len(),
534 plugin: plugin.arguments.len(),
535 });
536 }
537
538 for (position, (declared_arg, registered_arg)) in include
539 .arguments
540 .iter()
541 .zip(plugin.arguments.iter())
542 .enumerate()
543 {
544 if declared_arg != registered_arg {
545 findings.push(Divergence::ArgumentShape {
546 native: include.name.clone(),
547 position: position + 1,
548 include: declared_arg.clone(),
549 plugin: registered_arg.clone(),
550 });
551 }
552 }
553 }
554 findings
555}
556
557pub(crate) fn path_for_plugin(var: &str) -> Option<std::ffi::OsString> {
571 let value = std::env::var_os(var)?;
572 let text = value.to_string_lossy();
573
574 if !text.contains('=') {
576 return Some(value);
577 }
578
579 let plugin = crate::runtime::Runtime::try_get().map_or("plugin", |rt| rt.plugin_name());
580 for entry in text.split(',') {
581 if let Some((name, path)) = entry.split_once('=')
582 && name.trim() == plugin
583 {
584 return Some(std::ffi::OsString::from(path.trim()));
585 }
586 }
587 None
588}
589
590pub(crate) fn write_from_template(template: &std::ffi::OsStr, out: &std::ffi::OsStr) {
601 let shown = template.to_string_lossy().into_owned();
602
603 let source = match std::fs::read_to_string(template) {
604 Ok(source) => source,
605 Err(e) => {
606 crate::macros::sdk_warn!("could not read {shown}: {e}");
607 return;
608 }
609 };
610
611 let decls = crate::plugin::native_decls();
612 let mut rendering = Template::new(&source, &decls);
613 for (key, value) in std::env::vars() {
614 if let Some(name) = key.strip_prefix("SAMP_PAWN_VAR_") {
615 rendering = rendering.var(name, value);
616 }
617 }
618
619 match rendering.write(out) {
620 Ok(()) => crate::macros::sdk_info!(
621 "Pawn include written to {} from {shown}",
622 out.to_string_lossy()
623 ),
624 Err(e) => crate::macros::sdk_warn!("{shown}: {e}"),
625 }
626}
627
628pub(crate) fn check_if_requested() {
638 let Some(path) = path_for_plugin("SAMP_PAWN_INCLUDE_CHECK") else {
639 return;
640 };
641 let shown = path.to_string_lossy().into_owned();
642
643 match compare_file(&path) {
644 Ok(findings) if findings.is_empty() => {
645 crate::macros::sdk_info!("{shown} matches the registered natives");
646 }
647 Ok(findings) => {
648 crate::macros::sdk_warn!("{shown} disagrees with the registered natives:");
649 for finding in findings {
650 crate::macros::sdk_warn!(" {finding}");
651 }
652 }
653 Err(e) => crate::macros::sdk_warn!("could not read {shown}: {e}"),
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 fn argument(tag: &str, by_reference: bool, array: bool) -> Argument {
662 Argument {
663 tag: tag.to_string(),
664 by_reference,
665 array,
666 }
667 }
668
669 #[test]
670 fn reads_a_plain_declaration() {
671 let declarations = parse("native bool:Counter_Get(&out);");
672 assert_eq!(declarations.len(), 1);
673 assert_eq!(declarations[0].name, "Counter_Get");
674 assert_eq!(declarations[0].tag, "bool");
675 assert_eq!(declarations[0].arguments, vec![argument("", true, false)]);
676 }
677
678 #[test]
679 fn ignores_what_only_an_include_can_say() {
680 let declarations = parse(
683 "/* docs */ native bool:email_status(account = 0, dest[], dest_len = sizeof(dest));",
684 );
685 assert_eq!(declarations.len(), 1);
686 assert_eq!(
687 declarations[0].arguments,
688 vec![
689 argument("", false, false),
690 argument("", false, true),
691 argument("", false, false),
692 ]
693 );
694 }
695
696 #[test]
697 fn a_commented_out_declaration_is_not_one() {
698 let source = "// native Old_Removed(a);\n/* native Also_Gone(); */\nnative Live(a);";
699 let names: Vec<_> = parse(source).into_iter().map(|d| d.name).collect();
700 assert_eq!(names, vec!["Live"]);
701 }
702
703 #[test]
704 fn varargs_leave_the_argument_count_open() {
705 let declarations =
706 parse("native bool:email_test(account = 0, const format[], {Float,_}:...);");
707 assert!(declarations[0].variadic);
708 assert_eq!(declarations[0].arguments.len(), 2);
710
711 let registered = parse("native bool:email_test(account, const format[], extra1, extra2);");
712 assert!(compare_declarations(&declarations, ®istered).is_empty());
714 }
715
716 #[test]
717 fn reports_a_native_the_include_forgot() {
718 let findings = compare_declarations(&parse(""), &parse("native Foo(a);"));
719 assert_eq!(
720 findings,
721 vec![Divergence::MissingFromInclude {
722 native: "Foo".into()
723 }]
724 );
725 assert!(findings[0].to_string().contains("will not compile"));
726 }
727
728 #[test]
729 fn reports_a_declaration_with_no_native_behind_it() {
730 let findings = compare_declarations(&parse("native Ghost(a);"), &parse(""));
731 assert_eq!(
732 findings,
733 vec![Divergence::NotRegistered {
734 native: "Ghost".into()
735 }]
736 );
737 assert!(findings[0].to_string().contains("fails at runtime"));
738 }
739
740 #[test]
741 fn reports_arity_and_tag_and_shape() {
742 let declared = parse("native Foo(a, b);\nnative bool:Bar(x);\nnative Baz(n);");
743 let registered = parse("native Foo(a);\nnative Bar(x);\nnative Baz(&Float:n);");
744 let findings = compare_declarations(&declared, ®istered);
745
746 assert!(findings.contains(&Divergence::Arity {
747 native: "Foo".into(),
748 include: 2,
749 plugin: 1
750 }));
751 assert!(findings.contains(&Divergence::ReturnTag {
752 native: "Bar".into(),
753 include: "bool".into(),
754 plugin: String::new()
755 }));
756 assert!(findings.iter().any(|f| matches!(
757 f,
758 Divergence::ArgumentShape { native, position: 1, .. } if native == "Baz"
759 )));
760 }
761
762 #[test]
763 fn a_raw_native_is_compared_by_name_only() {
764 let mut registered = parse("native bool:email_send_to(...);");
767 registered[0].shape_known = false;
768 let declared = parse(
769 "native bool:email_send_to(const to[], const subject[], const body[], {Float,_}:...);",
770 );
771 assert!(compare_declarations(&declared, ®istered).is_empty());
772
773 assert_eq!(
775 compare_declarations(&parse(""), ®istered),
776 vec![Divergence::MissingFromInclude {
777 native: "email_send_to".into()
778 }]
779 );
780 }
781
782 #[test]
783 fn an_alias_is_matched_by_what_implements_it() {
784 let declared = parse("native bool:Email_Close(account = 0) = email_close;");
787 assert_eq!(declared[0].implemented_by.as_deref(), Some("email_close"));
788 assert!(
789 compare_declarations(&declared, &parse("native bool:email_close(account);")).is_empty()
790 );
791
792 let findings = compare_declarations(
794 &declared,
795 &parse("native bool:email_close(account, force);"),
796 );
797 assert_eq!(
798 findings,
799 vec![Divergence::Arity {
800 native: "Email_Close".into(),
801 include: 1,
802 plugin: 2
803 }]
804 );
805 }
806
807 #[test]
808 fn an_include_that_matches_reports_nothing() {
809 let source = "native bool:Counter_Get(&out);\nnative Counter_Reset();";
810 assert!(compare_declarations(&parse(source), &parse(source)).is_empty());
811 }
812
813 const DECLS: &[&str] = &[
818 "native Counter_Increment();",
819 "native bool:Counter_Get(&out);",
820 "// native Counter_Send(...); // raw native — fill in the arguments",
821 ];
822
823 #[test]
824 fn the_prose_stays_and_the_declarations_are_filled_in() {
825 let template = "\
826// My plugin v{{VERSION}}
827#if defined {{GUARD}}
828 #endinput
829#endif
830#define {{GUARD}}
831
832// Adds one.
833{{NATIVE:Counter_Increment}}
834
835{{NATIVES}}
836";
837 let out = Template::new(template, DECLS)
838 .plugin_name("counter")
839 .var("VERSION", "1.2.3")
840 .render()
841 .expect("the template places every native");
842
843 assert!(out.contains("// My plugin v1.2.3"));
844 assert!(out.contains("#define _counter_included"));
845 assert_eq!(out.matches("native Counter_Increment();").count(), 1);
847 assert!(out.contains("native bool:Counter_Get(&out);"));
848 }
849
850 #[test]
851 fn a_native_can_be_placed_under_another_name() {
852 let out = Template::new("{{NATIVE:Counter_Get as Counter_Read}}\n{{NATIVES}}", DECLS)
853 .render()
854 .unwrap();
855
856 assert!(out.contains("native bool:Counter_Read(&out) = Counter_Get;"));
857 assert!(
858 !out.contains("native bool:Counter_Get(&out);"),
859 "the alias replaces the original, it does not add to it"
860 );
861 }
862
863 #[test]
864 fn a_raw_native_can_be_placed_by_name() {
865 let out = Template::new("{{NATIVES}}", DECLS).render().unwrap();
868 assert!(out.contains("// native Counter_Send(...);"));
869 }
870
871 #[test]
872 fn a_native_the_template_forgets_is_an_error() {
873 let errors = Template::new("{{NATIVE:Counter_Increment}}", DECLS)
874 .render()
875 .expect_err("two natives are left out");
876
877 assert!(errors.contains(&TemplateError::NativeNotPlaced {
878 native: "Counter_Get".into()
879 }));
880 assert!(errors[0].to_string().contains("{{NATIVES}}"));
881 }
882
883 #[test]
884 fn a_placeholder_with_nothing_behind_it_is_an_error() {
885 let errors = Template::new("{{RELEASED}}{{NATIVES}}", DECLS)
886 .render()
887 .expect_err("RELEASED was never supplied");
888
889 assert_eq!(
890 errors,
891 vec![TemplateError::UnknownPlaceholder {
892 name: "RELEASED".into()
893 }]
894 );
895 }
896
897 #[test]
898 fn naming_a_native_that_does_not_exist_is_an_error() {
899 let errors = Template::new("{{NATIVE:Counter_Gone}}{{NATIVES}}", DECLS)
900 .render()
901 .expect_err("no such native");
902
903 assert!(errors.contains(&TemplateError::UnknownNative {
904 native: "Counter_Gone".into()
905 }));
906 }
907
908 #[test]
909 fn an_unclosed_placeholder_is_an_error() {
910 let errors = Template::new("{{NATIVES}} and then {{OOPS", DECLS)
911 .render()
912 .expect_err("the second placeholder never closes");
913
914 assert!(
915 errors
916 .iter()
917 .any(|e| matches!(e, TemplateError::UnclosedPlaceholder { .. }))
918 );
919 }
920
921 #[test]
922 fn version_comes_from_the_plugin_unless_the_caller_says_otherwise() {
923 let out = Template::new("v{{VERSION}}\n{{NATIVES}}", DECLS)
926 .var("VERSION", "9.9.9")
927 .render()
928 .unwrap();
929
930 assert!(out.starts_with("v9.9.9"));
931 }
932
933 #[test]
934 fn the_last_value_for_a_name_wins() {
935 let out = Template::new("{{V}}{{NATIVES}}", DECLS)
936 .var("V", "first")
937 .var("V", "second")
938 .render()
939 .unwrap();
940
941 assert!(out.starts_with("second"));
942 }
943
944 #[test]
945 fn checking_a_template_compares_what_it_renders_to() {
946 let shaped: &[&str] = &[
949 "native Counter_Increment();",
950 "native bool:Counter_Get(&out);",
951 ];
952 assert!(compare_with("{{NATIVES}}", shaped).is_empty());
953
954 let with_a_stale_line = "{{NATIVES}}\nnative Counter_Removed(a);";
955 assert_eq!(
956 compare_with(with_a_stale_line, shaped),
957 vec![Divergence::NotRegistered {
958 native: "Counter_Removed".into()
959 }]
960 );
961 }
962
963 #[test]
964 fn a_raw_native_left_commented_out_is_still_reported_as_missing() {
965 assert_eq!(
969 compare_with("{{NATIVES}}", DECLS),
970 vec![Divergence::MissingFromInclude {
971 native: "Counter_Send".into()
972 }]
973 );
974 }
975
976 #[test]
977 fn a_template_that_will_not_render_is_reported_by_the_check() {
978 let findings = compare_with("{{NOPE}}{{NATIVES}}", DECLS);
979 assert!(matches!(findings.as_slice(), [Divergence::Template { .. }]));
980 assert!(findings[0].to_string().contains("NOPE"));
981 }
982
983 #[test]
988 fn a_rust_doc_becomes_the_format_the_openmp_includes_use() {
989 let out = pawndoc(
990 "Set a player's position.\n\
991 @param playerid The ID of the player\n\
992 @param x The x coordinate\n\
993 @returns 1 on success, 0 otherwise.\n\
994 @remarks Removes the player from any vehicle.\n\
995 @seealso GetPlayerPos",
996 );
997
998 assert_eq!(
999 out,
1000 "/**\n\
1001 \x20* <summary>Set a player\'s position.</summary>\n\
1002 \x20* <param name=\"playerid\">The ID of the player</param>\n\
1003 \x20* <param name=\"x\">The x coordinate</param>\n\
1004 \x20* <returns>1 on success, 0 otherwise.</returns>\n\
1005 \x20* <remarks>Removes the player from any vehicle.</remarks>\n\
1006 \x20* <seealso name=\"GetPlayerPos\" />\n\
1007 \x20*/"
1008 );
1009 }
1010
1011 #[test]
1012 fn pawndoc_written_by_hand_passes_through() {
1013 let out = pawndoc("<library>counter</library>\n<summary>Adds <em>one</em>.</summary>");
1016 assert!(out.contains("<library>counter</library>"));
1017 assert!(out.contains("<summary>Adds <em>one</em>.</summary>"));
1018 }
1019
1020 #[test]
1021 fn text_that_would_break_a_tag_is_escaped() {
1022 let out = pawndoc("True when a < b && c > d.");
1023 assert!(out.contains("a < b && c > d"));
1024 }
1025
1026 #[test]
1027 fn an_undocumented_native_produces_no_comment() {
1028 assert_eq!(pawndoc(" \n "), "");
1029 }
1030
1031 #[test]
1032 fn a_long_summary_is_wrapped_as_written() {
1033 let out = pawndoc("First line.\nSecond line.");
1034 assert!(out.contains(" * <summary>\n * First line.\n * Second line.\n * </summary>"));
1035 }
1036
1037 #[test]
1038 fn docs_can_be_placed_on_their_own_or_above_each_declaration() {
1039 let docs = ["Adds one.\n@returns The new value.", "", ""];
1040
1041 let out = Template::new("{{DOC:Counter_Increment}}\n{{NATIVES}}", DECLS)
1043 .docs(&docs)
1044 .render()
1045 .unwrap();
1046 assert!(out.contains("<summary>Adds one.</summary>"));
1047 assert!(out.contains("<returns>The new value.</returns>"));
1048
1049 let out = Template::new("{{NATIVES}}", DECLS)
1051 .docs(&docs)
1052 .with_docs()
1053 .render()
1054 .unwrap();
1055 let at = out
1056 .find("<summary>Adds one.</summary>")
1057 .expect("documented");
1058 let decl = out.find("native Counter_Increment();").expect("declared");
1059 assert!(at < decl, "the documentation comes above the declaration");
1060
1061 assert!(out.contains("native bool:Counter_Get(&out);"));
1063 }
1064
1065 #[test]
1066 fn callbacks_become_forwards() {
1067 let out = Template::new("{{CALLBACKS}}\n{{NATIVES}}", DECLS)
1068 .callbacks(&["OnCounterWorkDone(delay)", "OnCounterMax(value);"])
1069 .render()
1070 .unwrap();
1071
1072 assert!(out.contains("forward OnCounterWorkDone(delay);"));
1073 assert!(out.contains("forward OnCounterMax(value);"));
1075 assert!(!out.contains(";;"));
1076 }
1077
1078 #[test]
1079 fn a_callback_the_include_never_forwards_is_reported() {
1080 let include = "native Counter_Increment();\nforward OnCounterWorkDone(delay);";
1082 let callbacks = ["OnCounterWorkDone(delay)", "OnCounterMax(value)"];
1083
1084 assert_eq!(
1085 missing_forwards(include, &callbacks),
1086 vec![Divergence::CallbackNotForwarded {
1087 callback: "OnCounterMax".into()
1088 }]
1089 );
1090 assert!(
1091 missing_forwards(include, &callbacks)[0]
1092 .to_string()
1093 .contains("never be called")
1094 );
1095 }
1096
1097 #[test]
1098 fn a_forward_is_read_like_a_native_declaration() {
1099 let forwards = parse_forwards(
1100 "// forward OnOld(a);\nforward OnCounterWorkDone(delay);\nnative Nope();",
1101 );
1102 let names: Vec<_> = forwards.into_iter().map(|f| f.name).collect();
1103 assert_eq!(names, vec!["OnCounterWorkDone"]);
1104 }
1105
1106 #[test]
1107 fn stock_functions_and_macros_in_a_template_are_left_alone() {
1108 let include = "\
1111#define Counter::%0(%1) forward %0(%1); public %0(%1)
1112stock Counter_Double(v) { return v * 2; }
1113native Counter_Increment();
1114";
1115 let names: Vec<_> = parse(include).into_iter().map(|d| d.name).collect();
1116 assert_eq!(names, vec!["Counter_Increment"]);
1117 assert!(compare_with(include, &["native Counter_Increment();"]).is_empty());
1118 }
1119
1120 #[test]
1121 fn placing_the_same_native_twice_is_an_error() {
1122 let errors = Template::new(
1123 "{{NATIVE:Counter_Increment}}{{NATIVE:Counter_Increment}}{{NATIVES}}",
1124 DECLS,
1125 )
1126 .render()
1127 .expect_err("Pawn would reject the second declaration");
1128
1129 assert!(errors.contains(&TemplateError::PlacedTwice {
1130 native: "Counter_Increment".into()
1131 }));
1132 }
1133
1134 #[test]
1135 fn an_alias_beside_the_original_is_not_a_duplicate() {
1136 Template::new(
1137 "{{NATIVE:Counter_Get}}{{NATIVE:Counter_Get as Counter_Read}}{{NATIVES}}",
1138 DECLS,
1139 )
1140 .render()
1141 .expect("two names, two declarations, no clash");
1142 }
1143
1144 #[test]
1145 fn an_env_path_can_name_the_plugin_it_is_for() {
1146 const VAR: &str = "SAMP_PAWN_INCLUDE_TEST_PATH";
1149 let _fixture = crate::test_support::exclusive();
1150
1151 unsafe { std::env::set_var(VAR, "plain.inc") };
1154 assert_eq!(path_for_plugin(VAR).unwrap(), "plain.inc");
1155
1156 unsafe { std::env::set_var(VAR, "counter=counter.inc, other=other.inc") };
1157 let plugin = crate::runtime::Runtime::try_get().map_or("plugin", |rt| rt.plugin_name());
1158 assert_eq!(
1159 path_for_plugin(VAR),
1160 None,
1161 "the test plugin ({plugin}) is not named, so it takes nothing"
1162 );
1163
1164 unsafe { std::env::set_var(VAR, format!("{plugin}=mine.inc,other=other.inc")) };
1165 assert_eq!(path_for_plugin(VAR).unwrap(), "mine.inc");
1166
1167 unsafe { std::env::remove_var(VAR) };
1168 assert_eq!(path_for_plugin(VAR), None);
1169 }
1170
1171 #[test]
1172 fn findings_come_back_in_a_stable_order() {
1173 let registered = parse("native Zeta();\nnative Alpha();");
1174 let findings = compare_declarations(&parse(""), ®istered);
1175 let names: Vec<_> = findings
1176 .iter()
1177 .map(|f| match f {
1178 Divergence::MissingFromInclude { native } => native.clone(),
1179 _ => unreachable!(),
1180 })
1181 .collect();
1182 assert_eq!(names, vec!["Alpha", "Zeta"]);
1183 }
1184}
1185
1186#[derive(Debug, Clone, PartialEq, Eq)]
1195pub enum TemplateError {
1196 UnknownPlaceholder { name: String },
1198 UnknownNative { native: String },
1200 NativeNotPlaced { native: String },
1203 UnclosedPlaceholder { at: usize },
1205 PlacedTwice { native: String },
1208}
1209
1210impl fmt::Display for TemplateError {
1211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1212 match self {
1213 Self::UnknownPlaceholder { name } => {
1214 write!(
1215 f,
1216 "{{{{{name}}}}} has no value; pass it with .var(\"{name}\", …)"
1217 )
1218 }
1219 Self::UnknownNative { native } => {
1220 write!(
1221 f,
1222 "{{{{NATIVE:{native}}}}} names a native this plugin does not register"
1223 )
1224 }
1225 Self::NativeNotPlaced { native } => write!(
1226 f,
1227 "{native} is registered but the template never places it; \
1228 name it with {{{{NATIVE:{native}}}}} or add {{{{NATIVES}}}}"
1229 ),
1230 Self::PlacedTwice { native } => write!(
1231 f,
1232 "{native} is placed twice; Pawn rejects the second declaration as \
1233 an already defined symbol"
1234 ),
1235 Self::UnclosedPlaceholder { at } => {
1236 write!(
1237 f,
1238 "a placeholder opened at byte {at} is never closed with }}}}"
1239 )
1240 }
1241 }
1242 }
1243}
1244
1245pub struct Template<'a> {
1280 source: &'a str,
1281 callbacks: Vec<String>,
1283 docs: Vec<String>,
1285 with_docs: bool,
1287 natives: Vec<(String, String)>,
1291 vars: Vec<(String, String)>,
1292 plugin_name: String,
1293}
1294
1295impl<'a> Template<'a> {
1296 #[must_use]
1299 pub fn new(source: &'a str, decls: &[&str]) -> Self {
1300 let natives = decls
1301 .iter()
1302 .map(|decl| {
1303 let body = decl.trim_start().trim_start_matches("//").trim_start();
1307 let name = parse(body)
1308 .first()
1309 .map(|d| d.name.clone())
1310 .unwrap_or_default();
1311 ((*decl).to_string(), name)
1312 })
1313 .collect();
1314
1315 Self {
1316 source,
1317 docs: crate::runtime::Runtime::try_get()
1318 .map(|rt| rt.native_docs().iter().map(|d| (*d).to_string()).collect())
1319 .unwrap_or_default(),
1320 with_docs: false,
1321 natives,
1322 callbacks: crate::runtime::Runtime::try_get()
1323 .map(|rt| {
1324 rt.callback_decls()
1325 .iter()
1326 .map(|c| (*c).to_string())
1327 .collect()
1328 })
1329 .unwrap_or_default(),
1330 vars: Vec::new(),
1331 plugin_name: String::new(),
1332 }
1333 }
1334
1335 #[must_use]
1337 pub fn var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1338 self.vars.push((name.into(), value.into()));
1339 self
1340 }
1341
1342 #[must_use]
1347 pub fn docs(mut self, docs: &[&str]) -> Self {
1348 self.docs = docs.iter().map(|d| (*d).to_string()).collect();
1349 self
1350 }
1351
1352 #[must_use]
1359 pub fn with_docs(mut self) -> Self {
1360 self.with_docs = true;
1361 self
1362 }
1363
1364 #[must_use]
1369 pub fn callbacks(mut self, callbacks: &[&str]) -> Self {
1370 self.callbacks = callbacks.iter().map(|c| (*c).to_string()).collect();
1371 self
1372 }
1373
1374 #[must_use]
1378 pub fn plugin_name(mut self, name: impl Into<String>) -> Self {
1379 self.plugin_name = name.into();
1380 self
1381 }
1382
1383 pub fn render(&self) -> Result<String, Vec<TemplateError>> {
1389 let mut out = String::with_capacity(self.source.len() + 256);
1390 let mut errors = Vec::new();
1391 let mut placed: Vec<usize> = Vec::new();
1392 let mut emitted: Vec<String> = Vec::new();
1393 let mut rest = self.source;
1394 let mut consumed = 0usize;
1395
1396 while let Some(open) = rest.find("{{") {
1397 out.push_str(&rest[..open]);
1398 let after = &rest[open + 2..];
1399
1400 let Some(close) = after.find("}}") else {
1401 errors.push(TemplateError::UnclosedPlaceholder {
1402 at: consumed + open,
1403 });
1404 break;
1405 };
1406
1407 let name = after[..close].trim();
1408 match self.expand(name, &mut placed) {
1409 Ok(text) => {
1410 for declared in parse(&text) {
1413 if emitted.contains(&declared.name) {
1414 errors.push(TemplateError::PlacedTwice {
1415 native: declared.name.clone(),
1416 });
1417 } else {
1418 emitted.push(declared.name);
1419 }
1420 }
1421 out.push_str(&text);
1422 }
1423 Err(e) => errors.push(e),
1424 }
1425
1426 consumed += open + 2 + close + 2;
1427 rest = &after[close + 2..];
1428 }
1429
1430 if errors.is_empty() {
1431 out.push_str(rest);
1432 }
1433
1434 for (i, (_, name)) in self.natives.iter().enumerate() {
1437 if !placed.contains(&i) {
1438 errors.push(TemplateError::NativeNotPlaced {
1439 native: name.clone(),
1440 });
1441 }
1442 }
1443
1444 if errors.is_empty() {
1445 Ok(out)
1446 } else {
1447 Err(errors)
1448 }
1449 }
1450
1451 pub fn write(&self, path: impl AsRef<Path>) -> Result<(), WriteError> {
1456 let rendered = self.render().map_err(WriteError::Template)?;
1457 std::fs::write(path, rendered).map_err(WriteError::Io)
1458 }
1459
1460 fn expand(&self, name: &str, placed: &mut Vec<usize>) -> Result<String, TemplateError> {
1462 if name == "NATIVES" {
1463 let mut lines = Vec::new();
1464 for i in 0..self.natives.len() {
1465 if !placed.contains(&i) {
1466 placed.push(i);
1467 lines.push(self.declaration(i, None));
1468 }
1469 }
1470 let separator = if self.with_docs { "\n\n" } else { "\n" };
1472 return Ok(lines.join(separator));
1473 }
1474
1475 if let Some(spec) = name.strip_prefix("NATIVE:") {
1476 let (native, alias) = match spec.split_once(" as ") {
1477 Some((native, alias)) => (native.trim(), Some(alias.trim())),
1478 None => (spec.trim(), None),
1479 };
1480 let Some(i) = self.natives.iter().position(|(_, name)| name == native) else {
1481 return Err(TemplateError::UnknownNative {
1482 native: native.to_string(),
1483 });
1484 };
1485 if !placed.contains(&i) {
1486 placed.push(i);
1487 }
1488 return Ok(self.declaration(i, alias));
1489 }
1490
1491 if let Some(native) = name.strip_prefix("DOC:") {
1492 let native = native.trim();
1493 let Some(i) = self.natives.iter().position(|(_, name)| name == native) else {
1494 return Err(TemplateError::UnknownNative {
1495 native: native.to_string(),
1496 });
1497 };
1498 return Ok(pawndoc(self.docs.get(i).map_or("", String::as_str)));
1499 }
1500
1501 if name == "CALLBACKS" {
1502 return Ok(self
1503 .callbacks
1504 .iter()
1505 .map(|c| format!("forward {};", c.trim().trim_end_matches(';')))
1506 .collect::<Vec<_>>()
1507 .join("\n"));
1508 }
1509 if name == "PLUGIN" {
1510 return Ok(self.name());
1511 }
1512 if name == "VERSION"
1515 && !self.vars.iter().any(|(key, _)| key == "VERSION")
1516 && let Some(rt) = crate::runtime::Runtime::try_get()
1517 {
1518 return Ok(rt.plugin_version().to_string());
1519 }
1520 if name == "GUARD" {
1521 return Ok(guard_symbol(&self.name()));
1522 }
1523
1524 self.vars
1525 .iter()
1526 .rev()
1527 .find(|(key, _)| key == name)
1528 .map(|(_, value)| value.clone())
1529 .ok_or_else(|| TemplateError::UnknownPlaceholder {
1530 name: name.to_string(),
1531 })
1532 }
1533
1534 fn declaration(&self, i: usize, alias: Option<&str>) -> String {
1537 let (decl, native) = &self.natives[i];
1538 let line = match alias {
1539 Some(alias) => alias_line(decl, native, alias),
1540 None => decl.clone(),
1541 };
1542
1543 if !self.with_docs {
1544 return line;
1545 }
1546 match pawndoc(self.docs.get(i).map_or("", String::as_str)) {
1547 doc if doc.is_empty() => line,
1548 doc => format!("{doc}\n{line}"),
1549 }
1550 }
1551
1552 fn name(&self) -> String {
1553 if self.plugin_name.is_empty() {
1554 crate::runtime::Runtime::try_get()
1555 .map_or_else(|| String::from("plugin"), |rt| rt.plugin_name().to_string())
1556 } else {
1557 self.plugin_name.clone()
1558 }
1559 }
1560}
1561
1562#[derive(Debug)]
1564pub enum WriteError {
1565 Template(Vec<TemplateError>),
1566 Io(std::io::Error),
1567}
1568
1569impl fmt::Display for WriteError {
1570 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1571 match self {
1572 Self::Template(errors) => {
1573 writeln!(f, "the template could not be rendered:")?;
1574 for e in errors {
1575 writeln!(f, " {e}")?;
1576 }
1577 Ok(())
1578 }
1579 Self::Io(e) => write!(f, "{e}"),
1580 }
1581 }
1582}
1583
1584impl std::error::Error for WriteError {}
1585
1586#[must_use]
1610pub fn pawndoc(doc: &str) -> String {
1611 if doc.trim().is_empty() {
1612 return String::new();
1613 }
1614
1615 fn escape(text: &str) -> String {
1616 text.replace('&', "&")
1617 .replace('<', "<")
1618 .replace('>', ">")
1619 }
1620
1621 let mut summary: Vec<String> = Vec::new();
1622 let mut tagged: Vec<String> = Vec::new();
1623
1624 for line in doc.lines() {
1625 let line = line.trim_end();
1626 let trimmed = line.trim_start();
1627
1628 if trimmed.starts_with('<') {
1629 tagged.push(trimmed.to_string());
1630 } else if let Some(rest) = trimmed.strip_prefix("@param ") {
1631 let (name, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
1632 tagged.push(format!(
1633 "<param name=\"{}\">{}</param>",
1634 escape(name),
1635 escape(text.trim())
1636 ));
1637 } else if let Some(rest) = trimmed.strip_prefix("@returns ") {
1638 tagged.push(format!("<returns>{}</returns>", escape(rest.trim())));
1639 } else if let Some(rest) = trimmed.strip_prefix("@remarks ") {
1640 tagged.push(format!("<remarks>{}</remarks>", escape(rest.trim())));
1641 } else if let Some(rest) = trimmed.strip_prefix("@seealso ") {
1642 tagged.push(format!("<seealso name=\"{}\" />", escape(rest.trim())));
1643 } else if !trimmed.is_empty() || !summary.is_empty() {
1644 summary.push(escape(trimmed));
1645 }
1646 }
1647
1648 while summary.last().is_some_and(|l| l.is_empty()) {
1649 summary.pop();
1650 }
1651
1652 let mut body: Vec<String> = Vec::new();
1653 if !summary.is_empty() {
1654 if summary.len() == 1 {
1657 body.push(format!("<summary>{}</summary>", summary[0]));
1658 } else {
1659 body.push("<summary>".to_string());
1660 body.extend(summary.iter().map(|l| format!(" {l}")));
1661 body.push("</summary>".to_string());
1662 }
1663 }
1664 body.extend(tagged);
1665
1666 let mut out = String::from("/**\n");
1667 for line in body {
1668 if line.is_empty() {
1669 out.push_str(" *\n");
1670 } else {
1671 out.push_str(&format!(" * {line}\n"));
1672 }
1673 }
1674 out.push_str(" */");
1675 out
1676}
1677
1678fn alias_line(decl: &str, native: &str, alias: &str) -> String {
1680 let renamed = decl.replacen(native, alias, 1);
1681 match renamed.rfind(';') {
1682 Some(at) => format!("{} = {native};", &renamed[..at]),
1683 None => renamed,
1684 }
1685}
1686
1687fn guard_symbol(plugin_name: &str) -> String {
1689 let body: String = plugin_name
1690 .chars()
1691 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1692 .collect();
1693 format!("_{body}_included")
1694}