extern crate rustc_ast;
extern crate rustc_span;
use rustc_ast::{Attribute, FieldDef, Item, ItemKind, VariantData};
use rustc_lint::{EarlyContext, EarlyLintPass, LintContext};
use crate::lint_utils::is_in_api_rest_folder;
dylint_linting::declare_pre_expansion_lint! {
#[doc = include_str!("../../docs/de08_rest_api_conventions/de0803_api_snake_case/README.md")]
pub DE0803_API_SNAKE_CASE,
Deny,
"API DTOs must use snake_case in serde rename attributes (DE0803)"
}
impl EarlyLintPass for De0803ApiSnakeCase {
fn check_item(&mut self, cx: &EarlyContext<'_>, item: &Item) {
if !is_in_api_rest_folder(cx.sess().source_map(), item.span) {
return;
}
match &item.kind {
ItemKind::Struct(_, _, variant_data) => {
check_type_rename_all(cx, &item.attrs);
check_fields(cx, variant_data);
}
ItemKind::Enum(_, _, enum_def) => {
check_type_rename_all(cx, &item.attrs);
for variant in &enum_def.variants {
check_variant_rename(cx, &variant.attrs);
check_fields(cx, &variant.data);
}
}
_ => {}
}
}
}
fn find_serde_attribute_value(
attrs: &[Attribute],
attribute_name: &str,
) -> Vec<(rustc_span::Span, String)> {
let mut results = Vec::new();
for attr in attrs {
if !attr.has_name(rustc_span::Symbol::intern("serde")) {
continue;
}
let Some(list) = attr.meta_item_list() else {
continue;
};
for nested in list {
let Some(meta_item) = nested.meta_item() else {
continue;
};
if !meta_item.has_name(rustc_span::Symbol::intern(attribute_name)) {
continue;
}
if let Some(value) = meta_item.value_str() {
results.push((meta_item.span, value.as_str().to_string()));
}
if let Some(inner_list) = meta_item.meta_item_list() {
for inner_nested in inner_list {
let Some(inner_meta_item) = inner_nested.meta_item() else {
continue;
};
if let Some(inner_value) = inner_meta_item.value_str() {
results.push((inner_meta_item.span, inner_value.as_str().to_string()));
}
}
}
}
}
results
}
fn check_type_rename_all(cx: &EarlyContext<'_>, attrs: &[Attribute]) {
for (span, value) in find_serde_attribute_value(attrs, "rename_all") {
if value != "snake_case" {
cx.span_lint(DE0803_API_SNAKE_CASE, span, |diag| {
diag.primary_message(
"DTOs must not use non-snake_case in serde rename_all (DE0803)",
);
diag.help(
"DTOs in api/rest must use snake_case (or default) to match API standards",
);
});
}
}
}
fn check_variant_rename(cx: &EarlyContext<'_>, attrs: &[Attribute]) {
for (span, value) in find_serde_attribute_value(attrs, "rename") {
if !is_snake_case(&value) {
cx.span_lint(DE0803_API_SNAKE_CASE, span, |diag| {
diag.primary_message(
"Enum variants must not use non-snake_case in serde rename (DE0803)",
);
diag.help("Enum variants in api/rest must use snake_case to match API standards");
});
}
}
}
fn check_fields(cx: &EarlyContext<'_>, variant_data: &VariantData) {
for field in variant_data.fields() {
check_field_snake_case(cx, field);
}
}
fn check_field_snake_case(cx: &EarlyContext<'_>, field: &FieldDef) {
let field_name = match &field.ident {
Some(ident) => ident.name.as_str().to_string(),
None => return, };
let rename_values = find_serde_attribute_value(&field.attrs, "rename");
if rename_values.is_empty() {
if !is_snake_case(&field_name) {
cx.span_lint(
DE0803_API_SNAKE_CASE,
field.ident.unwrap().span,
|diag| {
diag.primary_message(
"DTO field name must be snake_case or have a serde rename to snake_case (DE0803)"
);
diag.help(format!(
"rename field to snake_case or add #[serde(rename = \"{}\")]",
to_snake_case(&field_name)
));
},
);
}
} else {
for (span, value) in rename_values {
if !is_snake_case(&value) {
cx.span_lint(DE0803_API_SNAKE_CASE, span, |diag| {
diag.primary_message(
"DTO fields must not use non-snake_case in serde rename (DE0803)",
);
diag.help("DTO fields in api/rest must use snake_case to match API standards");
});
}
}
}
}
fn is_snake_case(s: &str) -> bool {
if s.is_empty() {
return false;
}
if s.starts_with('_') || s.ends_with('_') {
return false;
}
if s.contains("__") {
return false;
}
s.chars()
.all(|c| c.is_lowercase() || c.is_ascii_digit() || c == '_')
}
fn to_snake_case(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(c.to_lowercase().next().unwrap());
} else {
result.push(c);
}
}
result
}