#![allow(clippy::collapsible_if)]
#![cfg_attr(
test,
allow(
clippy::field_reassign_with_default,
clippy::len_zero,
clippy::useless_vec
)
)]
rust_i18n::i18n!("locales", fallback = "en");
pub mod authoring;
pub mod config;
pub mod diagnostics;
#[cfg(feature = "filesystem")]
pub mod eval;
pub mod file_types;
mod file_utils;
pub mod fixes;
pub mod fs;
pub mod i18n;
pub(crate) mod parsers;
mod pipeline;
mod regex_util;
mod registry;
mod rules;
mod schemas;
pub(crate) mod span_utils;
pub mod validation;
pub use config::{ConfigWarning, FilesConfig, LintConfig, generate_schema};
pub use diagnostics::{
ConfigError, CoreError, Diagnostic, DiagnosticLevel, FileError, Fix, FixConfidenceTier,
LintError, LintResult, RuleMetadata, ValidationError, ValidationOutcome,
};
pub use file_types::{FileType, detect_file_type};
pub use file_types::{FileTypeDetector, FileTypeDetectorChain};
pub use fixes::{
FixApplyMode, FixApplyOptions, FixResult, apply_fixes, apply_fixes_with_fs,
apply_fixes_with_fs_options, apply_fixes_with_options,
};
pub use fs::{FileSystem, MockFileSystem, RealFileSystem};
pub use pipeline::{ValidationResult, resolve_file_type, validate_content};
#[cfg(feature = "filesystem")]
pub use pipeline::{
validate_file, validate_file_with_registry, validate_project, validate_project_rules,
validate_project_with_registry,
};
pub use registry::{
ValidatorFactory, ValidatorProvider, ValidatorRegistry, ValidatorRegistryBuilder,
};
pub use rules::{Validator, ValidatorMetadata};
pub use parsers::frontmatter::normalize_line_endings;
#[doc(hidden)]
#[cfg(any(test, feature = "__internal"))]
pub mod __internal {
pub use crate::parsers::ImportCache;
pub use crate::parsers::frontmatter::{FrontmatterParts, split_frontmatter};
pub use crate::parsers::json::parse_json_config;
pub use crate::parsers::markdown::Import;
pub use crate::parsers::markdown::{
MAX_REGEX_INPUT_SIZE, MarkdownLink, XmlTag, check_xml_balance,
check_xml_balance_with_content_end, extract_imports, extract_markdown_links,
extract_xml_tags, sanitize_for_pulldown_cmark,
};
pub use crate::schemas::cross_platform::is_instruction_file;
}
#[cfg(test)]
mod i18n_tests {
use rust_i18n::t;
use std::sync::Mutex;
static LOCALE_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_english_translations_load() {
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("en");
let xml_msg = t!("rules.xml_001.message", tag = "test");
assert!(
xml_msg.contains("Unclosed XML tag"),
"Expected English translation, got: {}",
xml_msg
);
let cli_validating = t!("cli.validating");
assert_eq!(cli_validating, "Validating:");
let lsp_label = t!("lsp.suggestion_label");
assert_eq!(lsp_label, "Suggestion:");
}
#[test]
fn test_spanish_translations_load() {
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("es");
let xml_msg = t!("rules.xml_001.message", tag = "test");
assert!(
xml_msg.contains("Etiqueta XML sin cerrar"),
"Expected Spanish translation, got: {}",
xml_msg
);
let cli_validating = t!("cli.validating");
assert_eq!(cli_validating, "Validando:");
rust_i18n::set_locale("en");
}
#[test]
fn test_chinese_translations_load() {
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("zh-CN");
let xml_msg = t!("rules.xml_001.message", tag = "test");
assert!(
xml_msg.contains("\u{672A}\u{5173}\u{95ED}"),
"Expected Chinese translation, got: {}",
xml_msg
);
let cli_validating = t!("cli.validating");
assert!(
cli_validating.contains("\u{6B63}\u{5728}\u{9A8C}\u{8BC1}"),
"Expected Chinese translation, got: {}",
cli_validating
);
rust_i18n::set_locale("en");
}
#[test]
fn test_fallback_to_english() {
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("fr");
let msg = t!("cli.validating");
assert_eq!(
msg, "Validating:",
"Should fall back to English, got: {}",
msg
);
rust_i18n::set_locale("en");
}
#[test]
fn test_parameterized_translations() {
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("en");
let msg = t!("rules.as_004.message", name = "TestName");
assert!(
msg.contains("TestName"),
"Parameter should be interpolated, got: {}",
msg
);
assert!(
msg.contains("must be 1-64 characters"),
"Message template should be filled, got: {}",
msg
);
}
#[test]
fn test_available_locales() {
let locales = rust_i18n::available_locales!();
assert!(
locales.contains(&"en"),
"English locale must be available, found: {:?}",
locales
);
assert!(
locales.contains(&"es"),
"Spanish locale must be available, found: {:?}",
locales
);
assert!(
locales.contains(&"zh-CN"),
"Chinese locale must be available, found: {:?}",
locales
);
}
#[test]
fn test_rule_ids_not_translated() {
use super::*;
use std::path::Path;
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("es");
let config = config::LintConfig::default();
let content = "---\nname: test\n---\nSome content";
let path = Path::new("test/.claude/skills/test/SKILL.md");
let validator = rules::skill::SkillValidator;
let diagnostics = validator.validate(path, content, &config);
for diag in &diagnostics {
assert!(
diag.rule.is_ascii(),
"Rule ID should be ASCII: {}",
diag.rule
);
}
rust_i18n::set_locale("en");
}
#[test]
fn test_spanish_diagnostics() {
use super::*;
use std::path::Path;
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("es");
let config = config::LintConfig::default();
let content = "<unclosed>";
let path = Path::new("test/CLAUDE.md");
let validator = rules::xml::XmlValidator;
let diagnostics = validator.validate(path, content, &config);
assert!(!diagnostics.is_empty(), "Should produce diagnostics");
let xml_diag = diagnostics.iter().find(|d| d.rule == "XML-001").unwrap();
assert!(
xml_diag.message.contains("Etiqueta XML sin cerrar"),
"Message should be in Spanish, got: {}",
xml_diag.message
);
rust_i18n::set_locale("en");
}
#[test]
fn test_new_suggestion_keys_resolve() {
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("en");
macro_rules! assert_key_resolves {
($key:expr) => {
let value = t!($key);
assert!(
!value.starts_with("rules."),
"Locale key '{}' should resolve to text, not raw key path: {}",
$key,
value
);
};
}
assert_key_resolves!("rules.as_016.suggestion");
assert_key_resolves!("rules.cc_hk_012.suggestion");
assert_key_resolves!("rules.mcp_007.suggestion");
assert_key_resolves!("rules.cc_pl_006.suggestion");
assert_key_resolves!("rules.cc_ag_007.parse_error_suggestion");
assert_key_resolves!("rules.cdx_000.suggestion");
assert_key_resolves!("rules.file_read_error_suggestion");
assert_key_resolves!("rules.xp_004_read_error_suggestion");
let cdx_msg = t!("rules.cdx_000.message", error = "test error");
assert!(
cdx_msg.contains("test error"),
"CDX-000 message should interpolate error param, got: {}",
cdx_msg
);
let file_msg = t!("rules.file_read_error", error = "permission denied");
assert!(
file_msg.contains("permission denied"),
"file_read_error should interpolate error param, got: {}",
file_msg
);
let xp_msg = t!("rules.xp_004_read_error", error = "not found");
assert!(
xp_msg.contains("not found"),
"xp_004_read_error should interpolate error param, got: {}",
xp_msg
);
}
#[test]
fn test_keys_resolve_to_text_not_raw_paths() {
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("en");
macro_rules! assert_not_raw_key {
($key:expr) => {
let value = t!($key);
assert!(
(!value.starts_with("rules."))
&& (!value.starts_with("cli."))
&& (!value.starts_with("lsp."))
&& (!value.starts_with("core.")),
"Key '{}' returned raw path instead of translated text: {}",
$key,
value
);
};
($key:expr, $($param:ident = $val:expr),+) => {
let value = t!($key, $($param = $val),+);
assert!(
(!value.starts_with("rules."))
&& (!value.starts_with("cli."))
&& (!value.starts_with("lsp."))
&& (!value.starts_with("core.")),
"Key '{}' returned raw path instead of translated text: {}",
$key,
value
);
};
}
assert_not_raw_key!("rules.as_001.message");
assert_not_raw_key!("rules.as_004.message", name = "test");
assert_not_raw_key!("rules.cc_ag_009.message", tool = "x", known = "y");
assert_not_raw_key!("rules.xml_001.message", tag = "div");
assert_not_raw_key!("rules.cc_hk_001.message");
assert_not_raw_key!("rules.pe_003.message");
assert_not_raw_key!("rules.cc_mem_009.message");
assert_not_raw_key!("rules.as_001.suggestion");
assert_not_raw_key!("rules.as_004.suggestion");
assert_not_raw_key!("rules.cc_ag_009.suggestion");
assert_not_raw_key!("rules.cc_hk_010.assumption");
assert_not_raw_key!("rules.mcp_008.assumption");
assert_not_raw_key!("cli.validating");
assert_not_raw_key!("cli.no_issues_found");
assert_not_raw_key!(
"cli.found_errors_warnings",
errors = "1",
error_word = "error",
warnings = "0",
warning_word = "warnings"
);
assert_not_raw_key!("lsp.suggestion_label");
assert_not_raw_key!("core.error.file_read", path = "/tmp/test");
}
#[test]
fn test_every_rule_locale_key_referenced_in_source_resolves() {
let _lock = LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("en");
let manifest_dir =
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo");
let src_dir = std::path::Path::new(&manifest_dir).join("src");
let mut keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
fn walk(dir: &std::path::Path, keys: &mut std::collections::BTreeSet<String>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, keys);
} else if p.extension().and_then(|s| s.to_str()) == Some("rs") {
if let Ok(text) = std::fs::read_to_string(&p) {
let mut i = 0;
let bytes = text.as_bytes();
while i + 10 < bytes.len() {
if &bytes[i..i + 3] == b"t!(" {
let prev_is_ident = i > 0
&& (bytes[i - 1].is_ascii_alphanumeric()
|| bytes[i - 1] == b'_');
if !prev_is_ident {
let end_pos = bytes
.get(i..(i + 300).min(bytes.len()))
.and_then(|s| s.iter().position(|&b| b == b')'))
.map(|p| p + i)
.unwrap_or(bytes.len().min(i + 300));
let rest = &text[i..end_pos];
if let Some(q) = rest.find(r#""rules."#)
&& let Some(endq) = rest[q + 1..].find('"')
{
let key = &rest[q + 1..q + 1 + endq];
if key.matches('.').count() == 2
&& key.chars().all(|c| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| c == '.'
|| c == '_'
})
{
keys.insert(key.to_string());
}
}
}
}
i += 1;
}
}
}
}
}
walk(&src_dir, &mut keys);
assert!(
!keys.is_empty(),
"scanner found no t!(\"rules.*.*\") calls under {}; check the walker",
src_dir.display()
);
let locale_path = std::path::Path::new(&manifest_dir)
.join("locales")
.join("en.yml");
let locale_text = std::fs::read_to_string(&locale_path).unwrap_or_else(|e| {
panic!("read {}: {}", locale_path.display(), e);
});
let mut available: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut current_rule: Option<String> = None;
let mut inside_rules = false;
for line in locale_text.lines() {
if line.starts_with("rules:") {
inside_rules = true;
continue;
}
if inside_rules
&& !line.is_empty()
&& !line.starts_with(' ')
&& !line.starts_with('#')
&& !line.starts_with("rules:")
{
inside_rules = false;
}
if !inside_rules {
continue;
}
if let Some(tail) = line.strip_prefix(" ")
&& !tail.starts_with(' ')
&& !tail.starts_with('#')
&& let Some(name) = tail.strip_suffix(':')
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
current_rule = Some(name.to_string());
continue;
}
if let Some(tail) = line.strip_prefix(" ")
&& !tail.starts_with(' ')
&& !tail.starts_with('#')
&& let Some(colon) = tail.find(':')
&& let Some(rule) = ¤t_rule
{
let field = &tail[..colon];
if field
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
available.insert(format!("rules.{rule}.{field}"));
}
}
}
let mut missing: Vec<String> = Vec::new();
for k in &keys {
if !available.contains(k) {
missing.push(k.clone());
}
}
if !missing.is_empty() {
let mut msg = format!(
"{} rule-locale keys are referenced in source but missing from locales/en.yml:\n",
missing.len()
);
for k in &missing {
msg.push_str(&format!(" {}\n", k));
}
panic!("{}", msg);
}
}
}