diff --git a/d2ast/d2ast.go b/d2ast/d2ast.go index 445625d75..24592d22e 100644 --- a/d2ast/d2ast.go +++ b/d2ast/d2ast.go @@ -166,7 +166,8 @@ func (r Range) Before(r2 Range) bool { type Position struct { Line int Column int - Byte int + // -1 is used as sentinel that a constructed position is missing byte offset (for LSP usage) + Byte int } var _ fmt.Stringer = Position{} @@ -276,7 +277,13 @@ func (p Position) SubtractString(s string, byUTF16 bool) Position { } func (p Position) Before(p2 Position) bool { - return p.Byte < p2.Byte + if p.Byte != p2.Byte && p.Byte != -1 && p2.Byte != -1 { + return p.Byte < p2.Byte + } + if p.Line != p2.Line { + return p.Line < p2.Line + } + return p.Column < p2.Column } // MapNode is implemented by nodes that may be children of Maps. diff --git a/d2lsp/d2lsp.go b/d2lsp/d2lsp.go index 21106ea74..19b1b31d3 100644 --- a/d2lsp/d2lsp.go +++ b/d2lsp/d2lsp.go @@ -92,3 +92,64 @@ func getBoardMap(path string, fs map[string]string, boardPath []string) (*d2ir.M } return m, nil } + +func GetBoardAtPosition(path string, fs map[string]string, pos d2ast.Position) ([]string, error) { + if _, ok := fs[path]; !ok { + return nil, fmt.Errorf(`"%s" not found`, path) + } + r := strings.NewReader(fs[path]) + ast, err := d2parser.Parse(path, r, nil) + if err != nil { + return nil, err + } + pos.Byte = -1 + return getBoardPathAtPosition(*ast, nil, pos), nil +} + +func getBoardPathAtPosition(m d2ast.Map, currPath []string, pos d2ast.Position) []string { + inRange := func(r d2ast.Range) bool { + return !pos.Before(r.Start) && pos.Before(r.End) + } + + if !inRange(m.Range) { + return nil + } + + for _, n := range m.Nodes { + if n.MapKey == nil { + continue + } + mk := n.MapKey + + if mk.Key == nil || len(mk.Key.Path) == 0 { + continue + } + + if mk.Value.Map == nil { + continue + } + + keyName := mk.Key.Path[0].Unbox().ScalarString() + + if len(currPath)%2 == 0 { + isBoardType := keyName == "layers" || keyName == "scenarios" || keyName == "steps" + if !isBoardType { + continue + } + } + + if inRange(mk.Value.Map.Range) { + newPath := append(currPath, keyName) + + // Check deeper + if deeperPath := getBoardPathAtPosition(*mk.Value.Map, newPath, pos); deeperPath != nil { + return deeperPath + } + + // Nothing deeper matched but we're in this map's range, return current path + return newPath + } + } + + return nil +} diff --git a/d2lsp/d2lsp_test.go b/d2lsp/d2lsp_test.go index c2444a3e4..9bac4b8a3 100644 --- a/d2lsp/d2lsp_test.go +++ b/d2lsp/d2lsp_test.go @@ -3,6 +3,7 @@ package d2lsp_test import ( "testing" + "oss.terrastruct.com/d2/d2ast" "oss.terrastruct.com/d2/d2lsp" "oss.terrastruct.com/util-go/assert" ) @@ -143,3 +144,179 @@ layers: { _, _, err = d2lsp.GetRefRanges("index.d2", fs, []string{"y"}, "hello") assert.Equal(t, `board "[y]" not found`, err.Error()) } + +func TestGetBoardAtPosition(t *testing.T) { + tests := []struct { + name string + fs map[string]string + path string + position d2ast.Position + want []string + }{ + { + name: "cursor in layer", + fs: map[string]string{ + "index.d2": `x +layers: { + basic: { + x -> y + } +}`, + }, + path: "index.d2", + position: d2ast.Position{Line: 3, Column: 4}, + want: []string{"layers", "basic"}, + }, + { + name: "cursor in nested layer", + fs: map[string]string{ + "index.d2": ` +layers: { + outer: { + layers: { + inner: { + x -> y + } + } + } +}`, + }, + path: "index.d2", + position: d2ast.Position{Line: 5, Column: 4}, + want: []string{"layers", "outer", "layers", "inner"}, + }, + { + name: "cursor in second sibling nested layer", + fs: map[string]string{ + "index.d2": ` +layers: { + outer: { + layers: { + first: { + a -> b + } + second: { + x -> y + } + } + } +}`, + }, + path: "index.d2", + position: d2ast.Position{Line: 8, Column: 4}, + want: []string{"layers", "outer", "layers", "second"}, + }, + { + name: "cursor in root container", + fs: map[string]string{ + "index.d2": ` +wumbo: { + car +}`, + }, + path: "index.d2", + position: d2ast.Position{Line: 2, Column: 4}, + want: nil, + }, + { + name: "cursor in layer container", + fs: map[string]string{ + "index.d2": ` +layers: { + x: { + wumbo: { + car + } + } +}`, + }, + path: "index.d2", + position: d2ast.Position{Line: 4, Column: 4}, + want: []string{"layers", "x"}, + }, + { + name: "cursor in scenario", + fs: map[string]string{ + "index.d2": ` + scenarios: { + happy: { + x -> y + } + }`, + }, + path: "index.d2", + position: d2ast.Position{Line: 3, Column: 4}, + want: []string{"scenarios", "happy"}, + }, + { + name: "cursor in step", + fs: map[string]string{ + "index.d2": ` + steps: { + first: { + x -> y + } + }`, + }, + path: "index.d2", + position: d2ast.Position{Line: 3, Column: 4}, + want: []string{"steps", "first"}, + }, + { + name: "cursor outside any board", + fs: map[string]string{ + "index.d2": ` + x -> y + layers: { + basic: { + a -> b + } + }`, + }, + path: "index.d2", + position: d2ast.Position{Line: 1, Column: 1}, + want: nil, + }, + { + name: "cursor in empty board", + fs: map[string]string{ + "index.d2": ` + layers: { + basic: { + } + }`, + }, + path: "index.d2", + position: d2ast.Position{Line: 3, Column: 2}, + want: []string{"layers", "basic"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := d2lsp.GetBoardAtPosition(tt.path, tt.fs, tt.position) + assert.Success(t, err) + if tt.want == nil { + assert.Equal(t, true, got == nil) + } else { + assert.Equal(t, len(tt.want), len(got)) + assert.Equal(t, tt.want[0], got[0]) // board type + assert.Equal(t, tt.want[1], got[1]) // board id + } + }) + } + + // Error cases + t.Run("invalid file", func(t *testing.T) { + fs := map[string]string{ + "index.d2": "x ->", + } + _, err := d2lsp.GetBoardAtPosition("index.d2", fs, d2ast.Position{Line: 0, Column: 0}) + assert.Error(t, err) + }) + + t.Run("file not found", func(t *testing.T) { + _, err := d2lsp.GetBoardAtPosition("notfound.d2", nil, d2ast.Position{Line: 0, Column: 0}) + assert.Error(t, err) + }) +}