Respect editorconfig formatting
This commit is contained in:
@@ -674,6 +674,8 @@ Consumer repos can fetch those binaries into their local `tools/` directory with
|
|||||||
## TopData
|
## TopData
|
||||||
|
|
||||||
The native topdata system is implemented in this repo and authored in `sow-module`.
|
The native topdata system is implemented in this repo and authored in `sow-module`.
|
||||||
|
Generated native topdata lockfiles use matching `.editorconfig` JSON formatting
|
||||||
|
settings when a consumer repository provides them.
|
||||||
|
|
||||||
High-level ownership split:
|
High-level ownership split:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+22
-10
@@ -804,9 +804,6 @@ func saveNativeLockfiles(collected []nativeCollectedDataset) (int, int, error) {
|
|||||||
}
|
}
|
||||||
added += countAddedLockEntries(current, dataset.LockData)
|
added += countAddedLockEntries(current, dataset.LockData)
|
||||||
pruned += countAddedLockEntries(dataset.LockData, current)
|
pruned += countAddedLockEntries(dataset.LockData, current)
|
||||||
if lockDataEqual(current, dataset.LockData) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := saveLockfile(dataset.Dataset.LockPath, dataset.LockData); err != nil {
|
if err := saveLockfile(dataset.Dataset.LockPath, dataset.LockData); err != nil {
|
||||||
return 0, 0, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
|
return 0, 0, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
|
||||||
}
|
}
|
||||||
@@ -6498,7 +6495,11 @@ func saveLockfile(path string, lockData map[string]int) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read lockfile order: %w", err)
|
return fmt.Errorf("read lockfile order: %w", err)
|
||||||
}
|
}
|
||||||
raw, err := marshalOrderedLockfile(keys, lockData)
|
formatting, err := resolveJSONFileFormatting(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve lockfile formatting: %w", err)
|
||||||
|
}
|
||||||
|
raw, err := marshalOrderedLockfile(keys, lockData, formatting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal lockfile: %w", err)
|
return fmt.Errorf("marshal lockfile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -6587,13 +6588,21 @@ func readLockfileKeyOrder(path string) ([]string, error) {
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func marshalOrderedLockfile(keys []string, lockData map[string]int) ([]byte, error) {
|
func marshalOrderedLockfile(keys []string, lockData map[string]int, formatting jsonFileFormatting) ([]byte, error) {
|
||||||
|
lineEnding := formatting.LineEnding
|
||||||
|
if lineEnding == "" {
|
||||||
|
lineEnding = "\n"
|
||||||
|
}
|
||||||
if len(keys) == 0 {
|
if len(keys) == 0 {
|
||||||
return []byte("{}\n"), nil
|
if formatting.FinalNewline {
|
||||||
|
return []byte("{}" + lineEnding), nil
|
||||||
|
}
|
||||||
|
return []byte("{}"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
buffer.WriteString("{\n")
|
buffer.WriteString("{")
|
||||||
|
buffer.WriteString(lineEnding)
|
||||||
for index, key := range keys {
|
for index, key := range keys {
|
||||||
keyRaw, err := json.Marshal(key)
|
keyRaw, err := json.Marshal(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -6603,16 +6612,19 @@ func marshalOrderedLockfile(keys []string, lockData map[string]int) ([]byte, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
buffer.WriteString(" ")
|
buffer.WriteString(formatting.Indent)
|
||||||
buffer.Write(keyRaw)
|
buffer.Write(keyRaw)
|
||||||
buffer.WriteString(": ")
|
buffer.WriteString(": ")
|
||||||
buffer.Write(valueRaw)
|
buffer.Write(valueRaw)
|
||||||
if index < len(keys)-1 {
|
if index < len(keys)-1 {
|
||||||
buffer.WriteByte(',')
|
buffer.WriteByte(',')
|
||||||
}
|
}
|
||||||
buffer.WriteByte('\n')
|
buffer.WriteString(lineEnding)
|
||||||
|
}
|
||||||
|
buffer.WriteString("}")
|
||||||
|
if formatting.FinalNewline {
|
||||||
|
buffer.WriteString(lineEnding)
|
||||||
}
|
}
|
||||||
buffer.WriteString("}\n")
|
|
||||||
return buffer.Bytes(), nil
|
return buffer.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1148,6 +1148,87 @@ func TestSaveLockfilePreservesExistingOrderAndAppendsNewKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveLockfileUsesEditorConfigJSONFormatting(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, ".editorconfig"), `root = true
|
||||||
|
|
||||||
|
[topdata/data/*.json]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[topdata/data/**/*.json]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
`)
|
||||||
|
lockPath := filepath.Join(root, "topdata", "data", "feat", "lock.json")
|
||||||
|
mkdirAll(t, filepath.Dir(lockPath))
|
||||||
|
writeFile(t, lockPath, "{\n \"b\": 2,\n \"a\": 1\n}\n")
|
||||||
|
|
||||||
|
if err := saveLockfile(lockPath, map[string]int{
|
||||||
|
"b": 2,
|
||||||
|
"a": 1,
|
||||||
|
"c": 3,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("saveLockfile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(lockPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read lockfile: %v", err)
|
||||||
|
}
|
||||||
|
want := "{\n \"b\": 2,\n \"a\": 1,\n \"c\": 3\n}\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("unexpected lockfile contents:\n%s", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveNativeLockfilesNormalizesEditorConfigFormattingWhenDataIsUnchanged(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, ".editorconfig"), `root = true
|
||||||
|
|
||||||
|
[topdata/data/**/*.json]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
`)
|
||||||
|
lockPath := filepath.Join(root, "topdata", "data", "feat", "lock.json")
|
||||||
|
mkdirAll(t, filepath.Dir(lockPath))
|
||||||
|
writeFile(t, lockPath, "{\n \"feat:a\": 1,\n \"feat:b\": 2\n}\n")
|
||||||
|
|
||||||
|
added, pruned, err := saveNativeLockfiles([]nativeCollectedDataset{
|
||||||
|
{
|
||||||
|
Dataset: nativeDataset{
|
||||||
|
Name: "feat",
|
||||||
|
LockPath: lockPath,
|
||||||
|
},
|
||||||
|
LockData: map[string]int{
|
||||||
|
"feat:a": 1,
|
||||||
|
"feat:b": 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("saveNativeLockfiles failed: %v", err)
|
||||||
|
}
|
||||||
|
if added != 0 || pruned != 0 {
|
||||||
|
t.Fatalf("expected formatting-only normalization not to count as lock data change, got added=%d pruned=%d", added, pruned)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(lockPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read lockfile: %v", err)
|
||||||
|
}
|
||||||
|
want := "{\n \"feat:a\": 1,\n \"feat:b\": 2\n}\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("unexpected lockfile contents:\n%s", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildNativeAllocatesConfiguredDatasetsIntoFirstNullBaseRows(t *testing.T) {
|
func TestBuildNativeAllocatesConfiguredDatasetsIntoFirstNullBaseRows(t *testing.T) {
|
||||||
root := testProjectRoot(t)
|
root := testProjectRoot(t)
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
|
||||||
|
|||||||
Reference in New Issue
Block a user