Something went wrong. Try again.
A Thrift language server, formatter, and linter.
lsp linter helix vim vscode thrift
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252package analyzers
import ( "slices" "strings" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.lsp.dev/uri"
"github.com/karitham/thrift-ls/analyzertest" "github.com/karitham/thrift-ls/sema" "github.com/karitham/thrift-ls/store")
// cyclePair identifies one reported cycle include: the file containing the// include statement and the resolved URI it points at. Diagnostics carry// this as the message "cycle dependency in <to>".type cyclePair struct { from uri.URI to uri.URI}
func sortedPairs(t *testing.T, res sema.Report) []cyclePair { t.Helper()
pairs := make([]cyclePair, 0) for file, diags := range res { for _, d := range diags { pairs = append(pairs, cyclePair{ from: file, to: uri.URI(strings.TrimPrefix(d.Message, "cycle dependency in ")), }) } }
slices.SortFunc(pairs, func(a, b cyclePair) int { if c := strings.Compare(string(a.from), string(b.from)); c != 0 { return c }
return strings.Compare(string(a.to), string(b.to)) })
if len(pairs) == 0 { return nil }
return pairs}
// runCycleCheck builds a snapshot from an in-memory file tree rooted at// file:///tmp and runs CycleCheck starting from root. It asserts every// diagnostic carries the cycle code and warning severity, and returns the// reported (from, to) pairs sorted.func runCycleCheck(t *testing.T, files map[string]string, root string) []cyclePair { t.Helper()
res := analyzertest.Run(t, &CycleCheck{}, files, root)
for file, diags := range res { for _, d := range diags { assert.Equal(t, sema.SeverityWarning, d.Severity, file) assert.Equal(t, sema.CodeIncludeCycle, d.Code, file) } }
return sortedPairs(t, res)}
// TestCycleCheck pins cycle detection end to end: an include edge X -> Y// closes a cycle when Y transitively includes X back, no matter the cycle// length. Every reported edge must point back along the cycle; acyclic// graphs and unresolvable includes must produce nothing.func TestCycleCheck(t *testing.T) { const ( a = "a.thrift" b = "b.thrift" c = "c.thrift" d = "d.thrift" club = "clubroom.thrift" tea = "tea_time.thrift" git = "gitah.thrift" bass = "mio.thrift" drum = "ritsu.thrift" )
tests := []struct { name string files map[string]string root string want []cyclePair }{ { name: "acyclic chain", files: map[string]string{ a: `include "b.thrift"`, b: `include "c.thrift"`, c: ``, }, root: a, want: nil, }, { name: "two file cycle", files: map[string]string{ a: `include "b.thrift"`, b: `include "a.thrift"`, }, root: a, want: []cyclePair{ {"file:///tmp/" + a, "file:///tmp/" + b}, {"file:///tmp/" + b, "file:///tmp/" + a}, }, }, { name: "three file cycle", files: map[string]string{ a: `include "b.thrift"`, b: `include "c.thrift"`, c: `include "a.thrift"`, }, root: a, want: []cyclePair{ {"file:///tmp/" + a, "file:///tmp/" + b}, {"file:///tmp/" + b, "file:///tmp/" + c}, {"file:///tmp/" + c, "file:///tmp/" + a}, }, }, { name: "four file cycle", files: map[string]string{ a: `include "b.thrift"`, b: `include "c.thrift"`, c: `include "d.thrift"`, d: `include "a.thrift"`, }, root: a, want: []cyclePair{ {"file:///tmp/" + a, "file:///tmp/" + b}, {"file:///tmp/" + b, "file:///tmp/" + c}, {"file:///tmp/" + c, "file:///tmp/" + d}, {"file:///tmp/" + d, "file:///tmp/" + a}, }, }, { name: "self include", files: map[string]string{ a: `include "a.thrift"`, }, root: a, want: []cyclePair{ {"file:///tmp/" + a, "file:///tmp/" + a}, }, }, { name: "diamond into a cycle", files: map[string]string{ club: "include \"" + tea + "\"\ninclude \"" + git + "\"", tea: "include \"" + bass + "\"", git: "include \"" + drum + "\"", bass: "include \"" + drum + "\"\ninclude \"" + club + "\"", drum: "include \"" + tea + "\"", }, root: club, want: []cyclePair{ {"file:///tmp/" + club, "file:///tmp/" + git}, {"file:///tmp/" + club, "file:///tmp/" + tea}, {"file:///tmp/" + git, "file:///tmp/" + drum}, {"file:///tmp/" + bass, "file:///tmp/" + club}, {"file:///tmp/" + bass, "file:///tmp/" + drum}, {"file:///tmp/" + drum, "file:///tmp/" + tea}, {"file:///tmp/" + tea, "file:///tmp/" + bass}, }, }, { name: "unresolvable include is not a cycle", files: map[string]string{ a: `include "ghost.thrift"`, }, root: a, want: nil, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := runCycleCheck(t, tt.files, tt.root) assert.Equal(t, tt.want, got) }) }}
func Test_getIncludes(t *testing.T) { file1 := `include "./test/goods.thrift"include "./test/address.thrift"` file2 := `include "../user.thrift"` file3 := `include "../user.thrift"`
view := analyzertest.View(t, map[string]string{ "user.thrift": file1, "test/goods.thrift": file2, "test/address.thrift": file3, })
// Expected includes, parsed through the same snapshot so the ParsedFile // pointers match the ones getIncludes stores. pfFor := func(name string) *store.ParsedFile { pf, err := view.Parse(t.Context(), analyzertest.URI(name)) require.NoError(t, err)
return pf } userPf := pfFor("user.thrift") goodsPf := pfFor("test/goods.thrift") addressPf := pfFor("test/address.thrift")
expectIncludeMap := map[uri.URI][]Include{ analyzertest.URI("user.thrift"): { Include{file: analyzertest.URI("test/goods.thrift"), include: userPf.AST().Includes()[0], pf: userPf}, Include{file: analyzertest.URI("test/address.thrift"), include: userPf.AST().Includes()[1], pf: userPf}, }, analyzertest.URI("test/goods.thrift"): { Include{file: analyzertest.URI("user.thrift"), include: goodsPf.AST().Includes()[0], pf: goodsPf}, }, analyzertest.URI("test/address.thrift"): { Include{file: analyzertest.URI("user.thrift"), include: addressPf.AST().Includes()[0], pf: addressPf}, }, }
includeMap := make(map[uri.URI][]Include)
err := getIncludes(t.Context(), view, analyzertest.URI("user.thrift"), &includeMap) require.NoError(t, err)
assert.Equal(t, expectIncludeMap, includeMap)}
func Test_getIncludes_Unresolvable(t *testing.T) { view := analyzertest.View(t, map[string]string{ "user.thrift": "include \"ghost.thrift\"\nstruct S {}", })
includeMap := make(map[uri.URI][]Include)
err := getIncludes(t.Context(), view, analyzertest.URI("user.thrift"), &includeMap) require.NoError(t, err, "unresolvable includes are data, not errors") require.Contains(t, includeMap, analyzertest.URI("user.thrift")) assert.Empty(t, includeMap["file:///tmp/ghost.thrift"], "ghost target is never parsed into the map")}