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) { var builder strings.Builder builder.WriteByte('^') 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("[^/]*") case '?': builder.WriteString("[^/]") case '.', '+', '(', ')', '|', '^', '$', '{', '}', '[', ']', '\\': builder.WriteByte('\\') builder.WriteByte(char) default: builder.WriteByte(char) } index++ } builder.WriteByte('$') expression := builder.String() if _, err := regexp.Compile(expression); err != nil { return "", err } return expression, nil } func pathBase(path string) string { if index := strings.LastIndex(path, "/"); index >= 0 { return path[index+1:] } return path }