diff --git a/README.md b/README.md index 34d8a25..bd1c5d4 100644 --- a/README.md +++ b/README.md @@ -674,6 +674,8 @@ Consumer repos can fetch those binaries into their local `tools/` directory with ## TopData 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: diff --git a/internal/topdata/editorconfig_format.go b/internal/topdata/editorconfig_format.go new file mode 100644 index 0000000..530e030 --- /dev/null +++ b/internal/topdata/editorconfig_format.go @@ -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 +} diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 1729ea4..7486ca1 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -804,9 +804,6 @@ func saveNativeLockfiles(collected []nativeCollectedDataset) (int, int, error) { } added += countAddedLockEntries(current, dataset.LockData) pruned += countAddedLockEntries(dataset.LockData, current) - if lockDataEqual(current, dataset.LockData) { - continue - } if err := saveLockfile(dataset.Dataset.LockPath, dataset.LockData); err != nil { 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 { 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 { return fmt.Errorf("marshal lockfile: %w", err) } @@ -6587,13 +6588,21 @@ func readLockfileKeyOrder(path string) ([]string, error) { 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 { - return []byte("{}\n"), nil + if formatting.FinalNewline { + return []byte("{}" + lineEnding), nil + } + return []byte("{}"), nil } var buffer bytes.Buffer - buffer.WriteString("{\n") + buffer.WriteString("{") + buffer.WriteString(lineEnding) for index, key := range keys { keyRaw, err := json.Marshal(key) if err != nil { @@ -6603,16 +6612,19 @@ func marshalOrderedLockfile(keys []string, lockData map[string]int) ([]byte, err if err != nil { return nil, err } - buffer.WriteString(" ") + buffer.WriteString(formatting.Indent) buffer.Write(keyRaw) buffer.WriteString(": ") buffer.Write(valueRaw) if index < len(keys)-1 { buffer.WriteByte(',') } - buffer.WriteByte('\n') + buffer.WriteString(lineEnding) + } + buffer.WriteString("}") + if formatting.FinalNewline { + buffer.WriteString(lineEnding) } - buffer.WriteString("}\n") return buffer.Bytes(), nil } diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 7027e9d..47baa25 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -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) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))