From b42b70da9695acd4c0837c6a9bda6348c8deaec1 Mon Sep 17 00:00:00 2001 From: Ali Ugur Date: Sun, 9 Nov 2025 18:27:55 +0300 Subject: [PATCH] Now you can disable rules per docstring --- README.md | 4 + src/rule_engine.rs | 378 ++++++++++++++++++++- src/test_rule_engine.rs | 1 + src/test_rule_engine/test_rule_suppress.rs | 71 ++++ 4 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 src/test_rule_engine/test_rule_suppress.rs diff --git a/README.md b/README.md index ec3d961..6a859cf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ vipyrdocs path/to/your/python/project Outputs any functions/classes missing docstrings or having incomplete ones. +### Inline Rule Suppression + +Add a `# vipyrdocs: disable=` comment to the end of a `def`/`class` line or statement to silence specific checks (for example, `# vipyrdocs: disable=D020`). Use `ALL` to silence everything for that scope, or `disable-next-docstring`/`disable-file` variants to target the next docstring or whole file when needed. + ## 🔮 Roadmap - Configurable docstring rules diff --git a/src/rule_engine.rs b/src/rule_engine.rs index b27ba58..bd43796 100644 --- a/src/rule_engine.rs +++ b/src/rule_engine.rs @@ -13,6 +13,7 @@ use crate::constants::{ use crate::plugin::{ get_result, ClassInfo, DocstringCollector, FunctionDefKind, FunctionInfo, YieldKind, }; +use regex::Regex; use rustpython_ast::text_size::TextRange; use rustpython_ast::{Arguments, Expr, ExprAttribute, ExprCall, StmtRaise, StmtReturn}; use rustpython_parser::text_size::TextSize; @@ -35,6 +36,369 @@ fn is_test_file(file_name: Option<&str>) -> bool { false } +lazy_static::lazy_static! { + static ref SUPPRESSION_REGEX: Regex = Regex::new( + r"(?i)^\s*vipyrdocs:\s*(disable(?:-next-docstring)?|disable-file)\s*=\s*(?P.+?)\s*$", + ) + .unwrap(); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DirectiveType { + Disable, + DisableNextDocstring, + DisableFile, +} + +#[derive(Default)] +struct DirectiveParseResult { + line_suppressions: HashMap>, + line_disable_directives: HashMap>, + next_docstring_comments: Vec<(usize, HashSet)>, + leading_disable_directives: HashMap>, + file_suppressions: HashSet, +} + +#[derive(Debug, Clone)] +struct BlockSuppression { + start_line: usize, + end_line: usize, + codes: HashSet, +} + +impl BlockSuppression { + fn contains(&self, line: usize) -> bool { + line >= self.start_line && line <= self.end_line + } + + fn matches_code(&self, code: &str) -> bool { + self.codes.contains(code) || self.codes.contains("ALL") + } +} + +#[derive(Clone, Copy)] +enum DocTargetKind { + Function, + Class, +} + +struct DocTarget { + docstring_line: usize, + start_line: usize, + end_line: usize, + kind: DocTargetKind, + consumed: bool, +} + +struct SuppressionIndex { + line_suppressions: HashMap>, + file_suppressions: HashSet, + function_blocks: Vec, + class_blocks: Vec, +} + +impl SuppressionIndex { + fn new(file_contents: &str, collector: &DocstringCollector) -> Self { + let parse_result = parse_suppression_directives(file_contents); + let mut function_blocks: Vec = Vec::new(); + let mut class_blocks: Vec = Vec::new(); + let mut doc_targets: Vec = Vec::new(); + + let add_block = |blocks: &mut Vec, + start_line: usize, + end_line: usize, + codes: &HashSet| { + if start_line == 0 || end_line == 0 { + return; + } + if let Some(existing) = blocks + .iter_mut() + .find(|block| block.start_line == start_line && block.end_line == end_line) + { + existing.codes.extend(codes.clone()); + } else { + blocks.push(BlockSuppression { + start_line, + end_line, + codes: codes.clone(), + }); + } + }; + + for function in &collector.function_infos { + let (start_line, end_line) = range_to_lines(function.def.range(), file_contents); + if let Some(codes) = parse_result.line_disable_directives.get(&start_line) { + add_block(&mut function_blocks, start_line, end_line, codes); + } + + if start_line > 0 { + let prev_line = start_line - 1; + if let Some(codes) = parse_result.leading_disable_directives.get(&prev_line) { + add_block(&mut function_blocks, start_line, end_line, codes); + } + } + + if let Some(docstring) = function.docstring.as_ref() { + let (doc_start, _) = range_to_lines(&docstring.get_range(), file_contents); + if doc_start != 0 { + doc_targets.push(DocTarget { + docstring_line: doc_start, + start_line, + end_line, + kind: DocTargetKind::Function, + consumed: false, + }); + } + } + } + + for class in &collector.class_infos { + let (class_start, class_end) = range_to_lines(&class.def.range, file_contents); + if let Some(codes) = parse_result.line_disable_directives.get(&class_start) { + add_block(&mut class_blocks, class_start, class_end, codes); + } + + if class_start > 0 { + let prev_line = class_start - 1; + if let Some(codes) = parse_result.leading_disable_directives.get(&prev_line) { + add_block(&mut class_blocks, class_start, class_end, codes); + } + } + + if let Some(docstring) = class.docstring.as_ref() { + let (doc_start, _) = range_to_lines(&docstring.get_range(), file_contents); + if doc_start != 0 { + doc_targets.push(DocTarget { + docstring_line: doc_start, + start_line: class_start, + end_line: class_end, + kind: DocTargetKind::Class, + consumed: false, + }); + } + } + + for method in &class.funcs { + let (start_line, end_line) = range_to_lines(method.def.range(), file_contents); + if let Some(codes) = parse_result.line_disable_directives.get(&start_line) { + add_block(&mut function_blocks, start_line, end_line, codes); + } + + if start_line > 0 { + let prev_line = start_line - 1; + if let Some(codes) = parse_result.leading_disable_directives.get(&prev_line) { + add_block(&mut function_blocks, start_line, end_line, codes); + } + } + + if let Some(docstring) = method.docstring.as_ref() { + let (doc_start, _) = range_to_lines(&docstring.get_range(), file_contents); + if doc_start != 0 { + doc_targets.push(DocTarget { + docstring_line: doc_start, + start_line, + end_line, + kind: DocTargetKind::Function, + consumed: false, + }); + } + } + } + } + + doc_targets.sort_by_key(|target| target.docstring_line); + + let mut comment_entries = parse_result.next_docstring_comments.clone(); + comment_entries.sort_by_key(|(line, _)| *line); + + for (comment_line, codes) in comment_entries { + if codes.is_empty() { + continue; + } + if let Some(target) = doc_targets + .iter_mut() + .find(|target| !target.consumed && target.docstring_line > comment_line) + { + target.consumed = true; + match target.kind { + DocTargetKind::Function => { + add_block( + &mut function_blocks, + target.start_line, + target.end_line, + &codes, + ); + } + DocTargetKind::Class => { + add_block( + &mut class_blocks, + target.start_line, + target.end_line, + &codes, + ); + } + } + } + } + + Self { + line_suppressions: parse_result.line_suppressions, + file_suppressions: parse_result.file_suppressions, + function_blocks, + class_blocks, + } + } + + fn is_suppressed_entry(&self, entry: &str) -> bool { + if let Some((line, _, message)) = parse_entry(entry) { + if let Some(code) = extract_code(&message) { + return self.is_suppressed(line, &code); + } + } + false + } + + fn is_suppressed(&self, line: usize, code: &str) -> bool { + let code_upper = code.to_ascii_uppercase(); + + if self.file_suppressions.contains("ALL") || self.file_suppressions.contains(&code_upper) { + return true; + } + + if let Some(codes) = self.line_suppressions.get(&line) { + if codes.contains("ALL") || codes.contains(&code_upper) { + return true; + } + } + + for block in &self.function_blocks { + if block.contains(line) && block.matches_code(&code_upper) { + return true; + } + } + + for block in &self.class_blocks { + if block.contains(line) && block.matches_code(&code_upper) { + return true; + } + } + + false + } +} + +fn parse_suppression_directives(file_contents: &str) -> DirectiveParseResult { + let mut result = DirectiveParseResult::default(); + for (idx, line) in file_contents.lines().enumerate() { + let line_number = idx + 1; + if let Some(hash_index) = line.find('#') { + let comment = &line[hash_index + 1..]; + if let Some((directive_type, codes)) = parse_comment_directive(comment) { + match directive_type { + DirectiveType::Disable => { + if !codes.is_empty() { + result + .line_suppressions + .entry(line_number) + .or_default() + .extend(codes.clone()); + + if line[..hash_index].trim().is_empty() { + result + .leading_disable_directives + .entry(line_number) + .or_default() + .extend(codes.clone()); + } + + result + .line_disable_directives + .entry(line_number) + .or_default() + .extend(codes); + } + } + DirectiveType::DisableNextDocstring => { + if !codes.is_empty() { + result.next_docstring_comments.push((line_number, codes)); + } + } + DirectiveType::DisableFile => { + result.file_suppressions.extend(codes); + } + } + } + } + } + + result +} + +fn parse_comment_directive(comment: &str) -> Option<(DirectiveType, HashSet)> { + let trimmed = comment.trim(); + let captures = SUPPRESSION_REGEX.captures(trimmed)?; + let keyword = captures.get(1)?.as_str().to_ascii_lowercase(); + let codes_raw = captures.name("codes")?.as_str(); + let codes = parse_codes(codes_raw); + if codes.is_empty() { + return None; + } + let directive_type = match keyword.as_str() { + "disable" => DirectiveType::Disable, + "disable-next-docstring" => DirectiveType::DisableNextDocstring, + "disable-file" => DirectiveType::DisableFile, + _ => return None, + }; + Some((directive_type, codes)) +} + +fn parse_codes(raw_codes: &str) -> HashSet { + raw_codes + .split(|c: char| c == ',' || c.is_whitespace()) + .filter_map(|token| { + let token = token.trim(); + if token.is_empty() { + return None; + } + let upper = token.to_ascii_uppercase(); + if upper == "ALL" || upper.starts_with('D') { + Some(upper) + } else { + None + } + }) + .collect() +} + +fn parse_entry(entry: &str) -> Option<(usize, usize, String)> { + let (line_part, rest) = entry.split_once(':')?; + let line: usize = line_part.trim().parse().ok()?; + let rest = rest.trim_start(); + let (column_part, message) = rest.split_once(' ')?; + let column: usize = column_part.trim().parse().ok()?; + Some((line, column, message.trim().to_string())) +} + +fn extract_code(message: &str) -> Option { + message + .split_whitespace() + .next() + .map(|token| token.trim().to_ascii_uppercase()) +} + +fn range_to_lines(range: &TextRange, file_contents: &str) -> (usize, usize) { + let start_offset = range.start().to_usize(); + let end_offset = range.end().to_usize(); + let start_line = find_line_and_column(file_contents, start_offset) + .map(|(line, _)| line) + .unwrap_or(0); + let end_index = if end_offset == 0 { 0 } else { end_offset - 1 }; + let end_line = find_line_and_column(file_contents, end_index) + .map(|(line, _)| line) + .unwrap_or(start_line); + (start_line, end_line) +} + pub fn lint_file(code: &str, file_name: Option<&str>) -> Vec { // Make a mutable String to hold the actual code let mut code = code.to_string(); @@ -87,11 +451,11 @@ pub fn find_string_in_text_range( let column_number = before .rfind('\n') - .map(|idx| absolute_pos - idx - 1) + .map(|idx| absolute_pos.saturating_sub(idx + 1)) .unwrap_or(absolute_pos); positions.push(( - line_number - 2, + line_number.saturating_sub(2), column_number, target_strings[i].to_string(), )); @@ -111,10 +475,10 @@ pub fn find_string_in_text_range( let column_number = before .rfind('\n') - .map(|idx| start - idx - 1) + .map(|idx| start.saturating_sub(idx + 1)) .unwrap_or(start); - positions.push((line_number - 2, column_number, "".to_string())); + positions.push((line_number.saturating_sub(2), column_number, "".to_string())); } positions @@ -128,7 +492,7 @@ fn find_line_and_column(s: &str, char_index: usize) -> Option<(usize, usize)> { let next_char_index = current_char_index + line_char_count; if char_index < next_char_index { - let column = char_index - current_char_index; + let column = char_index.saturating_sub(current_char_index); return Some((line_number + 1, column)); // Lines are 1-based, columns 0-based } @@ -1613,6 +1977,7 @@ fn generate_rules_output( things: &DocstringCollector, is_test_file: bool, ) -> Vec { + let suppressions = SuppressionIndex::new(file_contents, things); // DC0010: docstring missing on a function/ method/ class let mut problem_functions: Vec = Vec::new(); @@ -1894,6 +2259,9 @@ fn generate_rules_output( )); } problem_functions + .into_iter() + .filter(|entry| !suppressions.is_suppressed_entry(entry)) + .collect() } fn check_functions_for_missing_docstring( diff --git a/src/test_rule_engine.rs b/src/test_rule_engine.rs index 5e6fbef..da2aeec 100644 --- a/src/test_rule_engine.rs +++ b/src/test_rule_engine.rs @@ -24,6 +24,7 @@ mod test_rule_63; mod test_rule_64; mod test_rule_65; mod test_rule_6x; +mod test_rule_suppress; use crate::constants::{returns_section_in_docstr_msg, returns_section_not_in_docstr_msg}; use crate::rule_engine::lint_file; diff --git a/src/test_rule_engine/test_rule_suppress.rs b/src/test_rule_engine/test_rule_suppress.rs new file mode 100644 index 0000000..36757be --- /dev/null +++ b/src/test_rule_engine/test_rule_suppress.rs @@ -0,0 +1,71 @@ +use crate::rule_engine::lint_file; + +#[test] +fn test_disable_single_rule_on_function() { + let code = r#" +def function_1(arg_1): # vipyrdocs: disable=D020 + """Docstring.""" +"#; + let output = lint_file(code, None); + assert!( + output.is_empty(), + "Expected no lint messages, got {output:?}" + ); +} + +#[test] +fn test_disable_all_rules_on_function() { + let code = r#" + +def function_1(arg_1): # vipyrdocs: disable=ALL + """Docstring.""" + return 1 +"#; + let output = lint_file(code, None); + assert!( + output.is_empty(), + "Expected no lint messages, got {output:?}" + ); +} + +#[test] +fn test_disable_next_docstring_directive() { + let code = r#" +# vipyrdocs: disable-next-docstring=D020 +def function_1(arg_1): + """Docstring.""" +"#; + let output = lint_file(code, None); + assert!( + output.is_empty(), + "Expected no lint messages, got {output:?}" + ); +} + +#[test] +fn test_inline_disable_on_statement() { + let code = r#" +def function_1(): + """Docstring.""" + return 1 # vipyrdocs: disable=D030 +"#; + let output = lint_file(code, None); + assert!( + output.is_empty(), + "Expected no lint messages, got {output:?}" + ); +} + +#[test] +fn test_disable_comment_before_definition() { + let code = r#" +# vipyrdocs: disable=D020 +def function_1(arg_1): + """Docstring.""" +"#; + let output = lint_file(code, None); + assert!( + output.is_empty(), + "Expected no lint messages, got {output:?}" + ); +}