208 lines
5.1 KiB
Go
208 lines
5.1 KiB
Go
package apigen
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"text/scanner"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Format normalizes indentation and blank lines without rewriting tokens,
|
|
// comments, quoted metadata, struct tags or multiline literal contents.
|
|
func Format(source []byte) ([]byte, error) {
|
|
if len(source) > 1<<20 {
|
|
return nil, errors.New("api: source size limit exceeded")
|
|
}
|
|
var scan scanner.Scanner
|
|
scan.Init(bytes.NewReader(source))
|
|
scan.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments
|
|
var scanErr error
|
|
scan.Error = func(s *scanner.Scanner, msg string) {
|
|
if scanErr == nil {
|
|
scanErr = fmt.Errorf("%s: %s", s.Position, msg)
|
|
}
|
|
}
|
|
depths := map[int]int{}
|
|
protected := map[int]bool{}
|
|
depth := 0
|
|
for k := scan.Scan(); k != scanner.EOF; k = scan.Scan() {
|
|
line := scan.Position.Line
|
|
value := scan.TokenText()
|
|
if k == '}' || k == ')' {
|
|
depth--
|
|
if depth < 0 {
|
|
return nil, errors.New("api: unmatched closing delimiter")
|
|
}
|
|
}
|
|
if _, ok := depths[line]; !ok {
|
|
depths[line] = depth
|
|
}
|
|
// Preserve interior lines exactly, including the closing line of a literal.
|
|
count := strings.Count(value, "\n")
|
|
for n := 1; n <= count; n++ {
|
|
protected[line+n] = true
|
|
}
|
|
if k == '{' || k == '(' {
|
|
depth++
|
|
}
|
|
}
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
if depth != 0 {
|
|
return nil, errors.New("api: unclosed delimiter")
|
|
}
|
|
lines := strings.Split(string(source), "\n")
|
|
out := make([]string, 0, len(lines))
|
|
outProtected := map[int]bool{}
|
|
blank := true
|
|
lastDepth := 0
|
|
for i, line := range lines {
|
|
if protected[i+1] {
|
|
out = append(out, line)
|
|
outProtected[len(out)] = true
|
|
blank = false
|
|
continue
|
|
}
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
if !blank {
|
|
out = append(out, "")
|
|
blank = true
|
|
}
|
|
continue
|
|
}
|
|
d, ok := depths[i+1]
|
|
if ok {
|
|
lastDepth = d
|
|
}
|
|
out = append(out, strings.Repeat(" ", lastDepth)+trimmed)
|
|
blank = false
|
|
}
|
|
for len(out) > 0 && out[len(out)-1] == "" {
|
|
out = out[:len(out)-1]
|
|
}
|
|
alignFieldLines(out, outProtected)
|
|
return []byte(strings.Join(out, "\n") + "\n"), nil
|
|
}
|
|
|
|
// Only complete, single-line fields are aligned. Literal bytes and multiline
|
|
// comment contents are never candidates, even when they resemble a field.
|
|
var singleLineField = regexp.MustCompile("^([ \\t]*)([A-Z][A-Za-z0-9_]*)[ \\t]+([^ \\t`]+)[ \\t]+(`[^`]*`)[ \\t]*(//.*)?$")
|
|
|
|
func alignFieldLines(lines []string, protected map[int]bool) {
|
|
for start := 0; start < len(lines); {
|
|
var fields [][]string
|
|
widths := [3]int{}
|
|
end := start
|
|
for end < len(lines) && !protected[end+1] {
|
|
parts := singleLineField.FindStringSubmatch(lines[end])
|
|
if parts == nil || (len(fields) > 0 && parts[1] != fields[0][1]) {
|
|
break
|
|
}
|
|
for col := range widths {
|
|
if n := utf8.RuneCountInString(parts[col+2]); n > widths[col] {
|
|
widths[col] = n
|
|
}
|
|
}
|
|
fields = append(fields, parts)
|
|
end++
|
|
}
|
|
for i, parts := range fields {
|
|
line := parts[1]
|
|
for col := range widths {
|
|
line += parts[col+2]
|
|
if col < 2 || parts[5] != "" {
|
|
line += strings.Repeat(" ", widths[col]-utf8.RuneCountInString(parts[col+2])+1)
|
|
}
|
|
}
|
|
lines[start+i] = line + parts[5]
|
|
}
|
|
if end == start {
|
|
end++
|
|
}
|
|
start = end
|
|
}
|
|
}
|
|
|
|
// FormatProject validates the full contract before writing any local source.
|
|
// Check mode is read-only and reports every file needing formatting.
|
|
func FormatProject(entry string, check bool) ([]string, error) {
|
|
spec, err := Parse(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err = Compile(spec); err != nil {
|
|
return nil, err
|
|
}
|
|
real, err := filepath.EvalSymlinks(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
root := filepath.Dir(real)
|
|
names := make([]string, 0, len(spec.Sources))
|
|
for name := range spec.Sources {
|
|
if strings.HasPrefix(name, "@") || filepath.Ext(name) != ".api" {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
next := map[string][]byte{}
|
|
var changed []string
|
|
for _, name := range names {
|
|
data, err := Format(spec.Sources[name])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
if !bytes.Equal(data, spec.Sources[name]) {
|
|
next[name] = data
|
|
changed = append(changed, name)
|
|
}
|
|
}
|
|
if check {
|
|
if len(changed) > 0 {
|
|
return changed, fmt.Errorf("API format drift: %s", strings.Join(changed, ", "))
|
|
}
|
|
return changed, nil
|
|
}
|
|
for _, name := range changed {
|
|
path := filepath.Join(root, filepath.FromSlash(name))
|
|
old, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return changed, err
|
|
}
|
|
if !bytes.Equal(old, spec.Sources[name]) {
|
|
return changed, fmt.Errorf("API source changed during formatting: %s", name)
|
|
}
|
|
stat, err := os.Stat(path)
|
|
if err != nil {
|
|
return changed, err
|
|
}
|
|
f, err := os.CreateTemp(filepath.Dir(path), ".omni-format-*")
|
|
if err != nil {
|
|
return changed, err
|
|
}
|
|
tmp := f.Name()
|
|
err = f.Chmod(stat.Mode().Perm())
|
|
if err == nil {
|
|
_, err = f.Write(next[name])
|
|
}
|
|
err = errors.Join(err, f.Close())
|
|
if err == nil {
|
|
err = os.Rename(tmp, path)
|
|
}
|
|
_ = os.Remove(tmp)
|
|
if err != nil {
|
|
return changed, err
|
|
}
|
|
}
|
|
return changed, nil
|
|
}
|