v0.3.0 format a document, each comment with its owner; fewer allocations on the hot path
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
* Benchmarks for the parse, decode, encode and document paths A command's payload from text to its struct, a settings file with comment blocks, a scenario file of arrays of inline tables across lines, decoding and encoding that scenario, and ParseDocument: the readings the changes that follow are measured by. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThctvSRwt5kV1pVMKtsdWc * Fewer allocations on the parse, decode and encode paths The parser's lexer drops what it never reads: a comment's text and a number's literal, which the parser parses from the input bytes in place. A string without escapes is one allocation, one with escapes is decoded into a buffer sized to its source. The lexer scans ASCII bytewise, the parser holds its lexer by value, keeps key paths on the stack, gathers an array's elements in a reused buffer and allocates the array once at its length, and holds the first few frozen inline tables in place; an inline table that is an array element is not held, as no key can reach it. Decode, Marshal and Unmapped read a struct's fields from one cache per type instead of splitting every tag on every call. Decode reuses one key and one element per map; Marshal sets a map's values into one array, and writes keys, headers and numbers into its buffer without concatenating. Tokens, positions, trees, error messages, encoded bytes and decoded values are unchanged: a differential fuzz against the previous sources ran 1.7M inputs over the lexer and parser and 9.5M over Marshal and Decode without a difference. benchstat, 8 runs each, Xeon 2.1 GHz: payload, parse and decode 3.31us -> 2.30us 31 -> 14 allocs 1.41 -> 1.09 KiB parse payload 2.40us -> 1.89us 24 -> 12 allocs parse settings 10.25us -> 6.98us 68 -> 32 allocs parse scenario 536us -> 391us 4959 -> 2970 allocs decode scenario 225us -> 110us 1863 -> 776 allocs marshal scenario 397us -> 252us 2326 -> 976 allocs ParseDocument settings 21.7us -> 16.5us 128 -> 85 allocs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThctvSRwt5kV1pVMKtsdWc * Document: a comment goes with what fewer blank lines part it from ParseDocument lays a document out as statements, runs of statements no blank line parts, and the comment groups between them. A group belongs to the side fewer blank lines part it from, the side below on a tie: right above a statement it is that statement's own; right below a run, or apart from it, it is the run's. Between two runs several groups split at the widest gap. The file's start counts as no blank line and its end as half of one, so a comment at the top with a blank line under it, or at the end with one above it, is the file's, and one touching the last statement stays with it. Delete takes a statement with its own comments; a run going whole takes its comments too and one blank run beside it, so no comment is left orphaned and no gap doubles. Set puts a new table above the file's closing comments and a first root key above the first table's comments, a blank line apart. What v0.2.0 kept by position (the comment above a dropped key, a dropped table's heading) now goes with its owner. FuzzDocument checks that a delete leaves whole lines the document had, in order, and that adding a key loses none; it ran 12.6M inputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThctvSRwt5kV1pVMKtsdWc * Format: one layout for a document, every comment kept with its owner Format, and Formatter for its options, writes a document spaced as Marshal writes one: one space around = and after a comma, [1, 2] and { a = 1 }, LF line ends, a value's lines a level deeper than the line its bracket opens on, and a trailing comma exactly when the closing bracket has a line of its own. Keys, scalars and comments keep their spelling. Comments stay with what they belong to by the rule Document reads them with, and the result's blank lines encode it again: a run of blank lines becomes one, or two below a comment group that belongs above it, so a second Format reads every comment as the first did and writes the same bytes. Each table is written once, at its first header: a header opening it again gives its keys there, its comments above the key they touched or else below what is there before them, and a dotted key reaching a table that has a header moves into it with its own comments. Tables of an array of tables never merge. Options: Indent (up to 8 tabs or spaces, a tab by default), IndentTables (a level per enclosing table whose header comes first), Align (= and comments of consecutive keys). Hardening: Format refuses what Parse refuses, a comment holding a bidirectional embedding, override or isolate or a line break other than LF (U+202A-202E, U+2066-2069, U+0085, U+2028, U+2029), which can show a reader text other than what parses, and a result that would not parse to the same values. Such a rune in a string or quoted key is written as its \u escape, by Format and by the encoder alike. A line indents at most 16 levels and aligns within 32 runes of key and 80 of line, so the result stays within a fixed multiple of its source; table paths sit in a trie, so placing a key costs its length, not its square. Marshal writes { a = 1 } and no blank first line, so Format(Marshal(v)) is Marshal(v), which FuzzRoundTrip now checks. FuzzFormat checks, with the options off and on, that the result reads as the source, formats to itself, keeps every comment once, holds no CR or trailing blank and stays within the size bound; CI runs it for 10 s. All 29 TOML files in vif format idempotently to the same values; vif.toml and keymap.toml are unchanged byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThctvSRwt5kV1pVMKtsdWc --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
e2712e32a2
commit
170e71f10a
12 files changed
+1809
-440
No files matched your search
@@ -0,0 +1,730 @@
|
||||
package toml
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Formatter writes a document in one layout, each comment kept with what it
|
||||
// belongs to as a Document reads it. The zero value indents with a tab.
|
||||
type Formatter struct {
|
||||
Indent string // one level of indentation, up to 8 tabs or spaces; a tab when empty
|
||||
IndentTables bool // indent a table a level per table it is under whose header is above it
|
||||
Align bool // line up the = and the comments of keys on consecutive lines
|
||||
}
|
||||
|
||||
// A line indents at most maxIndent levels and aligns to at most alignKey runes of
|
||||
// key and alignCode of line, so no line of the result grows past its source by
|
||||
// more than a fixed amount, however deep or long the source
|
||||
const (
|
||||
maxIndent = 16
|
||||
alignKey = 32
|
||||
alignCode = 80
|
||||
)
|
||||
|
||||
// Format is Formatter{}.Format
|
||||
func Format(data []byte) ([]byte, error) { return Formatter{}.Format(data) }
|
||||
|
||||
// Format writes data spaced as Marshal writes, each table once at its first header:
|
||||
// a reopening header's keys, and a dotted key reaching a table with a header, move
|
||||
// there with their comments. A blank line run becomes one, or two below a comment
|
||||
// that belongs above. It refuses what Parse refuses, a comment with a rune that can
|
||||
// hide text, and a result that would not read as data does.
|
||||
func (f Formatter) Format(data []byte) ([]byte, error) {
|
||||
indent := cmp.Or(f.Indent, "\t")
|
||||
if strings.Trim(indent, " \t") != "" || len(indent) > 8 {
|
||||
return nil, fmt.Errorf("indent %q is not up to 8 tabs and spaces", f.Indent)
|
||||
}
|
||||
tree, err := NewParser(data).Parse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := hiddenInComments(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := layout(data)
|
||||
p := printer{indent: indent, align: f.Align}
|
||||
p.document(&d, regroup(&d, tree), f.IndentTables)
|
||||
out := p.buf.Bytes()
|
||||
got, err := NewParser(out).Parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("format refused, its result would not parse: %w", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tree) {
|
||||
return nil, fmt.Errorf("format refused, its result would read otherwise")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hides reports a rune that changes how the text after it displays: an
|
||||
// embedding, override or isolate of bidirectional text, or a line break but LF
|
||||
func hides(r rune) bool {
|
||||
return r >= 0x202A && r <= 0x202E || r >= 0x2066 && r <= 0x2069 || r == 0x85 || r == 0x2028 || r == 0x2029
|
||||
}
|
||||
|
||||
// hiddenInComments refuses a comment holding a rune hides reports; a comment has
|
||||
// no escape to write it as
|
||||
func hiddenInComments(data []byte) error {
|
||||
lx := NewLexer(data)
|
||||
for t := lx.NextToken(); t.Type != TokenEOF; t = lx.NextToken() {
|
||||
if t.Type != TokenComment {
|
||||
continue
|
||||
}
|
||||
if i := strings.IndexFunc(t.Literal, hides); i >= 0 {
|
||||
r, _ := utf8.DecodeRuneInString(t.Literal[i:])
|
||||
return fmt.Errorf("comment at line %d holds %U, which can hide text", t.Line, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// visible spells a string's source with each rune hides reports escaped, the
|
||||
// value the same: a lone backslash before one, an unknown escape kept as
|
||||
// written, becomes an escaped backslash
|
||||
func visible(src string) string {
|
||||
if !strings.ContainsFunc(src, hides) {
|
||||
return src
|
||||
}
|
||||
var b strings.Builder
|
||||
escaped := false
|
||||
for _, r := range src {
|
||||
switch {
|
||||
case hides(r):
|
||||
if escaped {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
fmt.Fprintf(&b, `\u%04X`, r)
|
||||
escaped = false
|
||||
case r == '\\':
|
||||
b.WriteRune(r)
|
||||
escaped = !escaped
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
escaped = false
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// piece is a statement as Format writes it, with the comments that go where it
|
||||
// goes
|
||||
type piece struct {
|
||||
key []string // its parts as spelled, relative to its section
|
||||
header bool
|
||||
array bool
|
||||
value *node
|
||||
note string // the comment after it on its line
|
||||
heads [][]string // comment groups above it, each a blank line apart
|
||||
lead []string // comment lines right above it
|
||||
tail []string // comment lines right below it
|
||||
trail [][]string // comment groups below it, each a blank line apart
|
||||
brk bool // a blank line above it
|
||||
}
|
||||
|
||||
// node is a value: a scalar's source, or an array or inline table
|
||||
type node struct {
|
||||
text string
|
||||
open TokenType // TokenLBracket or TokenLBrace for a container
|
||||
note string // a comment after the opening bracket on its line
|
||||
items []elem
|
||||
tail []line // comment lines after the last element
|
||||
closeOwn bool // the closing bracket on a line of its own
|
||||
}
|
||||
|
||||
type elem struct {
|
||||
before []line // comment lines above it
|
||||
broken bool // on a line after what precedes it
|
||||
blank bool // a blank line right above it
|
||||
key []string
|
||||
value *node
|
||||
note string
|
||||
}
|
||||
|
||||
type line struct {
|
||||
text string
|
||||
blank bool // a blank line right above it
|
||||
}
|
||||
|
||||
// section is a table and the pieces written under its header, the root's first
|
||||
type section struct {
|
||||
path Key
|
||||
header *piece
|
||||
pieces []*piece
|
||||
into *section // the first header of the table this one opens again
|
||||
keep bool // under an array of tables, where no key moves in or out
|
||||
depth int // the tables it is under whose headers come before it
|
||||
}
|
||||
|
||||
// tables is a trie of the paths headers name, so finding a key's table costs
|
||||
// its length: home is the section of a table's first header keys can move to
|
||||
type tables struct {
|
||||
sub map[string]*tables
|
||||
home *section
|
||||
headed bool
|
||||
}
|
||||
|
||||
// child is t's node for k, made if missing
|
||||
func (t *tables) child(k string) *tables {
|
||||
if t.sub == nil {
|
||||
t.sub = map[string]*tables{}
|
||||
}
|
||||
c := t.sub[k]
|
||||
if c == nil {
|
||||
c = &tables{}
|
||||
t.sub[k] = c
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// regroup gives each statement's piece to the section it is written in. A key
|
||||
// goes to the deepest table its path reaches that has a header, a reopened
|
||||
// header's keys to the first header; heads and lead go with the first piece of a
|
||||
// run, tail and trail with the last, and a run that loses its first piece keeps
|
||||
// its blank line above.
|
||||
func regroup(d *Document, tree map[string]any) []*section {
|
||||
pieces := make([]*piece, len(d.stmts))
|
||||
for i, s := range d.stmts {
|
||||
pieces[i] = d.piece(s)
|
||||
}
|
||||
for k, u := range d.units {
|
||||
first, last := pieces[u.first], pieces[u.last-1]
|
||||
first.heads, first.brk = d.groups(u.start, d.stmts[u.first].lead), k > 0 || d.pre > 0
|
||||
last.tail, last.trail = d.trailing(d.stmts[u.last-1].end, u.end)
|
||||
}
|
||||
|
||||
root := §ion{}
|
||||
sections := []*section{root}
|
||||
var trie tables
|
||||
in := make([]*section, len(d.stmts))
|
||||
cur := root
|
||||
for i, s := range d.stmts {
|
||||
if s.header {
|
||||
cur = §ion{path: s.key, header: pieces[i], keep: s.array || underArray(tree, s.key)}
|
||||
t := &trie
|
||||
for n, k := range s.key {
|
||||
if t = t.child(k); t.headed && n < len(s.key)-1 {
|
||||
cur.depth++
|
||||
}
|
||||
}
|
||||
t.headed = true
|
||||
switch {
|
||||
case cur.keep:
|
||||
sections = append(sections, cur)
|
||||
case t.home == nil:
|
||||
t.home = cur
|
||||
sections = append(sections, cur)
|
||||
default:
|
||||
cur.into = t.home
|
||||
}
|
||||
}
|
||||
in[i] = cur
|
||||
}
|
||||
dst := make([]*section, len(d.stmts))
|
||||
for i, s := range d.stmts {
|
||||
src := in[i]
|
||||
dst[i] = cmp.Or(src.into, src)
|
||||
if s.header || src.keep {
|
||||
continue
|
||||
}
|
||||
at, t := 0, &trie
|
||||
for _, k := range src.path {
|
||||
t = t.sub[k]
|
||||
}
|
||||
for n, k := range s.key[:len(s.key)-1] {
|
||||
if t = t.sub[k]; t == nil {
|
||||
break
|
||||
}
|
||||
if t.home != nil {
|
||||
dst[i], at = t.home, n+1
|
||||
}
|
||||
}
|
||||
pieces[i].key = pieces[i].key[at:]
|
||||
}
|
||||
for _, u := range d.units {
|
||||
if dst[u.first] == in[u.first] {
|
||||
continue
|
||||
}
|
||||
for i := u.first + 1; i < u.last; i++ {
|
||||
if dst[i] == in[i] {
|
||||
pieces[i].brk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reopened header's comments go above the key that touched it, else below
|
||||
// what is written before them where its keys go
|
||||
type move struct {
|
||||
p *piece
|
||||
to *section
|
||||
trail [][]string // with no piece, comments for the last piece of to
|
||||
}
|
||||
var moves []move
|
||||
opened := map[*section]bool{} // a reopened section that has begun in its first
|
||||
for i, s := range d.stmts {
|
||||
p, src := pieces[i], in[i]
|
||||
switch {
|
||||
case s.header && src.into == nil:
|
||||
case s.header:
|
||||
at := slices.Concat(p.lead, notes(p.note), p.tail) // the lines around the header's
|
||||
next := i + 1
|
||||
if len(at) > 0 && next < len(d.stmts) && in[next] == src && dst[next] == src.into && !pieces[next].brk {
|
||||
k := pieces[next]
|
||||
k.heads, k.lead = p.heads, slices.Concat(at, k.lead)
|
||||
} else {
|
||||
groups := slices.Clone(p.heads)
|
||||
if len(at) > 0 {
|
||||
groups = append(groups, at)
|
||||
}
|
||||
if groups = append(groups, p.trail...); len(groups) > 0 {
|
||||
moves = append(moves, move{to: src.into, trail: groups})
|
||||
}
|
||||
}
|
||||
case dst[i] == src:
|
||||
src.pieces = append(src.pieces, p)
|
||||
default:
|
||||
if dst[i] == src.into && !opened[src] {
|
||||
opened[src], p.brk = true, true
|
||||
}
|
||||
moves = append(moves, move{p: p, to: dst[i]})
|
||||
}
|
||||
}
|
||||
for _, m := range moves {
|
||||
if m.p == nil {
|
||||
last := m.to.header
|
||||
if n := len(m.to.pieces); n > 0 {
|
||||
last = m.to.pieces[n-1]
|
||||
}
|
||||
last.trail = append(last.trail, m.trail...)
|
||||
continue
|
||||
}
|
||||
if len(m.to.pieces) == 0 {
|
||||
m.p.brk = len(m.p.heads) > 0
|
||||
}
|
||||
m.to.pieces = append(m.to.pieces, m.p)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
// underArray reports a path through an array of tables, whose headers name a
|
||||
// different table each time
|
||||
func underArray(tree map[string]any, path Key) bool {
|
||||
var cur any = tree
|
||||
for _, k := range path {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
cur = m[k]
|
||||
}
|
||||
_, ok := cur.([]map[string]any)
|
||||
return ok
|
||||
}
|
||||
|
||||
// piece reads a statement's source into its piece
|
||||
func (d *Document) piece(s statement) *piece {
|
||||
c := cursor{src: d.data[s.start:s.end]}
|
||||
c.lx = Lexer{input: c.src, line: 1}
|
||||
c.next()
|
||||
p := &piece{header: s.header, array: s.array}
|
||||
if s.header {
|
||||
for c.t.Type == TokenLBracket {
|
||||
c.next()
|
||||
}
|
||||
p.key = c.key()
|
||||
for c.t.Type == TokenRBracket {
|
||||
c.next()
|
||||
}
|
||||
} else {
|
||||
p.key = c.key()
|
||||
c.next() // past =
|
||||
p.value = c.value()
|
||||
}
|
||||
if c.t.Type == TokenComment {
|
||||
p.note = comment(c.t.Literal)
|
||||
}
|
||||
if g := d.groups(s.lead, s.start); len(g) > 0 {
|
||||
p.lead = g[0]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// comment is a comment's text as written, its trailing blanks dropped
|
||||
func comment(text string) string { return "#" + strings.TrimRight(text, " \t") }
|
||||
|
||||
// notes is a header's comment on its line as a line of its own, if it has one
|
||||
func notes(note string) []string {
|
||||
if note == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{note}
|
||||
}
|
||||
|
||||
// groups reads the comment lines of data[from:to], which holds only comment and
|
||||
// blank lines, a group to each run no blank line parts
|
||||
func (d *Document) groups(from, to int) [][]string {
|
||||
var out [][]string
|
||||
fresh := true
|
||||
for l := range strings.SplitSeq(string(d.data[from:to]), "\n") {
|
||||
switch l = strings.Trim(l, " \t\r"); {
|
||||
case l == "":
|
||||
fresh = true
|
||||
case fresh:
|
||||
out, fresh = append(out, []string{l}), false
|
||||
default:
|
||||
out[len(out)-1] = append(out[len(out)-1], l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trailing reads the comments below a run: the group touching it, then the rest
|
||||
func (d *Document) trailing(from, to int) ([]string, [][]string) {
|
||||
g := d.groups(from, to)
|
||||
first, _, _ := bytes.Cut(d.data[from:to], []byte("\n"))
|
||||
if len(g) > 0 && len(bytes.Trim(first, " \t\r")) > 0 {
|
||||
return g[0], g[1:]
|
||||
}
|
||||
return nil, g
|
||||
}
|
||||
|
||||
// cursor walks a statement's tokens
|
||||
type cursor struct {
|
||||
src []byte
|
||||
lx Lexer
|
||||
t Token
|
||||
}
|
||||
|
||||
func (c *cursor) next() { c.t = c.lx.NextToken() }
|
||||
|
||||
// spell is the token as written; a string's runes that can hide text escaped
|
||||
func (c *cursor) spell() string {
|
||||
text := string(c.src[c.t.Pos:c.t.End])
|
||||
if c.t.Type == TokenString {
|
||||
return visible(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// key reads a dotted key's parts as spelled, leaving the cursor past them
|
||||
func (c *cursor) key() []string {
|
||||
var k []string
|
||||
for {
|
||||
k = append(k, c.spell())
|
||||
if c.next(); c.t.Type != TokenDot {
|
||||
return k
|
||||
}
|
||||
c.next()
|
||||
}
|
||||
}
|
||||
|
||||
// value reads a value, leaving the cursor past it
|
||||
func (c *cursor) value() *node {
|
||||
if c.t.Type != TokenLBracket && c.t.Type != TokenLBrace {
|
||||
n := &node{text: c.spell()}
|
||||
c.next()
|
||||
return n
|
||||
}
|
||||
n := &node{open: c.t.Type}
|
||||
closer := TokenRBracket
|
||||
if n.open == TokenLBrace {
|
||||
closer = TokenRBrace
|
||||
}
|
||||
c.next()
|
||||
var pending []line
|
||||
nl := 0 // newlines since the last element or comment
|
||||
for c.t.Type != closer {
|
||||
inside := len(n.items) > 0 || len(pending) > 0
|
||||
switch c.t.Type {
|
||||
case TokenNewline:
|
||||
nl++
|
||||
case TokenComma:
|
||||
case TokenComment:
|
||||
text := comment(c.t.Literal)
|
||||
switch {
|
||||
case nl > 0:
|
||||
pending = append(pending, line{text, nl > 1 && inside})
|
||||
case len(n.items) > 0:
|
||||
n.items[len(n.items)-1].note = text
|
||||
default:
|
||||
n.note = text
|
||||
}
|
||||
nl = 0
|
||||
default:
|
||||
e := elem{before: pending, broken: nl > 0 || len(pending) > 0, blank: nl > 1 && inside}
|
||||
pending = nil
|
||||
if n.open == TokenLBrace {
|
||||
e.key = c.key()
|
||||
c.next() // past =
|
||||
}
|
||||
e.value = c.value()
|
||||
n.items = append(n.items, e)
|
||||
nl = 0
|
||||
continue
|
||||
}
|
||||
c.next()
|
||||
}
|
||||
n.tail, n.closeOwn = pending, nl > 0
|
||||
c.next()
|
||||
return n
|
||||
}
|
||||
|
||||
// printer writes pieces, its depth the indentation of the line being written
|
||||
type printer struct {
|
||||
buf bytes.Buffer
|
||||
indent string
|
||||
align bool
|
||||
depth int
|
||||
}
|
||||
|
||||
func (p *printer) newline(depth int) {
|
||||
p.buf.WriteByte('\n')
|
||||
p.indentTo(depth)
|
||||
}
|
||||
|
||||
func (p *printer) indentTo(depth int) {
|
||||
for range min(depth, maxIndent) {
|
||||
p.buf.WriteString(p.indent)
|
||||
}
|
||||
p.depth = depth
|
||||
}
|
||||
|
||||
// document writes the file's opening comments, each section's pieces and the
|
||||
// file's closing comments. Two blank lines follow a group of comments that
|
||||
// belongs to what is above it, after a blank line, so it reads that way again.
|
||||
func (p *printer) document(d *Document, sections []*section, indentTables bool) {
|
||||
pre := d.groups(0, d.pre)
|
||||
p.groups(pre, 0)
|
||||
gap := min(len(pre), 2) // blank lines after the opening comments
|
||||
var foot [][]string
|
||||
if d.foot >= 0 {
|
||||
foot = d.groups(d.foot, len(d.data))
|
||||
} else if s := lastSection(sections); s != nil {
|
||||
// Comments below the last piece, a blank line apart, read as the file's
|
||||
// closing ones; written so, they read the same again
|
||||
last := s.header
|
||||
if n := len(s.pieces); n > 0 {
|
||||
last = s.pieces[n-1]
|
||||
}
|
||||
foot, last.trail = last.trail, nil
|
||||
}
|
||||
var last *piece
|
||||
for _, s := range sections {
|
||||
depth := 0
|
||||
if indentTables {
|
||||
depth = s.depth
|
||||
}
|
||||
all := s.pieces
|
||||
if s.header != nil {
|
||||
all = append([]*piece{s.header}, all...)
|
||||
}
|
||||
widths := p.widths(all, depth)
|
||||
for i, pc := range all {
|
||||
p.blanks(gap, last, pc.brk || len(pc.heads) > 0)
|
||||
p.piece(pc, depth, widths[i])
|
||||
last = pc
|
||||
}
|
||||
}
|
||||
if len(foot) > 0 {
|
||||
p.blanks(gap, last, true)
|
||||
p.groups(foot, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// lastSection is the last section with a piece to write
|
||||
func lastSection(sections []*section) *section {
|
||||
for i := len(sections) - 1; i >= 0; i-- {
|
||||
if s := sections[i]; s.header != nil || len(s.pieces) > 0 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// blanks writes the blank lines above what follows last: two below comment groups
|
||||
// that belong to it, else one where either asks for it
|
||||
func (p *printer) blanks(gap int, last *piece, brk bool) {
|
||||
n := 0
|
||||
switch {
|
||||
case last == nil:
|
||||
n = gap
|
||||
case len(last.trail) > 0:
|
||||
n = 2
|
||||
case brk || len(last.tail) > 0:
|
||||
n = 1
|
||||
}
|
||||
for range n {
|
||||
p.buf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
func (p *printer) groups(g [][]string, depth int) {
|
||||
for i, lines := range g {
|
||||
if i > 0 {
|
||||
p.buf.WriteByte('\n')
|
||||
}
|
||||
p.comments(lines, depth)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *printer) comments(lines []string, depth int) {
|
||||
for _, l := range lines {
|
||||
p.indentTo(depth)
|
||||
p.buf.WriteString(l)
|
||||
p.buf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// width is where a piece's = and comment go when its run is aligned
|
||||
type width struct{ key, code int }
|
||||
|
||||
// widths lines up each run of keys on consecutive lines, each on one line and
|
||||
// short enough, no comment line among them
|
||||
func (p *printer) widths(all []*piece, depth int) []width {
|
||||
w := make([]width, len(all))
|
||||
if !p.align {
|
||||
return w
|
||||
}
|
||||
codes := make([]string, len(all))
|
||||
for i, pc := range all {
|
||||
if pc.header || utf8.RuneCountInString(strings.Join(pc.key, ".")) > alignKey {
|
||||
continue
|
||||
}
|
||||
if c := p.code(pc, depth, alignKey); utf8.RuneCountInString(c) <= alignCode {
|
||||
codes[i] = p.code(pc, depth, 0)
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(all); {
|
||||
j := i + 1
|
||||
for j < len(all) && joins(all[j-1], all[j], codes[j-1], codes[j]) {
|
||||
j++
|
||||
}
|
||||
if codes[i] == "" {
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
var run width
|
||||
for k := i; k < j; k++ {
|
||||
run.key = max(run.key, utf8.RuneCountInString(strings.Join(all[k].key, ".")))
|
||||
}
|
||||
for k := i; k < j; k++ {
|
||||
run.code = max(run.code, utf8.RuneCountInString(p.code(all[k], depth, run.key)))
|
||||
}
|
||||
for k := i; k < j; k++ {
|
||||
w[k] = run
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// joins reports b on the line after a in one aligned run
|
||||
func joins(a, b *piece, ac, bc string) bool {
|
||||
return ac != "" && bc != "" && !b.brk && len(b.heads)+len(b.lead) == 0 &&
|
||||
len(a.tail)+len(a.trail) == 0 && !strings.Contains(ac+bc, "\n")
|
||||
}
|
||||
|
||||
// code is a key's line before its comment, the key padded to keyWidth
|
||||
func (p *printer) code(pc *piece, depth, keyWidth int) string {
|
||||
q := printer{indent: p.indent, depth: depth}
|
||||
k := strings.Join(pc.key, ".")
|
||||
q.buf.WriteString(k)
|
||||
q.buf.WriteString(strings.Repeat(" ", max(0, keyWidth-utf8.RuneCountInString(k))))
|
||||
q.buf.WriteString(" = ")
|
||||
q.node(pc.value)
|
||||
return q.buf.String()
|
||||
}
|
||||
|
||||
func (p *printer) piece(pc *piece, depth int, w width) {
|
||||
for _, g := range pc.heads {
|
||||
p.comments(g, depth)
|
||||
p.buf.WriteByte('\n')
|
||||
}
|
||||
p.comments(pc.lead, depth)
|
||||
p.indentTo(depth)
|
||||
code := "[" + strings.Join(pc.key, ".") + "]"
|
||||
switch {
|
||||
case pc.array:
|
||||
code = "[" + code + "]"
|
||||
case !pc.header:
|
||||
code = p.code(pc, depth, w.key)
|
||||
}
|
||||
p.buf.WriteString(code)
|
||||
if pc.note != "" {
|
||||
p.buf.WriteString(strings.Repeat(" ", max(0, w.code-utf8.RuneCountInString(code))+1))
|
||||
p.buf.WriteString(pc.note)
|
||||
}
|
||||
p.buf.WriteByte('\n')
|
||||
p.comments(pc.tail, depth)
|
||||
for _, g := range pc.trail {
|
||||
p.buf.WriteByte('\n')
|
||||
p.comments(g, depth)
|
||||
}
|
||||
}
|
||||
|
||||
// node writes a value; a container's elements on lines of their own go a level
|
||||
// deeper than the line its opening bracket is on, and it keeps a trailing comma
|
||||
// exactly when its closing bracket has a line of its own
|
||||
func (p *printer) node(n *node) {
|
||||
if n.open == 0 {
|
||||
p.buf.WriteString(n.text)
|
||||
return
|
||||
}
|
||||
open, close, pad := "[", "]", ""
|
||||
if n.open == TokenLBrace {
|
||||
open, close, pad = "{", "}", " "
|
||||
}
|
||||
base := p.depth
|
||||
p.buf.WriteString(open)
|
||||
note := n.note
|
||||
if note != "" {
|
||||
p.buf.WriteString(" " + note)
|
||||
}
|
||||
for i, e := range n.items {
|
||||
switch {
|
||||
case e.broken || note != "":
|
||||
for _, c := range e.before {
|
||||
p.line(c, base+1)
|
||||
}
|
||||
p.line(line{blank: e.blank}, base+1)
|
||||
case i == 0:
|
||||
p.buf.WriteString(pad)
|
||||
default:
|
||||
p.buf.WriteByte(' ')
|
||||
}
|
||||
if e.key != nil {
|
||||
p.buf.WriteString(strings.Join(e.key, ".") + " = ")
|
||||
}
|
||||
p.node(e.value)
|
||||
if i < len(n.items)-1 || n.closeOwn {
|
||||
p.buf.WriteByte(',')
|
||||
}
|
||||
if note = e.note; note != "" {
|
||||
p.buf.WriteString(" " + note)
|
||||
}
|
||||
}
|
||||
for _, c := range n.tail {
|
||||
p.line(c, base+1)
|
||||
}
|
||||
if n.closeOwn || note != "" || len(n.tail) > 0 {
|
||||
p.newline(base)
|
||||
} else if len(n.items) > 0 {
|
||||
p.buf.WriteString(pad)
|
||||
}
|
||||
p.buf.WriteString(close)
|
||||
}
|
||||
|
||||
// line starts a line at depth inside a value, a blank one above it if asked,
|
||||
// and writes the comment it holds
|
||||
func (p *printer) line(l line, depth int) {
|
||||
if l.blank {
|
||||
p.buf.WriteByte('\n')
|
||||
}
|
||||
p.newline(depth)
|
||||
p.buf.WriteString(l.text)
|
||||
}
|
||||
Reference in new issue
Block a user