1pub mod agent;
4pub mod agents_md;
5pub mod amp;
6pub mod claude_md;
7pub mod claude_rules;
8pub mod claude_settings;
9pub mod cline;
10pub mod codex;
11pub mod codex_plugin;
12pub mod copilot;
13pub mod cross_platform;
14pub mod cursor;
15pub mod gemini_agent;
16pub mod gemini_extension;
17pub mod gemini_ignore;
18pub mod gemini_md;
19pub mod gemini_settings;
20pub mod hooks;
21pub mod imports;
22pub mod kiro_agent;
23pub mod kiro_hook;
24pub mod kiro_mcp;
25pub mod kiro_power;
26pub mod kiro_settings;
27pub mod kiro_steering;
28pub mod mcp;
29pub mod opencode;
30pub mod output_style;
31pub mod per_client_skill;
32pub mod plugin;
33pub mod project_level;
34pub mod prompt;
35pub mod roo;
36pub mod skill;
37pub mod windsurf;
38pub mod xml;
39
40use crate::{config::LintConfig, diagnostics::Diagnostic};
41use std::path::Path;
42
43pub(crate) fn seems_plaintext_secret(value: &str) -> bool {
51 let trimmed = value.trim_matches(|ch| ch == '"' || ch == '\'').trim();
52 !trimmed.is_empty()
53 && !trimmed.starts_with("${")
54 && !trimmed.starts_with("$(")
55 && !trimmed.starts_with("{{")
56 && !trimmed.starts_with('<')
57 && !trimmed.starts_with("env:")
58 && trimmed.len() >= 8
59}
60
61pub(crate) fn line_col_at_offset(content: &str, offset: usize) -> (usize, usize) {
65 let mut line = 1usize;
66 let mut col = 1usize;
67
68 for (idx, ch) in content.char_indices() {
69 if idx >= offset {
70 break;
71 }
72 if ch == '\n' {
73 line += 1;
74 col = 1;
75 } else {
76 col += 1;
77 }
78 }
79
80 (line, col)
81}
82
83fn short_type_name<T: ?Sized + 'static>() -> &'static str {
90 let full = std::any::type_name::<T>();
91 let base = full.split('<').next().unwrap_or(full);
93 base.rsplit("::").next().unwrap_or(base)
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct ValidatorMetadata {
102 pub name: &'static str,
104 pub rule_ids: &'static [&'static str],
106}
107
108pub trait Validator: Send + Sync + 'static {
124 fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic>;
126
127 fn name(&self) -> &'static str {
136 short_type_name::<Self>()
137 }
138
139 fn metadata(&self) -> ValidatorMetadata {
150 ValidatorMetadata {
151 name: self.name(),
152 rule_ids: &[],
153 }
154 }
155}
156
157pub(crate) trait FrontmatterRanges {
160 fn raw_content(&self) -> &str;
161 fn start_line(&self) -> usize;
167}
168
169pub(crate) fn json_type_name(value: &serde_json::Value) -> &'static str {
171 match value {
172 serde_json::Value::Null => "null",
173 serde_json::Value::Bool(_) => "boolean",
174 serde_json::Value::Number(_) => "number",
175 serde_json::Value::String(_) => "string",
176 serde_json::Value::Array(_) => "array",
177 serde_json::Value::Object(_) => "object",
178 }
179}
180
181pub(crate) fn line_byte_range(content: &str, line_number: usize) -> Option<(usize, usize)> {
184 if line_number == 0 {
185 return None;
186 }
187
188 let mut current_line = 1usize;
189 let mut line_start = 0usize;
190
191 for (idx, ch) in content.char_indices() {
192 if current_line == line_number && ch == '\n' {
193 return Some((line_start, idx + 1));
194 }
195 if ch == '\n' {
196 current_line += 1;
197 line_start = idx + 1;
198 }
199 }
200
201 if current_line == line_number {
202 Some((line_start, content.len()))
203 } else {
204 None
205 }
206}
207
208pub(crate) fn frontmatter_content_offset(_content: &str, frontmatter_start: usize) -> usize {
216 frontmatter_start
217}
218
219pub(crate) fn find_yaml_value_range<T: FrontmatterRanges>(
223 full_content: &str,
224 parsed: &T,
225 key: &str,
226 include_quotes: bool,
227) -> Option<(usize, usize)> {
228 for (idx, line) in parsed.raw_content().lines().enumerate() {
229 let trimmed = line.trim_start();
230 if let Some(rest) = trimmed.strip_prefix(key) {
231 if let Some(after_colon) = rest.trim_start().strip_prefix(':') {
232 let after_colon_trimmed = after_colon.trim();
233
234 let value_str = if let Some(inner) = after_colon_trimmed.strip_prefix('"') {
236 if let Some(end_quote_idx) = inner.find('"') {
237 let quoted = &after_colon_trimmed[..end_quote_idx + 2];
238 if include_quotes {
239 quoted
240 } else {
241 "ed[1..quoted.len() - 1]
242 }
243 } else {
244 after_colon_trimmed
245 }
246 } else if let Some(inner) = after_colon_trimmed.strip_prefix('\'') {
247 if let Some(end_quote_idx) = inner.find('\'') {
248 let quoted = &after_colon_trimmed[..end_quote_idx + 2];
249 if include_quotes {
250 quoted
251 } else {
252 "ed[1..quoted.len() - 1]
253 }
254 } else {
255 after_colon_trimmed
256 }
257 } else {
258 after_colon_trimmed.split('#').next().unwrap_or("").trim()
260 };
261
262 if value_str.is_empty() {
263 continue;
264 }
265 let line_num = parsed.start_line() + 1 + idx;
266 let (line_start, _) = line_byte_range(full_content, line_num)?;
267 let line_content = &full_content[line_start..];
268 let val_offset = line_content.find(value_str)?;
269 let abs_start = line_start + val_offset;
270 let abs_end = abs_start + value_str.len();
271 return Some((abs_start, abs_end));
272 }
273 }
274 }
275 None
276}
277
278pub(crate) fn find_unique_json_string_value_span(
282 content: &str,
283 key: &str,
284 current_value: &str,
285) -> Option<(usize, usize)> {
286 crate::span_utils::find_unique_json_string_inner(content, key, current_value)
287}
288
289pub(crate) fn find_closest_value<'a>(invalid: &str, valid_values: &[&'a str]) -> Option<&'a str> {
297 if invalid.is_empty() {
298 return None;
299 }
300 for &v in valid_values {
302 if v.eq_ignore_ascii_case(invalid) {
303 return Some(v);
304 }
305 }
306 if invalid.len() < 3 {
308 return None;
309 }
310 let lower = invalid.to_ascii_lowercase();
311 valid_values
312 .iter()
313 .find(|&&v| {
314 contains_ignore_ascii_case(v.as_bytes(), lower.as_bytes())
315 || contains_ignore_ascii_case(lower.as_bytes(), v.as_bytes())
316 })
317 .copied()
318}
319
320fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
323 if needle.is_empty() || needle.len() > haystack.len() {
324 return false;
325 }
326 haystack
327 .windows(needle.len())
328 .any(|window| window.eq_ignore_ascii_case(needle))
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn test_find_closest_value_exact_case_insensitive() {
337 assert_eq!(
338 find_closest_value("Stdio", &["stdio", "http", "sse"]),
339 Some("stdio")
340 );
341 assert_eq!(
342 find_closest_value("HTTP", &["stdio", "http", "sse"]),
343 Some("http")
344 );
345 }
346
347 #[test]
348 fn test_find_closest_value_substring_match() {
349 assert_eq!(
350 find_closest_value("code", &["code-review", "coding-agent"]),
351 Some("code-review")
352 );
353 assert_eq!(
354 find_closest_value("coding-agent-v2", &["code-review", "coding-agent"]),
355 Some("coding-agent")
356 );
357 }
358
359 #[test]
360 fn test_find_closest_value_no_match() {
361 assert_eq!(
362 find_closest_value("nonsense", &["stdio", "http", "sse"]),
363 None
364 );
365 assert_eq!(
366 find_closest_value("xyz", &["code-review", "coding-agent"]),
367 None
368 );
369 }
370
371 #[test]
372 fn test_find_closest_value_empty_input() {
373 assert_eq!(find_closest_value("", &["stdio", "http", "sse"]), None);
374 }
375
376 #[test]
377 fn test_find_closest_value_exact_preferred_over_substring() {
378 assert_eq!(
380 find_closest_value("User", &["user", "project", "local"]),
381 Some("user")
382 );
383 }
384
385 #[test]
386 fn test_find_closest_value_short_input_no_substring() {
387 assert_eq!(
389 find_closest_value("ss", &["stdio", "http", "sse"]),
390 None,
391 "2-char input should not substring-match"
392 );
393 assert_eq!(
394 find_closest_value("a", &["coding-agent", "code-review"]),
395 None,
396 "1-char input should not substring-match"
397 );
398 assert_eq!(
400 find_closest_value("SS", &["stdio", "http", "ss"]),
401 Some("ss"),
402 "2-char exact match (case-insensitive) should still work"
403 );
404 }
405
406 #[test]
407 fn test_validator_metadata_default_has_empty_rule_ids() {
408 struct DummyValidator;
409 impl Validator for DummyValidator {
410 fn validate(&self, _: &Path, _: &str, _: &LintConfig) -> Vec<Diagnostic> {
411 vec![]
412 }
413 }
414 let v = DummyValidator;
415 let meta = v.metadata();
416 assert_eq!(meta.name, "DummyValidator");
417 assert!(meta.rule_ids.is_empty());
418 }
419
420 #[test]
421 fn test_validator_metadata_custom_override() {
422 const IDS: &[&str] = &["TEST-001", "TEST-002"];
423 struct CustomValidator;
424 impl Validator for CustomValidator {
425 fn validate(&self, _: &Path, _: &str, _: &LintConfig) -> Vec<Diagnostic> {
426 vec![]
427 }
428 fn metadata(&self) -> ValidatorMetadata {
429 ValidatorMetadata {
430 name: "CustomValidator",
431 rule_ids: IDS,
432 }
433 }
434 }
435 let v = CustomValidator;
436 let meta = v.metadata();
437 assert_eq!(meta.name, "CustomValidator");
438 assert_eq!(meta.rule_ids, &["TEST-001", "TEST-002"]);
439 }
440
441 #[test]
442 fn test_validator_metadata_is_copy() {
443 let meta = ValidatorMetadata {
444 name: "Test",
445 rule_ids: &["R-001"],
446 };
447 let copy = meta;
448 assert_eq!(meta, copy);
449 }
450}