#![allow(clippy::print_stdout, clippy::print_stderr)]
use std::{
collections::{BTreeMap, HashMap},
fs,
path::PathBuf,
process,
sync::LazyLock,
};
use anyhow::Result;
use convert_case::{Case, Casing};
use glob::glob;
use proc_macro2::TokenStream;
use quote::ToTokens;
use regex::Regex;
use syn::{
ItemImpl, ItemStruct, Type,
spanned::Spanned,
visit::{self, Visit},
};
const BYTE_SIZE_COUNT: &[&str] = &["byte_size", "count"];
const METRIC_NAME_EVENTS_DROPPED: &str = "component_discarded_events_total";
const METRIC_NAME_ERROR: &str = "component_errors_total";
struct EventClass {
message: &'static str,
counters: &'static [&'static str],
additional_tags: &'static [&'static str],
}
const EVENT_CLASSES: &[(&str, EventClass)] = &[
(
"BytesReceived",
EventClass {
message: "Bytes received.",
counters: &["received_bytes"],
additional_tags: &["byte_size", "protocol"],
},
),
(
"EventsReceived",
EventClass {
message: "Events received.",
counters: &["received_events", "received_event_bytes"],
additional_tags: &["count", "byte_size"],
},
),
(
"EventsSent",
EventClass {
message: "Events sent.",
counters: &["sent_events", "sent_event_bytes"],
additional_tags: &["count", "byte_size"],
},
),
(
"BytesSent",
EventClass {
message: "Bytes sent.",
counters: &["sent_bytes"],
additional_tags: &["byte_size", "protocol"],
},
),
];
#[derive(Debug, Default, Clone)]
struct SkipFlags {
dropped_events: bool,
duplicate_check: bool,
validity_check: bool,
}
#[derive(Debug, Default, Clone)]
struct Event {
path: Option<String>,
skip: SkipFlags,
emits_component_events_dropped: bool,
members: BTreeMap<String, String>,
counters: BTreeMap<String, BTreeMap<String, String>>,
metrics: BTreeMap<String, BTreeMap<String, String>>,
logs: Vec<LogCall>,
uses: u32,
internal_impl: bool,
register_impl: Option<String>,
impl_event_handle: bool,
reports: Vec<String>,
}
#[derive(Debug, Clone)]
struct LogCall {
level: String,
message: String,
parameters: Vec<String>,
}
impl Event {
fn add_metric(&mut self, ty: &str, name: &str, tags: BTreeMap<String, String>) {
let key = format!("{ty}:{name}");
self.metrics.insert(key, tags.clone());
if ty == "counter" {
self.counters.insert(name.to_string(), tags);
}
}
fn add_log(&mut self, level: &str, message: &str, parameters: Vec<String>) {
self.logs.push(LogCall {
level: level.to_string(),
message: message.to_string(),
parameters,
});
}
fn append(&mut self, report: impl Into<String>) {
self.reports.push(report.into());
}
fn signature(&self) -> Option<String> {
if self.metrics.is_empty() && self.logs.is_empty() {
return None;
}
let members: Vec<String> = self
.members
.iter()
.map(|(name, ty)| format!("{name}:{ty}"))
.collect();
let mut metrics: Vec<String> = self
.metrics
.iter()
.map(|(name, tags)| {
let mut keys: Vec<&str> = tags.keys().map(String::as_str).collect();
keys.sort_unstable();
format!("{name}({})", keys.join(","))
})
.collect();
metrics.sort();
let mut logs: Vec<String> = self
.logs
.iter()
.map(|l| format!("[\"{}\", \"{}\", {:?}]", l.level, l.message, l.parameters))
.collect();
logs.sort();
Some(format!(
"{}[{}][{}]",
members.join(":"),
logs.join(";"),
metrics.join(";")
))
}
}
fn log_level_one_of(reports: &mut Vec<String>, logs: &[LogCall], levels: &[&str]) {
if !logs.iter().any(|l| levels.contains(&l.level.as_str())) {
reports.push(format!(
"This event MUST log with one of these levels: [{}].",
levels
.iter()
.map(|l| format!("\"{l}\""))
.collect::<Vec<_>>()
.join(", ")
));
}
}
fn counters_must_include_exclude_tags(
reports: &mut Vec<String>,
counters: &BTreeMap<String, BTreeMap<String, String>>,
name: &str,
required_tags: &[&str],
exclude_tags: &[&str],
) {
let Some(tags) = counters.get(name) else {
reports.push(format!("This event MUST increment counter \"{name}\"."));
return;
};
for tag in required_tags {
if !tags.contains_key(*tag) {
reports.push(format!("Counter \"{name}\" MUST include tag \"{tag}\"."));
}
}
for tag in exclude_tags {
if tags.contains_key(*tag) {
reports.push(format!(
"Counter \"{name}\" MUST NOT include tag \"{tag}\"."
));
}
}
}
fn check_event_class(reports: &mut Vec<String>, name: &str, event: &Event, handle: &Event) {
for (suffix, class) in EVENT_CLASSES {
if !name.ends_with(suffix) {
continue;
}
for log in &handle.logs {
if log.level != "trace" {
reports.push("Log type MUST be \"trace!\".".to_string());
}
if log.message != class.message {
reports.push(format!(
"Log message MUST be \"{}\" (is \"{}\").",
class.message, log.message
));
}
for tag in class.additional_tags {
if !log.parameters.iter().any(|p| p == tag) {
reports.push(format!("Log MUST contain tag \"{tag}\""));
}
}
}
for counter in class.counters {
let counter_name = format!("component_{counter}_total");
let required: Vec<&str> = class
.additional_tags
.iter()
.copied()
.filter(|t| !BYTE_SIZE_COUNT.contains(t))
.collect();
counters_must_include_exclude_tags(
reports,
&event.counters,
&counter_name,
&required,
&[],
);
}
}
}
fn check_error_event(reports: &mut Vec<String>, name: &str, event: &Event, handle: &Event) {
if !name.ends_with("Error") {
reports.push("Error events MUST be named \"___Error\".".to_string());
}
log_level_one_of(reports, &handle.logs, &["error"]);
counters_must_include_exclude_tags(
reports,
&event.counters,
METRIC_NAME_ERROR,
&["error_type", "stage"],
&[],
);
for log in &handle.logs {
if log.level != "error" {
continue;
}
for parameter in ["error_type", "stage"] {
if !log.parameters.iter().any(|p| p == parameter) {
reports.push(format!(
"Error log for Error event MUST include parameter \"{parameter}\"."
));
}
}
for parameter in ["error_code", "error_type", "stage"] {
if log.parameters.iter().any(|p| p == parameter)
&& !event
.counters
.get(METRIC_NAME_ERROR)
.is_some_and(|m| m.contains_key(parameter))
{
reports.push(format!(
"Counter \"{METRIC_NAME_ERROR}\" must include \"{parameter}\" to match error log."
));
}
}
}
}
fn check_events_dropped(reports: &mut Vec<String>, name: &str, event: &Event, handle: &Event) {
if event.emits_component_events_dropped {
if event.counters.contains_key(METRIC_NAME_EVENTS_DROPPED) {
reports.push(format!(
"Event emitting ComponentEventsDropped should not also increment counter `{METRIC_NAME_EVENTS_DROPPED}`"
));
}
return;
}
if !name.ends_with("EventsDropped") {
reports.push("EventsDropped events MUST be named \"___EventsDropped\".".to_string());
}
log_level_one_of(reports, &handle.logs, &["error", "debug"]);
counters_must_include_exclude_tags(
reports,
&event.counters,
METRIC_NAME_EVENTS_DROPPED,
&["intentional"],
&["reason", "count"],
);
for log in &handle.logs {
if log.level != "error" {
continue;
}
for parameter in ["count", "intentional", "reason"] {
if !log.parameters.iter().any(|p| p == parameter) {
reports.push(format!(
"Error log for EventsDropped event MUST include parameter \"{parameter}\"."
));
}
}
if log.parameters.iter().any(|p| p == "intentional")
&& !event
.counters
.get(METRIC_NAME_EVENTS_DROPPED)
.is_some_and(|m| m.contains_key("intentional"))
{
reports.push(format!(
"Counter \"{METRIC_NAME_EVENTS_DROPPED}\" must include \"intentional\" to match error log."
));
}
}
}
fn check_error_counter_tag_constants(reports: &mut Vec<String>, event: &Event) {
for (cname, tags) in &event.counters {
if cname != METRIC_NAME_ERROR && cname != METRIC_NAME_EVENTS_DROPPED {
continue;
}
for (tag, value) in tags {
if tag == "stage" && !value.starts_with("error_stage::") {
reports.push(format!(
"Counter \"{cname}\" tag \"{tag}\" value must be an \"error_stage\" constant."
));
} else if tag == "error_type" && !value.starts_with("error_type::") {
reports.push(format!(
"Counter \"{cname}\" tag \"{tag}\" value must be an \"error_type\" constant."
));
}
}
}
}
fn validate_event(events: &HashMap<String, Event>, name: &str, handle_name: &str) -> Vec<String> {
let event = events.get(name).expect("event present");
let handle = events.get(handle_name).expect("handle present");
let mut reports: Vec<String> = Vec::new();
if event.uses == 0 {
reports.push("Event has no uses.".to_string());
}
check_event_class(&mut reports, name, event, handle);
let has_error_logs = handle.logs.iter().filter(|l| l.level == "error").count() == 1;
let is_events_dropped_event =
name.ends_with("EventsDropped") || event.counters.contains_key(METRIC_NAME_EVENTS_DROPPED);
if (has_error_logs && !is_events_dropped_event) || name.ends_with("Error") {
check_error_event(&mut reports, name, event, handle);
}
if is_events_dropped_event && !event.skip.dropped_events {
check_events_dropped(&mut reports, name, event, handle);
}
check_error_counter_tag_constants(&mut reports, event);
for r in &event.reports {
reports.push(r.clone());
}
reports
}
static RE_EMIT_DROPPED: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:emit|register)!\([ \t\r\n]*ComponentEventsDropped(?:[^A-Za-z0-9_]|$)").unwrap()
});
static RE_USES: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?:^|[^A-Za-z0-9_])(?:emit!?|register!?)\((?:[a-z][a-z0-9_:]+)?([A-Z][A-Za-z0-9]+)",
)
.unwrap()
});
static RE_LOG_CALL_OPEN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:^|[^A-Za-z0-9_])(trace|debug|info|warn|error)!\(").unwrap());
static RE_TAG_PAIR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#""([^"]+)"[ \t\r\n]*=[ \t\r\n]*>[ \t\r\n]*(.+?)(?:,|$)"#).unwrap()
});
fn normalize_value(s: &str) -> String {
let trimmed = s.trim();
let collapsed = Regex::new(r"[ \t\r\n]*::[ \t\r\n]*")
.unwrap()
.replace_all(trimmed, "::");
collapsed.into_owned()
}
#[expect(
clippy::string_slice,
reason = "indices from byte-iterator over ASCII delimiters, always char boundaries"
)]
fn split_comma_args(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut depth: i32 = 0;
let mut angle_depth: i32 = 0;
let mut in_str = false;
let mut esc = false;
let mut start = 0;
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if in_str {
if esc {
esc = false;
} else if b == b'\\' {
esc = true;
} else if b == b'"' {
in_str = false;
}
continue;
}
match b {
b'"' => in_str = true,
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b'<' => angle_depth += 1,
b'>' if angle_depth > 0 => angle_depth -= 1,
b',' if depth == 0 && angle_depth == 0 => {
out.push(s[start..i].trim().to_string());
start = i + 1;
}
_ => {}
}
}
let last = s[start..].trim().to_string();
if !last.is_empty() {
out.push(last);
}
out
}
#[derive(Debug)]
struct ParsedMetric {
ty: String,
name: String,
tags: BTreeMap<String, String>,
}
fn parse_metric_args(ty: &str, tokens: &TokenStream) -> Option<ParsedMetric> {
let raw = tokens.to_string();
let args = split_comma_args(&raw);
if args.is_empty() {
return None;
}
let name = parse_metric_name(args[0].as_str())?;
let mut tags = BTreeMap::new();
let rest = args[1..].join(",");
for caps in RE_TAG_PAIR.captures_iter(&rest) {
tags.insert(caps[1].to_string(), normalize_value(&caps[2]));
}
if tags.is_empty() {
for caps in RE_TAG_PAIR.captures_iter(&raw) {
tags.insert(caps[1].to_string(), normalize_value(&caps[2]));
}
}
Some(ParsedMetric {
ty: ty.to_string(),
name,
tags,
})
}
fn parse_metric_name(arg: &str) -> Option<String> {
let arg = arg.trim();
if let Some(stripped) = arg.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
return Some(stripped.to_string());
}
let re = Regex::new(r"^[A-Za-z0-9_]+Name[ \t]*::[ \t]*([A-Za-z0-9_]+)").unwrap();
re.captures(arg).map(|c| (&c[1]).to_case(Case::Snake))
}
#[derive(Debug)]
struct ParsedLog {
message: String,
has_literal_message: bool,
parameters: Vec<String>,
}
fn parse_log_args(raw: &str) -> ParsedLog {
let args = split_comma_args(raw);
let mut literal_message: Option<String> = None;
let mut named_var_message: Option<String> = None;
let mut bare_positional_message: Option<String> = None;
let mut parameters: Vec<String> = Vec::new();
for arg in &args {
let trimmed = arg.trim();
if trimmed.starts_with("target :") || trimmed.starts_with("parent :") {
continue;
}
if let Some(rest) = trimmed.strip_prefix("message")
&& let Some(value) = rest.trim_start().strip_prefix('=').map(str::trim_start)
{
let value = value.trim();
if let Some(stripped) = value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
if literal_message.is_none() {
literal_message = Some(stripped.to_string());
}
} else if named_var_message.is_none() {
named_var_message = Some(value.to_string());
}
continue;
}
if let Some(stripped) = trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
if literal_message.is_none() {
literal_message = Some(stripped.to_string());
}
continue;
}
if !trimmed.contains('=') {
if bare_positional_message.is_none() {
bare_positional_message = Some(trimmed.to_string());
}
if let Some(name) = parameter_name(trimmed) {
parameters.push(name);
}
continue;
}
if let Some(name) = parameter_name(trimmed) {
parameters.push(name);
}
}
let (message, has_literal_message) = if let Some(m) = literal_message {
(m, true)
} else if let Some(m) = named_var_message {
(m, false)
} else if let Some(m) = bare_positional_message {
(m, false)
} else {
(String::new(), false)
};
ParsedLog {
message,
has_literal_message,
parameters,
}
}
fn parameter_name(arg: &str) -> Option<String> {
let s = arg.trim();
if s.is_empty() {
return None;
}
if let Some((lhs, _)) = s.split_once('=') {
let lhs = lhs
.trim()
.trim_start_matches('?')
.trim_start_matches('%')
.trim();
if is_identifier(lhs) {
return Some(lhs.to_string());
}
}
let stripped = s
.trim_start_matches('?')
.trim_start_matches('%')
.trim_start();
let head: String = stripped
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '.')
.collect();
if !head.is_empty() && head.chars().any(|c| c.is_ascii_alphabetic() || c == '_') {
return Some(head);
}
None
}
fn is_identifier(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
}
#[derive(Clone)]
struct ImplCtx {
event_name: String,
}
struct Scanner<'a> {
events: &'a mut HashMap<String, Event>,
path_str: String,
in_internal_events_dir: bool,
skip_dropped_for_file: bool,
text: &'a str,
impl_stack: Vec<ImplCtx>,
}
impl<'ast> Visit<'ast> for Scanner<'_> {
fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
if self.in_internal_events_dir {
let name = node.ident.to_string();
let event = self.events.entry(name).or_default();
event.path = Some(self.path_str.clone());
event.skip.dropped_events = self.skip_dropped_for_file;
for field in &node.fields {
if let Some(ident) = &field.ident {
let ty = field.ty.to_token_stream().to_string();
event.members.insert(ident.to_string(), ty);
}
}
}
visit::visit_item_struct(self, node);
}
fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
let trait_name = node
.trait_
.as_ref()
.and_then(|(_, path, _)| path.segments.last())
.map(|s| s.ident.to_string());
let event_name = match &*node.self_ty {
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
_ => None,
};
if self.in_internal_events_dir
&& let (Some(trait_name), Some(event_name)) = (trait_name.as_deref(), event_name)
{
let mut handled = false;
if matches!(
trait_name,
"InternalEvent" | "RegisterInternalEvent" | "InternalEventHandle"
) {
let raw_block = source_slice(self.text, node.span());
let registers_inside = node.to_token_stream().to_string().contains("register (");
let event = self.events.entry(event_name.clone()).or_default();
event.path = Some(self.path_str.clone());
event.skip.duplicate_check |=
raw_block.contains("## skip check-duplicate-events ##");
event.skip.validity_check |= raw_block.contains("## skip check-validity-events ##");
match trait_name {
"InternalEvent" if !registers_inside => {
event.internal_impl = true;
}
"RegisterInternalEvent" => {
event.register_impl = Some(event_name.clone());
event.append(
"Do not implement RegisterInternalEvent manually. Use the registered_event! macro instead.",
);
}
"InternalEventHandle" => event.impl_event_handle = true,
_ => {}
}
if RE_EMIT_DROPPED.is_match(&raw_block) {
event.emits_component_events_dropped = true;
}
self.impl_stack.push(ImplCtx { event_name });
handled = true;
}
visit::visit_item_impl(self, node);
if handled {
self.impl_stack.pop();
}
return;
}
visit::visit_item_impl(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
let name = node
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
if let Some(ctx) = self.impl_stack.last().cloned() {
match name.as_str() {
"trace" | "debug" | "info" | "warn" | "error" => {
let parsed = parse_log_args(&node.tokens.to_string());
let event = self.events.entry(ctx.event_name.clone()).or_default();
event.add_log(&name, &parsed.message, parsed.parameters);
}
"counter" | "gauge" | "histogram" => {
if let Some(metric) = parse_metric_args(&name, &node.tokens) {
let event = self.events.entry(ctx.event_name.clone()).or_default();
event.add_metric(&metric.ty, &metric.name, metric.tags);
}
}
_ => {}
}
}
if name == "registered_event" {
self.handle_registered_event(node);
}
visit::visit_macro(self, node);
}
}
impl Scanner<'_> {
#[expect(
clippy::string_slice,
reason = "indices from find() on ASCII patterns or match_paren_end(), always char boundaries"
)]
fn handle_registered_event(&mut self, mac: &syn::Macro) {
let raw = mac.tokens.to_string();
let Some(event_name) = first_ident(&raw) else {
return;
};
let event = self.events.entry(event_name.clone()).or_default();
event.path = Some(self.path_str.clone());
let after_name = match raw.find(&event_name) {
Some(idx) => &raw[idx + event_name.len()..],
None => return,
};
let after_name = after_name.trim_start();
let (event_fields_text, after_fields): (Option<String>, &str) =
if after_name.starts_with('{') {
let (block, rest) = split_brace_block(after_name);
(Some(block.to_string()), rest)
} else {
(None, after_name)
};
if let Some(block) = event_fields_text {
for arg in split_comma_args(&block) {
if let Some((name, ty)) = arg.split_once(':') {
event
.members
.insert(name.trim().to_string(), ty.trim().to_string());
}
}
}
let after_arrow = after_fields.trim_start();
let after_arrow = after_arrow
.strip_prefix("=>")
.unwrap_or(after_arrow)
.trim_start();
let (handle_block, _after_handle) = if after_arrow.starts_with('{') {
let (block, rest) = split_brace_block(after_arrow);
(block.to_string(), rest)
} else {
return;
};
for arg in split_comma_args(&handle_block) {
let arg = arg.trim();
if arg.is_empty() {
continue;
}
let after_colon = match arg.find(':') {
Some(i) => &arg[i + 1..],
None => continue,
};
let Some((_ty, expr)) = after_colon.split_once('=') else {
continue;
};
let expr = expr.trim();
for ty in ["counter", "gauge", "histogram"] {
let needle = format!("{ty} ! (");
if let Some(idx) = expr.find(&needle) {
let after = &expr[idx + needle.len()..];
if let Some(end) = match_paren_end(after) {
let inside = &after[..end];
let toks: TokenStream = inside.parse().unwrap_or_default();
if let Some(metric) = parse_metric_args(ty, &toks) {
event.add_metric(&metric.ty, &metric.name, metric.tags);
}
}
}
}
if expr.contains("emit ! (ComponentEventsDropped")
|| expr.contains("register ! (ComponentEventsDropped")
{
event.emits_component_events_dropped = true;
}
}
for ty in ["trace", "debug", "info", "warn", "error"] {
let needle = format!("{ty} ! (");
let mut start = 0;
while let Some(idx) = raw[start..].find(&needle) {
let after = &raw[start + idx + needle.len()..];
if let Some(end) = match_paren_end(after) {
let inside = &after[..end];
let parsed = parse_log_args(inside);
let event = self.events.entry(event_name.clone()).or_default();
event.add_log(ty, &parsed.message, parsed.parameters);
start = start + idx + needle.len() + end;
} else {
break;
}
}
}
}
}
#[expect(
clippy::string_slice,
reason = "indices from regex .end() and match_paren_end(), always char boundaries"
)]
fn format_check_log_messages(text: &str, path_str: &str) -> Vec<String> {
let mut reports = Vec::new();
for caps in RE_LOG_CALL_OPEN.captures_iter(text) {
let level_match = caps.get(1).expect("group 1 is the level");
let level = level_match.as_str();
let after_paren = caps.get(0).expect("full match").end();
if after_paren > text.len() {
continue;
}
let body_start = after_paren; let Some(close_offset) = match_paren_end(&text[body_start..]) else {
continue;
};
let inside = &text[body_start..body_start + close_offset];
let parsed = parse_log_args(inside);
if !parsed.has_literal_message {
continue;
}
let message = parsed.message;
if message.is_empty() {
continue;
}
let is_capitalized = message.starts_with('{')
|| !message
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
|| message
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase());
let has_trailing_period = message.ends_with('}') || message.ends_with('.');
if is_capitalized && has_trailing_period {
continue;
}
let line_no = text[..level_match.start()].matches('\n').count() + 1;
if !is_capitalized {
reports.push(format!(
" Message must start with a capital. (`{level}` call on {path_str}:{line_no})"
));
}
if !has_trailing_period {
reports.push(format!(
" Message must end with a period. (`{level}` call on {path_str}:{line_no})"
));
}
}
reports
}
fn source_slice(text: &str, span: proc_macro2::Span) -> String {
let start = span.start();
let end = span.end();
let mut out = String::new();
for (i, line) in text.lines().enumerate() {
let line_no = i + 1;
if line_no >= start.line && line_no <= end.line {
out.push_str(line);
out.push('\n');
}
if line_no > end.line {
break;
}
}
out
}
fn match_paren_end(s: &str) -> Option<usize> {
let mut depth: i32 = 1;
let mut in_str = false;
let mut esc = false;
for (i, b) in s.bytes().enumerate() {
if in_str {
if esc {
esc = false;
} else if b == b'\\' {
esc = true;
} else if b == b'"' {
in_str = false;
}
continue;
}
match b {
b'"' => in_str = true,
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
#[expect(
clippy::string_slice,
reason = "indices from byte-iterator over ASCII '{' '}', always char boundaries"
)]
fn split_brace_block(s: &str) -> (&str, &str) {
if !s.starts_with('{') {
return ("", s);
}
let mut depth = 0i32;
let mut in_str = false;
let mut esc = false;
for (i, b) in s.bytes().enumerate() {
if in_str {
if esc {
esc = false;
} else if b == b'\\' {
esc = true;
} else if b == b'"' {
in_str = false;
}
continue;
}
match b {
b'"' => in_str = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return (&s[1..i], &s[i + 1..]);
}
}
_ => {}
}
}
("", s)
}
fn first_ident(s: &str) -> Option<String> {
for tok in s.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
if !tok.is_empty()
&& tok
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
{
return Some(tok.to_string());
}
}
None
}
#[derive(clap::Args, Debug)]
#[command()]
pub(super) struct Cli {}
fn collect_source_paths() -> Result<Vec<PathBuf>> {
let mut paths: Vec<PathBuf> = Vec::new();
for pattern in ["src/**/*.rs", "lib/**/*.rs"] {
for entry in glob(pattern)? {
paths.push(entry?);
}
}
paths.sort();
Ok(paths)
}
fn scan_file(path: &PathBuf, events: &mut HashMap<String, Event>) -> Result<usize> {
let path_str = path.to_string_lossy().replace('\\', "/");
let text = fs::read_to_string(path)?;
let lower = text.to_ascii_lowercase();
let in_internal_events = path_str.starts_with("src/internal_events/")
|| path_str.starts_with("lib/vector-common/src/internal_event/");
let in_src = path_str.starts_with("src/");
let skip_dropped = lower.contains("## skip check-dropped-events ##");
for caps in RE_USES.captures_iter(&text) {
let name = caps[1].to_string();
events.entry(name).or_default().uses += 1;
}
let mut errors = 0usize;
if in_src {
let format_reports = format_check_log_messages(&text, &path_str);
if !format_reports.is_empty() {
for r in &format_reports {
println!("{r}");
}
errors += format_reports.len();
}
}
let file = match syn::parse_file(&text) {
Ok(f) => f,
Err(e) => {
eprintln!("warning: failed to parse {path_str}: {e}");
return Ok(errors);
}
};
let mut scanner = Scanner {
events,
path_str: path_str.clone(),
in_internal_events_dir: in_internal_events,
skip_dropped_for_file: skip_dropped,
text: &text,
impl_stack: Vec::new(),
};
visit::visit_file(&mut scanner, &file);
Ok(errors)
}
fn report_event_errors(events: &HashMap<String, Event>, name: &str, handle_name: &str) -> bool {
let reports = validate_event(events, name, handle_name);
if reports.is_empty() {
return false;
}
let path = events
.get(name)
.and_then(|e| e.path.as_deref())
.unwrap_or("?");
println!("{path}: Errors in event {name}:");
for r in &reports {
println!(" {r}");
}
true
}
fn validate_all(events: &HashMap<String, Event>) -> usize {
let mut names: Vec<String> = events.keys().cloned().collect();
names.sort();
let mut duplicates: HashMap<String, Vec<String>> = HashMap::new();
let mut error_count = 0usize;
for name in &names {
let event = events.get(name).expect("present");
if !event.skip.duplicate_check
&& (event.internal_impl || event.impl_event_handle)
&& let Some(sig) = event.signature()
{
duplicates.entry(sig).or_default().push(name.clone());
}
if event.skip.validity_check {
continue;
}
if event.internal_impl {
if report_event_errors(events, name, name) {
error_count += 1;
}
} else if let Some(handle_name) = event.register_impl.as_deref() {
if events.contains_key(handle_name) {
if report_event_errors(events, name, handle_name) {
error_count += 1;
}
} else {
println!("Registered event {name} references nonexistent handle {handle_name}");
error_count += 1;
}
}
}
let mut dup_keys: Vec<&String> = duplicates.keys().collect();
dup_keys.sort();
for sig in dup_keys {
let dupes = &duplicates[sig];
if dupes.len() > 1 {
println!("Duplicate events detected: {}", dupes.join(", "));
error_count += 1;
}
}
error_count
}
impl Cli {
pub(super) fn exec(self) -> Result<()> {
let repo_root = crate::utils::paths::find_repo_root()?;
std::env::set_current_dir(&repo_root)?;
let mut events: HashMap<String, Event> = HashMap::new();
let mut error_count = 0usize;
for path in &collect_source_paths()? {
error_count += scan_file(path, &mut events)?;
}
error_count += validate_all(&events);
println!("{error_count} error(s)");
if error_count > 0 {
process::exit(1);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_comma_args_respects_nesting() {
assert_eq!(
split_comma_args(r#""a", "b" => "c, d", e"#),
vec![
r#""a""#.to_string(),
r#""b" => "c, d""#.to_string(),
"e".to_string(),
]
);
}
#[test]
fn split_comma_args_respects_angle_brackets() {
let input = "events_dropped : Registered<ComponentEventsDropped<'static, INTENTIONAL>> = register!(X)";
assert_eq!(split_comma_args(input), vec![input.to_string()]);
}
#[test]
fn parse_metric_name_string_or_variant() {
assert_eq!(
parse_metric_name(r#""my_metric""#),
Some("my_metric".to_string())
);
assert_eq!(
parse_metric_name("CounterName::ComponentErrorsTotal"),
Some("component_errors_total".to_string())
);
assert_eq!(parse_metric_name("not_a_metric"), None);
}
#[test]
fn signature_none_when_empty() {
assert!(Event::default().signature().is_none());
}
fn parse(src: &str) -> ParsedLog {
let mac: syn::Macro = syn::parse_str(src).expect("parse macro");
parse_log_args(&mac.tokens.to_string())
}
#[test]
fn parse_log_args_literal_message_first() {
let p = parse(r#"trace!("Hello there.", count = 1)"#);
assert_eq!(p.message, "Hello there.");
assert!(p.has_literal_message);
assert_eq!(p.parameters, vec!["count".to_string()]);
}
#[test]
fn parse_log_args_literal_message_named() {
let p = parse(r#"error!(message = "Stuff broke.", error_type = err)"#);
assert_eq!(p.message, "Stuff broke.");
assert!(p.has_literal_message);
assert_eq!(p.parameters, vec!["error_type".to_string()]);
}
#[test]
fn parse_log_args_variable_message_named() {
let p = parse("error!(message = exec_reason, error_type = err, stage = stg)");
assert_eq!(p.message, "exec_reason");
assert!(!p.has_literal_message);
assert_eq!(
p.parameters,
vec!["error_type".to_string(), "stage".to_string()]
);
}
#[test]
fn parse_log_args_trailing_string_literal() {
let p = parse(r#"error!(path = req.uri().path(), "Bad request.")"#);
assert_eq!(p.message, "Bad request.");
assert!(p.has_literal_message);
assert!(p.parameters.contains(&"path".to_string()));
}
#[test]
fn format_check_finds_nested_log_calls() {
let src = r#"
fn _f() {
error!("missing period");
tokio::select! {
_ = something() => {
info!("lowercase first.");
}
}
}
"#;
let reports = format_check_log_messages(src, "fixture.rs");
let joined = reports.join("\n");
assert!(
joined.contains("Message must end with a period.") && joined.contains("`error` call"),
"expected period-violation report, got: {joined}"
);
assert!(
joined.contains("Message must start with a capital.") && joined.contains("`info` call"),
"expected capital-violation report on the nested info!, got: {joined}"
);
}
#[test]
fn format_check_skips_non_literal_messages() {
let src = r#"fn _f() { error!(?err, "Plain text."); }"#;
let reports = format_check_log_messages(src, "fixture.rs");
assert!(reports.is_empty(), "expected no reports, got: {reports:?}");
}
#[test]
fn parse_log_args_bare_field_then_trailing_literal() {
let p = parse(r#"warn!(%error, "Failed to flush.")"#);
assert_eq!(p.message, "Failed to flush.");
assert!(p.has_literal_message);
assert!(p.parameters.contains(&"error".to_string()));
}
#[test]
fn parse_log_args_percent_capture() {
let p = parse(r#"trace!(message = "Bytes received.", byte_size = bs, %protocol)"#);
assert!(p.has_literal_message);
assert_eq!(p.message, "Bytes received.");
assert!(p.parameters.contains(&"byte_size".to_string()));
assert!(p.parameters.contains(&"protocol".to_string()));
}
fn check(message: &str) -> (bool, bool) {
let is_capitalized = message.starts_with('{')
|| !message
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
|| message
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase());
let has_trailing_period = message.ends_with('}') || message.ends_with('.');
(is_capitalized, has_trailing_period)
}
#[test]
fn message_format_capital_period_pass() {
assert_eq!(check("Hello there."), (true, true));
}
#[test]
fn message_format_lowercase_first_fails() {
let (cap, _) = check("hello there.");
assert!(!cap);
}
#[test]
fn message_format_no_period_fails() {
let (_, period) = check("Hello there");
assert!(!period);
}
#[test]
fn message_format_interpolation_passes() {
assert_eq!(check("{count} dropped."), (true, true));
assert_eq!(check("Dropped {count}"), (true, true));
}
#[test]
fn message_format_non_alpha_first_passes() {
assert_eq!(check("42 things happened."), (true, true));
}
fn mk_event() -> Event {
Event {
uses: 1, internal_impl: true,
..Default::default()
}
}
fn one_log(level: &str, message: &str, params: &[&str]) -> Vec<LogCall> {
vec![LogCall {
level: level.to_string(),
message: message.to_string(),
parameters: params.iter().map(|s| (*s).to_string()).collect(),
}]
}
fn counter(tags: &[(&str, &str)]) -> BTreeMap<String, String> {
tags.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
fn run(name: &str, event: Event) -> Vec<String> {
let mut events = HashMap::new();
events.insert(name.to_string(), event);
validate_event(&events, name, name)
}
#[test]
fn validate_event_no_uses_reported() {
let mut e = mk_event();
e.uses = 0;
let r = run("Foo", e);
assert!(r.iter().any(|m| m == "Event has no uses."));
}
#[test]
fn validate_bytes_received_log_type_must_be_trace() {
let mut e = mk_event();
e.logs = one_log("info", "Bytes received.", &["byte_size", "protocol"]);
e.counters.insert(
"component_received_bytes_total".to_string(),
counter(&[("protocol", "tcp")]),
);
let r = run("FooBytesReceived", e);
assert!(r.iter().any(|m| m == "Log type MUST be \"trace!\"."));
}
#[test]
fn validate_bytes_received_log_message_exact() {
let mut e = mk_event();
e.logs = one_log(
"trace",
"Bytes were received here.",
&["byte_size", "protocol"],
);
e.counters.insert(
"component_received_bytes_total".to_string(),
counter(&[("protocol", "tcp")]),
);
let r = run("FooBytesReceived", e);
assert!(
r.iter()
.any(|m| m.contains("Log message MUST be \"Bytes received.\""))
);
}
#[test]
fn validate_bytes_received_log_required_tag() {
let mut e = mk_event();
e.logs = one_log("trace", "Bytes received.", &["byte_size"]); e.counters.insert(
"component_received_bytes_total".to_string(),
counter(&[("protocol", "tcp")]),
);
let r = run("FooBytesReceived", e);
assert!(r.iter().any(|m| m == "Log MUST contain tag \"protocol\""));
}
#[test]
fn validate_bytes_received_counter_required_tag() {
let mut e = mk_event();
e.logs = one_log("trace", "Bytes received.", &["byte_size", "protocol"]);
e.counters
.insert("component_received_bytes_total".to_string(), counter(&[])); let r = run("FooBytesReceived", e);
assert!(r.iter().any(|m| {
m == "Counter \"component_received_bytes_total\" MUST include tag \"protocol\"."
}));
}
#[test]
fn validate_events_received_class() {
let mut e = mk_event();
e.logs = one_log("trace", "Wrong message.", &["count", "byte_size"]);
let r = run("FooEventsReceived", e);
assert!(
r.iter()
.any(|m| m.contains("Log message MUST be \"Events received.\""))
);
assert!(
r.iter()
.any(|m| m
== "This event MUST increment counter \"component_received_events_total\".")
);
}
#[test]
fn validate_error_event_must_be_named_error() {
let mut e = mk_event();
e.logs = one_log("error", "Something failed.", &["error_type", "stage"]);
e.counters.insert(
METRIC_NAME_ERROR.to_string(),
counter(&[
("error_type", "error_type::CONNECTION_FAILED"),
("stage", "error_stage::PROCESSING"),
]),
);
let r = run("BadlyNamed", e);
assert!(
r.iter()
.any(|m| m == "Error events MUST be named \"___Error\".")
);
}
#[test]
fn validate_error_event_log_level_must_be_error() {
let mut e = mk_event();
e.logs = one_log("info", "Something failed.", &["error_type", "stage"]);
e.counters.insert(
METRIC_NAME_ERROR.to_string(),
counter(&[
("error_type", "error_type::CONNECTION_FAILED"),
("stage", "error_stage::PROCESSING"),
]),
);
let r = run("FooError", e);
assert!(
r.iter()
.any(|m| m.contains("MUST log with one of these levels: [\"error\"]"))
);
}
#[test]
fn validate_error_event_log_must_include_error_type_and_stage() {
let mut e = mk_event();
e.logs = one_log("error", "Something failed.", &[]); e.counters.insert(
METRIC_NAME_ERROR.to_string(),
counter(&[
("error_type", "error_type::CONNECTION_FAILED"),
("stage", "error_stage::PROCESSING"),
]),
);
let r = run("FooError", e);
assert!(
r.iter()
.any(|m| m == "Error log for Error event MUST include parameter \"error_type\".")
);
assert!(
r.iter()
.any(|m| m == "Error log for Error event MUST include parameter \"stage\".")
);
}
#[test]
fn validate_error_counter_must_match_error_log_params() {
let mut e = mk_event();
e.logs = one_log("error", "Failed.", &["error_type", "stage", "error_code"]);
e.counters.insert(
METRIC_NAME_ERROR.to_string(),
counter(&[
("error_type", "error_type::CONNECTION_FAILED"),
("stage", "error_stage::PROCESSING"),
]),
);
let r = run("FooError", e);
assert!(r.iter().any(|m| {
m == "Counter \"component_errors_total\" must include \"error_code\" to match error log."
}));
}
#[test]
fn validate_error_stage_must_be_constant() {
let mut e = mk_event();
e.logs = one_log("error", "Failed.", &["error_type", "stage"]);
e.counters.insert(
METRIC_NAME_ERROR.to_string(),
counter(&[
("error_type", "error_type::CONNECTION_FAILED"),
("stage", "\"processing\""),
]),
);
let r = run("FooError", e);
assert!(
r.iter()
.any(|m| m.contains("must be an \"error_stage\" constant"))
);
}
#[test]
fn validate_error_type_must_be_constant() {
let mut e = mk_event();
e.logs = one_log("error", "Failed.", &["error_type", "stage"]);
e.counters.insert(
METRIC_NAME_ERROR.to_string(),
counter(&[
("error_type", "\"connection_failed\""),
("stage", "error_stage::PROCESSING"),
]),
);
let r = run("FooError", e);
assert!(
r.iter()
.any(|m| m.contains("must be an \"error_type\" constant"))
);
}
#[test]
fn validate_events_dropped_must_be_named_events_dropped() {
let mut e = mk_event();
e.logs = one_log(
"error",
"Events dropped.",
&["count", "intentional", "reason"],
);
e.counters.insert(
METRIC_NAME_EVENTS_DROPPED.to_string(),
counter(&[("intentional", "false")]),
);
let r = run("BadlyNamed", e);
assert!(
r.iter()
.any(|m| m == "EventsDropped events MUST be named \"___EventsDropped\".")
);
}
#[test]
fn validate_events_dropped_log_level_error_or_debug() {
let mut e = mk_event();
e.logs = one_log("info", "Dropped.", &["count", "intentional", "reason"]);
e.counters.insert(
METRIC_NAME_EVENTS_DROPPED.to_string(),
counter(&[("intentional", "false")]),
);
let r = run("FooEventsDropped", e);
assert!(
r.iter().any(|m| {
m.contains("MUST log with one of these levels: [\"error\", \"debug\"]")
})
);
}
#[test]
fn validate_events_dropped_counter_required_and_excluded_tags() {
let mut e = mk_event();
e.logs = one_log("error", "Dropped.", &["count", "intentional", "reason"]);
e.counters.insert(
METRIC_NAME_EVENTS_DROPPED.to_string(),
counter(&[("reason", "\"r\""), ("count", "1")]),
);
let r = run("FooEventsDropped", e);
assert!(r.iter().any(|m| {
m == "Counter \"component_discarded_events_total\" MUST include tag \"intentional\"."
}));
assert!(r.iter().any(|m| {
m == "Counter \"component_discarded_events_total\" MUST NOT include tag \"reason\"."
}));
assert!(r.iter().any(|m| {
m == "Counter \"component_discarded_events_total\" MUST NOT include tag \"count\"."
}));
}
#[test]
fn validate_events_dropped_log_required_params() {
let mut e = mk_event();
e.logs = one_log("error", "Dropped.", &[]);
e.counters.insert(
METRIC_NAME_EVENTS_DROPPED.to_string(),
counter(&[("intentional", "false")]),
);
let r = run("FooEventsDropped", e);
for p in ["count", "intentional", "reason"] {
assert!(
r.iter().any(|m| m
== &format!(
"Error log for EventsDropped event MUST include parameter \"{p}\"."
)),
"missing report for parameter {p} in: {r:?}"
);
}
}
#[test]
fn validate_emits_dropped_must_not_also_increment_counter() {
let mut e = mk_event();
e.emits_component_events_dropped = true;
e.counters.insert(
METRIC_NAME_EVENTS_DROPPED.to_string(),
counter(&[("intentional", "false")]),
);
let r = run("FooEventsDropped", e);
assert!(r.iter().any(|m| {
m.contains("should not also increment counter")
&& m.contains(METRIC_NAME_EVENTS_DROPPED)
}));
}
#[test]
fn validate_clean_event_no_reports() {
let mut e = mk_event();
e.logs = one_log("error", "Connection failed.", &["error_type", "stage"]);
e.counters.insert(
METRIC_NAME_ERROR.to_string(),
counter(&[
("error_type", "error_type::CONNECTION_FAILED"),
("stage", "error_stage::PROCESSING"),
]),
);
let r = run("ConnectionFailedError", e);
assert!(r.is_empty(), "expected no reports, got: {r:?}");
}
}