// Copyright IBM Corp. 2018, 2025 // SPDX-License-Identifier: MPL-2.0 package ignorefiles import ( "bufio" "fmt" "io" "os" "path/filepath" "regexp" "strings" "text/scanner" "golang.org/x/text/unicode/norm" ) func readRules(input io.Reader) ([]rule, error) { rules := defaultExclusions scanner := bufio.NewScanner(input) scanner.Split(bufio.ScanLines) currentRuleIndex := len(defaultExclusions) - 1 for scanner.Scan() { pattern := scanner.Text() // Ignore blank lines if len(pattern) == 0 { continue } // Trim spaces pattern = strings.TrimSpace(pattern) // Ignore comments if pattern[0] == '#' { continue } // New rule structure rule := rule{} // Exclusions if pattern[0] == '!' { rule.negated = true pattern = pattern[1:] // Mark all previous rules as having negations after it for i := currentRuleIndex; i >= 0; i-- { if rules[i].negationsAfter { break } rules[i].negationsAfter = true } } // If it is a directory, add ** so we catch descendants if pattern[len(pattern)-1] == os.PathSeparator { pattern = pattern + "**" } // If it starts with /, it is absolute if pattern[0] == os.PathSeparator { pattern = pattern[1:] } else { // Otherwise prepend **/ pattern = "**" + string(os.PathSeparator) + pattern } rule.val = pattern rules = append(rules, rule) currentRuleIndex += 1 } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("syntax error in .terraformignore: %w", err) } return rules, nil } type rule struct { val string // the value of the rule itself negated bool // prefixed by !, a negated rule negationsAfter bool // negatied rules appear after this rule regex *regexp.Regexp // regular expression to match for the rule } // normalizeUnicode normalizes a string to NFC (Canonical Composition) form // to ensure consistent matching across different Unicode representations. func normalizeUnicode(s string) string { return norm.NFC.String(s) } func (r *rule) match(path string) (bool, error) { if r.regex == nil { if err := r.compile(); err != nil { return false, filepath.ErrBadPattern } } // Normalize path to ensure consistent matching across different Unicode formats normalizedPath := normalizeUnicode(path) b := r.regex.MatchString(normalizedPath) return b, nil } func (r *rule) compile() error { regStr := "^" // Normalize pattern to ensure consistent matching across different Unicode formats pattern := normalizeUnicode(r.val) // Go through the pattern and convert it to a regexp. // Use a scanner to support utf-8 chars. var scan scanner.Scanner scan.Init(strings.NewReader(pattern)) sl := string(os.PathSeparator) escSL := sl if sl == `\` { escSL += `\` } for scan.Peek() != scanner.EOF { ch := scan.Next() if ch == '*' { if scan.Peek() == '*' { // is some flavor of "**" scan.Next() // Treat **/ as ** so eat the "/" if string(scan.Peek()) == sl { scan.Next() } if scan.Peek() == scanner.EOF { // is "**EOF" - to align with .gitignore just accept all regStr += ".*" } else { // is "**" // Note that this allows for any # of /'s (even 0) because // the .* will eat everything, even /'s regStr += "(.*" + escSL + ")?" } } else { // is "*" so map it to anything but "/" regStr += "[^" + escSL + "]*" } } else if ch == '?' { // "?" is any char except "/" regStr += "[^" + escSL + "]" } else if ch == '.' || ch == '$' { // Escape some regexp special chars that have no meaning // in golang's filepath.Match regStr += `\` + string(ch) } else if ch == '\\' { // escape next char. Note that a trailing \ in the pattern // will be left alone (but need to escape it) if sl == `\` { // On windows map "\" to "\\", meaning an escaped backslash, // and then just continue because filepath.Match on // Windows doesn't allow escaping at all regStr += escSL continue } if scan.Peek() != scanner.EOF { regStr += `\` + string(scan.Next()) } else { regStr += `\` } } else { regStr += string(ch) } } regStr += "$" re, err := regexp.Compile(regStr) if err != nil { return err } r.regex = re return nil } /* Default rules as they would appear in .terraformignore: .git/ .terraform/ !.terraform/modules/ */ var defaultExclusions = []rule{ { val: strings.Join([]string{"**", ".terraform", "**"}, string(os.PathSeparator)), negated: false, negationsAfter: true, }, // Place negation rules as high as possible in the list { val: strings.Join([]string{"**", ".terraform", "modules", "**"}, string(os.PathSeparator)), negated: true, negationsAfter: false, }, { val: strings.Join([]string{"**", ".git", "**"}, string(os.PathSeparator)), negated: false, negationsAfter: false, }, } func init() { // We'll precompile all of the default rules at initialization, so we // don't need to recompile them every time we encounter a package that // doesn't have any rules (the common case). for _, r := range defaultExclusions { err := r.compile() if err != nil { panic(fmt.Sprintf("invalid default rule %q: %s", r.val, err)) } } }