use perl_diagnostics_codes::DiagnosticCode;
use perl_parser_core::ast::{Node, NodeKind};
use super::super::walker::walk_node;
use perl_lsp_diagnostic_types::{Diagnostic, DiagnosticSeverity, RelatedInformation};
pub fn check_printf_format(node: &Node, diagnostics: &mut Vec<Diagnostic>) {
walk_node(node, &mut |n| match &n.kind {
NodeKind::FunctionCall { name, args } if name == "printf" || name == "sprintf" => {
check_format_args(name, args, n, diagnostics);
}
NodeKind::IndirectCall { method, args, .. } if method == "printf" => {
check_format_args(method, args, n, diagnostics);
}
_ => {}
});
}
fn check_format_args(name: &str, args: &[Node], node: &Node, diagnostics: &mut Vec<Diagnostic>) {
let Some(fmt_node) = args.first() else { return };
let raw_value = match &fmt_node.kind {
NodeKind::String { value, .. } => value.as_str(),
_ => return,
};
let fmt_content = unquote_string(raw_value);
if fmt_content.contains('$') {
return;
}
let specifier_count = count_format_specifiers(fmt_content);
let arg_count = args.len().saturating_sub(1);
if specifier_count != arg_count {
let msg = format!(
"`{}` format string has {} specifier{} but {} argument{} supplied",
name,
specifier_count,
if specifier_count == 1 { "" } else { "s" },
arg_count,
if arg_count == 1 { "" } else { "s" },
);
diagnostics.push(Diagnostic {
range: (node.location.start, node.location.end),
severity: DiagnosticSeverity::Warning,
code: Some(DiagnosticCode::PrintfFormatMismatch.as_str().to_string()),
message: msg,
related_information: vec![RelatedInformation {
location: (fmt_node.location.start, fmt_node.location.end),
message: format!(
"Format string contains {} specifier{}",
specifier_count,
if specifier_count == 1 { "" } else { "s" }
),
}],
tags: Vec::new(),
suggestion: Some(format!(
"Add {} argument{} to match {} format specifier{}, or adjust the format string",
specifier_count,
if specifier_count == 1 { "" } else { "s" },
specifier_count,
if specifier_count == 1 { "" } else { "s" },
)),
});
}
}
fn count_format_specifiers(s: &str) -> usize {
let bytes = s.as_bytes();
let mut count = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'%' {
i += 1;
continue;
}
i += 1; if i >= bytes.len() {
break;
}
if bytes[i] == b'%' {
i += 1;
continue;
}
while i < bytes.len() && matches!(bytes[i], b'-' | b'+' | b' ' | b'0' | b'#') {
i += 1;
}
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'*' {
i += 1;
}
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'*' {
i += 1;
}
}
if i < bytes.len() {
match bytes[i] {
b'h' | b'l' | b'L' | b'q' | b'v' | b'z' | b't' => {
i += 1;
if i < bytes.len() && (bytes[i] == b'h' || bytes[i] == b'l') {
i += 1; }
}
_ => {}
}
}
if i < bytes.len()
&& matches!(
bytes[i],
b's' | b'd'
| b'i'
| b'u'
| b'o'
| b'x'
| b'X'
| b'e'
| b'E'
| b'f'
| b'F'
| b'g'
| b'G'
| b'c'
| b'p'
| b'n'
| b'b'
)
{
count += 1;
}
i += 1;
}
count
}
fn unquote_string(raw: &str) -> &str {
if raw.len() >= 2 {
let bytes = raw.as_bytes();
let first = bytes[0];
let last = bytes[raw.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &raw[1..raw.len() - 1];
}
}
raw
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn count_basic_specifiers() {
assert_eq!(count_format_specifiers("%s %d"), 2);
assert_eq!(count_format_specifiers("%s"), 1);
assert_eq!(count_format_specifiers("no specifiers"), 0);
}
#[test]
fn double_percent_not_counted() {
assert_eq!(count_format_specifiers("%%"), 0);
assert_eq!(count_format_specifiers("%s %%"), 1);
assert_eq!(count_format_specifiers("%% %d"), 1);
}
#[test]
fn flags_and_width_handled() {
assert_eq!(count_format_specifiers("%-10s"), 1);
assert_eq!(count_format_specifiers("%+.2f"), 1);
assert_eq!(count_format_specifiers("%05d"), 1);
}
#[test]
fn unquote_double_quotes() {
assert_eq!(unquote_string(r#""hello""#), "hello");
}
#[test]
fn unquote_single_quotes() {
assert_eq!(unquote_string("'world'"), "world");
}
#[test]
fn unquote_no_quotes_unchanged() {
assert_eq!(unquote_string("bare"), "bare");
}
}