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")
- }
-}