use std::fmt;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Argument {
pub tag: String,
pub by_reference: bool,
pub array: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Declaration {
pub name: String,
pub tag: String,
pub arguments: Vec<Argument>,
pub variadic: bool,
pub implemented_by: Option<String>,
pub shape_known: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Divergence {
MissingFromInclude { native: String },
NotRegistered { native: String },
Arity {
native: String,
include: usize,
plugin: usize,
},
ReturnTag {
native: String,
include: String,
plugin: String,
},
CallbackNotForwarded { callback: String },
Template { problem: String },
ArgumentShape {
native: String,
position: usize,
include: Argument,
plugin: Argument,
},
}
impl fmt::Display for Divergence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fn describe(argument: &Argument) -> String {
let mut text = String::new();
if argument.by_reference {
text.push('&');
}
if !argument.tag.is_empty() {
text.push_str(&argument.tag);
text.push(':');
}
text.push_str("arg");
if argument.array {
text.push_str("[]");
}
text
}
match self {
Self::MissingFromInclude { native } => write!(
f,
"{native} is registered but missing from the include; a script calling it will not compile"
),
Self::NotRegistered { native } => write!(
f,
"{native} is declared in the include but not registered; a script calling it fails at runtime"
),
Self::Arity {
native,
include,
plugin,
} => write!(
f,
"{native} takes {plugin} argument(s), the include declares {include}"
),
Self::ReturnTag {
native,
include,
plugin,
} => {
let shown = |tag: &str| {
if tag.is_empty() {
"untagged".into()
} else {
format!("{tag}:")
}
};
write!(
f,
"{native} returns {}, the include declares {}",
shown(plugin),
shown(include)
)
}
Self::CallbackNotForwarded { callback } => write!(
f,
"{callback} is called by the plugin but the include does not forward it; \
a script implementing it as `public` would never be called"
),
Self::Template { problem } => write!(f, "{problem}"),
Self::ArgumentShape {
native,
position,
include,
plugin,
} => write!(
f,
"{native} argument {position} is {}, the include declares {}",
describe(plugin),
describe(include)
),
}
}
}
fn without_comments(source: &str) -> String {
let mut out = String::with_capacity(source.len());
let mut chars = source.chars().peekable();
let mut in_block = false;
while let Some(c) = chars.next() {
if in_block {
if c == '*' && chars.peek() == Some(&'/') {
chars.next();
in_block = false;
}
continue;
}
match (c, chars.peek()) {
('/', Some('/')) => {
for c in chars.by_ref() {
if c == '\n' {
out.push('\n');
break;
}
}
}
('/', Some('*')) => {
chars.next();
in_block = true;
}
_ => out.push(c),
}
}
out
}
fn split_arguments(list: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut current = String::new();
for c in list.chars() {
match c {
'{' | '(' | '[' => {
depth += 1;
current.push(c);
}
'}' | ')' | ']' => {
depth -= 1;
current.push(c);
}
',' if depth == 0 => parts.push(std::mem::take(&mut current)),
_ => current.push(c),
}
}
if !current.trim().is_empty() {
parts.push(current);
}
parts
}
fn parse_argument(text: &str) -> Option<Argument> {
let text = text.split('=').next().unwrap_or(text).trim();
let text = text.trim_start_matches("const").trim();
if text.is_empty() || text.contains("...") {
return None;
}
let by_reference = text.starts_with('&');
let text = text.trim_start_matches('&').trim();
let (tag, rest) = match text.split_once(':') {
Some((tag, rest)) if !tag.starts_with('{') => (tag.trim().to_lowercase(), rest),
_ => (String::new(), text),
};
Some(Argument {
tag,
by_reference,
array: rest.contains('['),
})
}
#[must_use]
pub fn parse(source: &str) -> Vec<Declaration> {
parse_declarations(source, "native ")
}
fn parse_declarations(source: &str, keyword: &str) -> Vec<Declaration> {
let cleaned = without_comments(source);
let mut declarations = Vec::new();
let mut rest = cleaned.as_str();
while let Some(start) = rest.find(keyword) {
rest = &rest[start + keyword.len()..];
let Some(open) = rest.find('(') else { break };
let Some(close) = rest.find(')') else { break };
if close < open {
continue;
}
let head = rest[..open].trim();
let (tag, name) = match head.rsplit_once(':') {
Some((tag, name)) => (tag.trim().to_lowercase(), name.trim()),
None => (String::new(), head),
};
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
rest = &rest[close..];
continue;
}
let tail = &rest[close + 1..];
let tail = &tail[..tail.find(';').unwrap_or(0)];
let tail_len = tail.len();
let implemented_by = tail
.split_once('=')
.map(|(_, target)| target.trim().to_string())
.filter(|target| {
!target.is_empty() && target.chars().all(|c| c.is_alphanumeric() || c == '_')
});
let list = &rest[open + 1..close];
let variadic = list.contains("...");
let arguments = split_arguments(list)
.iter()
.filter_map(|argument| parse_argument(argument))
.collect();
declarations.push(Declaration {
name: name.to_string(),
tag,
arguments,
variadic,
implemented_by,
shape_known: true,
});
rest = &rest[close + 1 + tail_len..];
}
declarations
}
#[must_use]
pub fn parse_forwards(source: &str) -> Vec<Declaration> {
parse_declarations(source, "forward ")
}
#[must_use]
pub fn compare(include_source: &str) -> Vec<Divergence> {
compare_with(include_source, &crate::plugin::native_decls())
}
#[must_use]
pub fn render(plugin_name: &str, decls: &[&str]) -> String {
crate::plugin::render_include(plugin_name, decls)
}
#[must_use]
pub fn registered(decls: &[&str]) -> Vec<Declaration> {
decls
.iter()
.filter_map(|decl| {
let (source, shape_known) = match decl.trim_start().strip_prefix("//") {
Some(rest) => (rest.trim_start(), false),
None => (*decl, true),
};
let mut parsed = parse(source).into_iter().next()?;
parsed.shape_known = shape_known;
Some(parsed)
})
.collect()
}
#[must_use]
pub fn compare_with(include_source: &str, decls: &[&str]) -> Vec<Divergence> {
let rendered = if include_source.contains("{{") {
match Template::new(include_source, decls).render() {
Ok(rendered) => rendered,
Err(errors) => {
return errors
.into_iter()
.map(|e| Divergence::Template {
problem: e.to_string(),
})
.collect();
}
}
} else {
include_source.to_string()
};
let mut findings = compare_declarations(&parse(&rendered), ®istered(decls));
findings.extend(missing_forwards(
&rendered,
&crate::runtime::Runtime::try_get()
.map(|rt| rt.callback_decls().to_vec())
.unwrap_or_default(),
));
findings
}
#[must_use]
pub fn missing_forwards(include_source: &str, callbacks: &[&str]) -> Vec<Divergence> {
let forwarded = parse_forwards(include_source);
let mut missing: Vec<String> = callbacks
.iter()
.filter_map(|declared| {
let name = declared
.trim()
.split('(')
.next()
.unwrap_or("")
.trim()
.to_string();
(!name.is_empty() && !forwarded.iter().any(|f| f.name == name)).then_some(name)
})
.collect();
missing.sort();
missing
.into_iter()
.map(|callback| Divergence::CallbackNotForwarded { callback })
.collect()
}
pub fn compare_file_with(
path: impl AsRef<Path>,
decls: &[&str],
) -> std::io::Result<Vec<Divergence>> {
Ok(compare_with(&std::fs::read_to_string(path)?, decls))
}
pub fn compare_file(path: impl AsRef<Path>) -> std::io::Result<Vec<Divergence>> {
Ok(compare(&std::fs::read_to_string(path)?))
}
#[must_use]
pub fn compare_declarations(
declared: &[Declaration],
registered: &[Declaration],
) -> Vec<Divergence> {
fn implementing(declaration: &Declaration) -> &str {
declaration
.implemented_by
.as_deref()
.unwrap_or(&declaration.name)
}
let mut findings = Vec::new();
let mut missing: Vec<&Declaration> = registered
.iter()
.filter(|r| !declared.iter().any(|d| implementing(d) == r.name))
.collect();
missing.sort_by(|a, b| a.name.cmp(&b.name));
findings.extend(missing.into_iter().map(|r| Divergence::MissingFromInclude {
native: r.name.clone(),
}));
let mut extra: Vec<&Declaration> = declared
.iter()
.filter(|d| !registered.iter().any(|r| r.name == implementing(d)))
.collect();
extra.sort_by(|a, b| a.name.cmp(&b.name));
findings.extend(extra.into_iter().map(|d| Divergence::NotRegistered {
native: d.name.clone(),
}));
let mut shared: Vec<(&Declaration, &Declaration)> = declared
.iter()
.filter_map(|d| {
registered
.iter()
.find(|r| r.name == implementing(d))
.map(|r| (d, r))
})
.collect();
shared.sort_by(|a, b| a.0.name.cmp(&b.0.name));
for (include, plugin) in shared {
if !plugin.shape_known {
continue;
}
if include.tag != plugin.tag {
findings.push(Divergence::ReturnTag {
native: include.name.clone(),
include: include.tag.clone(),
plugin: plugin.tag.clone(),
});
}
if !include.variadic && include.arguments.len() != plugin.arguments.len() {
findings.push(Divergence::Arity {
native: include.name.clone(),
include: include.arguments.len(),
plugin: plugin.arguments.len(),
});
}
for (position, (declared_arg, registered_arg)) in include
.arguments
.iter()
.zip(plugin.arguments.iter())
.enumerate()
{
if declared_arg != registered_arg {
findings.push(Divergence::ArgumentShape {
native: include.name.clone(),
position: position + 1,
include: declared_arg.clone(),
plugin: registered_arg.clone(),
});
}
}
}
findings
}
pub(crate) fn path_for_plugin(var: &str) -> Option<std::ffi::OsString> {
let value = std::env::var_os(var)?;
let text = value.to_string_lossy();
if !text.contains('=') {
return Some(value);
}
let plugin = crate::runtime::Runtime::try_get().map_or("plugin", |rt| rt.plugin_name());
for entry in text.split(',') {
if let Some((name, path)) = entry.split_once('=')
&& name.trim() == plugin
{
return Some(std::ffi::OsString::from(path.trim()));
}
}
None
}
pub(crate) fn write_from_template(template: &std::ffi::OsStr, out: &std::ffi::OsStr) {
let shown = template.to_string_lossy().into_owned();
let source = match std::fs::read_to_string(template) {
Ok(source) => source,
Err(e) => {
crate::macros::sdk_warn!("could not read {shown}: {e}");
return;
}
};
let decls = crate::plugin::native_decls();
let mut rendering = Template::new(&source, &decls);
for (key, value) in std::env::vars() {
if let Some(name) = key.strip_prefix("SAMP_PAWN_VAR_") {
rendering = rendering.var(name, value);
}
}
match rendering.write(out) {
Ok(()) => crate::macros::sdk_info!(
"Pawn include written to {} from {shown}",
out.to_string_lossy()
),
Err(e) => crate::macros::sdk_warn!("{shown}: {e}"),
}
}
pub(crate) fn check_if_requested() {
let Some(path) = path_for_plugin("SAMP_PAWN_INCLUDE_CHECK") else {
return;
};
let shown = path.to_string_lossy().into_owned();
match compare_file(&path) {
Ok(findings) if findings.is_empty() => {
crate::macros::sdk_info!("{shown} matches the registered natives");
}
Ok(findings) => {
crate::macros::sdk_warn!("{shown} disagrees with the registered natives:");
for finding in findings {
crate::macros::sdk_warn!(" {finding}");
}
}
Err(e) => crate::macros::sdk_warn!("could not read {shown}: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn argument(tag: &str, by_reference: bool, array: bool) -> Argument {
Argument {
tag: tag.to_string(),
by_reference,
array,
}
}
#[test]
fn reads_a_plain_declaration() {
let declarations = parse("native bool:Counter_Get(&out);");
assert_eq!(declarations.len(), 1);
assert_eq!(declarations[0].name, "Counter_Get");
assert_eq!(declarations[0].tag, "bool");
assert_eq!(declarations[0].arguments, vec![argument("", true, false)]);
}
#[test]
fn ignores_what_only_an_include_can_say() {
let declarations = parse(
"/* docs */ native bool:email_status(account = 0, dest[], dest_len = sizeof(dest));",
);
assert_eq!(declarations.len(), 1);
assert_eq!(
declarations[0].arguments,
vec![
argument("", false, false),
argument("", false, true),
argument("", false, false),
]
);
}
#[test]
fn a_commented_out_declaration_is_not_one() {
let source = "// native Old_Removed(a);\n/* native Also_Gone(); */\nnative Live(a);";
let names: Vec<_> = parse(source).into_iter().map(|d| d.name).collect();
assert_eq!(names, vec!["Live"]);
}
#[test]
fn varargs_leave_the_argument_count_open() {
let declarations =
parse("native bool:email_test(account = 0, const format[], {Float,_}:...);");
assert!(declarations[0].variadic);
assert_eq!(declarations[0].arguments.len(), 2);
let registered = parse("native bool:email_test(account, const format[], extra1, extra2);");
assert!(compare_declarations(&declarations, ®istered).is_empty());
}
#[test]
fn reports_a_native_the_include_forgot() {
let findings = compare_declarations(&parse(""), &parse("native Foo(a);"));
assert_eq!(
findings,
vec![Divergence::MissingFromInclude {
native: "Foo".into()
}]
);
assert!(findings[0].to_string().contains("will not compile"));
}
#[test]
fn reports_a_declaration_with_no_native_behind_it() {
let findings = compare_declarations(&parse("native Ghost(a);"), &parse(""));
assert_eq!(
findings,
vec![Divergence::NotRegistered {
native: "Ghost".into()
}]
);
assert!(findings[0].to_string().contains("fails at runtime"));
}
#[test]
fn reports_arity_and_tag_and_shape() {
let declared = parse("native Foo(a, b);\nnative bool:Bar(x);\nnative Baz(n);");
let registered = parse("native Foo(a);\nnative Bar(x);\nnative Baz(&Float:n);");
let findings = compare_declarations(&declared, ®istered);
assert!(findings.contains(&Divergence::Arity {
native: "Foo".into(),
include: 2,
plugin: 1
}));
assert!(findings.contains(&Divergence::ReturnTag {
native: "Bar".into(),
include: "bool".into(),
plugin: String::new()
}));
assert!(findings.iter().any(|f| matches!(
f,
Divergence::ArgumentShape { native, position: 1, .. } if native == "Baz"
)));
}
#[test]
fn a_raw_native_is_compared_by_name_only() {
let mut registered = parse("native bool:email_send_to(...);");
registered[0].shape_known = false;
let declared = parse(
"native bool:email_send_to(const to[], const subject[], const body[], {Float,_}:...);",
);
assert!(compare_declarations(&declared, ®istered).is_empty());
assert_eq!(
compare_declarations(&parse(""), ®istered),
vec![Divergence::MissingFromInclude {
native: "email_send_to".into()
}]
);
}
#[test]
fn an_alias_is_matched_by_what_implements_it() {
let declared = parse("native bool:Email_Close(account = 0) = email_close;");
assert_eq!(declared[0].implemented_by.as_deref(), Some("email_close"));
assert!(
compare_declarations(&declared, &parse("native bool:email_close(account);")).is_empty()
);
let findings = compare_declarations(
&declared,
&parse("native bool:email_close(account, force);"),
);
assert_eq!(
findings,
vec![Divergence::Arity {
native: "Email_Close".into(),
include: 1,
plugin: 2
}]
);
}
#[test]
fn an_include_that_matches_reports_nothing() {
let source = "native bool:Counter_Get(&out);\nnative Counter_Reset();";
assert!(compare_declarations(&parse(source), &parse(source)).is_empty());
}
const DECLS: &[&str] = &[
"native Counter_Increment();",
"native bool:Counter_Get(&out);",
"// native Counter_Send(...); // raw native — fill in the arguments",
];
#[test]
fn the_prose_stays_and_the_declarations_are_filled_in() {
let template = "\
// My plugin v{{VERSION}}
#if defined {{GUARD}}
#endinput
#endif
#define {{GUARD}}
// Adds one.
{{NATIVE:Counter_Increment}}
{{NATIVES}}
";
let out = Template::new(template, DECLS)
.plugin_name("counter")
.var("VERSION", "1.2.3")
.render()
.expect("the template places every native");
assert!(out.contains("// My plugin v1.2.3"));
assert!(out.contains("#define _counter_included"));
assert_eq!(out.matches("native Counter_Increment();").count(), 1);
assert!(out.contains("native bool:Counter_Get(&out);"));
}
#[test]
fn a_native_can_be_placed_under_another_name() {
let out = Template::new("{{NATIVE:Counter_Get as Counter_Read}}\n{{NATIVES}}", DECLS)
.render()
.unwrap();
assert!(out.contains("native bool:Counter_Read(&out) = Counter_Get;"));
assert!(
!out.contains("native bool:Counter_Get(&out);"),
"the alias replaces the original, it does not add to it"
);
}
#[test]
fn a_raw_native_can_be_placed_by_name() {
let out = Template::new("{{NATIVES}}", DECLS).render().unwrap();
assert!(out.contains("// native Counter_Send(...);"));
}
#[test]
fn a_native_the_template_forgets_is_an_error() {
let errors = Template::new("{{NATIVE:Counter_Increment}}", DECLS)
.render()
.expect_err("two natives are left out");
assert!(errors.contains(&TemplateError::NativeNotPlaced {
native: "Counter_Get".into()
}));
assert!(errors[0].to_string().contains("{{NATIVES}}"));
}
#[test]
fn a_placeholder_with_nothing_behind_it_is_an_error() {
let errors = Template::new("{{RELEASED}}{{NATIVES}}", DECLS)
.render()
.expect_err("RELEASED was never supplied");
assert_eq!(
errors,
vec![TemplateError::UnknownPlaceholder {
name: "RELEASED".into()
}]
);
}
#[test]
fn naming_a_native_that_does_not_exist_is_an_error() {
let errors = Template::new("{{NATIVE:Counter_Gone}}{{NATIVES}}", DECLS)
.render()
.expect_err("no such native");
assert!(errors.contains(&TemplateError::UnknownNative {
native: "Counter_Gone".into()
}));
}
#[test]
fn an_unclosed_placeholder_is_an_error() {
let errors = Template::new("{{NATIVES}} and then {{OOPS", DECLS)
.render()
.expect_err("the second placeholder never closes");
assert!(
errors
.iter()
.any(|e| matches!(e, TemplateError::UnclosedPlaceholder { .. }))
);
}
#[test]
fn version_comes_from_the_plugin_unless_the_caller_says_otherwise() {
let out = Template::new("v{{VERSION}}\n{{NATIVES}}", DECLS)
.var("VERSION", "9.9.9")
.render()
.unwrap();
assert!(out.starts_with("v9.9.9"));
}
#[test]
fn the_last_value_for_a_name_wins() {
let out = Template::new("{{V}}{{NATIVES}}", DECLS)
.var("V", "first")
.var("V", "second")
.render()
.unwrap();
assert!(out.starts_with("second"));
}
#[test]
fn checking_a_template_compares_what_it_renders_to() {
let shaped: &[&str] = &[
"native Counter_Increment();",
"native bool:Counter_Get(&out);",
];
assert!(compare_with("{{NATIVES}}", shaped).is_empty());
let with_a_stale_line = "{{NATIVES}}\nnative Counter_Removed(a);";
assert_eq!(
compare_with(with_a_stale_line, shaped),
vec![Divergence::NotRegistered {
native: "Counter_Removed".into()
}]
);
}
#[test]
fn a_raw_native_left_commented_out_is_still_reported_as_missing() {
assert_eq!(
compare_with("{{NATIVES}}", DECLS),
vec![Divergence::MissingFromInclude {
native: "Counter_Send".into()
}]
);
}
#[test]
fn a_template_that_will_not_render_is_reported_by_the_check() {
let findings = compare_with("{{NOPE}}{{NATIVES}}", DECLS);
assert!(matches!(findings.as_slice(), [Divergence::Template { .. }]));
assert!(findings[0].to_string().contains("NOPE"));
}
#[test]
fn a_rust_doc_becomes_the_format_the_openmp_includes_use() {
let out = pawndoc(
"Set a player's position.\n\
@param playerid The ID of the player\n\
@param x The x coordinate\n\
@returns 1 on success, 0 otherwise.\n\
@remarks Removes the player from any vehicle.\n\
@seealso GetPlayerPos",
);
assert_eq!(
out,
"/**\n\
\x20* <summary>Set a player\'s position.</summary>\n\
\x20* <param name=\"playerid\">The ID of the player</param>\n\
\x20* <param name=\"x\">The x coordinate</param>\n\
\x20* <returns>1 on success, 0 otherwise.</returns>\n\
\x20* <remarks>Removes the player from any vehicle.</remarks>\n\
\x20* <seealso name=\"GetPlayerPos\" />\n\
\x20*/"
);
}
#[test]
fn pawndoc_written_by_hand_passes_through() {
let out = pawndoc("<library>counter</library>\n<summary>Adds <em>one</em>.</summary>");
assert!(out.contains("<library>counter</library>"));
assert!(out.contains("<summary>Adds <em>one</em>.</summary>"));
}
#[test]
fn text_that_would_break_a_tag_is_escaped() {
let out = pawndoc("True when a < b && c > d.");
assert!(out.contains("a < b && c > d"));
}
#[test]
fn an_undocumented_native_produces_no_comment() {
assert_eq!(pawndoc(" \n "), "");
}
#[test]
fn a_long_summary_is_wrapped_as_written() {
let out = pawndoc("First line.\nSecond line.");
assert!(out.contains(" * <summary>\n * First line.\n * Second line.\n * </summary>"));
}
#[test]
fn docs_can_be_placed_on_their_own_or_above_each_declaration() {
let docs = ["Adds one.\n@returns The new value.", "", ""];
let out = Template::new("{{DOC:Counter_Increment}}\n{{NATIVES}}", DECLS)
.docs(&docs)
.render()
.unwrap();
assert!(out.contains("<summary>Adds one.</summary>"));
assert!(out.contains("<returns>The new value.</returns>"));
let out = Template::new("{{NATIVES}}", DECLS)
.docs(&docs)
.with_docs()
.render()
.unwrap();
let at = out
.find("<summary>Adds one.</summary>")
.expect("documented");
let decl = out.find("native Counter_Increment();").expect("declared");
assert!(at < decl, "the documentation comes above the declaration");
assert!(out.contains("native bool:Counter_Get(&out);"));
}
#[test]
fn callbacks_become_forwards() {
let out = Template::new("{{CALLBACKS}}\n{{NATIVES}}", DECLS)
.callbacks(&["OnCounterWorkDone(delay)", "OnCounterMax(value);"])
.render()
.unwrap();
assert!(out.contains("forward OnCounterWorkDone(delay);"));
assert!(out.contains("forward OnCounterMax(value);"));
assert!(!out.contains(";;"));
}
#[test]
fn a_callback_the_include_never_forwards_is_reported() {
let include = "native Counter_Increment();\nforward OnCounterWorkDone(delay);";
let callbacks = ["OnCounterWorkDone(delay)", "OnCounterMax(value)"];
assert_eq!(
missing_forwards(include, &callbacks),
vec![Divergence::CallbackNotForwarded {
callback: "OnCounterMax".into()
}]
);
assert!(
missing_forwards(include, &callbacks)[0]
.to_string()
.contains("never be called")
);
}
#[test]
fn a_forward_is_read_like_a_native_declaration() {
let forwards = parse_forwards(
"// forward OnOld(a);\nforward OnCounterWorkDone(delay);\nnative Nope();",
);
let names: Vec<_> = forwards.into_iter().map(|f| f.name).collect();
assert_eq!(names, vec!["OnCounterWorkDone"]);
}
#[test]
fn stock_functions_and_macros_in_a_template_are_left_alone() {
let include = "\
#define Counter::%0(%1) forward %0(%1); public %0(%1)
stock Counter_Double(v) { return v * 2; }
native Counter_Increment();
";
let names: Vec<_> = parse(include).into_iter().map(|d| d.name).collect();
assert_eq!(names, vec!["Counter_Increment"]);
assert!(compare_with(include, &["native Counter_Increment();"]).is_empty());
}
#[test]
fn placing_the_same_native_twice_is_an_error() {
let errors = Template::new(
"{{NATIVE:Counter_Increment}}{{NATIVE:Counter_Increment}}{{NATIVES}}",
DECLS,
)
.render()
.expect_err("Pawn would reject the second declaration");
assert!(errors.contains(&TemplateError::PlacedTwice {
native: "Counter_Increment".into()
}));
}
#[test]
fn an_alias_beside_the_original_is_not_a_duplicate() {
Template::new(
"{{NATIVE:Counter_Get}}{{NATIVE:Counter_Get as Counter_Read}}{{NATIVES}}",
DECLS,
)
.render()
.expect("two names, two declarations, no clash");
}
#[test]
fn an_env_path_can_name_the_plugin_it_is_for() {
const VAR: &str = "SAMP_PAWN_INCLUDE_TEST_PATH";
let _fixture = crate::test_support::exclusive();
unsafe { std::env::set_var(VAR, "plain.inc") };
assert_eq!(path_for_plugin(VAR).unwrap(), "plain.inc");
unsafe { std::env::set_var(VAR, "counter=counter.inc, other=other.inc") };
let plugin = crate::runtime::Runtime::try_get().map_or("plugin", |rt| rt.plugin_name());
assert_eq!(
path_for_plugin(VAR),
None,
"the test plugin ({plugin}) is not named, so it takes nothing"
);
unsafe { std::env::set_var(VAR, format!("{plugin}=mine.inc,other=other.inc")) };
assert_eq!(path_for_plugin(VAR).unwrap(), "mine.inc");
unsafe { std::env::remove_var(VAR) };
assert_eq!(path_for_plugin(VAR), None);
}
#[test]
fn findings_come_back_in_a_stable_order() {
let registered = parse("native Zeta();\nnative Alpha();");
let findings = compare_declarations(&parse(""), ®istered);
let names: Vec<_> = findings
.iter()
.map(|f| match f {
Divergence::MissingFromInclude { native } => native.clone(),
_ => unreachable!(),
})
.collect();
assert_eq!(names, vec!["Alpha", "Zeta"]);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateError {
UnknownPlaceholder { name: String },
UnknownNative { native: String },
NativeNotPlaced { native: String },
UnclosedPlaceholder { at: usize },
PlacedTwice { native: String },
}
impl fmt::Display for TemplateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownPlaceholder { name } => {
write!(
f,
"{{{{{name}}}}} has no value; pass it with .var(\"{name}\", …)"
)
}
Self::UnknownNative { native } => {
write!(
f,
"{{{{NATIVE:{native}}}}} names a native this plugin does not register"
)
}
Self::NativeNotPlaced { native } => write!(
f,
"{native} is registered but the template never places it; \
name it with {{{{NATIVE:{native}}}}} or add {{{{NATIVES}}}}"
),
Self::PlacedTwice { native } => write!(
f,
"{native} is placed twice; Pawn rejects the second declaration as \
an already defined symbol"
),
Self::UnclosedPlaceholder { at } => {
write!(
f,
"a placeholder opened at byte {at} is never closed with }}}}"
)
}
}
}
}
pub struct Template<'a> {
source: &'a str,
callbacks: Vec<String>,
docs: Vec<String>,
with_docs: bool,
natives: Vec<(String, String)>,
vars: Vec<(String, String)>,
plugin_name: String,
}
impl<'a> Template<'a> {
#[must_use]
pub fn new(source: &'a str, decls: &[&str]) -> Self {
let natives = decls
.iter()
.map(|decl| {
let body = decl.trim_start().trim_start_matches("//").trim_start();
let name = parse(body)
.first()
.map(|d| d.name.clone())
.unwrap_or_default();
((*decl).to_string(), name)
})
.collect();
Self {
source,
docs: crate::runtime::Runtime::try_get()
.map(|rt| rt.native_docs().iter().map(|d| (*d).to_string()).collect())
.unwrap_or_default(),
with_docs: false,
natives,
callbacks: crate::runtime::Runtime::try_get()
.map(|rt| {
rt.callback_decls()
.iter()
.map(|c| (*c).to_string())
.collect()
})
.unwrap_or_default(),
vars: Vec::new(),
plugin_name: String::new(),
}
}
#[must_use]
pub fn var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.vars.push((name.into(), value.into()));
self
}
#[must_use]
pub fn docs(mut self, docs: &[&str]) -> Self {
self.docs = docs.iter().map(|d| (*d).to_string()).collect();
self
}
#[must_use]
pub fn with_docs(mut self) -> Self {
self.with_docs = true;
self
}
#[must_use]
pub fn callbacks(mut self, callbacks: &[&str]) -> Self {
self.callbacks = callbacks.iter().map(|c| (*c).to_string()).collect();
self
}
#[must_use]
pub fn plugin_name(mut self, name: impl Into<String>) -> Self {
self.plugin_name = name.into();
self
}
pub fn render(&self) -> Result<String, Vec<TemplateError>> {
let mut out = String::with_capacity(self.source.len() + 256);
let mut errors = Vec::new();
let mut placed: Vec<usize> = Vec::new();
let mut emitted: Vec<String> = Vec::new();
let mut rest = self.source;
let mut consumed = 0usize;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
let Some(close) = after.find("}}") else {
errors.push(TemplateError::UnclosedPlaceholder {
at: consumed + open,
});
break;
};
let name = after[..close].trim();
match self.expand(name, &mut placed) {
Ok(text) => {
for declared in parse(&text) {
if emitted.contains(&declared.name) {
errors.push(TemplateError::PlacedTwice {
native: declared.name.clone(),
});
} else {
emitted.push(declared.name);
}
}
out.push_str(&text);
}
Err(e) => errors.push(e),
}
consumed += open + 2 + close + 2;
rest = &after[close + 2..];
}
if errors.is_empty() {
out.push_str(rest);
}
for (i, (_, name)) in self.natives.iter().enumerate() {
if !placed.contains(&i) {
errors.push(TemplateError::NativeNotPlaced {
native: name.clone(),
});
}
}
if errors.is_empty() {
Ok(out)
} else {
Err(errors)
}
}
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), WriteError> {
let rendered = self.render().map_err(WriteError::Template)?;
std::fs::write(path, rendered).map_err(WriteError::Io)
}
fn expand(&self, name: &str, placed: &mut Vec<usize>) -> Result<String, TemplateError> {
if name == "NATIVES" {
let mut lines = Vec::new();
for i in 0..self.natives.len() {
if !placed.contains(&i) {
placed.push(i);
lines.push(self.declaration(i, None));
}
}
let separator = if self.with_docs { "\n\n" } else { "\n" };
return Ok(lines.join(separator));
}
if let Some(spec) = name.strip_prefix("NATIVE:") {
let (native, alias) = match spec.split_once(" as ") {
Some((native, alias)) => (native.trim(), Some(alias.trim())),
None => (spec.trim(), None),
};
let Some(i) = self.natives.iter().position(|(_, name)| name == native) else {
return Err(TemplateError::UnknownNative {
native: native.to_string(),
});
};
if !placed.contains(&i) {
placed.push(i);
}
return Ok(self.declaration(i, alias));
}
if let Some(native) = name.strip_prefix("DOC:") {
let native = native.trim();
let Some(i) = self.natives.iter().position(|(_, name)| name == native) else {
return Err(TemplateError::UnknownNative {
native: native.to_string(),
});
};
return Ok(pawndoc(self.docs.get(i).map_or("", String::as_str)));
}
if name == "CALLBACKS" {
return Ok(self
.callbacks
.iter()
.map(|c| format!("forward {};", c.trim().trim_end_matches(';')))
.collect::<Vec<_>>()
.join("\n"));
}
if name == "PLUGIN" {
return Ok(self.name());
}
if name == "VERSION"
&& !self.vars.iter().any(|(key, _)| key == "VERSION")
&& let Some(rt) = crate::runtime::Runtime::try_get()
{
return Ok(rt.plugin_version().to_string());
}
if name == "GUARD" {
return Ok(guard_symbol(&self.name()));
}
self.vars
.iter()
.rev()
.find(|(key, _)| key == name)
.map(|(_, value)| value.clone())
.ok_or_else(|| TemplateError::UnknownPlaceholder {
name: name.to_string(),
})
}
fn declaration(&self, i: usize, alias: Option<&str>) -> String {
let (decl, native) = &self.natives[i];
let line = match alias {
Some(alias) => alias_line(decl, native, alias),
None => decl.clone(),
};
if !self.with_docs {
return line;
}
match pawndoc(self.docs.get(i).map_or("", String::as_str)) {
doc if doc.is_empty() => line,
doc => format!("{doc}\n{line}"),
}
}
fn name(&self) -> String {
if self.plugin_name.is_empty() {
crate::runtime::Runtime::try_get()
.map_or_else(|| String::from("plugin"), |rt| rt.plugin_name().to_string())
} else {
self.plugin_name.clone()
}
}
}
#[derive(Debug)]
pub enum WriteError {
Template(Vec<TemplateError>),
Io(std::io::Error),
}
impl fmt::Display for WriteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Template(errors) => {
writeln!(f, "the template could not be rendered:")?;
for e in errors {
writeln!(f, " {e}")?;
}
Ok(())
}
Self::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for WriteError {}
#[must_use]
pub fn pawndoc(doc: &str) -> String {
if doc.trim().is_empty() {
return String::new();
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
let mut summary: Vec<String> = Vec::new();
let mut tagged: Vec<String> = Vec::new();
for line in doc.lines() {
let line = line.trim_end();
let trimmed = line.trim_start();
if trimmed.starts_with('<') {
tagged.push(trimmed.to_string());
} else if let Some(rest) = trimmed.strip_prefix("@param ") {
let (name, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
tagged.push(format!(
"<param name=\"{}\">{}</param>",
escape(name),
escape(text.trim())
));
} else if let Some(rest) = trimmed.strip_prefix("@returns ") {
tagged.push(format!("<returns>{}</returns>", escape(rest.trim())));
} else if let Some(rest) = trimmed.strip_prefix("@remarks ") {
tagged.push(format!("<remarks>{}</remarks>", escape(rest.trim())));
} else if let Some(rest) = trimmed.strip_prefix("@seealso ") {
tagged.push(format!("<seealso name=\"{}\" />", escape(rest.trim())));
} else if !trimmed.is_empty() || !summary.is_empty() {
summary.push(escape(trimmed));
}
}
while summary.last().is_some_and(|l| l.is_empty()) {
summary.pop();
}
let mut body: Vec<String> = Vec::new();
if !summary.is_empty() {
if summary.len() == 1 {
body.push(format!("<summary>{}</summary>", summary[0]));
} else {
body.push("<summary>".to_string());
body.extend(summary.iter().map(|l| format!(" {l}")));
body.push("</summary>".to_string());
}
}
body.extend(tagged);
let mut out = String::from("/**\n");
for line in body {
if line.is_empty() {
out.push_str(" *\n");
} else {
out.push_str(&format!(" * {line}\n"));
}
}
out.push_str(" */");
out
}
fn alias_line(decl: &str, native: &str, alias: &str) -> String {
let renamed = decl.replacen(native, alias, 1);
match renamed.rfind(';') {
Some(at) => format!("{} = {native};", &renamed[..at]),
None => renamed,
}
}
fn guard_symbol(plugin_name: &str) -> String {
let body: String = plugin_name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
format!("_{body}_included")
}