package topdata import ( "bufio" "bytes" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" ) type jsonFileFormatting struct { Indent string LineEnding string FinalNewline bool } type editorConfigDocument struct { Dir string Root bool Sections []editorConfigSection } type editorConfigSection struct { Pattern string Properties map[string]string } func defaultJSONFileFormatting() jsonFileFormatting { return jsonFileFormatting{ Indent: " ", LineEnding: "\n", FinalNewline: true, } } func resolveJSONFileFormatting(path string) (jsonFileFormatting, error) { formatting := defaultJSONFileFormatting() documents, err := editorConfigDocumentsForPath(path) if err != nil { return jsonFileFormatting{}, err } for _, document := range documents { relative, err := filepath.Rel(document.Dir, path) if err != nil { return jsonFileFormatting{}, fmt.Errorf("resolve editorconfig path for %s relative to %s: %w", path, document.Dir, err) } relative = filepath.ToSlash(relative) if strings.HasPrefix(relative, "../") || relative == ".." { continue } for _, section := range document.Sections { matches, err := editorConfigPatternMatches(section.Pattern, relative) if err != nil { return jsonFileFormatting{}, fmt.Errorf("%s/.editorconfig section [%s]: %w", document.Dir, section.Pattern, err) } if !matches { continue } next, err := applyEditorConfigProperties(formatting, section.Properties) if err != nil { return jsonFileFormatting{}, fmt.Errorf("%s/.editorconfig section [%s]: %w", document.Dir, section.Pattern, err) } formatting = next } } return formatting, nil } func editorConfigDocumentsForPath(path string) ([]editorConfigDocument, error) { dir := filepath.Dir(path) documents := []editorConfigDocument{} for { configPath := filepath.Join(dir, ".editorconfig") raw, err := os.ReadFile(configPath) if err == nil { document, err := parseEditorConfig(configPath, raw) if err != nil { return nil, err } document.Dir = dir documents = append(documents, document) if document.Root { break } } else if !os.IsNotExist(err) { return nil, fmt.Errorf("read %s: %w", configPath, err) } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } for left, right := 0, len(documents)-1; left < right; left, right = left+1, right-1 { documents[left], documents[right] = documents[right], documents[left] } return documents, nil } func parseEditorConfig(path string, raw []byte) (editorConfigDocument, error) { document := editorConfigDocument{} scanner := bufio.NewScanner(bytes.NewReader(raw)) var current *editorConfigSection for lineNumber := 1; scanner.Scan(); lineNumber++ { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } if strings.HasPrefix(line, "[") { end := strings.LastIndex(line, "]") if end < 0 { return editorConfigDocument{}, fmt.Errorf("%s:%d: malformed section header", path, lineNumber) } section := editorConfigSection{ Pattern: strings.TrimSpace(line[1:end]), Properties: map[string]string{}, } document.Sections = append(document.Sections, section) current = &document.Sections[len(document.Sections)-1] continue } index := strings.IndexAny(line, "=:") if index < 0 { return editorConfigDocument{}, fmt.Errorf("%s:%d: expected key/value property", path, lineNumber) } key := strings.ToLower(strings.TrimSpace(line[:index])) value := strings.TrimSpace(line[index+1:]) if current == nil { if key == "root" { document.Root = strings.EqualFold(value, "true") } continue } current.Properties[key] = value } if err := scanner.Err(); err != nil { return editorConfigDocument{}, fmt.Errorf("scan %s: %w", path, err) } return document, nil } func applyEditorConfigProperties(formatting jsonFileFormatting, properties map[string]string) (jsonFileFormatting, error) { style := strings.ToLower(strings.TrimSpace(properties["indent_style"])) sizeText := strings.ToLower(strings.TrimSpace(properties["indent_size"])) if style == "tab" { formatting.Indent = "\t" } else if style != "" && style != "space" && style != "unset" { return jsonFileFormatting{}, fmt.Errorf("unsupported indent_style %q", properties["indent_style"]) } if sizeText != "" && sizeText != "unset" && sizeText != "tab" { size, err := strconv.Atoi(sizeText) if err != nil || size < 1 { return jsonFileFormatting{}, fmt.Errorf("indent_size must be a positive integer, got %q", properties["indent_size"]) } if style != "tab" { formatting.Indent = strings.Repeat(" ", size) } } switch value := strings.ToLower(strings.TrimSpace(properties["end_of_line"])); value { case "", "unset": case "lf": formatting.LineEnding = "\n" case "crlf": formatting.LineEnding = "\r\n" case "cr": formatting.LineEnding = "\r" default: return jsonFileFormatting{}, fmt.Errorf("unsupported end_of_line %q", properties["end_of_line"]) } switch value := strings.ToLower(strings.TrimSpace(properties["insert_final_newline"])); value { case "", "unset": case "true": formatting.FinalNewline = true case "false": formatting.FinalNewline = false default: return jsonFileFormatting{}, fmt.Errorf("insert_final_newline must be true or false, got %q", properties["insert_final_newline"]) } return formatting, nil } func editorConfigPatternMatches(pattern, relativePath string) (bool, error) { pattern = filepath.ToSlash(strings.TrimSpace(pattern)) pattern = strings.TrimPrefix(pattern, "/") if pattern == "" { return false, nil } if !strings.Contains(pattern, "/") { relativePath = pathBase(relativePath) } expression, err := editorConfigGlobRegexp(pattern) if err != nil { return false, err } return regexp.MatchString(expression, relativePath) } func editorConfigGlobRegexp(pattern string) (string, error) { body, err := translateEditorConfigGlobSegment(pattern) if err != nil { return "", err } expression := "^" + body + "$" if _, err := regexp.Compile(expression); err != nil { return "", err } return expression, nil } // translateEditorConfigGlobSegment converts an editorconfig glob into a regular // expression body. In addition to *, **, and ?, it implements brace expansion: // comma lists ({a,b,c}) become alternations and numeric ranges ({n..m}) expand // to the integers in the range, matching the editorconfig specification. func translateEditorConfigGlobSegment(pattern string) (string, error) { var builder strings.Builder for index := 0; index < len(pattern); { char := pattern[index] switch char { case '*': if index+1 < len(pattern) && pattern[index+1] == '*' { if index+2 < len(pattern) && pattern[index+2] == '/' { builder.WriteString("(?:.*/)?") index += 3 } else { builder.WriteString(".*") index += 2 } continue } builder.WriteString("[^/]*") index++ case '?': builder.WriteString("[^/]") index++ case '{': end := matchingEditorConfigBrace(pattern, index) if end < 0 { // An unbalanced brace is matched literally. builder.WriteString("\\{") index++ continue } expansion, err := expandEditorConfigBrace(pattern[index+1 : end]) if err != nil { return "", err } builder.WriteString(expansion) index = end + 1 case '.', '+', '(', ')', '|', '^', '$', '}', '[', ']', '\\': builder.WriteByte('\\') builder.WriteByte(char) index++ default: builder.WriteByte(char) index++ } } return builder.String(), nil } // matchingEditorConfigBrace returns the index of the '}' that closes the '{' at // open, accounting for nesting, or -1 when the braces are unbalanced. func matchingEditorConfigBrace(pattern string, open int) int { depth := 0 for index := open; index < len(pattern); index++ { switch pattern[index] { case '{': depth++ case '}': depth-- if depth == 0 { return index } } } return -1 } // expandEditorConfigBrace turns the body of a brace expression into a regex // fragment. A comma list becomes an alternation; a lone "n..m" body becomes a // numeric range. A body with neither comma nor range is treated as literal // braces, matching the editorconfig reference implementation. func expandEditorConfigBrace(inner string) (string, error) { options := splitTopLevelBraceCommas(inner) if len(options) == 1 { if rangeExpr, ok, err := editorConfigNumericRange(inner); err != nil { return "", err } else if ok { return rangeExpr, nil } segment, err := translateEditorConfigGlobSegment(inner) if err != nil { return "", err } return "\\{" + segment + "\\}", nil } parts := make([]string, 0, len(options)) for _, option := range options { segment, err := translateEditorConfigGlobSegment(option) if err != nil { return "", err } parts = append(parts, segment) } return "(?:" + strings.Join(parts, "|") + ")", nil } // splitTopLevelBraceCommas splits a brace body on commas that are not nested // inside an inner brace group. func splitTopLevelBraceCommas(inner string) []string { parts := []string{} depth := 0 start := 0 for index := 0; index < len(inner); index++ { switch inner[index] { case '{': depth++ case '}': depth-- case ',': if depth == 0 { parts = append(parts, inner[start:index]) start = index + 1 } } } return append(parts, inner[start:]) } // editorConfigNumericRange expands "n..m" into an alternation of the integers // in [n, m]. It returns ok=false when the body is not a numeric range. func editorConfigNumericRange(inner string) (string, bool, error) { separator := strings.Index(inner, "..") if separator < 0 { return "", false, nil } low, err := strconv.Atoi(strings.TrimSpace(inner[:separator])) if err != nil { return "", false, nil } high, err := strconv.Atoi(strings.TrimSpace(inner[separator+2:])) if err != nil { return "", false, nil } if low > high { low, high = high, low } const maxRangeSize = 65536 if high-low >= maxRangeSize { return "", false, fmt.Errorf("editorconfig numeric range {%s} is too large", inner) } parts := make([]string, 0, high-low+1) for value := low; value <= high; value++ { parts = append(parts, strconv.Itoa(value)) } return "(?:" + strings.Join(parts, "|") + ")", true, nil } func pathBase(path string) string { if index := strings.LastIndex(path, "/"); index >= 0 { return path[index+1:] } return path }