dupe_core/
directives.rs

1//! Inline directive detection for suppressing false positives
2//!
3//! This module provides support for inline comments that suppress duplicate
4//! detection warnings directly in source code, similar to linter directives.
5//!
6//! # Supported Directive Formats
7//!
8//! ## JavaScript/TypeScript/Rust
9//! ```javascript
10//! // polydup-ignore: intentional code reuse
11//! function duplicateCode() { ... }
12//! ```
13//!
14//! ## Python
15//! ```python
16//! # polydup-ignore: framework boilerplate
17//! def duplicate_function():
18//!     pass
19//! ```
20//!
21//! # Detection Strategy
22//!
23//! Directives are detected by scanning comment lines immediately before
24//! a function or code block. The directive suppresses duplicate detection
25//! for the entire function/block that follows it.
26
27use std::collections::HashMap;
28use std::path::Path;
29
30/// Represents a directive found in source code
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Directive {
33    /// Line number where the directive appears (1-indexed)
34    pub line: usize,
35    /// Optional reason provided in the directive
36    pub reason: Option<String>,
37}
38
39/// Directive detection result for a single file
40#[derive(Debug, Clone)]
41pub struct FileDirectives {
42    /// Map of line numbers to directives
43    /// Key: Line number where suppression applies (function start line)
44    /// Value: The directive that applies
45    directives: HashMap<usize, Directive>,
46}
47
48impl FileDirectives {
49    /// Creates an empty directive set
50    pub fn new() -> Self {
51        Self {
52            directives: HashMap::new(),
53        }
54    }
55
56    /// Checks if a line range is suppressed by a directive
57    ///
58    /// A directive suppresses a range if it appears within 3 lines before
59    /// the start of the range (allowing for blank lines).
60    ///
61    /// # Arguments
62    /// * `start_line` - Starting line of the code block (1-indexed)
63    /// * `end_line` - Ending line of the code block (1-indexed)
64    ///
65    /// # Returns
66    /// * `Some(Directive)` - If the range is suppressed
67    /// * `None` - If no directive applies
68    pub fn is_suppressed(&self, start_line: usize, _end_line: usize) -> Option<&Directive> {
69        // Check the function start line and up to 3 lines before it
70        // This allows for blank lines and multi-line comments between directive and function
71        for offset in 0..=3 {
72            if start_line > offset {
73                let check_line = start_line - offset;
74                if let Some(directive) = self.directives.get(&check_line) {
75                    return Some(directive);
76                }
77            }
78        }
79
80        None
81    }
82
83    /// Adds a directive for a specific line
84    fn add_directive(&mut self, line: usize, directive: Directive) {
85        self.directives.insert(line, directive);
86    }
87
88    /// Returns the number of directives in this file
89    pub fn len(&self) -> usize {
90        self.directives.len()
91    }
92
93    /// Checks if there are any directives
94    pub fn is_empty(&self) -> bool {
95        self.directives.is_empty()
96    }
97}
98
99impl Default for FileDirectives {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105/// Detects polydup-ignore directives in source code
106///
107/// Scans for comment lines containing "polydup-ignore" and extracts
108/// optional reasons.
109///
110/// # Supported Formats
111/// - `// polydup-ignore` (no reason)
112/// - `// polydup-ignore: reason here`
113/// - `# polydup-ignore: reason here` (Python)
114///
115/// # Arguments
116/// * `source` - The source code to scan
117///
118/// # Returns
119/// * `FileDirectives` - Detected directives with line numbers
120pub fn detect_directives(source: &str) -> FileDirectives {
121    let mut directives = FileDirectives::new();
122    let lines: Vec<&str> = source.lines().collect();
123
124    for (i, line) in lines.iter().enumerate() {
125        let line_num = i + 1; // 1-indexed
126        let trimmed = line.trim();
127
128        // Check for polydup-ignore directive in comments
129        if let Some(directive) = parse_directive_line(trimmed) {
130            // Store the directive at the line where it appears
131            // The is_suppressed() method will handle checking nearby lines
132            directives.add_directive(line_num, directive);
133        }
134    }
135
136    directives
137}
138
139/// Parses a single line to detect a polydup-ignore directive
140///
141/// # Arguments
142/// * `line` - A trimmed line of source code
143///
144/// # Returns
145/// * `Some(Directive)` - If the line contains a valid directive
146/// * `None` - If no directive is found
147fn parse_directive_line(line: &str) -> Option<Directive> {
148    // Check for JavaScript/TypeScript/Rust style comments
149    if let Some(rest) = line.strip_prefix("//") {
150        return parse_comment_content(rest, line.len());
151    }
152
153    // Check for Python style comments
154    if let Some(rest) = line.strip_prefix('#') {
155        return parse_comment_content(rest, line.len());
156    }
157
158    None
159}
160
161/// Extracts directive information from comment content
162fn parse_comment_content(content: &str, _line_len: usize) -> Option<Directive> {
163    let content = content.trim();
164
165    // Check for exact match or with colon
166    if let Some(rest) = content.strip_prefix("polydup-ignore") {
167        let rest = rest.trim();
168
169        // Extract reason if provided after colon
170        let reason = if let Some(after_colon) = rest.strip_prefix(':') {
171            let r = after_colon.trim();
172            if r.is_empty() {
173                None
174            } else {
175                Some(r.to_string())
176            }
177        } else if rest.is_empty() {
178            None
179        } else {
180            // If there's content but no colon, treat it as reason
181            Some(rest.to_string())
182        };
183
184        return Some(Directive {
185            line: 0, // Will be set by caller
186            reason,
187        });
188    }
189
190    None
191}
192
193/// Detects directives in a file
194///
195/// # Arguments
196/// * `path` - Path to the source file
197///
198/// # Returns
199/// * `Result<FileDirectives>` - Detected directives or error
200pub fn detect_directives_in_file(path: &Path) -> crate::Result<FileDirectives> {
201    let source = std::fs::read_to_string(path).map_err(crate::PolyDupError::Io)?;
202    Ok(detect_directives(&source))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_detect_javascript_directive_with_reason() {
211        let source = r#"
212// polydup-ignore: intentional code reuse
213function duplicate() {
214    console.log("test");
215}
216"#;
217        let directives = detect_directives(source);
218        assert_eq!(directives.len(), 1);
219
220        // Directive is at line 2
221        assert!(directives.is_suppressed(2, 5).is_some());
222        assert!(directives.is_suppressed(3, 5).is_some()); // Function start should also match
223    }
224
225    #[test]
226    fn test_detect_python_directive() {
227        let source = r#"
228# polydup-ignore: framework requirement
229def duplicate_function():
230    pass
231"#;
232        let directives = detect_directives(source);
233        assert_eq!(directives.len(), 1);
234        assert!(directives.is_suppressed(2, 4).is_some());
235    }
236
237    #[test]
238    fn test_directive_without_reason() {
239        let source = "// polydup-ignore\nfunction test() {}";
240        let directives = detect_directives(source);
241        assert_eq!(directives.len(), 1);
242
243        let directive = directives.is_suppressed(1, 2).unwrap();
244        assert!(directive.reason.is_none());
245    }
246
247    #[test]
248    fn test_directive_with_colon_but_no_reason() {
249        let source = "// polydup-ignore:\nfunction test() {}";
250        let directives = detect_directives(source);
251        assert_eq!(directives.len(), 1);
252
253        let directive = directives.is_suppressed(1, 2).unwrap();
254        assert!(directive.reason.is_none());
255    }
256
257    #[test]
258    fn test_no_directive() {
259        let source = r#"
260// This is just a regular comment
261function not_ignored() {
262    return 42;
263}
264"#;
265        let directives = detect_directives(source);
266        assert_eq!(directives.len(), 0);
267        assert!(directives.is_suppressed(2, 5).is_none());
268    }
269
270    #[test]
271    fn test_multiple_directives() {
272        let source = r#"
273// polydup-ignore: reason 1
274function fn1() {}
275
276// polydup-ignore: reason 2
277function fn2() {}
278"#;
279        let directives = detect_directives(source);
280        assert_eq!(directives.len(), 2);
281
282        assert!(directives.is_suppressed(2, 3).is_some());
283        assert!(directives.is_suppressed(5, 6).is_some());
284    }
285
286    #[test]
287    fn test_rust_directive() {
288        let source = r#"
289// polydup-ignore: generated code
290fn duplicate() -> i32 {
291    42
292}
293"#;
294        let directives = detect_directives(source);
295        assert_eq!(directives.len(), 1);
296        assert!(directives.is_suppressed(2, 5).is_some());
297    }
298}