Skip to main content

grit_lib/
userdiff.rs

1//! User-defined and built-in diff function-name matching.
2//!
3//! This module implements the subset of Git's `userdiff` behavior needed for
4//! hunk-header function context extraction.
5//!
6//! POSIX extended patterns from Git config are translated to Rust [`regex`] syntax
7//! (including bracket classes like `[[:alpha:]]`). Matchers for built-in drivers are
8//! compiled once and cached. Rust's regex engine uses leftmost-first alternation; GNU
9//! `grep -E` uses leftmost-longest. Git's built-in funcname patterns are written so
10//! this difference does not change hunk headers for the shipped drivers.
11
12use crate::attributes::{collect_attrs_for_path, AttrValue, MacroTable};
13use crate::config::ConfigSet;
14use regex::{Regex, RegexBuilder};
15use std::collections::BTreeMap;
16use std::sync::{Arc, OnceLock};
17
18/// Built-in diff driver funcname patterns (same strings as Git's userdiff builtin drivers).
19const BUILTIN_PATTERN_DEFS: &[(&str, &str, bool)] = &[
20    (
21        "ada",
22        r"!^(.*[ 	])?(is[ 	]+new|renames|is[ 	]+separate)([ 	].*)?$
23!^[ 	]*with[ 	].*$
24^[ 	]*((procedure|function)[ 	]+.*)$
25^[ 	]*((package|protected|task)[ 	]+.*)$",
26        true,
27    ),
28    (
29        "bash",
30        r"^[ 	]*((([a-zA-Z_][a-zA-Z0-9_]*[ 	]*\([ 	]*\))|(function[ 	]+[a-zA-Z_][a-zA-Z0-9_]*(([ 	]*\([ 	]*\))|([ 	]+)))).*$)",
31        false,
32    ),
33    (
34        "bibtex",
35        r#"(@[a-zA-Z]{1,}[ 	]*\{{0,1}[ 	]*[^ 	"@',\#}{~%]*).*$"#,
36        false,
37    ),
38    (
39        "cpp",
40        r"!^[ 	]*[A-Za-z_][A-Za-z_0-9]*:[[:space:]]*($|/[/*])
41^((::[[:space:]]*)?[A-Za-z_].*)$",
42        false,
43    ),
44    (
45        "csharp",
46        r"!(^|[ 	]+)(do|while|for|foreach|if|else|new|default|return|switch|case|throw|catch|using|lock|fixed)([ 	(]+|$)
47^[ 	]*(([][[:alnum:]@_.](<[][[:alnum:]@_, 	<>]+>)?)+([ 	]+([][[:alnum:]@_.](<[][[:alnum:]@_, 	<>]+>)?)+)+[ 	]*\([^;]*)$
48^[ 	]*(([][[:alnum:]@_.](<[][[:alnum:]@_, 	<>]+>)?)+([ 	]+([][[:alnum:]@_.](<[][[:alnum:]@_, 	<>]+>)?)+)+[^;=:,()]*)$
49^[ 	]*(((static|public|internal|private|protected|new|unsafe|sealed|abstract|partial)[ 	]+)*(class|enum|interface|struct|record)[ 	]+.*)$
50^[ 	]*(namespace[ 	]+.*)$",
51        false,
52    ),
53    (
54        "css",
55        r"![:;][[:space:]]*$
56^[:[@.#]?[_a-z0-9].*$",
57        true,
58    ),
59    (
60        "dts",
61        r"!;
62!=
63^[ 	]*((/[ 	]*\{|&?[a-zA-Z_]).*)",
64        false,
65    ),
66    (
67        "elixir",
68        r"^[ 	]*((def(macro|module|impl|protocol|p)?|test)[ 	].*)$",
69        false,
70    ),
71    (
72        "fortran",
73        r#"!^([C*]|[ 	]*!)
74!^[ 	]*MODULE[ 	]+PROCEDURE[ 	]
75^[ 	]*((END[ 	]+)?(PROGRAM|MODULE|BLOCK[ 	]+DATA|([^!'" 	]+[ 	]+)*(SUBROUTINE|FUNCTION))[ 	]+[A-Z].*)$"#,
76        true,
77    ),
78    (
79        "fountain",
80        r"^((\.[^.]|(int|ext|est|int\.?/ext|i/e)[. ]).*)$",
81        true,
82    ),
83    (
84        "golang",
85        r"^[ 	]*(func[ 	]*.*(\{[ 	]*)?)
86^[ 	]*(type[ 	].*(struct|interface)[ 	]*(\{[ 	]*)?)",
87        false,
88    ),
89    ("html", r"^[ 	]*(<[Hh][1-6]([ 	].*)?>.*)$", false),
90    ("ini", r"^[ 	]*\[[^]]+\]", false),
91    (
92        "java",
93        r"!^[ 	]*(catch|do|for|if|instanceof|new|return|switch|throw|while)
94^[ 	]*(([a-z-]+[ 	]+)*(class|enum|interface|record)[ 	]+.*)$
95^[ 	]*(([A-Za-z_<>&][][?&<>.,A-Za-z_0-9]*[ 	]+)+[A-Za-z_][A-Za-z_0-9]*[ 	]*\([^;]*)$",
96        false,
97    ),
98    (
99        "kotlin",
100        r"^[ 	]*(([a-z]+[ 	]+)*(fun|class|interface)[ 	]+.*)$",
101        false,
102    ),
103    ("markdown", r"^ {0,3}#{1,6}[ 	].*", false),
104    (
105        "matlab",
106        r"^[[:space:]]*((classdef|function)[[:space:]].*)$|^(%%%?|##)[[:space:]].*$",
107        false,
108    ),
109    (
110        "objc",
111        r"!^[ 	]*(do|for|if|else|return|switch|while)
112^[ 	]*([-+][ 	]*\([ 	]*[A-Za-z_][A-Za-z_0-9* 	]*\)[ 	]*[A-Za-z_].*)$
113^[ 	]*(([A-Za-z_][A-Za-z_0-9]*[ 	]+)+[A-Za-z_][A-Za-z_0-9]*[ 	]*\([^;]*)$
114^(@(implementation|interface|protocol)[ 	].*)$",
115        false,
116    ),
117    (
118        "pascal",
119        r"^(((class[ 	]+)?(procedure|function)|constructor|destructor|interface|implementation|initialization|finalization)[ 	]*.*)$
120^(.*=[ 	]*(class|record).*)$",
121        false,
122    ),
123    (
124        "perl",
125        r"^package .*
126^sub [[:alnum:]_':]+[ 	]*(\([^)]*\)[ 	]*)?(:[^;#]*)?(\{[ 	]*)?(#.*)?$
127^(BEGIN|END|INIT|CHECK|UNITCHECK|AUTOLOAD|DESTROY)[ 	]*(\{[ 	]*)?(#.*)?$
128^=head[0-9] .*",
129        false,
130    ),
131    (
132        "php",
133        r"^[	 ]*(((public|protected|private|static|abstract|final)[	 ]+)*function.*)$
134^[	 ]*((((final|abstract)[	 ]+)?class|enum|interface|trait).*)$",
135        false,
136    ),
137    ("python", r"^[ 	]*((class|(async[ 	]+)?def)[ 	].*)$", false),
138    (
139        "r",
140        r"^[ 	]*([a-zA-z][a-zA-Z0-9_.]*[ 	]*(<-|=)[ 	]*function.*)$",
141        false,
142    ),
143    ("ruby", r"^[ 	]*((class|module|def)[ 	].*)$", false),
144    (
145        "rust",
146        r#"^[	 ]*((pub(\([^\)]+\))?[	 ]+)?((async|const|unsafe|extern([	 ]+"[^"]+"))[	 ]+)?(struct|enum|union|mod|trait|fn|impl|macro_rules!)[< 	]+[^;]*)$"#,
147        false,
148    ),
149    (
150        "scheme",
151        r"^[	 ]*(\(((define|def(struct|syntax|class|method|rules|record|proto|alias)?)[-*/ 	]|(library|module|struct|class)[*+ 	]).*)$",
152        false,
153    ),
154    (
155        "tex",
156        r"^(\\((sub)*section|chapter|part)\*{0,1}\{.*)$",
157        false,
158    ),
159];
160
161#[derive(Debug, Clone)]
162struct FuncRule {
163    matcher: RuleMatcher,
164    negate: bool,
165}
166
167#[derive(Debug, Clone)]
168enum RuleMatcher {
169    Rust(Regex),
170}
171
172#[derive(Debug, Clone)]
173struct BuiltinPattern {
174    pattern: String,
175    ignore_case: bool,
176}
177
178// hygiene: immutable built-in diff funcname matchers, initialized once
179static BUILTIN_FUNCNAME_MATCHERS: OnceLock<BTreeMap<String, Arc<FuncnameMatcher>>> =
180    OnceLock::new();
181
182/// Compiled function-name matcher used for diff hunk headers.
183#[derive(Debug, Clone)]
184pub struct FuncnameMatcher {
185    rules: Vec<FuncRule>,
186}
187
188impl FuncnameMatcher {
189    /// Match a source line against configured funcname rules.
190    ///
191    /// Returns the text to show after the hunk header when matched.
192    #[must_use]
193    pub fn match_line(&self, line: &str) -> Option<String> {
194        let mut text = line;
195        if let Some(stripped) = text.strip_suffix('\n') {
196            text = stripped;
197            if let Some(stripped_cr) = text.strip_suffix('\r') {
198                text = stripped_cr;
199            }
200        }
201
202        for rule in &self.rules {
203            let matched_text = match &rule.matcher {
204                RuleMatcher::Rust(regex) => {
205                    if let Some(caps) = regex.captures(text) {
206                        caps.get(1)
207                            .or_else(|| caps.get(0))
208                            .map(|m| m.as_str())
209                            .unwrap_or_default()
210                            .trim_end_matches(char::is_whitespace)
211                            .to_owned()
212                    } else if regex.is_match(text) {
213                        text.trim_end_matches(char::is_whitespace).to_owned()
214                    } else {
215                        continue;
216                    }
217                }
218            };
219            if rule.negate {
220                return None;
221            }
222            return Some(matched_text.trim_end().to_owned());
223        }
224        None
225    }
226}
227
228/// Resolve a function-name matcher for `rel_path` from parsed `.gitattributes` rules + config.
229///
230/// Returns `Ok(None)` when no diff driver is configured for the path.
231pub fn matcher_for_path_parsed(
232    config: &ConfigSet,
233    rules: &[crate::attributes::AttrRule],
234    macros: &MacroTable,
235    rel_path: &str,
236    ignore_case: bool,
237) -> Result<Option<FuncnameMatcher>, String> {
238    let map = collect_attrs_for_path(rules, macros, rel_path, ignore_case);
239    let Some(AttrValue::Value(driver)) = map.get("diff") else {
240        return Ok(None);
241    };
242    matcher_for_driver(config, driver.as_str())
243}
244
245/// Resolve a function-name matcher for a named diff driver.
246///
247/// Returns `Ok(None)` when the driver has no built-in or configured funcname
248/// pattern.
249pub fn matcher_for_driver(
250    config: &ConfigSet,
251    driver: &str,
252) -> Result<Option<FuncnameMatcher>, String> {
253    if let Some(pattern) = config.get(&format!("diff.{driver}.xfuncname")) {
254        return compile_matcher(&pattern, true, false).map(Some);
255    }
256    if let Some(pattern) = config.get(&format!("diff.{driver}.funcname")) {
257        return compile_matcher(&pattern, false, false).map(Some);
258    }
259    if let Some(matcher) = cached_builtin_matcher(driver) {
260        return Ok(Some((*matcher).clone()));
261    }
262    Ok(None)
263}
264
265fn cached_builtin_matcher(driver: &str) -> Option<Arc<FuncnameMatcher>> {
266    let map = BUILTIN_FUNCNAME_MATCHERS.get_or_init(|| {
267        builtin_patterns()
268            .clone()
269            .into_iter()
270            .map(|(name, builtin)| {
271                let matcher = compile_matcher(&builtin.pattern, true, builtin.ignore_case)
272                    .unwrap_or_else(|e| panic!("built-in userdiff driver {name}: {e}"));
273                (name, Arc::new(matcher))
274            })
275            .collect()
276    });
277    map.get(driver).cloned()
278}
279
280fn compile_matcher(
281    pattern: &str,
282    extended: bool,
283    ignore_case: bool,
284) -> Result<FuncnameMatcher, String> {
285    let lines: Vec<&str> = pattern.split('\n').collect();
286    if lines.is_empty() {
287        return Ok(FuncnameMatcher { rules: Vec::new() });
288    }
289
290    let mut rules = Vec::with_capacity(lines.len());
291    for (idx, raw) in lines.iter().enumerate() {
292        let mut line = *raw;
293        let negate = line.starts_with('!');
294        if negate {
295            if idx == lines.len() - 1 {
296                return Err(format!("Last expression must not be negated: {line}"));
297            }
298            line = &line[1..];
299        }
300
301        let posix_pattern = if extended {
302            line.to_owned()
303        } else {
304            bre_to_ere(line)
305        };
306
307        let matcher = compile_posix_ere_rule(&posix_pattern, ignore_case, line)?;
308        rules.push(FuncRule { matcher, negate });
309    }
310
311    Ok(FuncnameMatcher { rules })
312}
313
314fn builtin_patterns() -> &'static BTreeMap<String, BuiltinPattern> {
315    // hygiene: immutable built-in diff funcname patterns, initialized once
316    static BUILTIN_PATTERNS: OnceLock<BTreeMap<String, BuiltinPattern>> = OnceLock::new();
317    BUILTIN_PATTERNS.get_or_init(parse_builtin_patterns)
318}
319
320fn parse_builtin_patterns() -> BTreeMap<String, BuiltinPattern> {
321    BUILTIN_PATTERN_DEFS
322        .iter()
323        .filter(|(name, _, _)| !name.is_empty() && *name != "default")
324        .map(|(name, pattern, ignore_case)| {
325            (
326                (*name).to_owned(),
327                BuiltinPattern {
328                    pattern: (*pattern).to_owned(),
329                    ignore_case: *ignore_case,
330                },
331            )
332        })
333        .collect()
334}
335
336fn bre_to_ere(pattern: &str) -> String {
337    let mut result = String::with_capacity(pattern.len());
338    let chars: Vec<char> = pattern.chars().collect();
339    let mut i = 0usize;
340    let mut in_bracket = false;
341
342    while i < chars.len() {
343        if in_bracket {
344            if chars[i] == ']' && i > 0 {
345                result.push(']');
346                in_bracket = false;
347                i += 1;
348            } else if chars[i] == '[' {
349                result.push('[');
350                i += 1;
351            } else if chars[i] == '\\' {
352                // Preserve literal backslashes inside character classes.
353                // Rust `regex` understands POSIX classes like `[:alnum:]`,
354                // so we only need to escape unknown escapes.
355                if i + 1 < chars.len() {
356                    let next = chars[i + 1];
357                    if next.is_ascii_alphabetic() {
358                        result.push('\\');
359                        result.push('\\');
360                        result.push(next);
361                        i += 2;
362                    } else {
363                        result.push('\\');
364                        result.push(next);
365                        i += 2;
366                    }
367                } else {
368                    result.push('\\');
369                    i += 1;
370                }
371            } else {
372                result.push(chars[i]);
373                i += 1;
374            }
375        } else if chars[i] == '[' {
376            result.push('[');
377            in_bracket = true;
378            i += 1;
379            if i < chars.len() && (chars[i] == '^' || chars[i] == '!') {
380                result.push(chars[i]);
381                i += 1;
382            }
383            if i < chars.len() && chars[i] == ']' {
384                result.push(']');
385                i += 1;
386            }
387        } else if chars[i] == '\\' && i + 1 < chars.len() {
388            match chars[i + 1] {
389                '+' | '?' | '{' | '}' | '(' | ')' | '|' => {
390                    result.push(chars[i + 1]);
391                    i += 2;
392                }
393                _ => {
394                    result.push(chars[i]);
395                    result.push(chars[i + 1]);
396                    i += 2;
397                }
398            }
399        } else if matches!(chars[i], '+' | '?' | '{' | '}' | '(' | ')' | '|') {
400            result.push('\\');
401            result.push(chars[i]);
402            i += 1;
403        } else {
404            result.push(chars[i]);
405            i += 1;
406        }
407    }
408
409    result
410}
411
412/// Translate a validated POSIX ERE (as Git uses for `xfuncname`) into Rust `regex` syntax.
413///
414/// Returns `None` when the pattern is not valid POSIX ERE (e.g. an unterminated `[` class).
415fn posix_ere_to_rust_regex(pattern: &str) -> Option<String> {
416    let mut out = String::with_capacity(pattern.len() + 16);
417    let chars: Vec<char> = pattern.chars().collect();
418    let mut i = 0usize;
419    while i < chars.len() {
420        if chars[i] == '[' {
421            let (translated, consumed) = translate_posix_bracket(&chars[i..])?;
422            out.push_str(&translated);
423            i += consumed;
424        } else if chars[i] == '\\' && i + 1 < chars.len() {
425            out.push(chars[i]);
426            out.push(chars[i + 1]);
427            i += 2;
428        } else {
429            out.push(chars[i]);
430            i += 1;
431        }
432    }
433    Some(out)
434}
435
436/// Translate one POSIX bracket expression starting at `[`; returns Rust class + bytes consumed.
437fn translate_posix_bracket(src: &[char]) -> Option<(String, usize)> {
438    debug_assert!(src.first() == Some(&'['));
439    let mut out = String::from("[");
440    let mut i = 1usize;
441    if i < src.len() && (src[i] == '^' || src[i] == '!') {
442        out.push(src[i]);
443        i += 1;
444    }
445    if i < src.len() && src[i] == ']' {
446        out.push(']');
447        i += 1;
448    }
449    while i < src.len() {
450        if src[i] == ']' {
451            out.push(']');
452            return Some((out, i + 1));
453        }
454        if src[i] == '[' && i + 2 < src.len() && src[i + 1] == ':' {
455            let start = i;
456            i += 2;
457            while i + 1 < src.len() {
458                if src[i] == ':' && src[i + 1] == ']' {
459                    i += 2;
460                    break;
461                }
462                i += 1;
463            }
464            for ch in &src[start..i] {
465                out.push(*ch);
466            }
467            continue;
468        }
469        if src[i] == '\\' && i + 1 < src.len() {
470            out.push('\\');
471            out.push(src[i + 1]);
472            i += 2;
473            continue;
474        }
475        let ch = src[i];
476        if matches!(ch, '[' | ']' | '\\') {
477            out.push('\\');
478        }
479        out.push(ch);
480        i += 1;
481    }
482    None
483}
484
485/// Compile-check a POSIX ERE without shelling out to `grep`.
486fn validate_posix_ere(pattern: &str, ignore_case: bool) -> Result<(), regex::Error> {
487    let Some(rust_pattern) = posix_ere_to_rust_regex(pattern) else {
488        return Err(regex::Error::Syntax(
489            "unterminated bracket expression".into(),
490        ));
491    };
492    RegexBuilder::new(&rust_pattern)
493        .case_insensitive(ignore_case)
494        .build()
495        .map(|_| ())
496}
497
498/// Compile a POSIX ERE matcher rule after validation.
499fn compile_posix_ere_rule(
500    pattern: &str,
501    ignore_case: bool,
502    line: &str,
503) -> Result<RuleMatcher, String> {
504    validate_posix_ere(pattern, ignore_case)
505        .map_err(|_| format!("Invalid regexp to look for hunk header: {line}"))?;
506    let rust_pattern = posix_ere_to_rust_regex(pattern)
507        .ok_or_else(|| format!("Invalid regexp to look for hunk header: {line}"))?;
508    RegexBuilder::new(&rust_pattern)
509        .case_insensitive(ignore_case)
510        .build()
511        .map_err(|_| format!("Invalid regexp to look for hunk header: {line}"))
512        .map(RuleMatcher::Rust)
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use std::path::Path;
519
520    use crate::config::{ConfigFile, ConfigScope, ConfigSet};
521
522    fn config_with_xfuncname(driver: &str, pattern: &str) -> ConfigSet {
523        let snippet = format!("[diff \"{driver}\"]\n\txfuncname = {pattern}\n");
524        let file = ConfigFile::parse(Path::new(".git/config"), &snippet, ConfigScope::Local)
525            .expect("parse config snippet");
526        let mut set = ConfigSet::new();
527        set.merge(&file);
528        set
529    }
530
531    #[test]
532    fn unterminated_posix_bracket_class_is_rejected() {
533        let err = compile_matcher("[a", true, false).unwrap_err();
534        assert!(err.contains("Invalid regexp"), "unexpected error: {err}");
535        let cfg = config_with_xfuncname("probe", "[a");
536        let err = matcher_for_driver(&cfg, "probe").unwrap_err();
537        assert!(err.contains("Invalid regexp"), "unexpected error: {err}");
538    }
539
540    #[test]
541    fn all_builtin_userdiff_drivers_compile_and_cache() {
542        let config = ConfigSet::default();
543        for (name, _, _) in BUILTIN_PATTERN_DEFS {
544            if name.is_empty() || *name == "default" {
545                continue;
546            }
547            let matcher = matcher_for_driver(&config, name)
548                .unwrap_or_else(|e| panic!("driver {name}: {e}"))
549                .unwrap_or_else(|| panic!("driver {name} missing builtin matcher"));
550            assert!(
551                cached_builtin_matcher(name).is_some(),
552                "driver {name} must be cached"
553            );
554            let _ = matcher.match_line(" \n");
555        }
556    }
557}