372 lines
10 KiB
Go
372 lines
10 KiB
Go
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
|
|
}
|