Something went wrong. Try again.
A Thrift language server, formatter, and linter.
lsp linter helix vim vscode thrift
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278package sema_test
import ( "context" "errors" "testing"
"github.com/stretchr/testify/require" "go.lsp.dev/uri"
"github.com/karitham/thrift-ls/analyzers" "github.com/karitham/thrift-ls/analyzertest" "github.com/karitham/thrift-ls/sema" "github.com/karitham/thrift-ls/store" "github.com/karitham/thrift-ls/syntax")
func spanOf(start, end int) sema.Span { return sema.Span{ Start: syntax.Position{Line: 1, Col: start + 1, Offset: start}, End: syntax.Position{Line: 1, Col: end + 1, Offset: end}, }}
func insertAt(offset int, text string) sema.Fix { return sema.Fix{Title: "insert", Edits: []sema.Edit{{Span: spanOf(offset, offset), NewText: text}}}}
func replaceRange(start, end int, text string) sema.Fix { return sema.Fix{Title: "replace", Edits: []sema.Edit{{Span: spanOf(start, end), NewText: text}}}}
func TestApply(t *testing.T) { t.Parallel()
tests := []struct { name string content string fixes []sema.Fix want string wantAp []string // titles of applied fixes, in order wantSkip []string // titles of skipped fixes wantErr bool }{ { name: "insertion", content: "ab", fixes: []sema.Fix{insertAt(1, "X")}, want: "aXb", wantAp: []string{"insert"}, }, { name: "replacement", content: "abcd", fixes: []sema.Fix{replaceRange(1, 3, "XY")}, want: "aXYd", wantAp: []string{"replace"}, }, { name: "deletion", content: "abcd", fixes: []sema.Fix{replaceRange(1, 3, "")}, want: "ad", wantAp: []string{"replace"}, }, { name: "later edits shift nothing", content: "abcd", fixes: []sema.Fix{replaceRange(0, 1, "X"), replaceRange(3, 4, "Y")}, want: "XbcY", wantAp: []string{"replace", "replace"}, }, { name: "overlapping fixes: first wins, second skipped whole", content: "abcd", fixes: []sema.Fix{replaceRange(1, 3, "X"), replaceRange(2, 4, "Y")}, want: "aXd", wantAp: []string{"replace"}, wantSkip: []string{"replace"}, }, { name: "overlapping fixes: all-or-nothing per fix", content: "abcdef", fixes: []sema.Fix{{ Title: "multi", Edits: []sema.Edit{ {Span: spanOf(0, 1), NewText: "X"}, {Span: spanOf(3, 4), NewText: "Y"}, }, }, replaceRange(3, 5, "Z")}, want: "XbcYef", wantAp: []string{"multi"}, wantSkip: []string{"replace"}, }, { name: "self-overlapping fix skipped whole", content: "abcd", fixes: []sema.Fix{{ Title: "self", Edits: []sema.Edit{ {Span: spanOf(0, 2), NewText: "X"}, {Span: spanOf(1, 3), NewText: "Y"}, }, }}, want: "abcd", wantSkip: []string{"self"}, }, { name: "adjacent edits do not overlap", content: "abcd", fixes: []sema.Fix{replaceRange(0, 2, "X"), replaceRange(2, 4, "Y")}, want: "XY", wantAp: []string{"replace", "replace"}, }, { name: "same-offset insertions land in argument order", content: "ab", fixes: []sema.Fix{insertAt(1, "1"), insertAt(1, "2")}, want: "a12b", wantAp: []string{"insert", "insert"}, }, { name: "insertion at a replacement's start lands before it", content: "abcd", fixes: []sema.Fix{replaceRange(1, 3, "R"), insertAt(1, "I")}, want: "aIRd", wantAp: []string{"replace", "insert"}, }, { name: "insertion and replacement commute at the same offset", content: "abcd", fixes: []sema.Fix{insertAt(1, "I"), replaceRange(1, 3, "R")}, want: "aIRd", wantAp: []string{"insert", "replace"}, }, { name: "out-of-range edit is an error", content: "ab", fixes: []sema.Fix{replaceRange(1, 5, "X")}, wantErr: true, }, { name: "inverted edit is an error", content: "ab", fixes: []sema.Fix{replaceRange(2, 1, "X")}, wantErr: true, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel()
out, applied, skipped, err := sema.Apply([]byte(tt.content), tt.fixes)
if tt.wantErr { require.Error(t, err)
return }
require.NoError(t, err) require.Equal(t, tt.want, string(out))
var gotAp []string for _, fx := range applied { gotAp = append(gotAp, fx.Title) }
var gotSkip []string for _, fx := range skipped { gotSkip = append(gotSkip, fx.Title) }
require.Equal(t, tt.wantAp, gotAp) require.Equal(t, tt.wantSkip, gotSkip) }) }}
// TestFixAllGreenfield fixes a new file referencing existing types: the// undefined type resolves through the workspace, the include lands in the// target file only, and the untouched files keep their content.func TestFixAllGreenfield(t *testing.T) { t.Parallel()
files := map[string]string{ "a.thrift": "struct Foo {}\n", "b.thrift": "struct Bar {\n 1: Foo f\n}\n", // Fixable on its own, but not a target: must stay untouched. "c.thrift": "include \"a.thrift\"\n\nstruct Other {}\n", }
b := analyzertest.URI("b.thrift")
res := analyzertest.RunFixAll(t, analyzers.DefaultPipeline(sema.Config{}), files, "b.thrift")
require.Equal(t, 1, res.Applied) require.Equal(t, []uri.URI{b}, res.FixedFiles) require.Empty(t, res.Skipped) require.Equal(t, 2, res.Passes, "pass 1 adds the include, pass 2 finds nothing")
require.Equal(t, "include \"a.thrift\"\nstruct Bar {\n 1: Foo f\n}\n", files["b.thrift"])
// Nothing but the target changed. require.Equal(t, "struct Foo {}\n", files["a.thrift"]) require.Equal(t, "include \"a.thrift\"\n\nstruct Other {}\n", files["c.thrift"])
// The remaining report is clean for the target. require.Empty(t, res.Remaining[b])}
// TestFixAllParseErrorGuard refuses to fix files that do not parse: their// fixable diagnostics are skipped with a reason, and their content is// untouched.func TestFixAllParseErrorGuard(t *testing.T) { t.Parallel()
brokenContent := "include \"a.thrift\"\n\nstruct Broken {\n 1: i32 a\n @@@\n}\n" files := map[string]string{ // The include parses; the body does not. "broken.thrift": brokenContent, }
broken := analyzertest.URI("broken.thrift")
// Applied == 0 means persist never ran: FixAll only persists files // with applied fixes, so the content assertion below pins that // nothing was written for the unparseable file. res := analyzertest.RunFixAll(t, analyzers.DefaultPipeline(sema.Config{}), files, "broken.thrift")
require.Zero(t, res.Applied) require.Empty(t, res.FixedFiles) require.Equal(t, brokenContent, files["broken.thrift"])
for _, s := range res.Skipped { require.Equal(t, "file has parse errors", s.Reason) require.Equal(t, broken, s.File) } require.NotEmpty(t, res.Skipped, "the parse-error guard must report what it skipped")}
// TestFixAllPersistFailure keeps the partial summary: fixes persisted// before the failure count as applied and their files are listed, so the// caller can report the mutations a failed run already made.func TestFixAllPersistFailure(t *testing.T) { t.Parallel()
folder := t.TempDir() files := map[uri.URI][]byte{ uri.File(folder + "/a.thrift"): []byte("struct Bar {\n 1: Foo f\n}\n"), // Fixable (unused include), but its persist fails. uri.File(folder + "/b.thrift"): []byte("include \"a.thrift\"\n\nstruct Other {}\n"), uri.File(folder + "/c.thrift"): []byte("struct Foo {}\n"), }
view := store.NewView(uri.File(folder), store.NewMemFS(files), nil)
persist := func(_ context.Context, u uri.URI, content []byte) error { files[u] = content
if u.FsPath() == folder+"/b.thrift" { return errors.New("disk full") }
return nil }
res, err := analyzers.DefaultPipeline(sema.Config{}).FixAll(t.Context(), view, []uri.URI{uri.File(folder + "/a.thrift"), uri.File(folder + "/b.thrift")}, persist)
require.Error(t, err) require.ErrorContains(t, err, "b.thrift") require.Equal(t, 1, res.Applied, "a.thrift's fix persisted before the failure") require.Equal(t, []uri.URI{uri.File(folder + "/a.thrift")}, res.FixedFiles) require.Contains(t, string(files[uri.File(folder+"/a.thrift")]), "include \"c.thrift\"")}