From 78201b7d72732bdd6961cce5e7edba2c48373641 Mon Sep 17 00:00:00 2001 From: karitham Date: Sun, 23 Aug 2026 00:24:13 +0200 Subject: [PATCH] formatter: own format config schema --- formatter/body.go | 25 ++- formatter/config.go | 53 ----- formatter/config_test.go | 156 +++++++++++---- formatter/field.go | 7 +- formatter/format.go | 7 +- formatter/format_config.go | 353 ++++++++++++++++++++++++++++++++++ formatter/format_fuzz_test.go | 5 +- formatter/format_test.go | 63 +++--- formatter/sepspace_test.go | 10 +- formatter/value.go | 21 +- lsp/server.go | 4 +- main.go | 28 +-- options/construct.go | 112 ----------- options/options.go | 234 +++------------------- options/options_test.go | 145 -------------- 15 files changed, 586 insertions(+), 637 deletions(-) create mode 100644 formatter/format_config.go delete mode 100644 options/construct.go diff --git a/formatter/body.go b/formatter/body.go index 831d842..a3f21ec 100644 --- a/formatter/body.go +++ b/formatter/body.go @@ -2,7 +2,6 @@ package formatter import ( "github.com/karitham/thrift-ls/doc" - "github.com/karitham/thrift-ls/options" "github.com/karitham/thrift-ls/syntax" ) @@ -26,7 +25,7 @@ func (f *formatter) structLike(v *syntax.Struct) doc.Doc { // bracedBody formats struct, union, and exception declarations. The header // renders as a token run up to and including the open brace; the brace // text itself is emitted by bracedGroup. -func (f *formatter) bracedBody(fields []*syntax.Field, open, close int, closeTrailing bool, c options.Construct) doc.Doc { +func (f *formatter) bracedBody(fields []*syntax.Field, open, close int, closeTrailing bool, c Construct) doc.Doc { bodyID := f.id() sepMode := f.opts.Separator.Get(c) forced := f.opts.Break.Get(c) || sepForcesBreak(sepsOfFields(fields), sepMode) @@ -62,15 +61,15 @@ func (f *formatter) scanKind(start, end int, kind syntax.TokenKind) int { } // constructOf returns the construct for the struct-like kind. -func (f *formatter) constructOf(kind syntax.StructKind) options.Construct { +func (f *formatter) constructOf(kind syntax.StructKind) Construct { switch kind { case syntax.TokenUnion: - return options.ConstructUnion + return ConstructUnion case syntax.TokenException: - return options.ConstructException + return ConstructException } - return options.ConstructStruct + return ConstructStruct } // bracedGroup assembles "{ body }" from the prebuilt body list: flat as @@ -113,8 +112,8 @@ func (f *formatter) bracedGroup(body doc.Doc, bodyID, n, open, close int, closeT // bracedEnumBody is bracedBody for enum values. func (f *formatter) bracedEnumBody(values []*syntax.EnumValue, open, close int, closeTrailing bool) doc.Doc { bodyID := f.id() - sepMode := f.opts.Separator.Get(options.ConstructEnum) - forced := f.opts.Break.Get(options.ConstructEnum) || sepForcesBreak(sepsOfValues(values), sepMode) + sepMode := f.opts.Separator.Get(ConstructEnum) + forced := f.opts.Break.Get(ConstructEnum) || sepForcesBreak(sepsOfValues(values), sepMode) return f.bracedGroup(f.enumValueList(values, bodyID), bodyID, len(values), open, close, closeTrailing, forced) } @@ -206,8 +205,8 @@ func (f *formatter) functionBody(v *syntax.Function) doc.Doc { // Comments or blank lines in the arguments force the multiline layout: // the flat argument group would drop them. - argsMode := f.opts.Separator.Get(options.ConstructArguments) - if f.fieldsForcedBroken(v.Args) || sepForcesBreak(sepsOfFields(v.Args), argsMode) || f.opts.Break.Get(options.ConstructArguments) { + argsMode := f.opts.Separator.Get(ConstructArguments) + if f.fieldsForcedBroken(v.Args) || sepForcesBreak(sepsOfFields(v.Args), argsMode) || f.opts.Break.Get(ConstructArguments) { return f.functionBrokenArgs(v, header) } @@ -250,11 +249,11 @@ func (f *formatter) parenGroup(fields []*syntax.Field, open, close int, forced b // throwsGroup renders the throws clause with the same folding as the // arguments, so it stays flat when it fits even if the arguments broke. func (f *formatter) throwsGroup(v *syntax.Function) doc.Doc { - forced := f.fieldsForcedBroken(v.Throws.Fields) || sepForcesBreak(sepsOfFields(v.Throws.Fields), f.opts.Separator.Get(options.ConstructThrows)) || f.opts.Break.Get(options.ConstructThrows) + forced := f.fieldsForcedBroken(v.Throws.Fields) || sepForcesBreak(sepsOfFields(v.Throws.Fields), f.opts.Separator.Get(ConstructThrows)) || f.opts.Break.Get(ConstructThrows) p := f.Parts(2) p = append(p, f.Text(" throws ")) - p = append(p, f.parenGroup(v.Throws.Fields, v.Throws.TokStart(), v.Throws.TokEnd(), forced, f.opts.Separator.Get(options.ConstructThrows))) + p = append(p, f.parenGroup(v.Throws.Fields, v.Throws.TokStart(), v.Throws.TokEnd(), forced, f.opts.Separator.Get(ConstructThrows))) return f.Concat(p...) } @@ -336,7 +335,7 @@ func (f *formatter) functionBrokenArgs(v *syntax.Function, header doc.Doc) doc.D parts := []doc.Doc{ header, - f.parenGroup(v.Args, open, f.parenClose(v.Args, open), true, f.opts.Separator.Get(options.ConstructArguments)), + f.parenGroup(v.Args, open, f.parenClose(v.Args, open), true, f.opts.Separator.Get(ConstructArguments)), } if v.Throws != nil { parts = append(parts, f.throwsGroup(v)) diff --git a/formatter/config.go b/formatter/config.go index 88c61df..995afc6 100644 --- a/formatter/config.go +++ b/formatter/config.go @@ -1,58 +1,5 @@ package formatter -import ( - "github.com/karitham/thrift-ls/options" -) - -// FromConfig converts a validated configuration patch to formatter options. -// The formatter owns this translation because the config strings ("field", -// "comma", ...) name formatting concepts only it defines. -func FromConfig(p options.Patch) (Options, error) { - if err := p.Validate(); err != nil { - return Options{}, err - } - - o := DefaultOptions() - if p.PrintWidth != nil { - o.PrintWidth = *p.PrintWidth - } - - if p.Indent != nil { - o.Indent = p.Indent.Value - o.TabWidth = p.Indent.Width - } - - if p.TabWidth != nil { - o.TabWidth = *p.TabWidth - } - - if p.Align != nil { - if mode, ok := alignMode(*p.Align); ok { - o.Align = mode - } - } - - if p.Separators != nil { - for _, c := range options.AllConstructs { - if v := p.Separators.Get(c); v != nil { - if mode, ok := separatorMode(*v); ok { - o.Separator.Set(c, mode) - } - } - } - } - - if p.Break != nil { - for _, c := range options.AllConstructs { - if v := p.Break.Get(c); v != nil { - o.Break.Set(c, *v) - } - } - } - - return o, nil -} - // alignMode maps a config value to an align mode. The second result reports // whether the value is a known align mode. func alignMode(s string) (AlignMode, bool) { diff --git a/formatter/config_test.go b/formatter/config_test.go index 7e012f2..359b032 100644 --- a/formatter/config_test.go +++ b/formatter/config_test.go @@ -1,47 +1,46 @@ package formatter import ( + "encoding/json" "testing" - - "github.com/karitham/thrift-ls/options" ) -func TestFromConfig(t *testing.T) { - indent := options.Indent{Value: " ", Width: 2} +func TestFormatPatchOptions(t *testing.T) { + indent := Indent{Value: " ", Width: 2} printWidth := 100 - p := options.Patch{Indent: &indent, PrintWidth: &printWidth} + p := FormatPatch{Indent: &indent, PrintWidth: &printWidth} - o, err := FromConfig(p) + o, err := p.Options() if err != nil { - t.Fatalf("FromConfig: %v", err) + t.Fatalf("Options: %v", err) } if o.PrintWidth != 100 || o.Indent != " " || o.TabWidth != 2 { t.Errorf("got %+v", o) } - if o.Align != AlignField || o.Separator.Get(options.ConstructStruct) != SeparatorPreserve { + if o.Align != AlignField || o.Separator.Get(ConstructStruct) != SeparatorPreserve { t.Errorf("defaults wrong: %+v", o) } comma := "comma" align := "assign" - separators := options.Separators{Structs: &comma} - p = options.Patch{Separators: &separators, Align: &align} + separators := Separators{Structs: &comma} + p = FormatPatch{Separators: &separators, Align: &align} - o, err = FromConfig(p) + o, err = p.Options() if err != nil { - t.Fatalf("FromConfig: %v", err) + t.Fatalf("Options: %v", err) } - if o.Separator.Get(options.ConstructStruct) != SeparatorComma || o.Align != AlignAssign { + if o.Separator.Get(ConstructStruct) != SeparatorComma || o.Align != AlignAssign { t.Errorf("got %+v", o) } } -// TestFromConfigSeparatorModes maps every config value to the separator +// TestFormatPatchSeparatorModes maps every config value to the separator // modes, per construct. -func TestFromConfigSeparatorModes(t *testing.T) { +func TestFormatPatchSeparatorModes(t *testing.T) { tests := []struct { value string want SeparatorMode @@ -54,18 +53,18 @@ func TestFromConfigSeparatorModes(t *testing.T) { for _, tt := range tests { t.Run(tt.value, func(t *testing.T) { value := tt.value - p := options.Patch{Separators: &options.Separators{ + p := FormatPatch{Separators: &Separators{ Structs: &value, Unions: &value, Exceptions: &value, Enums: &value, Arguments: &value, Throws: &value, Lists: &value, Maps: &value, Sets: &value, }} - o, err := FromConfig(p) + o, err := p.Options() if err != nil { - t.Fatalf("FromConfig: %v", err) + t.Fatalf("Options: %v", err) } - for _, c := range options.AllConstructs { + for _, c := range AllConstructs { if o.Separator.Get(c) != tt.want { t.Errorf("value %q: construct %s = %v, want %v", tt.value, c, o.Separator.Get(c), tt.want) } @@ -75,43 +74,134 @@ func TestFromConfigSeparatorModes(t *testing.T) { // The option maps independently per construct. semicolon, comma := "semicolon", "comma" - separators := options.Separators{Structs: &semicolon, Enums: &semicolon, Arguments: &comma, Throws: &comma} - p := options.Patch{Separators: &separators} + separators := Separators{Structs: &semicolon, Enums: &semicolon, Arguments: &comma, Throws: &comma} + p := FormatPatch{Separators: &separators} - o, err := FromConfig(p) + o, err := p.Options() if err != nil { - t.Fatalf("FromConfig: %v", err) + t.Fatalf("Options: %v", err) } - if o.Separator.Get(options.ConstructStruct) != SeparatorSemicolon || o.Separator.Get(options.ConstructArguments) != SeparatorComma { + if o.Separator.Get(ConstructStruct) != SeparatorSemicolon || o.Separator.Get(ConstructArguments) != SeparatorComma { t.Errorf("independent mapping failed: %+v", o) } } -// TestFromConfigBreak maps the break group to the formatter options. -func TestFromConfigBreak(t *testing.T) { +// TestFormatPatchBreak maps the break group to the formatter options. +func TestFormatPatchBreak(t *testing.T) { trueVal, falseVal := true, false - p := options.Patch{Break: &options.Break{Structs: &trueVal, Enums: &falseVal}} + p := FormatPatch{Break: &Break{Structs: &trueVal, Enums: &falseVal}} - o, err := FromConfig(p) + o, err := p.Options() if err != nil { - t.Fatalf("FromConfig: %v", err) + t.Fatalf("Options: %v", err) } - if !o.Break.Get(options.ConstructStruct) || o.Break.Get(options.ConstructEnum) { + if !o.Break.Get(ConstructStruct) || o.Break.Get(ConstructEnum) { t.Errorf("break mapping wrong: %+v", o) } // Zero patch keeps the defaults (no forced breaks). - o, err = FromConfig(options.Patch{}) + o, err = (FormatPatch{}).Options() if err != nil { - t.Fatalf("FromConfig: %v", err) + t.Fatalf("Options: %v", err) } - for _, c := range options.AllConstructs { + for _, c := range AllConstructs { if o.Break.Get(c) { t.Errorf("breaks should default to false for %s: %+v", c, o) } } } + +func TestFormatPatchValidate(t *testing.T) { + intPtr := func(n int) *int { return &n } + strPtr := func(s string) *string { return &s } + + tests := []struct { + name string + patch FormatPatch + wantErr bool + }{ + {"default is valid", DefaultFormatPatch(), false}, + {"bad printWidth", FormatPatch{PrintWidth: intPtr(0)}, true}, + {"bad tabWidth", FormatPatch{TabWidth: intPtr(-1)}, true}, + {"bad align", FormatPatch{Align: strPtr("sideways")}, true}, + {"bad separator value", FormatPatch{Separators: &Separators{Structs: strPtr("maybe")}}, true}, + {"preserve alias", FormatPatch{Separators: &Separators{Structs: strPtr("preserve")}}, false}, + {"bad indent value", FormatPatch{Indent: &Indent{Value: "x", Width: 1}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.patch.Validate() + if tt.wantErr && err == nil { + t.Error("expected error") + } + + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestParseIndentValue(t *testing.T) { + tests := []struct { + name string + in string + want Indent + wantErr bool + }{ + {"empty defaults", "", Indent{" ", 4}, false}, + {"literal two spaces", " ", Indent{" ", 2}, false}, + {"literal four spaces", " ", Indent{" ", 4}, false}, + {"literal tab", "\t", Indent{"\t", 4}, false}, + {"literal two tabs", "\t\t", Indent{"\t\t", 8}, false}, + {"mixed spaces and tabs", " \t", Indent{}, true}, + {"garbage", "banana", Indent{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseIndentValue(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %+v", got) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestIndentUnmarshal(t *testing.T) { + tests := []struct { + name string + json string + want Indent + }{ + {"string spaces", `" "`, Indent{" ", 2}}, + {"string tab", `"\t"`, Indent{"\t", 4}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var i Indent + if err := json.Unmarshal([]byte(tt.json), &i); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if i != tt.want { + t.Errorf("got %+v, want %+v", i, tt.want) + } + }) + } +} diff --git a/formatter/field.go b/formatter/field.go index a95ed2a..e8dc532 100644 --- a/formatter/field.go +++ b/formatter/field.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/karitham/thrift-ls/doc" - "github.com/karitham/thrift-ls/options" "github.com/karitham/thrift-ls/syntax" ) @@ -70,11 +69,11 @@ func (f *formatter) enumValueList(values []*syntax.EnumValue, bodyID int) doc.Do for i, value := range values { if i > 0 { - parts = append(parts, f.fieldSep(values[i-1].Sep, f.opts.Separator.Get(options.ConstructEnum))) + parts = append(parts, f.fieldSep(values[i-1].Sep, f.opts.Separator.Get(ConstructEnum))) } parts = append(parts, f.blankLines(value, doc.HardLine)...) - parts = append(parts, f.enumValue(value, f.alignmentForEnum(values, i, f.opts.Separator.Get(options.ConstructEnum)), bodyID)) + parts = append(parts, f.enumValue(value, f.alignmentForEnum(values, i, f.opts.Separator.Get(ConstructEnum)), bodyID)) } return f.Concat(parts...) @@ -449,7 +448,7 @@ func (f *formatter) fieldPads(v *syntax.Field, a *columnAlign) ([]padEntry, stri // enumValue assembles an enum value with comments, aligning '=' signs when // the body breaks. func (f *formatter) enumValue(v *syntax.EnumValue, align *columnAlign, bodyID int) doc.Doc { - sepMode := f.opts.Separator.Get(options.ConstructEnum) + sepMode := f.opts.Separator.Get(ConstructEnum) broken := f.Concat( f.enumValueContent(v, align, true, sepMode), diff --git a/formatter/format.go b/formatter/format.go index 7960e6a..1b968e0 100644 --- a/formatter/format.go +++ b/formatter/format.go @@ -16,7 +16,6 @@ import ( "sync" "github.com/karitham/thrift-ls/doc" - "github.com/karitham/thrift-ls/options" "github.com/karitham/thrift-ls/syntax" ) @@ -62,10 +61,10 @@ type Options struct { Align AlignMode // Separator controls trailing separators per construct (default // SeparatorPreserve). - Separator options.PerConstruct[SeparatorMode] + Separator PerConstruct[SeparatorMode] // Break forces the multiline layout per construct, even when the body // fits on one line. - Break options.PerConstruct[bool] + Break PerConstruct[bool] // NoTrailingNewline suppresses the final newline that is otherwise // appended to the formatted output. NoTrailingNewline bool @@ -78,7 +77,7 @@ func DefaultOptions() Options { Indent: " ", TabWidth: 4, Align: AlignField, - Separator: options.PerConstruct[SeparatorMode]{ + Separator: PerConstruct[SeparatorMode]{ Structs: SeparatorPreserve, Unions: SeparatorPreserve, Exceptions: SeparatorPreserve, diff --git a/formatter/format_config.go b/formatter/format_config.go new file mode 100644 index 0000000..b9ef2ac --- /dev/null +++ b/formatter/format_config.go @@ -0,0 +1,353 @@ +package formatter + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Construct identifies one formatting construct that per-construct options +// apply to: the container bodies (structs, unions, exceptions, enums, +// arguments, throws) and the collection types (lists, maps, sets). +type Construct int + +const ( + ConstructStruct Construct = iota + ConstructUnion + ConstructException + ConstructEnum + ConstructArguments + ConstructThrows + ConstructList + ConstructMap + ConstructSet +) + +// PerConstruct holds one option value per construct. The JSON tags make the +// per-construct option maps config-compatible ("structs", "arguments", ...), +// so the config layer and the CLI share this single source of truth. +type PerConstruct[T any] struct { + Structs T `json:"structs"` + Unions T `json:"unions"` + Exceptions T `json:"exceptions"` + Enums T `json:"enums"` + Arguments T `json:"arguments"` + Throws T `json:"throws"` + Lists T `json:"lists"` + Maps T `json:"maps"` + Sets T `json:"sets"` +} + +// Get returns the value for the construct. +func (p PerConstruct[T]) Get(c Construct) T { + switch c { + case ConstructUnion: + return p.Unions + case ConstructException: + return p.Exceptions + case ConstructEnum: + return p.Enums + case ConstructArguments: + return p.Arguments + case ConstructThrows: + return p.Throws + case ConstructList: + return p.Lists + case ConstructMap: + return p.Maps + case ConstructSet: + return p.Sets + } + + return p.Structs +} + +// Set assigns the value for the construct. +func (p *PerConstruct[T]) Set(c Construct, v T) { + switch c { + case ConstructUnion: + p.Unions = v + case ConstructException: + p.Exceptions = v + case ConstructEnum: + p.Enums = v + case ConstructArguments: + p.Arguments = v + case ConstructThrows: + p.Throws = v + case ConstructList: + p.Lists = v + case ConstructMap: + p.Maps = v + case ConstructSet: + p.Sets = v + default: + p.Structs = v + } +} + +// AllConstructs lists every construct, in config order. +var AllConstructs = []Construct{ + ConstructStruct, ConstructUnion, ConstructException, + ConstructEnum, ConstructArguments, ConstructThrows, + ConstructList, ConstructMap, ConstructSet, +} + +// String returns the config key of the construct. +func (c Construct) String() string { + switch c { + case ConstructUnion: + return "unions" + case ConstructException: + return "exceptions" + case ConstructEnum: + return "enums" + case ConstructArguments: + return "arguments" + case ConstructThrows: + return "throws" + case ConstructList: + return "lists" + case ConstructMap: + return "maps" + case ConstructSet: + return "sets" + } + + return "structs" +} + +// Separators configures trailing separators per construct. A nil value is +// unset. +type Separators = PerConstruct[*string] + +// Break configures layouts that are forced multiline per construct. A nil +// value is unset. +type Break = PerConstruct[*bool] + +// FormatPatch is a partial formatting configuration; nil fields are unset, +// so layered sources (defaults, config file, CLI flags, workspace settings) +// override each other field by field. +type FormatPatch struct { + PrintWidth *int `json:"printWidth"` + Indent *Indent `json:"indent"` + TabWidth *int `json:"tabWidth"` + Align *string `json:"align"` + Separators *Separators `json:"separators"` + Break *Break `json:"break"` +} + +// Apply overlays p onto base: every set field of p replaces the +// corresponding field of base. +func (p FormatPatch) Apply(base FormatPatch) FormatPatch { + out := base + if p.PrintWidth != nil { + out.PrintWidth = p.PrintWidth + } + + if p.Indent != nil { + out.Indent = p.Indent + } + + if p.TabWidth != nil { + out.TabWidth = p.TabWidth + } + + if p.Align != nil { + out.Align = p.Align + } + + out.Separators = overlayPerConstruct(out.Separators, p.Separators) + out.Break = overlayPerConstruct(out.Break, p.Break) + + return out +} + +// overlayPerConstruct copies the set fields of src onto dst, creating dst +// when it is nil. +func overlayPerConstruct[T *E, E any](dst, src *PerConstruct[T]) *PerConstruct[T] { + if src == nil { + return dst + } + + if dst == nil { + dst = &PerConstruct[T]{} + } + + for _, c := range AllConstructs { + if v := src.Get(c); v != nil { + dst.Set(c, v) + } + } + + return dst +} + +// DefaultFormatPatch returns the default formatting configuration as a +// fully-set patch. +func DefaultFormatPatch() FormatPatch { + printWidth := 80 + indent := Indent{Value: " ", Width: 4} + tabWidth := 4 + align := "field" + separators := Separators{ + Structs: new("preserve"), + Unions: new("preserve"), + Exceptions: new("preserve"), + Enums: new("preserve"), + Arguments: new("preserve"), + Throws: new("preserve"), + } + + return FormatPatch{ + PrintWidth: &printWidth, + Indent: &indent, + TabWidth: &tabWidth, + Align: &align, + Separators: &separators, + } +} + +// Validate checks every set field for validity. +func (p FormatPatch) Validate() error { + if p.PrintWidth != nil && *p.PrintWidth <= 0 { + return errors.New("printWidth must be positive") + } + + if p.TabWidth != nil && *p.TabWidth <= 0 { + return errors.New("tabWidth must be positive") + } + + if p.Align != nil { + if _, ok := alignMode(*p.Align); !ok { + return fmt.Errorf("align must be one of \"field\", \"assign\", \"disable\", got %q", *p.Align) + } + } + + if p.Separators != nil { + for _, c := range AllConstructs { + if v := p.Separators.Get(c); v != nil { + if _, ok := separatorMode(*v); !ok { + return fmt.Errorf("separators.%s must be one of \"comma\", \"semicolon\", \"none\", \"preserve\" (keep as written), got %q", c, *v) + } + } + } + } + + if p.Indent != nil && (p.Indent.Width <= 0 || !isWhitespaceOnly(p.Indent.Value)) { + return errors.New("indent must be a string of spaces or tabs") + } + + return nil +} + +// Options converts the patch to formatter options, validating first. +func (p FormatPatch) Options() (Options, error) { + if err := p.Validate(); err != nil { + return Options{}, err + } + + o := DefaultOptions() + if p.PrintWidth != nil { + o.PrintWidth = *p.PrintWidth + } + + if p.Indent != nil { + o.Indent = p.Indent.Value + o.TabWidth = p.Indent.Width + } + + if p.TabWidth != nil { + o.TabWidth = *p.TabWidth + } + + if p.Align != nil { + if mode, ok := alignMode(*p.Align); ok { + o.Align = mode + } + } + + if p.Separators != nil { + for _, c := range AllConstructs { + if v := p.Separators.Get(c); v != nil { + if mode, ok := separatorMode(*v); ok { + o.Separator.Set(c, mode) + } + } + } + } + + if p.Break != nil { + for _, c := range AllConstructs { + if v := p.Break.Get(c); v != nil { + o.Break.Set(c, *v) + } + } + } + + return o, nil +} + +// Indent is a resolved indentation: the string emitted for one level and +// its display width. It is set from a literal string of spaces or tabs. +type Indent struct { + Value string // the indentation string, spaces or tabs + Width int // display width of one level +} + +// UnmarshalJSON accepts a literal string of spaces or tabs. +func (i *Indent) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return errors.New("indent must be a string of spaces or tabs") + } + + ind, err := ParseIndentValue(s) + if err != nil { + return err + } + + *i = ind + + return nil +} + +// ParseIndentValue resolves a literal indent string: +// +// " " literal spaces, used as written +// "\t" literal tabs, used as written +// +// An empty spec yields the default of four spaces. +func ParseIndentValue(s string) (Indent, error) { + if s == "" { + return Indent{Value: " ", Width: 4}, nil + } + + if isWhitespaceOnly(s) { + spaces := strings.Count(s, " ") + + tabs := strings.Count(s, "\t") + if spaces > 0 && tabs > 0 { + return Indent{}, fmt.Errorf("indent %q mixes spaces and tabs", s) + } + + if tabs > 0 { + return Indent{Value: s, Width: tabs * 4}, nil + } + + return Indent{Value: s, Width: spaces}, nil + } + + return Indent{}, errors.New("indent must be a string of spaces or tabs") +} + +func isWhitespaceOnly(s string) bool { + for _, r := range s { + if r != ' ' && r != '\t' { + return false + } + } + + return true +} diff --git a/formatter/format_fuzz_test.go b/formatter/format_fuzz_test.go index 8e8285f..ef9036d 100644 --- a/formatter/format_fuzz_test.go +++ b/formatter/format_fuzz_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - "github.com/karitham/thrift-ls/options" "github.com/karitham/thrift-ls/syntax" ) @@ -69,7 +68,7 @@ func FuzzFormat(f *testing.F) { // In preserve mode every field and enum separator survives. allPreserve := true - for _, c := range options.AllConstructs { + for _, c := range AllConstructs { if opts.Separator.Get(c) != SeparatorPreserve { allPreserve = false } @@ -137,7 +136,7 @@ func fuzzOpts(src string) Options { o.Align = AlignDisable } - for i, c := range options.AllConstructs { + for i, c := range AllConstructs { o.Separator.Set(c, SeparatorMode((h[i%4]+i)%4)) o.Break.Set(c, h[(i+1)%4]%2 == 0) } diff --git a/formatter/format_test.go b/formatter/format_test.go index c1f7bb7..a7511f6 100644 --- a/formatter/format_test.go +++ b/formatter/format_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/karitham/thrift-ls/options" "github.com/karitham/thrift-ls/syntax" ) @@ -56,7 +55,7 @@ func testOpts(width int) Options { // commaOpts returns testOpts at width with the given struct separator. func commaOpts(width int, mode SeparatorMode) Options { o := testOpts(width) - o.Separator.Set(options.ConstructStruct, mode) + o.Separator.Set(ConstructStruct, mode) return o } @@ -664,9 +663,9 @@ func TestFormatSeparators(t *testing.T) { name: "fields semicolon, functions comma", opts: func() Options { o := testOpts(30) - o.Separator.Set(options.ConstructStruct, SeparatorSemicolon) - o.Separator.Set(options.ConstructArguments, SeparatorComma) - o.Separator.Set(options.ConstructThrows, SeparatorComma) + o.Separator.Set(ConstructStruct, SeparatorSemicolon) + o.Separator.Set(ConstructArguments, SeparatorComma) + o.Separator.Set(ConstructThrows, SeparatorComma) return o }(), @@ -695,8 +694,8 @@ func TestFormatSeparators(t *testing.T) { name: "function comma add forces commas on throws", opts: func() Options { o := testOpts(30) - o.Separator.Set(options.ConstructArguments, SeparatorComma) - o.Separator.Set(options.ConstructThrows, SeparatorComma) + o.Separator.Set(ConstructArguments, SeparatorComma) + o.Separator.Set(ConstructThrows, SeparatorComma) return o }(), @@ -707,8 +706,8 @@ func TestFormatSeparators(t *testing.T) { name: "function comma remove drops argument separators", opts: func() Options { o := testOpts(30) - o.Separator.Set(options.ConstructArguments, SeparatorNone) - o.Separator.Set(options.ConstructThrows, SeparatorNone) + o.Separator.Set(ConstructArguments, SeparatorNone) + o.Separator.Set(ConstructThrows, SeparatorNone) return o }(), @@ -734,7 +733,7 @@ func TestFormatAlwaysBreak(t *testing.T) { name: "break structs forces multiline", opts: func() Options { o := testOpts(80) - o.Break.Set(options.ConstructStruct, true) + o.Break.Set(ConstructStruct, true) return o }(), @@ -745,7 +744,7 @@ func TestFormatAlwaysBreak(t *testing.T) { name: "break enums forces multiline", opts: func() Options { o := testOpts(80) - o.Break.Set(options.ConstructEnum, true) + o.Break.Set(ConstructEnum, true) return o }(), @@ -756,7 +755,7 @@ func TestFormatAlwaysBreak(t *testing.T) { name: "break structs keeps empty bodies flat", opts: func() Options { o := testOpts(80) - o.Break.Set(options.ConstructStruct, true) + o.Break.Set(ConstructStruct, true) return o }(), @@ -767,7 +766,7 @@ func TestFormatAlwaysBreak(t *testing.T) { name: "break structs does not affect enums", opts: func() Options { o := testOpts(80) - o.Break.Set(options.ConstructStruct, true) + o.Break.Set(ConstructStruct, true) return o }(), @@ -1262,10 +1261,10 @@ func TestFormatThrowsFoldsWithBrokenArgs(t *testing.T) { func TestFormatSeparatorsPerConstruct(t *testing.T) { opts := testOpts(80) - opts.Separator.Set(options.ConstructStruct, SeparatorSemicolon) - opts.Separator.Set(options.ConstructUnion, SeparatorSemicolon) - opts.Separator.Set(options.ConstructException, SeparatorSemicolon) - opts.Separator.Set(options.ConstructEnum, SeparatorComma) + opts.Separator.Set(ConstructStruct, SeparatorSemicolon) + opts.Separator.Set(ConstructUnion, SeparatorSemicolon) + opts.Separator.Set(ConstructException, SeparatorSemicolon) + opts.Separator.Set(ConstructEnum, SeparatorComma) src := "struct S {\n 1: i32 a\n 2: i32 b\n}\n\nunion U {\n 1: i32 a\n 2: i32 b\n}\n\nexception X {\n 1: i32 a\n 2: i32 b\n}\n\nenum E {\n A\n B\n}" want := "struct S { 1: i32 a; 2: i32 b }\n\nunion U { 1: i32 a; 2: i32 b }\n\nexception X { 1: i32 a; 2: i32 b }\n\nenum E { A, B }\n" @@ -1336,8 +1335,8 @@ func TestFormatConstsOptions(t *testing.T) { name: "lists forced comma with break", src: "const list a = [1, 2]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructList, SeparatorComma) - o.Break.Set(options.ConstructList, true) + o.Separator.Set(ConstructList, SeparatorComma) + o.Break.Set(ConstructList, true) }), want: "const list a = [\n 1,\n 2,\n]\n", }, @@ -1345,7 +1344,7 @@ func TestFormatConstsOptions(t *testing.T) { name: "lists forced semicolon flat", src: "const list a = [1, 2]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructList, SeparatorSemicolon) + o.Separator.Set(ConstructList, SeparatorSemicolon) }), want: "const list a = [1; 2; ]\n", }, @@ -1353,7 +1352,7 @@ func TestFormatConstsOptions(t *testing.T) { name: "lists none drops separators", src: "const list a = [1, 2]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructList, SeparatorNone) + o.Separator.Set(ConstructList, SeparatorNone) }), want: "const list a = [1 2]\n", }, @@ -1361,8 +1360,8 @@ func TestFormatConstsOptions(t *testing.T) { name: "maps forced comma with break", src: "const map m = {\"a\": 1, \"b\": 2}", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructMap, SeparatorComma) - o.Break.Set(options.ConstructMap, true) + o.Separator.Set(ConstructMap, SeparatorComma) + o.Break.Set(ConstructMap, true) }), want: "const map m = {\n \"a\": 1,\n \"b\": 2,\n}\n", }, @@ -1372,8 +1371,8 @@ func TestFormatConstsOptions(t *testing.T) { name: "sets forced comma with break", src: "const set s = [1, 2]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructSet, SeparatorComma) - o.Break.Set(options.ConstructSet, true) + o.Separator.Set(ConstructSet, SeparatorComma) + o.Break.Set(ConstructSet, true) }), want: "const set s = [\n 1,\n 2,\n]\n", }, @@ -1381,7 +1380,7 @@ func TestFormatConstsOptions(t *testing.T) { name: "sets semicolon separators", src: "const set s = [1, 2]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructSet, SeparatorSemicolon) + o.Separator.Set(ConstructSet, SeparatorSemicolon) }), want: "const set s = [1; 2; ]\n", }, @@ -1389,8 +1388,8 @@ func TestFormatConstsOptions(t *testing.T) { name: "trailing separator never leaves a blank before the close", src: "const list a = [\n 1,\n 2,\n]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructList, SeparatorComma) - o.Break.Set(options.ConstructList, true) + o.Separator.Set(ConstructList, SeparatorComma) + o.Break.Set(ConstructList, true) }), want: "const list a = [\n 1,\n 2,\n]\n", }, @@ -1398,8 +1397,8 @@ func TestFormatConstsOptions(t *testing.T) { name: "line comment after trailing separator owns its line end", src: "const list a = [1; #0\n]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructList, SeparatorPreserve) - o.Break.Set(options.ConstructList, true) + o.Separator.Set(ConstructList, SeparatorPreserve) + o.Break.Set(ConstructList, true) }), want: "const list a = [\n 1; #0\n]\n", }, @@ -1407,7 +1406,7 @@ func TestFormatConstsOptions(t *testing.T) { name: "suppressed separator keeps the comment inline", src: "const list a = [1, // c\n2]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructList, SeparatorNone) + o.Separator.Set(ConstructList, SeparatorNone) }), want: "const list a = [\n 1 // c\n 2\n]\n", }, @@ -1415,7 +1414,7 @@ func TestFormatConstsOptions(t *testing.T) { name: "nested containers stay flat with a comment at the item boundary", src: "const list a = [[0]#\n]", opts: opts(func(o *Options) { - o.Separator.Set(options.ConstructList, SeparatorNone) + o.Separator.Set(ConstructList, SeparatorNone) }), want: "const list a = [\n [0] #\n]\n", }, diff --git a/formatter/sepspace_test.go b/formatter/sepspace_test.go index 032cec2..2ba124c 100644 --- a/formatter/sepspace_test.go +++ b/formatter/sepspace_test.go @@ -3,8 +3,6 @@ package formatter import ( "strings" "testing" - - "github.com/karitham/thrift-ls/options" ) // sweepSeparatorSpace asserts the invariant behind the "space before @@ -224,10 +222,10 @@ func TestNoSpaceBeforeSeparator(t *testing.T) { for _, w := range widths { o := DefaultOptions() o.PrintWidth = w - o.Separator.Set(options.ConstructStruct, m.mode) - o.Separator.Set(options.ConstructEnum, m.mode) - o.Separator.Set(options.ConstructArguments, m.mode) - o.Separator.Set(options.ConstructThrows, m.mode) + o.Separator.Set(ConstructStruct, m.mode) + o.Separator.Set(ConstructEnum, m.mode) + o.Separator.Set(ConstructArguments, m.mode) + o.Separator.Set(ConstructThrows, m.mode) o.Align = a.align label := src.name + "/" + m.name + "/" + a.name diff --git a/formatter/value.go b/formatter/value.go index 2246e7e..7f82cec 100644 --- a/formatter/value.go +++ b/formatter/value.go @@ -2,7 +2,6 @@ package formatter import ( "github.com/karitham/thrift-ls/doc" - "github.com/karitham/thrift-ls/options" "github.com/karitham/thrift-ls/syntax" ) @@ -15,18 +14,18 @@ func isListSep(kind syntax.TokenKind) bool { // containerConstruct returns the per-construct key of a declared container // type. Set values are written with the list literal syntax, so the // declared type is the only way to select the sets construct. -func containerConstruct(t *syntax.FieldType) options.Construct { +func containerConstruct(t *syntax.FieldType) Construct { if t == nil { - return options.ConstructList + return ConstructList } switch t.Kind { case syntax.TypeSet: - return options.ConstructSet + return ConstructSet case syntax.TypeMap: - return options.ConstructMap + return ConstructMap default: - return options.ConstructList + return ConstructList } } @@ -38,7 +37,7 @@ func containerConstruct(t *syntax.FieldType) options.Construct { // a token run, so comments inside the value are preserved. isLast reports // whether the value ends the enclosing declaration, in which case its // trailing trivia belongs to the declaration's trailing comments. -func (f *formatter) constValue(v *syntax.ConstValue, isLast bool, c options.Construct) doc.Doc { +func (f *formatter) constValue(v *syntax.ConstValue, isLast bool, c Construct) doc.Doc { if v == nil { return f.Concat() } @@ -57,10 +56,10 @@ func (f *formatter) constValue(v *syntax.ConstValue, isLast bool, c options.Cons // constList formats "[ items ]" as a foldable group honoring the c // construct's separator and break options. -func (f *formatter) constList(v *syntax.ConstValue, isLast bool, c options.Construct) doc.Doc { +func (f *formatter) constList(v *syntax.ConstValue, isLast bool, c Construct) doc.Doc { items := make([]constItem, len(v.List)) for i, item := range v.List { - items[i] = constItem{start: item.TokStart(), end: item.TokEnd(), doc: f.constValue(item, false, options.ConstructList)} + items[i] = constItem{start: item.TokStart(), end: item.TokEnd(), doc: f.constValue(item, false, ConstructList)} } return f.constItems(items, v.TokStart(), v.TokEnd(), c, isLast) @@ -78,7 +77,7 @@ func (f *formatter) constMap(v *syntax.ConstValue, isLast bool) doc.Doc { } } - return f.constItems(items, v.TokStart(), v.TokEnd(), options.ConstructMap, isLast) + return f.constItems(items, v.TokStart(), v.TokEnd(), ConstructMap, isLast) } // constItem is one list/map entry: its formatted doc and the token span of @@ -94,7 +93,7 @@ type constItem struct { // options. The separator between entries and the trailing separator follow // the mode; the closing bracket gets exactly one break, so a trailing // separator never leaves a blank line before it. -func (f *formatter) constItems(items []constItem, open, close int, c options.Construct, isLast bool) doc.Doc { +func (f *formatter) constItems(items []constItem, open, close int, c Construct, isLast bool) doc.Doc { sepMode := f.opts.Separator.Get(c) openOpts := emitOpts{trailing: true} diff --git a/lsp/server.go b/lsp/server.go index 816d0d0..72159a9 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -155,13 +155,13 @@ func (s *Server) formatOptions(view *cache.View) formatter.Options { overlay := s.workspaceOverlay s.optsMu.RUnlock() - fopts, err := formatter.FromConfig(overlay.Apply(view.Config())) + fopts, err := overlay.Apply(view.Config()).FormatPatch.Options() if err != nil { // Both layers were validated when stored; this is unreachable // unless a view config was corrupted. logError("formatter options rejected", err) - fopts, _ = formatter.FromConfig(view.Config()) + fopts, _ = view.Config().FormatPatch.Options() } return fopts diff --git a/main.go b/main.go index 536a6bf..6d5fb55 100644 --- a/main.go +++ b/main.go @@ -113,17 +113,17 @@ func lspFlags() []cli.Flag { // constructFlags maps the per-construct format flag names to constructs. var constructFlags = []struct { name string - construct options.Construct + construct formatter.Construct }{ - {"struct", options.ConstructStruct}, - {"union", options.ConstructUnion}, - {"exception", options.ConstructException}, - {"enum", options.ConstructEnum}, - {"argument", options.ConstructArguments}, - {"throws", options.ConstructThrows}, - {"list", options.ConstructList}, - {"map", options.ConstructMap}, - {"set", options.ConstructSet}, + {"struct", formatter.ConstructStruct}, + {"union", formatter.ConstructUnion}, + {"exception", formatter.ConstructException}, + {"enum", formatter.ConstructEnum}, + {"argument", formatter.ConstructArguments}, + {"throws", formatter.ConstructThrows}, + {"list", formatter.ConstructList}, + {"map", formatter.ConstructMap}, + {"set", formatter.ConstructSet}, } // formatFlags are the flags of the format subcommand. @@ -482,7 +482,7 @@ func formatPatch(cmd *cli.Command) (options.Patch, error) { } if cmd.IsSet("indent") { - ind, err := options.ParseIndentValue(cmd.String("indent")) + ind, err := formatter.ParseIndentValue(cmd.String("indent")) if err != nil { return options.Patch{}, err } @@ -500,7 +500,7 @@ func formatPatch(cmd *cli.Command) (options.Patch, error) { v := cmd.String(cf.name + "-separator") if p.Separators == nil { - p.Separators = &options.Separators{} + p.Separators = &formatter.Separators{} } p.Separators.Set(cf.construct, &v) @@ -510,7 +510,7 @@ func formatPatch(cmd *cli.Command) (options.Patch, error) { v := cmd.Bool("break-" + cf.name) if p.Break == nil { - p.Break = &options.Break{} + p.Break = &formatter.Break{} } p.Break.Set(cf.construct, &v) @@ -574,7 +574,7 @@ func formatFile(file string, w io.Writer, write, diffOut bool, configPath string patch := options.Effective(cfg) patch = cli.Apply(patch) - fopts, err := formatter.FromConfig(patch) + fopts, err := patch.FormatPatch.Options() if err != nil { return err } diff --git a/options/construct.go b/options/construct.go deleted file mode 100644 index f7f4986..0000000 --- a/options/construct.go +++ /dev/null @@ -1,112 +0,0 @@ -package options - -// Construct identifies one formatting construct that per-construct options -// apply to: the container bodies (structs, unions, exceptions, enums, -// arguments, throws) and the collection types (lists, maps, sets). -type Construct int - -const ( - ConstructStruct Construct = iota - ConstructUnion - ConstructException - ConstructEnum - ConstructArguments - ConstructThrows - ConstructList - ConstructMap - ConstructSet -) - -// PerConstruct holds one option value per construct. The JSON tags make the -// per-construct option maps config-compatible ("structs", "arguments", ...), -// so the options layer and the CLI share this single source of truth. -type PerConstruct[T any] struct { - Structs T `json:"structs"` - Unions T `json:"unions"` - Exceptions T `json:"exceptions"` - Enums T `json:"enums"` - Arguments T `json:"arguments"` - Throws T `json:"throws"` - Lists T `json:"lists"` - Maps T `json:"maps"` - Sets T `json:"sets"` -} - -// Get returns the value for the construct. -func (p PerConstruct[T]) Get(c Construct) T { - switch c { - case ConstructUnion: - return p.Unions - case ConstructException: - return p.Exceptions - case ConstructEnum: - return p.Enums - case ConstructArguments: - return p.Arguments - case ConstructThrows: - return p.Throws - case ConstructList: - return p.Lists - case ConstructMap: - return p.Maps - case ConstructSet: - return p.Sets - } - - return p.Structs -} - -// Set assigns the value for the construct. -func (p *PerConstruct[T]) Set(c Construct, v T) { - switch c { - case ConstructUnion: - p.Unions = v - case ConstructException: - p.Exceptions = v - case ConstructEnum: - p.Enums = v - case ConstructArguments: - p.Arguments = v - case ConstructThrows: - p.Throws = v - case ConstructList: - p.Lists = v - case ConstructMap: - p.Maps = v - case ConstructSet: - p.Sets = v - default: - p.Structs = v - } -} - -// AllConstructs lists every construct, in config order. -var AllConstructs = []Construct{ - ConstructStruct, ConstructUnion, ConstructException, - ConstructEnum, ConstructArguments, ConstructThrows, - ConstructList, ConstructMap, ConstructSet, -} - -// String returns the config key of the construct. -func (c Construct) String() string { - switch c { - case ConstructUnion: - return "unions" - case ConstructException: - return "exceptions" - case ConstructEnum: - return "enums" - case ConstructArguments: - return "arguments" - case ConstructThrows: - return "throws" - case ConstructList: - return "lists" - case ConstructMap: - return "maps" - case ConstructSet: - return "sets" - } - - return "structs" -} diff --git a/options/options.go b/options/options.go index 25600d2..3ce7acd 100644 --- a/options/options.go +++ b/options/options.go @@ -1,69 +1,42 @@ -// Package options is the configuration layer of thrift-ls. It owns a -// partial-options model (Patch): every field is optional so configuration -// sources can be layered — defaults, a JSON config file, CLI flags, and LSP -// workspace settings — each overriding the previous. +// Package options is the configuration layer of thrift-ls. It loads and +// layers the thrift-ls.json document — formatting settings (owned by the +// formatter), include paths, and the log level — so configuration sources +// can override each other: defaults, a JSON config file, CLI flags, and LSP +// workspace settings. // -// The config file is thrift-ls.json, discovered by walking up from the file -// being formatted, like Biome's config discovery. THRIFT_LS_CONFIG overrides -// the search with an explicit path. +// The config file is discovered by walking up from the file being +// formatted, like Biome's config discovery. THRIFT_LS_CONFIG overrides the +// search with an explicit path. package options import ( "bytes" "encoding/json" - "errors" "fmt" "os" "path/filepath" - "strings" + + "github.com/karitham/thrift-ls/formatter" ) // ConfigFileName is the JSON config file name. const ConfigFileName = "thrift-ls.json" -// Separators configures trailing separators per construct. A nil value is -// unset. It is an alias of the per-construct container, so adding a -// construct adds the config key, CLI flags, and validation in one place. -type Separators = PerConstruct[*string] - -// Break configures layouts that are forced multiline per construct. A nil -// value is unset. -type Break = PerConstruct[*bool] - -// Patch is a partial set of options; nil fields are unset. +// Patch is a partial set of options; nil fields are unset. The formatting +// fields are promoted from the embedded formatter patch and decode flat, +// so thrift-ls.json keeps its top-level keys ("printWidth", ...). type Patch struct { - PrintWidth *int `json:"printWidth"` - Indent *Indent `json:"indent"` - TabWidth *int `json:"tabWidth"` - Align *string `json:"align"` - Separators *Separators `json:"separators"` - Break *Break `json:"break"` - IncludePaths *[]string `json:"includePaths"` - LogLevel *int `json:"logLevel"` + formatter.FormatPatch + + IncludePaths *[]string `json:"includePaths"` + LogLevel *int `json:"logLevel"` } // Apply overlays p onto base: every set field of p replaces the // corresponding field of base. func (p Patch) Apply(base Patch) Patch { out := base - if p.PrintWidth != nil { - out.PrintWidth = p.PrintWidth - } - - if p.Indent != nil { - out.Indent = p.Indent - } - - if p.TabWidth != nil { - out.TabWidth = p.TabWidth - } - - if p.Align != nil { - out.Align = p.Align - } - - out.Separators = overlayPerConstruct(out.Separators, p.Separators) - out.Break = overlayPerConstruct(out.Break, p.Break) + out.FormatPatch = p.FormatPatch.Apply(base.FormatPatch) if p.IncludePaths != nil { out.IncludePaths = p.IncludePaths @@ -76,170 +49,20 @@ func (p Patch) Apply(base Patch) Patch { return out } -// overlayPerConstruct copies the set fields of src onto dst, creating dst -// when it is nil. -func overlayPerConstruct[T *E, E any](dst, src *PerConstruct[T]) *PerConstruct[T] { - if src == nil { - return dst - } - - if dst == nil { - dst = &PerConstruct[T]{} - } - - for _, c := range AllConstructs { - if v := src.Get(c); v != nil { - dst.Set(c, v) - } - } - - return dst -} - // Default returns the default options as a fully-set patch. func Default() Patch { - printWidth := 80 - indent := Indent{Value: " ", Width: 4} - tabWidth := 4 - align := "field" - separators := Separators{ - Structs: new("preserve"), - Unions: new("preserve"), - Exceptions: new("preserve"), - Enums: new("preserve"), - Arguments: new("preserve"), - Throws: new("preserve"), - } - - return Patch{ - PrintWidth: &printWidth, - Indent: &indent, - TabWidth: &tabWidth, - Align: &align, - Separators: &separators, - } + return Patch{FormatPatch: formatter.DefaultFormatPatch()} } // Validate checks every set field for validity. func (p Patch) Validate() error { - if p.PrintWidth != nil && *p.PrintWidth <= 0 { - return errors.New("printWidth must be positive") - } - - if p.TabWidth != nil && *p.TabWidth <= 0 { - return errors.New("tabWidth must be positive") - } - - if p.Align != nil { - if !validAlign(*p.Align) { - return fmt.Errorf("align must be one of \"field\", \"assign\", \"disable\", got %q", *p.Align) - } - } - - if p.Separators != nil { - for _, c := range AllConstructs { - if v := p.Separators.Get(c); v != nil { - if !validSeparator(*v) { - return fmt.Errorf("separators.%s must be one of \"comma\", \"semicolon\", \"none\", \"preserve\" (keep as written), got %q", c, *v) - } - } - } - } - - if p.Indent != nil && (p.Indent.Width <= 0 || !isWhitespaceOnly(p.Indent.Value)) { - return errors.New("indent must be a string of spaces or tabs") - } - - return nil -} - -// validAlign reports whether s is a known align config value. -func validAlign(s string) bool { - switch s { - case "field", "assign", "disable": - return true - } - - return false -} - -// validSeparator reports whether s is a known separator config value. -func validSeparator(s string) bool { - switch s { - case "comma", "semicolon", "none", "preserve": - return true - } - - return false -} - -// Indent is a resolved indentation: the string emitted for one level and -// its display width. It is set from a literal string of spaces or tabs. -type Indent struct { - Value string // the indentation string, spaces or tabs - Width int // display width of one level -} - -// UnmarshalJSON accepts a literal string of spaces or tabs. -func (i *Indent) UnmarshalJSON(data []byte) error { - var s string - if err := json.Unmarshal(data, &s); err != nil { - return errors.New("indent must be a string of spaces or tabs") - } - - ind, err := ParseIndentValue(s) - if err != nil { - return err - } - - *i = ind - - return nil -} - -// ParseIndentValue resolves a literal indent string: -// -// " " literal spaces, used as written -// "\t" literal tabs, used as written -// -// An empty spec yields the default of four spaces. -func ParseIndentValue(s string) (Indent, error) { - if s == "" { - return Indent{Value: " ", Width: 4}, nil - } - - if isWhitespaceOnly(s) { - spaces := strings.Count(s, " ") - - tabs := strings.Count(s, "\t") - if spaces > 0 && tabs > 0 { - return Indent{}, fmt.Errorf("indent %q mixes spaces and tabs", s) - } - - if tabs > 0 { - return Indent{Value: s, Width: tabs * 4}, nil - } - - return Indent{Value: s, Width: spaces}, nil - } - - return Indent{}, errors.New("indent must be a string of spaces or tabs") -} - -func isWhitespaceOnly(s string) bool { - for _, r := range s { - if r != ' ' && r != '\t' { - return false - } - } - - return true + return p.FormatPatch.Validate() } -// Parse reads and parses a config document. Unknown keys are rejected so -// that typos and stale settings (e.g. the removed overrides feature) fail -// loudly. Include paths in the document are left as written; Load resolves -// them against the config file's directory. +// Parse parses a config document. Unknown keys are rejected so that typos +// and stale settings (e.g. the removed overrides feature) fail loudly. +// Include paths in the document are left as written; Load resolves them +// against the config file's directory. func Parse(data []byte) (*Patch, error) { var p Patch @@ -258,7 +81,8 @@ func Parse(data []byte) (*Patch, error) { } // Load reads and parses a config file. Unknown keys are rejected so that -// typos and stale settings (e.g. the removed overrides feature) fail loudly. +// typos and stale settings (e.g. the removed overrides feature) fail +// loudly. func Load(path string) (*Patch, error) { data, err := os.ReadFile(path) if err != nil { @@ -289,9 +113,9 @@ func Load(path string) (*Patch, error) { return p, nil } -// FindConfig returns the config file path for dir: THRIFT_LS_CONFIG when set, -// otherwise the nearest thrift-ls.json walking up from dir. It returns an -// empty path when no config exists. +// FindConfig returns the config file path for dir: THRIFT_LS_CONFIG when +// set, otherwise the nearest thrift-ls.json walking up from dir. It returns +// an empty path when no config exists. func FindConfig(dir string) (string, error) { if path := os.Getenv("THRIFT_LS_CONFIG"); path != "" { return path, nil diff --git a/options/options_test.go b/options/options_test.go index 67d5c88..44e519b 100644 --- a/options/options_test.go +++ b/options/options_test.go @@ -1,119 +1,11 @@ package options import ( - "encoding/json" "os" "path/filepath" "testing" ) -func TestParseIndentValue(t *testing.T) { - tests := []struct { - name string - in string - want Indent - wantErr bool - }{ - {"empty defaults", "", Indent{" ", 4}, false}, - {"literal two spaces", " ", Indent{" ", 2}, false}, - {"literal four spaces", " ", Indent{" ", 4}, false}, - {"literal tab", "\t", Indent{"\t", 4}, false}, - {"literal two tabs", "\t\t", Indent{"\t\t", 8}, false}, - {"mixed spaces and tabs", " \t", Indent{}, true}, - {"garbage", "banana", Indent{}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseIndentValue(tt.in) - if tt.wantErr { - if err == nil { - t.Fatalf("expected error, got %+v", got) - } - - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if got != tt.want { - t.Errorf("got %+v, want %+v", got, tt.want) - } - }) - } -} - -func TestIndentUnmarshal(t *testing.T) { - tests := []struct { - name string - json string - want Indent - }{ - {"string spaces", `" "`, Indent{" ", 2}}, - {"string tab", `"\t"`, Indent{"\t", 4}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var i Indent - if err := json.Unmarshal([]byte(tt.json), &i); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if i != tt.want { - t.Errorf("got %+v, want %+v", i, tt.want) - } - }) - } -} - -func TestPatchApply(t *testing.T) { - base := Default() - - overlay := Patch{} - printWidth := 100 - overlay.PrintWidth = &printWidth - - got := overlay.Apply(base) - if got.PrintWidth == nil || *got.PrintWidth != 100 { - t.Errorf("PrintWidth not overridden: %v", got.PrintWidth) - } - - if got.Align == nil || *got.Align != "field" { - t.Errorf("Align should stay from base: %v", got.Align) - } -} - -func TestPatchValidate(t *testing.T) { - intPtr := func(n int) *int { return &n } - strPtr := func(s string) *string { return &s } - - tests := []struct { - name string - patch Patch - wantErr bool - }{ - {"default is valid", Default(), false}, - {"bad printWidth", Patch{PrintWidth: intPtr(0)}, true}, - {"bad tabWidth", Patch{TabWidth: intPtr(-1)}, true}, - {"bad align", Patch{Align: strPtr("sideways")}, true}, - {"bad comma", Patch{Separators: &Separators{Structs: strPtr("maybe")}}, true}, - {"preserve alias", Patch{Separators: &Separators{Structs: strPtr("preserve")}}, false}, - {"bad indent value", Patch{Indent: &Indent{Value: "x", Width: 1}}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.patch.Validate() - if tt.wantErr && err == nil { - t.Error("expected error") - } - - if !tt.wantErr && err != nil { - t.Errorf("unexpected error: %v", err) - } - }) - } -} func TestFindConfig(t *testing.T) { dir := t.TempDir() @@ -222,40 +114,3 @@ func TestLoadRejectsUnknownOverrideKeys(t *testing.T) { t.Fatal("Load accepted a config with an overrides key") } } - -// TestPatchSeparatorModes maps every config value to the formatter modes. -func TestPatchSeparatorModes(t *testing.T) { - tests := []struct { - value string - }{ - {"comma"}, - {"none"}, - {"semicolon"}, - {"preserve"}, - } - for _, tt := range tests { - t.Run(tt.value, func(t *testing.T) { - p := Patch{Separators: &Separators{ - Structs: &tt.value, Unions: &tt.value, Exceptions: &tt.value, - Enums: &tt.value, Arguments: &tt.value, Throws: &tt.value, - Lists: &tt.value, Maps: &tt.value, Sets: &tt.value, - }} - - if err := p.Validate(); err != nil { - t.Fatalf("Validate: %v", err) - } - }) - } - - // Invalid values are rejected. - bogus := "bogus" - p := Patch{Align: &bogus} - if err := p.Validate(); err == nil { - t.Fatal("Validate accepted an unknown align value") - } - - p = Patch{Separators: &Separators{Structs: &bogus}} - if err := p.Validate(); err == nil { - t.Fatal("Validate accepted an unknown separator value") - } -} -- 2.51.2