pub mod agent;
pub mod agents_md;
pub mod amp;
pub mod claude_md;
pub mod claude_rules;
pub mod cline;
pub mod codex;
pub mod codex_plugin;
pub mod copilot;
pub mod cross_platform;
pub mod cursor;
pub mod gemini_extension;
pub mod gemini_ignore;
pub mod gemini_md;
pub mod gemini_settings;
pub mod hooks;
pub mod imports;
pub mod kiro_agent;
pub mod kiro_hook;
pub mod kiro_mcp;
pub mod kiro_power;
pub mod kiro_steering;
pub mod mcp;
pub mod opencode;
pub mod output_style;
pub mod per_client_skill;
pub mod plugin;
pub mod project_level;
pub mod prompt;
pub mod roo;
pub mod skill;
pub mod windsurf;
pub mod xml;
use crate::{config::LintConfig, diagnostics::Diagnostic};
use std::path::Path;
pub(crate) fn seems_plaintext_secret(value: &str) -> bool {
let trimmed = value.trim_matches(|ch| ch == '"' || ch == '\'').trim();
!trimmed.is_empty()
&& !trimmed.starts_with("${")
&& !trimmed.starts_with("$(")
&& !trimmed.starts_with("{{")
&& !trimmed.starts_with('<')
&& !trimmed.starts_with("env:")
&& trimmed.len() >= 8
}
pub(crate) fn line_col_at_offset(content: &str, offset: usize) -> (usize, usize) {
let mut line = 1usize;
let mut col = 1usize;
for (idx, ch) in content.char_indices() {
if idx >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
fn short_type_name<T: ?Sized + 'static>() -> &'static str {
let full = std::any::type_name::<T>();
let base = full.split('<').next().unwrap_or(full);
base.rsplit("::").next().unwrap_or(base)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ValidatorMetadata {
pub name: &'static str,
pub rule_ids: &'static [&'static str],
}
pub trait Validator: Send + Sync + 'static {
fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic>;
fn name(&self) -> &'static str {
short_type_name::<Self>()
}
fn metadata(&self) -> ValidatorMetadata {
ValidatorMetadata {
name: self.name(),
rule_ids: &[],
}
}
}
pub(crate) trait FrontmatterRanges {
fn raw_content(&self) -> &str;
fn start_line(&self) -> usize;
}
pub(crate) fn json_type_name(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
pub(crate) fn line_byte_range(content: &str, line_number: usize) -> Option<(usize, usize)> {
if line_number == 0 {
return None;
}
let mut current_line = 1usize;
let mut line_start = 0usize;
for (idx, ch) in content.char_indices() {
if current_line == line_number && ch == '\n' {
return Some((line_start, idx + 1));
}
if ch == '\n' {
current_line += 1;
line_start = idx + 1;
}
}
if current_line == line_number {
Some((line_start, content.len()))
} else {
None
}
}
pub(crate) fn frontmatter_content_offset(_content: &str, frontmatter_start: usize) -> usize {
frontmatter_start
}
pub(crate) fn find_yaml_value_range<T: FrontmatterRanges>(
full_content: &str,
parsed: &T,
key: &str,
include_quotes: bool,
) -> Option<(usize, usize)> {
for (idx, line) in parsed.raw_content().lines().enumerate() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix(key) {
if let Some(after_colon) = rest.trim_start().strip_prefix(':') {
let after_colon_trimmed = after_colon.trim();
let value_str = if let Some(inner) = after_colon_trimmed.strip_prefix('"') {
if let Some(end_quote_idx) = inner.find('"') {
let quoted = &after_colon_trimmed[..end_quote_idx + 2];
if include_quotes {
quoted
} else {
"ed[1..quoted.len() - 1]
}
} else {
after_colon_trimmed
}
} else if let Some(inner) = after_colon_trimmed.strip_prefix('\'') {
if let Some(end_quote_idx) = inner.find('\'') {
let quoted = &after_colon_trimmed[..end_quote_idx + 2];
if include_quotes {
quoted
} else {
"ed[1..quoted.len() - 1]
}
} else {
after_colon_trimmed
}
} else {
after_colon_trimmed.split('#').next().unwrap_or("").trim()
};
if value_str.is_empty() {
continue;
}
let line_num = parsed.start_line() + 1 + idx;
let (line_start, _) = line_byte_range(full_content, line_num)?;
let line_content = &full_content[line_start..];
let val_offset = line_content.find(value_str)?;
let abs_start = line_start + val_offset;
let abs_end = abs_start + value_str.len();
return Some((abs_start, abs_end));
}
}
}
None
}
pub(crate) fn find_unique_json_string_value_span(
content: &str,
key: &str,
current_value: &str,
) -> Option<(usize, usize)> {
crate::span_utils::find_unique_json_string_inner(content, key, current_value)
}
pub(crate) fn find_closest_value<'a>(invalid: &str, valid_values: &[&'a str]) -> Option<&'a str> {
if invalid.is_empty() {
return None;
}
for &v in valid_values {
if v.eq_ignore_ascii_case(invalid) {
return Some(v);
}
}
if invalid.len() < 3 {
return None;
}
let lower = invalid.to_ascii_lowercase();
valid_values
.iter()
.find(|&&v| {
contains_ignore_ascii_case(v.as_bytes(), lower.as_bytes())
|| contains_ignore_ascii_case(lower.as_bytes(), v.as_bytes())
})
.copied()
}
fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_closest_value_exact_case_insensitive() {
assert_eq!(
find_closest_value("Stdio", &["stdio", "http", "sse"]),
Some("stdio")
);
assert_eq!(
find_closest_value("HTTP", &["stdio", "http", "sse"]),
Some("http")
);
}
#[test]
fn test_find_closest_value_substring_match() {
assert_eq!(
find_closest_value("code", &["code-review", "coding-agent"]),
Some("code-review")
);
assert_eq!(
find_closest_value("coding-agent-v2", &["code-review", "coding-agent"]),
Some("coding-agent")
);
}
#[test]
fn test_find_closest_value_no_match() {
assert_eq!(
find_closest_value("nonsense", &["stdio", "http", "sse"]),
None
);
assert_eq!(
find_closest_value("xyz", &["code-review", "coding-agent"]),
None
);
}
#[test]
fn test_find_closest_value_empty_input() {
assert_eq!(find_closest_value("", &["stdio", "http", "sse"]), None);
}
#[test]
fn test_find_closest_value_exact_preferred_over_substring() {
assert_eq!(
find_closest_value("User", &["user", "project", "local"]),
Some("user")
);
}
#[test]
fn test_find_closest_value_short_input_no_substring() {
assert_eq!(
find_closest_value("ss", &["stdio", "http", "sse"]),
None,
"2-char input should not substring-match"
);
assert_eq!(
find_closest_value("a", &["coding-agent", "code-review"]),
None,
"1-char input should not substring-match"
);
assert_eq!(
find_closest_value("SS", &["stdio", "http", "ss"]),
Some("ss"),
"2-char exact match (case-insensitive) should still work"
);
}
#[test]
fn test_validator_metadata_default_has_empty_rule_ids() {
struct DummyValidator;
impl Validator for DummyValidator {
fn validate(&self, _: &Path, _: &str, _: &LintConfig) -> Vec<Diagnostic> {
vec![]
}
}
let v = DummyValidator;
let meta = v.metadata();
assert_eq!(meta.name, "DummyValidator");
assert!(meta.rule_ids.is_empty());
}
#[test]
fn test_validator_metadata_custom_override() {
const IDS: &[&str] = &["TEST-001", "TEST-002"];
struct CustomValidator;
impl Validator for CustomValidator {
fn validate(&self, _: &Path, _: &str, _: &LintConfig) -> Vec<Diagnostic> {
vec![]
}
fn metadata(&self) -> ValidatorMetadata {
ValidatorMetadata {
name: "CustomValidator",
rule_ids: IDS,
}
}
}
let v = CustomValidator;
let meta = v.metadata();
assert_eq!(meta.name, "CustomValidator");
assert_eq!(meta.rule_ids, &["TEST-001", "TEST-002"]);
}
#[test]
fn test_validator_metadata_is_copy() {
let meta = ValidatorMetadata {
name: "Test",
rule_ids: &["R-001"],
};
let copy = meta;
assert_eq!(meta, copy);
}
}