#![forbid(unsafe_code)]
use clap::Args;
use std::path::PathBuf;
#[derive(Args)]
pub struct LintArgs {
#[arg(value_name = "PROTO_FILE", required = true)]
pub protos: Vec<PathBuf>,
#[arg(short = 'I', long = "include")]
pub include: Vec<PathBuf>,
#[arg(long, default_value = "text")]
pub output: String,
}
pub struct LintViolation {
pub file: String,
pub line: u32,
pub rule: String,
pub message: String,
}
pub fn run(
args: LintArgs,
_verbosity: crate::util::Verbosity,
) -> Result<(), Box<dyn std::error::Error>> {
for p in &args.protos {
if !p.exists() {
return Err(format!("proto file not found: {}", p.display()).into());
}
}
let fds = oxiproto_build::compile_to_fds(&args.protos, &args.include)?;
let mut violations: Vec<LintViolation> = Vec::new();
for file in &fds.file {
let fname = file.name.as_deref().unwrap_or("");
if fname.starts_with("google/protobuf/") {
continue;
}
lint_file(file, fname, &mut violations);
}
match args.output.as_str() {
"json" => print_json(&violations),
_ => print_text(&violations),
}
if violations.is_empty() {
Ok(())
} else {
Err(format!("{} lint violation(s) found", violations.len()).into())
}
}
fn lint_file(file: &prost_types::FileDescriptorProto, fname: &str, out: &mut Vec<LintViolation>) {
for msg in &file.message_type {
lint_message(msg, fname, out);
}
for en in &file.enum_type {
lint_enum(en, fname, out);
}
for svc in &file.service {
lint_service(svc, fname, out);
}
}
fn lint_message(msg: &prost_types::DescriptorProto, fname: &str, out: &mut Vec<LintViolation>) {
if msg.options.as_ref().is_some_and(|o| o.map_entry()) {
return;
}
let name = msg.name.as_deref().unwrap_or("");
if !is_upper_camel_case(name) {
out.push(LintViolation {
file: fname.to_owned(),
line: 0,
rule: "MESSAGE_NAMES_UPPER_CAMEL_CASE".to_owned(),
message: format!(
"message name '{name}' must be UpperCamelCase (first char uppercase, no underscores)"
),
});
}
for field in &msg.field {
let fname_field = field.name.as_deref().unwrap_or("");
if !is_lower_snake_case(fname_field) {
out.push(LintViolation {
file: fname.to_owned(),
line: 0,
rule: "FIELD_NAMES_LOWER_SNAKE_CASE".to_owned(),
message: format!(
"field name '{fname_field}' in message '{name}' must be lower_snake_case"
),
});
}
}
for nested in &msg.nested_type {
lint_message(nested, fname, out);
}
for en in &msg.enum_type {
lint_enum(en, fname, out);
}
}
fn lint_enum(en: &prost_types::EnumDescriptorProto, fname: &str, out: &mut Vec<LintViolation>) {
let name = en.name.as_deref().unwrap_or("");
if !is_upper_camel_case(name) {
out.push(LintViolation {
file: fname.to_owned(),
line: 0,
rule: "ENUM_NAMES_UPPER_CAMEL_CASE".to_owned(),
message: format!("enum name '{name}' must be UpperCamelCase"),
});
}
let prefix = to_screaming_snake_case(name);
for val in &en.value {
let vname = val.name.as_deref().unwrap_or("");
if !is_screaming_snake_case(vname) {
out.push(LintViolation {
file: fname.to_owned(),
line: 0,
rule: "ENUM_VALUE_NAMES_UPPER_SNAKE_CASE".to_owned(),
message: format!(
"enum value '{vname}' in enum '{name}' must be SCREAMING_SNAKE_CASE"
),
});
}
let expected_prefix = format!("{prefix}_");
if !vname.starts_with(&expected_prefix) {
out.push(LintViolation {
file: fname.to_owned(),
line: 0,
rule: "ENUM_VALUE_PREFIX".to_owned(),
message: format!(
"enum value '{vname}' must start with '{expected_prefix}' (prefix of enum '{name}')"
),
});
}
}
}
fn lint_service(
svc: &prost_types::ServiceDescriptorProto,
fname: &str,
out: &mut Vec<LintViolation>,
) {
let name = svc.name.as_deref().unwrap_or("");
if !is_upper_camel_case(name) {
out.push(LintViolation {
file: fname.to_owned(),
line: 0,
rule: "SERVICE_NAMES_UPPER_CAMEL_CASE".to_owned(),
message: format!("service name '{name}' must be UpperCamelCase"),
});
}
for method in &svc.method {
let mname = method.name.as_deref().unwrap_or("");
if !is_upper_camel_case(mname) {
out.push(LintViolation {
file: fname.to_owned(),
line: 0,
rule: "RPC_NAMES_UPPER_CAMEL_CASE".to_owned(),
message: format!("rpc name '{mname}' in service '{name}' must be UpperCamelCase"),
});
}
}
}
fn print_text(violations: &[LintViolation]) {
for v in violations {
if v.line > 0 {
println!("{}:{}: [{}] {}", v.file, v.line, v.rule, v.message);
} else {
println!("{}: [{}] {}", v.file, v.rule, v.message);
}
}
}
fn print_json(violations: &[LintViolation]) {
let arr: Vec<serde_json::Value> = violations
.iter()
.map(|v| {
serde_json::json!({
"file": v.file,
"line": v.line,
"rule": v.rule,
"message": v.message,
})
})
.collect();
println!(
"{}",
serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
);
}
pub fn is_upper_camel_case(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let first = chars.next().expect("non-empty checked above");
if !first.is_ascii_uppercase() {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric())
}
pub fn is_lower_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_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub fn is_screaming_snake_case(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
pub fn to_screaming_snake_case(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
let mut prev_lower = false;
for ch in s.chars() {
if ch.is_ascii_uppercase() {
if prev_lower {
result.push('_');
}
result.push(ch);
prev_lower = false;
} else if ch.is_ascii_lowercase() {
result.push(ch.to_ascii_uppercase());
prev_lower = true;
} else {
result.push(ch);
prev_lower = false;
}
}
result
}