Skip to main content

samp/
pawn_include.rs

1//! Comparing a hand-written Pawn include against the natives a plugin registers.
2//!
3//! `#[native]` derives a declaration for every native, and
4//! [`crate::plugin::pawn_include`] writes them out as an `.inc`. That works when
5//! the include is generated. Plugins that maintain theirs by hand do so for
6//! reasons the Rust signature cannot express — default values, `sizeof(dest)`,
7//! varargs, documentation — and pay for it with drift: a native renamed in Rust
8//! and forgotten in the include fails only when a script calls it.
9//!
10//! This module finds that drift. It parses declarations out of an include and
11//! compares them with what the plugin registered, reporting what disagrees and
12//! staying quiet about what a hand-written include is entitled to add.
13//!
14//! ```rust,no_run
15//! for finding in samp::pawn_include::compare_file("email_samp.inc").unwrap() {
16//!     log::warn!("{finding}");
17//! }
18//! ```
19//!
20//! Setting `SAMP_PAWN_INCLUDE_CHECK` to a path makes the SDK run the comparison
21//! at load and log whatever it finds, which is the shape a CI job wants.
22//!
23//! ## What is compared, and what is not
24//!
25//! The name, the return tag, the number of arguments, and each argument's tag,
26//! by-reference marker and array marker. A hand-written include may add default
27//! values (`account = 0`), size expressions (`sizeof(dest)`), varargs
28//! (`{Float,_}:...`) and any amount of documentation: those are additions the
29//! Rust side has no way to state, so they are not divergences.
30//!
31//! An argument list ending in varargs also stops arity checking there — the
32//! declaration is deliberately open-ended.
33
34use std::fmt;
35use std::path::Path;
36
37/// One argument of a declaration, reduced to what both sides can state.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Argument {
40    /// `Float:` and friends, lowercased without the colon; empty when untagged.
41    pub tag: String,
42    /// Declared as `&arg`.
43    pub by_reference: bool,
44    /// Declared as `arg[]`.
45    pub array: bool,
46}
47
48/// A `native` declaration, from either side of the comparison.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Declaration {
51    pub name: String,
52    /// Return tag, lowercased without the colon; empty when untagged.
53    pub tag: String,
54    pub arguments: Vec<Argument>,
55    /// The list ends in `...`, so the argument count is open.
56    pub variadic: bool,
57    /// The native this one is an alias of: `native Email_Close(...) = email_close;`
58    /// declares the name a script calls, implemented by the registered native
59    /// after the `=`. Comparison follows the `=`, so an include that renames the
60    /// whole surface is not reported as drift.
61    pub implemented_by: Option<String>,
62    /// Whether the argument list is known at all. A `raw` native registers its
63    /// name but parses its own arguments, so nothing but the name can be
64    /// compared; the include is the only place its shape is written down.
65    pub shape_known: bool,
66}
67
68/// Something the include and the plugin disagree about.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Divergence {
71    /// Registered by the plugin, absent from the include — a script calling it
72    /// will not compile.
73    MissingFromInclude { native: String },
74    /// Declared in the include, not registered — a script calling it compiles
75    /// and fails at runtime.
76    NotRegistered { native: String },
77    /// Both sides have it, with a different number of arguments.
78    Arity {
79        native: String,
80        include: usize,
81        plugin: usize,
82    },
83    /// Both sides have it, with a different return tag.
84    ReturnTag {
85        native: String,
86        include: String,
87        plugin: String,
88    },
89    /// A callback the plugin calls that the include does not `forward`: a script
90    /// implementing it as `public` would not be called.
91    CallbackNotForwarded { callback: String },
92    /// The include is a template that does not render — checked here because a
93    /// template that cannot be rendered cannot be compared either.
94    Template { problem: String },
95    /// One argument is declared differently: a tag, a `&`, or a `[]`.
96    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
179/// Strips `//` and `/* */` comments, so a commented-out declaration is not read
180/// as one.
181fn 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
213/// Splits an argument list at top-level commas, ignoring those inside braces or
214/// parentheses — `{Float,_}:...` and `sizeof(a,b)` are single arguments.
215fn 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
240/// Reads one argument, dropping what only a hand-written include can say.
241fn parse_argument(text: &str) -> Option<Argument> {
242    // A default value is the include's business, not the plugin's.
243    let text = text.split('=').next().unwrap_or(text).trim();
244    let text = text.trim_start_matches("const").trim();
245    // `...`, alone or tagged as `{Float,_}:...`, marks varargs, not an argument.
246    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        // `{Float,_}:...` is the varargs form, which carries no single tag.
255        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/// Every `native` declaration in `source`.
267///
268/// Understands what a hand-written include contains: comments, default values,
269/// size expressions, varargs, and declarations spread over several lines.
270#[must_use]
271pub fn parse(source: &str) -> Vec<Declaration> {
272    parse_declarations(source, "native ")
273}
274
275/// Reads every declaration introduced by `keyword` (`native ` or `forward `).
276fn 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        // `native Alias(...) = real_name;` — everything up to the `;` after the
300        // argument list, which is where an alias is written.
301        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/// Every `forward` declaration in `source`.
332///
333/// A script implements a plugin's callbacks as `public`, and the include is
334/// where they are `forward`ed. Parsed the same way natives are, so the same
335/// tolerance applies: comments, defaults and several lines.
336#[must_use]
337pub fn parse_forwards(source: &str) -> Vec<Declaration> {
338    parse_declarations(source, "forward ")
339}
340
341/// Compares an include's text with the natives this plugin registered.
342///
343/// Findings come back in a stable order: what is missing, what is extra, then
344/// the mismatches, each sorted by native name, so a CI job's output does not
345/// churn between runs.
346#[must_use]
347pub fn compare(include_source: &str) -> Vec<Divergence> {
348    compare_with(include_source, &crate::plugin::native_decls())
349}
350
351/// Renders an include from a name and declarations, no server involved.
352///
353/// The counterpart of [`compare_with`] for generating rather than checking: a
354/// plugin can write its `.inc` from a test, instead of starting a server with
355/// `SAMP_PAWN_INCLUDE` set.
356#[must_use]
357pub fn render(plugin_name: &str, decls: &[&str]) -> String {
358    crate::plugin::render_include(plugin_name, decls)
359}
360
361/// Reads the declarations `#[native]` derived, as produced by the
362/// `pawn_native_decls()` the plugin macro generates.
363///
364/// A `raw` native comes rendered commented out, its arity not being in the Rust
365/// signature. The name is still registered, so it is read back here and marked
366/// [`Declaration::shape_known`] `false` — its presence is compared, its shape is
367/// not.
368#[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/// Compares an include with declarations in hand, no server involved.
385///
386/// This is the form a CI job wants: pass the `pawn_native_decls()` the plugin
387/// macro generates and the check runs in `cargo test`.
388#[must_use]
389pub fn compare_with(include_source: &str, decls: &[&str]) -> Vec<Divergence> {
390    // A template is checked as what it renders to: its declarations come from
391    // the same place, so the comparison is about the hand-written ones around
392    // them — and a template that will not render is worth hearing about here too.
393    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), &registered(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/// Callbacks the plugin calls that the include does not `forward`.
420///
421/// The names are compared, not the arguments: a callback's signature lives in
422/// the declaration the plugin wrote, and a script may legitimately forward it
423/// with its own argument names.
424#[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
448/// Reads `path` and compares it with declarations in hand.
449///
450/// # Errors
451/// Propagates the [`std::io::Error`] when the file cannot be read.
452pub 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
459/// Reads `path` and compares it with the registered natives.
460///
461/// # Errors
462/// Propagates the [`std::io::Error`] when the file cannot be read.
463pub fn compare_file(path: impl AsRef<Path>) -> std::io::Result<Vec<Divergence>> {
464    Ok(compare(&std::fs::read_to_string(path)?))
465}
466
467/// The comparison itself, over two parsed lists — the part worth testing
468/// without a plugin behind it.
469#[must_use]
470pub fn compare_declarations(
471    declared: &[Declaration],
472    registered: &[Declaration],
473) -> Vec<Divergence> {
474    // What the plugin registered is the name after an `=`, when there is one,
475    // and the declared name otherwise.
476    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        // A raw native wrote down neither its arguments nor, reliably, more
516        // than its name: only the include states its shape.
517        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        // A variadic declaration is open-ended on purpose, so only the
529        // arguments it does name are compared.
530        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
557/// Reads a path from `var`, honouring a per-plugin selector.
558///
559/// The environment belongs to the whole process, and a server can have several
560/// Rust plugins loaded, each with its own include — pointed at one path, they
561/// would overwrite each other's file. So a value may name the plugin it is for:
562///
563/// ```text
564/// SAMP_PAWN_INCLUDE=counter.inc                     # whichever plugin reads it
565/// SAMP_PAWN_INCLUDE=counter=counter.inc,email_samp=email.inc
566/// ```
567///
568/// A bare path applies to every plugin, which is what a single-plugin setup
569/// wants. With selectors, a plugin not named takes nothing.
570pub(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    // A Windows path (`C:\...`) has no `=`; a selector always does.
575    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
590/// Renders `template` into `out`, for the `SAMP_PAWN_INCLUDE_TEMPLATE` path.
591///
592/// Values for placeholders beyond the built-in ones come from the environment:
593/// `SAMP_PAWN_VAR_RELEASED=2026-09-26` supplies `{{RELEASED}}`. That keeps the
594/// generation usable with no code at all — start the server once with the two
595/// variables set — while a plugin that wants more control uses [`Template`]
596/// from a test.
597///
598/// Every failure is logged and otherwise ignored: producing a development
599/// artifact must never take the server down.
600pub(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
628/// Runs the comparison when `SAMP_PAWN_INCLUDE_CHECK` names an include.
629///
630/// Called by the SDK right after `on_load`, not from the entry point: the check
631/// has nothing but log output to show, and under native open.mp a component's
632/// entry point runs before any logger exists, so anything logged there is lost.
633///
634/// Anything found is logged as a warning; a missing or unreadable file is
635/// reported and otherwise ignored, since a development check must never take a
636/// server down.
637pub(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        // Defaults, `sizeof`, `const` and documentation are the include's to
681        // add; none of them is a divergence.
682        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        // The varargs marker itself is not an argument.
709        assert_eq!(declarations[0].arguments.len(), 2);
710
711        let registered = parse("native bool:email_test(account, const format[], extra1, extra2);");
712        // More arguments than the include names is fine: it said "and more".
713        assert!(compare_declarations(&declarations, &registered).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, &registered);
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        // `raw` natives parse their own arguments, so the include is the only
765        // place their shape is written down: presence is checked, shape is not.
766        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, &registered).is_empty());
772
773        // Its absence from the include is still reported.
774        assert_eq!(
775            compare_declarations(&parse(""), &registered),
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        // An include may present the whole surface under other names, each
785        // aliasing the registered native; that is not drift.
786        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        // The shape is still compared, and reported under the declared name.
793        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    // -----------------------------------------------------------------------
814    // Templates
815    // -----------------------------------------------------------------------
816
817    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        // Placed by name, under its own comment, and not repeated by {{NATIVES}}.
846        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        // Its rendered form is commented out, so a template that only wants the
866        // hand-written line places it and writes the arguments itself.
867        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        // The version is in every include header, so the SDK fills it in from
924        // the plugin crate; passing one takes precedence.
925        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        // The template's own declarations cannot drift; a line written by hand
947        // beside them still can.
948        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        // `{{NATIVES}}` emits it as `#[native]` rendered it — commented out —
966        // so the include does not declare it. Give the native an `args = "…"`
967        // in its attribute, or write the line in the template.
968        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    // -----------------------------------------------------------------------
984    // Pawn documentation comments
985    // -----------------------------------------------------------------------
986
987    #[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        // An author who wants the full format — <library>, nested markup —
1014        // writes it, and it is not touched.
1015        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 &lt; b &amp;&amp; c &gt; 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        // On its own, for a template that lays out each native by hand.
1042        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        // Or above every declaration at once.
1050        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        // An undocumented native is just its declaration.
1062        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        // A trailing `;` in the declaration is not doubled.
1074        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        // A script would implement it as `public` and never be called.
1081        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        // An include carries hand-written Pawn too — a `stock` helper, a macro
1109        // shorthand. Neither is a declaration, and neither is drift.
1110        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        // Several Rust plugins on one server read the same variable, so a value
1147        // may say which plugin each path is for. A bare path is for everyone.
1148        const VAR: &str = "SAMP_PAWN_INCLUDE_TEST_PATH";
1149        let _fixture = crate::test_support::exclusive();
1150
1151        // SAFETY: the fixture's lock keeps this the only test touching the
1152        // environment, and the variable is this test's own.
1153        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(""), &registered);
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// ---------------------------------------------------------------------------
1187// Generating from a template
1188// ---------------------------------------------------------------------------
1189
1190/// Why a template could not be rendered.
1191///
1192/// Each variant is a mistake that would otherwise ship as a broken include, so
1193/// rendering reports it instead of guessing.
1194#[derive(Debug, Clone, PartialEq, Eq)]
1195pub enum TemplateError {
1196    /// `{{SOMETHING}}` with no value behind it.
1197    UnknownPlaceholder { name: String },
1198    /// `{{NATIVE:Name}}` naming a native the plugin does not register.
1199    UnknownNative { native: String },
1200    /// A registered native that no placeholder emits, with no `{{NATIVES}}` to
1201    /// collect it: it would be missing from the include.
1202    NativeNotPlaced { native: String },
1203    /// A `{{` with no `}}` after it.
1204    UnclosedPlaceholder { at: usize },
1205    /// The same declaration emitted twice: Pawn rejects the second as a symbol
1206    /// that is already defined.
1207    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
1245/// A Pawn include written by hand, with the declarations filled in.
1246///
1247/// The prose, the sections, the constants and the callback documentation stay in
1248/// the template, where they belong; every `native` line comes from the Rust
1249/// signature, so it cannot drift. This is the middle ground between generating
1250/// the whole file — which throws the documentation away — and maintaining it by
1251/// hand, which drifts.
1252///
1253/// Placeholders, all written `{{NAME}}`:
1254///
1255/// | Placeholder | Becomes |
1256/// | ----------- | ------- |
1257/// | `{{NATIVES}}` | every declaration not placed individually, in registration order |
1258/// | `{{NATIVE:Name}}` | that one declaration |
1259/// | `{{NATIVE:Name as Alias}}` | `native Alias(…) = Name;`, the aliased form |
1260/// | `{{PLUGIN}}` | the plugin's crate name |
1261/// | `{{GUARD}}` | `_<plugin>_included`, the usual include guard symbol |
1262/// | anything else | what [`Template::var`] supplied, or an error |
1263///
1264/// ```rust,no_run
1265/// # fn pawn_native_decls() -> Vec<&'static str> { vec![] }
1266/// # fn example() -> std::io::Result<()> {
1267/// let template = std::fs::read_to_string("include/my_plugin.inc.in")?;
1268///
1269/// samp::pawn_include::Template::new(&template, &pawn_native_decls())
1270///     .var("VERSION", env!("CARGO_PKG_VERSION"))
1271///     .write("include/my_plugin.inc")
1272///     .expect("the template and the natives agree");
1273/// # Ok(())
1274/// # }
1275/// ```
1276///
1277/// A native the template never places is an error, not a silent omission: that
1278/// is the drift this exists to prevent.
1279pub struct Template<'a> {
1280    source: &'a str,
1281    /// Callbacks the plugin says it calls, for `{{CALLBACKS}}`.
1282    callbacks: Vec<String>,
1283    /// Doc comment of each native, aligned with `natives`.
1284    docs: Vec<String>,
1285    /// Whether a placed declaration is preceded by its documentation.
1286    with_docs: bool,
1287    /// Each declaration as `#[native]` rendered it, paired with the native's
1288    /// name. Kept side by side so a placeholder naming one and `{{NATIVES}}`
1289    /// collecting the rest agree on which is which.
1290    natives: Vec<(String, String)>,
1291    vars: Vec<(String, String)>,
1292    plugin_name: String,
1293}
1294
1295impl<'a> Template<'a> {
1296    /// Takes the template's text and the declarations to place into it, as
1297    /// `pawn_native_decls()` produces them.
1298    #[must_use]
1299    pub fn new(source: &'a str, decls: &[&str]) -> Self {
1300        let natives = decls
1301            .iter()
1302            .map(|decl| {
1303                // A `raw` native comes rendered commented out; its name is still
1304                // in there, and placing it by name is how a template gives it
1305                // the argument list the signature does not have.
1306                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    /// Supplies one `{{NAME}}`. The last value for a name wins.
1336    #[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    /// The doc comment of each native, in the order the declarations came.
1343    ///
1344    /// Defaults to what `#[native]` captured from the Rust doc comments, so this
1345    /// is for generating an include without a plugin loaded — from a test.
1346    #[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    /// Puts each native's documentation above its declaration, as a Pawn
1353    /// documentation comment.
1354    ///
1355    /// Off by default: a template that documents each native in its own prose
1356    /// should not get a second copy. With it on, the documentation lives once,
1357    /// next to the Rust code. `{{DOC:Name}}` places one on its own either way.
1358    #[must_use]
1359    pub fn with_docs(mut self) -> Self {
1360        self.with_docs = true;
1361        self
1362    }
1363
1364    /// The callbacks `{{CALLBACKS}}` should forward.
1365    ///
1366    /// Defaults to what `initialize_plugin!` declared in `callbacks: [...]`, so
1367    /// this is for generating an include without a plugin loaded — from a test.
1368    #[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    /// Names the plugin, for `{{PLUGIN}}` and `{{GUARD}}`.
1375    ///
1376    /// Defaults to the name the plugin registered, which is its crate name.
1377    #[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    /// Renders the include.
1384    ///
1385    /// # Errors
1386    /// Returns every [`TemplateError`] found, in the order they were met, so one
1387    /// pass names all of them rather than one per run.
1388    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                    // The same native under the same name twice is a Pawn error,
1411                    // and the template is where it can still be caught.
1412                    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        // Everything not placed by name goes where `{{NATIVES}}` asked for it,
1435        // and if nothing did, its absence is the error worth reporting.
1436        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    /// Renders and writes the include to `path`.
1452    ///
1453    /// # Errors
1454    /// The template's own errors, or the [`std::io::Error`] from writing.
1455    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    /// Expands one placeholder, recording which declarations it consumed.
1461    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            // Documented declarations are blocks, so they read better separated.
1471            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        // `{{VERSION}}` is the plugin crate's version unless the caller
1513        // supplied one: it is in every include header, and the SDK knows it.
1514        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    /// Declaration `i`, under `alias` if given, with its documentation above it
1535    /// when the template asked for documentation.
1536    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/// A template that could not be rendered, or could not be written.
1563#[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/// Renders a Rust doc comment as a Pawn documentation comment.
1587///
1588/// Pawn's own documentation format — what the open.mp includes use and what
1589/// `pawncc -r` reads — is a `/** */` block of XML tags:
1590///
1591/// ```text
1592/// /**
1593///  * <summary>Set a player's position.</summary>
1594///  * <param name="playerid">The ID of the player</param>
1595///  * <returns>1 on success, 0 otherwise.</returns>
1596///  */
1597/// ```
1598///
1599/// The mapping from the Rust side stays close to how one writes Rust docs:
1600///
1601/// - the text becomes `<summary>`;
1602/// - `@param name text` becomes `<param name="name">text</param>`;
1603/// - `@returns`, `@remarks` and `@seealso` become those tags;
1604/// - a line already starting with `<` passes through untouched, so an author who
1605///   wants full pawndoc — `<library>`, `<em>`, nested markup — just writes it.
1606///
1607/// Text that is not passed through has `&`, `<` and `>` escaped, so a doc
1608/// mentioning `a < b` cannot produce a broken tag.
1609#[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('&', "&amp;")
1617            .replace('<', "&lt;")
1618            .replace('>', "&gt;")
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        // A one-line summary stays on one line, which is how the open.mp
1655        // includes read; a longer one is wrapped as written.
1656        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
1678/// Turns a declaration into its aliased form: `native Alias(…) = original;`.
1679fn 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
1687/// The include guard a plugin name produces, with what Pawn rejects replaced.
1688fn 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}