1pub mod agent;
4pub mod agents_md;
5pub mod amp;
6pub mod claude_md;
7pub mod claude_rules;
8pub mod cline;
9pub mod codex;
10pub mod copilot;
11pub mod cross_platform;
12pub mod cursor;
13pub mod gemini_extension;
14pub mod gemini_ignore;
15pub mod gemini_md;
16pub mod gemini_settings;
17pub mod hooks;
18pub mod imports;
19pub mod kiro_agent;
20pub mod kiro_hook;
21pub mod kiro_mcp;
22pub mod kiro_power;
23pub mod kiro_steering;
24pub mod mcp;
25pub mod opencode;
26pub mod per_client_skill;
27pub mod plugin;
28pub mod project_level;
29pub mod prompt;
30pub mod roo;
31pub mod skill;
32pub mod windsurf;
33pub mod xml;
34
35use crate::{config::LintConfig, diagnostics::Diagnostic};
36use std::path::Path;
37
38pub(crate) fn seems_plaintext_secret(value: &str) -> bool {
46 let trimmed = value.trim_matches(|ch| ch == '"' || ch == '\'').trim();
47 !trimmed.is_empty()
48 && !trimmed.starts_with("${")
49 && !trimmed.starts_with("$(")
50 && !trimmed.starts_with("{{")
51 && !trimmed.starts_with('<')
52 && !trimmed.starts_with("env:")
53 && trimmed.len() >= 8
54}
55
56pub(crate) fn line_col_at_offset(content: &str, offset: usize) -> (usize, usize) {
60 let mut line = 1usize;
61 let mut col = 1usize;
62
63 for (idx, ch) in content.char_indices() {
64 if idx >= offset {
65 break;
66 }
67 if ch == '\n' {
68 line += 1;
69 col = 1;
70 } else {
71 col += 1;
72 }
73 }
74
75 (line, col)
76}
77
78fn short_type_name<T: ?Sized + 'static>() -> &'static str {
85 let full = std::any::type_name::<T>();
86 let base = full.split('<').next().unwrap_or(full);
88 base.rsplit("::").next().unwrap_or(base)
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct ValidatorMetadata {
97 pub name: &'static str,
99 pub rule_ids: &'static [&'static str],
101}
102
103pub trait Validator: Send + Sync + 'static {
119 fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic>;
121
122 fn name(&self) -> &'static str {
131 short_type_name::<Self>()
132 }
133
134 fn metadata(&self) -> ValidatorMetadata {
145 ValidatorMetadata {
146 name: self.name(),
147 rule_ids: &[],
148 }
149 }
150}
151
152pub(crate) trait FrontmatterRanges {
155 fn raw_content(&self) -> &str;
156 fn start_line(&self) -> usize;
162}
163
164pub(crate) fn line_byte_range(content: &str, line_number: usize) -> Option<(usize, usize)> {
167 if line_number == 0 {
168 return None;
169 }
170
171 let mut current_line = 1usize;
172 let mut line_start = 0usize;
173
174 for (idx, ch) in content.char_indices() {
175 if current_line == line_number && ch == '\n' {
176 return Some((line_start, idx + 1));
177 }
178 if ch == '\n' {
179 current_line += 1;
180 line_start = idx + 1;
181 }
182 }
183
184 if current_line == line_number {
185 Some((line_start, content.len()))
186 } else {
187 None
188 }
189}
190
191pub(crate) fn frontmatter_content_offset(_content: &str, frontmatter_start: usize) -> usize {
199 frontmatter_start
200}
201
202pub(crate) fn find_yaml_value_range<T: FrontmatterRanges>(
206 full_content: &str,
207 parsed: &T,
208 key: &str,
209 include_quotes: bool,
210) -> Option<(usize, usize)> {
211 for (idx, line) in parsed.raw_content().lines().enumerate() {
212 let trimmed = line.trim_start();
213 if let Some(rest) = trimmed.strip_prefix(key) {
214 if let Some(after_colon) = rest.trim_start().strip_prefix(':') {
215 let after_colon_trimmed = after_colon.trim();
216
217 let value_str = if let Some(inner) = after_colon_trimmed.strip_prefix('"') {
219 if let Some(end_quote_idx) = inner.find('"') {
220 let quoted = &after_colon_trimmed[..end_quote_idx + 2];
221 if include_quotes {
222 quoted
223 } else {
224 "ed[1..quoted.len() - 1]
225 }
226 } else {
227 after_colon_trimmed
228 }
229 } else if let Some(inner) = after_colon_trimmed.strip_prefix('\'') {
230 if let Some(end_quote_idx) = inner.find('\'') {
231 let quoted = &after_colon_trimmed[..end_quote_idx + 2];
232 if include_quotes {
233 quoted
234 } else {
235 "ed[1..quoted.len() - 1]
236 }
237 } else {
238 after_colon_trimmed
239 }
240 } else {
241 after_colon_trimmed.split('#').next().unwrap_or("").trim()
243 };
244
245 if value_str.is_empty() {
246 continue;
247 }
248 let line_num = parsed.start_line() + 1 + idx;
249 let (line_start, _) = line_byte_range(full_content, line_num)?;
250 let line_content = &full_content[line_start..];
251 let val_offset = line_content.find(value_str)?;
252 let abs_start = line_start + val_offset;
253 let abs_end = abs_start + value_str.len();
254 return Some((abs_start, abs_end));
255 }
256 }
257 }
258 None
259}
260
261pub(crate) fn find_unique_json_string_value_span(
265 content: &str,
266 key: &str,
267 current_value: &str,
268) -> Option<(usize, usize)> {
269 crate::span_utils::find_unique_json_string_inner(content, key, current_value)
270}
271
272pub(crate) fn find_closest_value<'a>(invalid: &str, valid_values: &[&'a str]) -> Option<&'a str> {
280 if invalid.is_empty() {
281 return None;
282 }
283 for &v in valid_values {
285 if v.eq_ignore_ascii_case(invalid) {
286 return Some(v);
287 }
288 }
289 if invalid.len() < 3 {
291 return None;
292 }
293 let lower = invalid.to_ascii_lowercase();
294 valid_values
295 .iter()
296 .find(|&&v| {
297 contains_ignore_ascii_case(v.as_bytes(), lower.as_bytes())
298 || contains_ignore_ascii_case(lower.as_bytes(), v.as_bytes())
299 })
300 .copied()
301}
302
303fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
306 if needle.is_empty() || needle.len() > haystack.len() {
307 return false;
308 }
309 haystack
310 .windows(needle.len())
311 .any(|window| window.eq_ignore_ascii_case(needle))
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_find_closest_value_exact_case_insensitive() {
320 assert_eq!(
321 find_closest_value("Stdio", &["stdio", "http", "sse"]),
322 Some("stdio")
323 );
324 assert_eq!(
325 find_closest_value("HTTP", &["stdio", "http", "sse"]),
326 Some("http")
327 );
328 }
329
330 #[test]
331 fn test_find_closest_value_substring_match() {
332 assert_eq!(
333 find_closest_value("code", &["code-review", "coding-agent"]),
334 Some("code-review")
335 );
336 assert_eq!(
337 find_closest_value("coding-agent-v2", &["code-review", "coding-agent"]),
338 Some("coding-agent")
339 );
340 }
341
342 #[test]
343 fn test_find_closest_value_no_match() {
344 assert_eq!(
345 find_closest_value("nonsense", &["stdio", "http", "sse"]),
346 None
347 );
348 assert_eq!(
349 find_closest_value("xyz", &["code-review", "coding-agent"]),
350 None
351 );
352 }
353
354 #[test]
355 fn test_find_closest_value_empty_input() {
356 assert_eq!(find_closest_value("", &["stdio", "http", "sse"]), None);
357 }
358
359 #[test]
360 fn test_find_closest_value_exact_preferred_over_substring() {
361 assert_eq!(
363 find_closest_value("User", &["user", "project", "local"]),
364 Some("user")
365 );
366 }
367
368 #[test]
369 fn test_find_closest_value_short_input_no_substring() {
370 assert_eq!(
372 find_closest_value("ss", &["stdio", "http", "sse"]),
373 None,
374 "2-char input should not substring-match"
375 );
376 assert_eq!(
377 find_closest_value("a", &["coding-agent", "code-review"]),
378 None,
379 "1-char input should not substring-match"
380 );
381 assert_eq!(
383 find_closest_value("SS", &["stdio", "http", "ss"]),
384 Some("ss"),
385 "2-char exact match (case-insensitive) should still work"
386 );
387 }
388
389 #[test]
390 fn test_validator_metadata_default_has_empty_rule_ids() {
391 struct DummyValidator;
392 impl Validator for DummyValidator {
393 fn validate(&self, _: &Path, _: &str, _: &LintConfig) -> Vec<Diagnostic> {
394 vec![]
395 }
396 }
397 let v = DummyValidator;
398 let meta = v.metadata();
399 assert_eq!(meta.name, "DummyValidator");
400 assert!(meta.rule_ids.is_empty());
401 }
402
403 #[test]
404 fn test_validator_metadata_custom_override() {
405 const IDS: &[&str] = &["TEST-001", "TEST-002"];
406 struct CustomValidator;
407 impl Validator for CustomValidator {
408 fn validate(&self, _: &Path, _: &str, _: &LintConfig) -> Vec<Diagnostic> {
409 vec![]
410 }
411 fn metadata(&self) -> ValidatorMetadata {
412 ValidatorMetadata {
413 name: "CustomValidator",
414 rule_ids: IDS,
415 }
416 }
417 }
418 let v = CustomValidator;
419 let meta = v.metadata();
420 assert_eq!(meta.name, "CustomValidator");
421 assert_eq!(meta.rule_ids, &["TEST-001", "TEST-002"]);
422 }
423
424 #[test]
425 fn test_validator_metadata_is_copy() {
426 let meta = ValidatorMetadata {
427 name: "Test",
428 rule_ids: &["R-001"],
429 };
430 let copy = meta;
431 assert_eq!(meta, copy);
432 }
433}