Respect editorconfig formatting
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user