d2lsp: implement autocomplete functions
This commit is contained in:
parent
8e94f36fc2
commit
5f9b32b442
3 changed files with 921 additions and 1 deletions
|
|
@ -8,7 +8,6 @@ var ReservedKeywords map[string]struct{}
|
|||
// Non Style/Holder keywords.
|
||||
var SimpleReservedKeywords = map[string]struct{}{
|
||||
"label": {},
|
||||
"desc": {},
|
||||
"shape": {},
|
||||
"icon": {},
|
||||
"constraint": {},
|
||||
|
|
|
|||
500
d2lsp/completion.go
Normal file
500
d2lsp/completion.go
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
// Completion implements lsp autocomplete features
|
||||
// Currently handles:
|
||||
// - Complete dot and inside maps for reserved keyword holders (style, labels, etc)
|
||||
// - Complete discrete values for keywords like shape
|
||||
// - Complete suggestions for formats for keywords like opacity
|
||||
package d2lsp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"oss.terrastruct.com/d2/d2ast"
|
||||
"oss.terrastruct.com/d2/d2parser"
|
||||
"oss.terrastruct.com/d2/d2target"
|
||||
)
|
||||
|
||||
type CompletionKind int
|
||||
|
||||
const (
|
||||
KeywordCompletion CompletionKind = iota
|
||||
StyleCompletion
|
||||
ShapeCompletion
|
||||
)
|
||||
|
||||
type CompletionItem struct {
|
||||
Label string
|
||||
Kind CompletionKind
|
||||
Detail string
|
||||
InsertText string
|
||||
}
|
||||
|
||||
func GetCompletionItems(text string, line, column int) ([]CompletionItem, error) {
|
||||
ast, err := d2parser.Parse("", strings.NewReader(text), nil)
|
||||
if err != nil {
|
||||
ast, _ = d2parser.Parse("", strings.NewReader(getTextUntilPosition(text, line, column)), nil)
|
||||
}
|
||||
|
||||
keyword := getKeywordContext(text, ast, line, column)
|
||||
switch keyword {
|
||||
case "style", "style.":
|
||||
return getStyleCompletions(), nil
|
||||
case "shape", "shape:":
|
||||
return getShapeCompletions(), nil
|
||||
case "shadow", "3d", "multiple", "animated", "bold", "italic", "underline", "filled", "double-border",
|
||||
"shadow:", "3d:", "multiple:", "animated:", "bold:", "italic:", "underline:", "filled:", "double-border:":
|
||||
return getBooleanCompletions(), nil
|
||||
case "fill-pattern", "fill-pattern:":
|
||||
return getFillPatternCompletions(), nil
|
||||
case "text-transform", "text-transform:":
|
||||
return getTextTransformCompletions(), nil
|
||||
case "opacity", "stroke-width", "stroke-dash", "border-radius", "font-size",
|
||||
"stroke", "fill", "font-color":
|
||||
return getValueCompletions(keyword), nil
|
||||
case "opacity:", "stroke-width:", "stroke-dash:", "border-radius:", "font-size:",
|
||||
"stroke:", "fill:", "font-color:":
|
||||
return getValueCompletions(keyword[:len(keyword)-1]), nil
|
||||
case "width", "height", "top", "left":
|
||||
return getValueCompletions(keyword), nil
|
||||
case "width:", "height:", "top:", "left:":
|
||||
return getValueCompletions(keyword[:len(keyword)-1]), nil
|
||||
case "source-arrowhead", "target-arrowhead":
|
||||
return getArrowheadCompletions(), nil
|
||||
case "source-arrowhead.shape:", "target-arrowhead.shape:":
|
||||
return getArrowheadShapeCompletions(), nil
|
||||
case "label", "label.":
|
||||
return getLabelCompletions(), nil
|
||||
case "icon", "icon:":
|
||||
return getIconCompletions(), nil
|
||||
case "icon.":
|
||||
return getLabelCompletions(), nil
|
||||
case "near", "near:":
|
||||
return getNearCompletions(), nil
|
||||
case "tooltip:", "tooltip":
|
||||
return getTooltipCompletions(), nil
|
||||
case "direction:", "direction":
|
||||
return getDirectionCompletions(), nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getTextUntilPosition(text string, line, column int) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
if line >= len(lines) {
|
||||
return text
|
||||
}
|
||||
|
||||
result := strings.Join(lines[:line], "\n")
|
||||
if len(result) > 0 {
|
||||
result += "\n"
|
||||
}
|
||||
if column > len(lines[line]) {
|
||||
result += lines[line]
|
||||
} else {
|
||||
result += lines[line][:column]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getKeywordContext(text string, m *d2ast.Map, line, column int) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
for _, n := range m.Nodes {
|
||||
if n.MapKey == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var firstPart, lastPart string
|
||||
var key *d2ast.KeyPath
|
||||
if len(n.MapKey.Edges) > 0 {
|
||||
key = n.MapKey.EdgeKey
|
||||
} else {
|
||||
key = n.MapKey.Key
|
||||
}
|
||||
if key != nil && len(key.Path) > 0 {
|
||||
firstKey := key.Path[0].Unbox()
|
||||
if !firstKey.IsUnquoted() {
|
||||
continue
|
||||
}
|
||||
firstPart = firstKey.ScalarString()
|
||||
|
||||
pathLen := len(key.Path)
|
||||
if pathLen > 1 {
|
||||
lastKey := key.Path[pathLen-1].Unbox()
|
||||
if lastKey.IsUnquoted() {
|
||||
lastPart = lastKey.ScalarString()
|
||||
_, isHolderLast := d2ast.ReservedKeywordHolders[lastPart]
|
||||
if !isHolderLast {
|
||||
_, isHolderLast = d2ast.CompositeReservedKeywords[lastPart]
|
||||
}
|
||||
keyRange := n.MapKey.Range
|
||||
lineText := lines[keyRange.End.Line]
|
||||
if isHolderLast && isAfterDot(lineText, column) {
|
||||
return lastPart + "."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, isBoard := d2ast.BoardKeywords[firstPart]; isBoard {
|
||||
firstPart = ""
|
||||
}
|
||||
|
||||
_, isHolder := d2ast.ReservedKeywordHolders[firstPart]
|
||||
if !isHolder {
|
||||
_, isHolder = d2ast.CompositeReservedKeywords[firstPart]
|
||||
}
|
||||
|
||||
// Check nested map
|
||||
if n.MapKey.Value.Map != nil && isPositionInMap(line, column, n.MapKey.Value.Map) {
|
||||
if nested := getKeywordContext(text, n.MapKey.Value.Map, line, column); nested != "" {
|
||||
if isHolder {
|
||||
// If we got a direct key completion from inside a holder's map,
|
||||
// prefix it with the holder's name
|
||||
if strings.HasSuffix(nested, ":") && !strings.Contains(nested, ".") {
|
||||
return firstPart + "." + strings.TrimSuffix(nested, ":") + ":"
|
||||
}
|
||||
}
|
||||
return nested
|
||||
}
|
||||
return firstPart
|
||||
}
|
||||
|
||||
keyRange := n.MapKey.Range
|
||||
if line != keyRange.End.Line {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1) Skip if cursor is well above/below this key
|
||||
if line < keyRange.Start.Line || line > keyRange.End.Line {
|
||||
continue
|
||||
}
|
||||
|
||||
// 2) If on the start line, skip if before the key
|
||||
if line == keyRange.Start.Line && column < keyRange.Start.Column {
|
||||
continue
|
||||
}
|
||||
|
||||
// 3) If on the end line, allow up to keyRange.End.Column + 1
|
||||
if line == keyRange.End.Line && column > keyRange.End.Column+1 {
|
||||
continue
|
||||
}
|
||||
|
||||
lineText := lines[keyRange.End.Line]
|
||||
|
||||
if isAfterColon(lineText, column) {
|
||||
if key != nil && len(key.Path) > 1 {
|
||||
if isHolder && (firstPart == "source-arrowhead" || firstPart == "target-arrowhead") {
|
||||
return firstPart + "." + lastPart + ":"
|
||||
}
|
||||
|
||||
_, isHolder := d2ast.ReservedKeywordHolders[lastPart]
|
||||
if !isHolder {
|
||||
return lastPart
|
||||
}
|
||||
}
|
||||
return firstPart + ":"
|
||||
}
|
||||
|
||||
if isAfterDot(lineText, column) && isHolder {
|
||||
return firstPart
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAfterDot(text string, pos int) bool {
|
||||
return pos > 0 && pos <= len(text) && text[pos-1] == '.'
|
||||
}
|
||||
|
||||
func isAfterColon(text string, pos int) bool {
|
||||
if pos < 1 || pos > len(text) {
|
||||
return false
|
||||
}
|
||||
i := pos - 1
|
||||
for i >= 0 && unicode.IsSpace(rune(text[i])) {
|
||||
i--
|
||||
}
|
||||
return i >= 0 && text[i] == ':'
|
||||
}
|
||||
|
||||
func isPositionInMap(line, column int, m *d2ast.Map) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
mapRange := m.Range
|
||||
if line < mapRange.Start.Line || line > mapRange.End.Line {
|
||||
return false
|
||||
}
|
||||
|
||||
if line == mapRange.Start.Line && column < mapRange.Start.Column {
|
||||
return false
|
||||
}
|
||||
if line == mapRange.End.Line && column > mapRange.End.Column {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getShapeCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2target.Shapes))
|
||||
for _, shape := range d2target.Shapes {
|
||||
item := CompletionItem{
|
||||
Label: shape,
|
||||
Kind: ShapeCompletion,
|
||||
Detail: "shape",
|
||||
InsertText: shape,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getValueCompletions(property string) []CompletionItem {
|
||||
switch property {
|
||||
case "opacity":
|
||||
return []CompletionItem{{
|
||||
Label: "(number between 0.0 and 1.0)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 0.4",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "stroke-width":
|
||||
return []CompletionItem{{
|
||||
Label: "(number between 0 and 15)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 2",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "font-size":
|
||||
return []CompletionItem{{
|
||||
Label: "(number between 8 and 100)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 14",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "stroke-dash":
|
||||
return []CompletionItem{{
|
||||
Label: "(number between 0 and 10)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 5",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "border-radius":
|
||||
return []CompletionItem{{
|
||||
Label: "(number greater than or equal to 0)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 4",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "font-color", "stroke", "fill":
|
||||
return []CompletionItem{{
|
||||
Label: "(color name or hex code)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. blue, #ff0000",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "width", "height", "top", "left":
|
||||
return []CompletionItem{{
|
||||
Label: "(pixels)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 400",
|
||||
InsertText: "",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStyleCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2ast.StyleKeywords))
|
||||
for keyword := range d2ast.StyleKeywords {
|
||||
item := CompletionItem{
|
||||
Label: keyword,
|
||||
Kind: StyleCompletion,
|
||||
Detail: "style property",
|
||||
InsertText: keyword + ": ",
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getBooleanCompletions() []CompletionItem {
|
||||
return []CompletionItem{
|
||||
{
|
||||
Label: "true",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "boolean",
|
||||
InsertText: "true",
|
||||
},
|
||||
{
|
||||
Label: "false",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "boolean",
|
||||
InsertText: "false",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getFillPatternCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2ast.FillPatterns))
|
||||
for _, pattern := range d2ast.FillPatterns {
|
||||
item := CompletionItem{
|
||||
Label: pattern,
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "fill pattern",
|
||||
InsertText: pattern,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getTextTransformCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2ast.TextTransforms))
|
||||
for _, transform := range d2ast.TextTransforms {
|
||||
item := CompletionItem{
|
||||
Label: transform,
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "text transform",
|
||||
InsertText: transform,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func isOnEmptyLine(text string, line int) bool {
|
||||
lines := strings.Split(text, "\n")
|
||||
if line >= len(lines) {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.TrimSpace(lines[line]) == ""
|
||||
}
|
||||
|
||||
func getLabelCompletions() []CompletionItem {
|
||||
return []CompletionItem{{
|
||||
Label: "near",
|
||||
Kind: StyleCompletion,
|
||||
Detail: "label position",
|
||||
InsertText: "near: ",
|
||||
}}
|
||||
}
|
||||
|
||||
func getNearCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2ast.LabelPositionsArray)+1)
|
||||
|
||||
items = append(items, CompletionItem{
|
||||
Label: "(object ID)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. container.inner_shape",
|
||||
InsertText: "",
|
||||
})
|
||||
|
||||
for _, pos := range d2ast.LabelPositionsArray {
|
||||
item := CompletionItem{
|
||||
Label: pos,
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "label position",
|
||||
InsertText: pos,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getTooltipCompletions() []CompletionItem {
|
||||
return []CompletionItem{
|
||||
{
|
||||
Label: "(markdown)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "markdown formatted text",
|
||||
InsertText: "|md\n # Tooltip\n Hello world\n|",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getIconCompletions() []CompletionItem {
|
||||
return []CompletionItem{
|
||||
{
|
||||
Label: "(URL, e.g. https://icons.terrastruct.com/xyz.svg)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "icon URL",
|
||||
InsertText: "https://icons.terrastruct.com/essentials%2F073-add.svg",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getDirectionCompletions() []CompletionItem {
|
||||
directions := []string{"up", "down", "right", "left"}
|
||||
items := make([]CompletionItem, len(directions))
|
||||
for i, dir := range directions {
|
||||
items[i] = CompletionItem{
|
||||
Label: dir,
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "direction",
|
||||
InsertText: dir,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getArrowheadShapeCompletions() []CompletionItem {
|
||||
arrowheads := []string{
|
||||
"triangle",
|
||||
"arrow",
|
||||
"diamond",
|
||||
"circle",
|
||||
"cf-one", "cf-one-required",
|
||||
"cf-many", "cf-many-required",
|
||||
}
|
||||
|
||||
items := make([]CompletionItem, len(arrowheads))
|
||||
details := map[string]string{
|
||||
"triangle": "default",
|
||||
"arrow": "like triangle but pointier",
|
||||
"cf-one": "crows foot one",
|
||||
"cf-one-required": "crows foot one (required)",
|
||||
"cf-many": "crows foot many",
|
||||
"cf-many-required": "crows foot many (required)",
|
||||
}
|
||||
|
||||
for i, shape := range arrowheads {
|
||||
detail := details[shape]
|
||||
if detail == "" {
|
||||
detail = "arrowhead shape"
|
||||
}
|
||||
items[i] = CompletionItem{
|
||||
Label: shape,
|
||||
Kind: ShapeCompletion,
|
||||
Detail: detail,
|
||||
InsertText: shape,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getArrowheadCompletions() []CompletionItem {
|
||||
completions := []string{
|
||||
"shape",
|
||||
"label",
|
||||
"style.filled",
|
||||
}
|
||||
|
||||
items := make([]CompletionItem, len(completions))
|
||||
|
||||
for i, shape := range completions {
|
||||
items[i] = CompletionItem{
|
||||
Label: shape,
|
||||
Kind: ShapeCompletion,
|
||||
InsertText: shape,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
421
d2lsp/completion_test.go
Normal file
421
d2lsp/completion_test.go
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
package d2lsp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetCompletionItems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
line int
|
||||
column int
|
||||
want []CompletionItem
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "style dot suggestions",
|
||||
text: "a.style.",
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: getStyleCompletions(),
|
||||
},
|
||||
{
|
||||
name: "style map suggestions",
|
||||
text: `a: {
|
||||
style.
|
||||
}
|
||||
`,
|
||||
line: 1,
|
||||
column: 8,
|
||||
want: getStyleCompletions(),
|
||||
},
|
||||
{
|
||||
name: "3d style map suggestions",
|
||||
text: `a.style: {
|
||||
3d:
|
||||
}
|
||||
`,
|
||||
line: 1,
|
||||
column: 5,
|
||||
want: getBooleanCompletions(),
|
||||
},
|
||||
{
|
||||
name: "fill pattern style map suggestions",
|
||||
text: `a.style: {
|
||||
fill-pattern:
|
||||
}
|
||||
`,
|
||||
line: 1,
|
||||
column: 15,
|
||||
want: getFillPatternCompletions(),
|
||||
},
|
||||
{
|
||||
name: "opacity style map suggestions",
|
||||
text: `a.style: {
|
||||
opacity:
|
||||
}
|
||||
`,
|
||||
line: 1,
|
||||
column: 10,
|
||||
want: getValueCompletions("opacity"),
|
||||
},
|
||||
{
|
||||
name: "width dot",
|
||||
text: `a.width:`,
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: getValueCompletions("width"),
|
||||
},
|
||||
{
|
||||
name: "layer shape",
|
||||
text: `a
|
||||
|
||||
layers: {
|
||||
hey: {
|
||||
go: {
|
||||
shape:
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
line: 5,
|
||||
column: 12,
|
||||
want: getShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "stroke width value",
|
||||
text: `a.style.stroke-width: 1`,
|
||||
line: 0,
|
||||
column: 23,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no style suggestions",
|
||||
text: `a.style:
|
||||
`,
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "style property suggestions",
|
||||
text: "a -> b: { style. }",
|
||||
line: 0,
|
||||
column: 16,
|
||||
want: getStyleCompletions(),
|
||||
},
|
||||
{
|
||||
name: "style.opacity value hint",
|
||||
text: "a -> b: { style.opacity: }",
|
||||
line: 0,
|
||||
column: 24,
|
||||
want: getValueCompletions("opacity"),
|
||||
},
|
||||
{
|
||||
name: "fill pattern completions",
|
||||
text: "a -> b: { style.fill-pattern: }",
|
||||
line: 0,
|
||||
column: 29,
|
||||
want: getFillPatternCompletions(),
|
||||
},
|
||||
{
|
||||
name: "text transform completions",
|
||||
text: "a -> b: { style.text-transform: }",
|
||||
line: 0,
|
||||
column: 31,
|
||||
want: getTextTransformCompletions(),
|
||||
},
|
||||
{
|
||||
name: "boolean property completions",
|
||||
text: "a -> b: { style.shadow: }",
|
||||
line: 0,
|
||||
column: 23,
|
||||
want: getBooleanCompletions(),
|
||||
},
|
||||
{
|
||||
name: "near position completions",
|
||||
text: "a -> b: { label.near: }",
|
||||
line: 0,
|
||||
column: 21,
|
||||
want: getNearCompletions(),
|
||||
},
|
||||
{
|
||||
name: "direction completions",
|
||||
text: "a -> b: { direction: }",
|
||||
line: 0,
|
||||
column: 20,
|
||||
want: getDirectionCompletions(),
|
||||
},
|
||||
{
|
||||
name: "icon url completions",
|
||||
text: "a -> b: { icon: }",
|
||||
line: 0,
|
||||
column: 15,
|
||||
want: getIconCompletions(),
|
||||
},
|
||||
{
|
||||
name: "icon dot url completions",
|
||||
text: "a.icon:",
|
||||
line: 0,
|
||||
column: 7,
|
||||
want: getIconCompletions(),
|
||||
},
|
||||
{
|
||||
name: "icon near completions",
|
||||
text: "a -> b: { icon.near: }",
|
||||
line: 0,
|
||||
column: 20,
|
||||
want: getNearCompletions(),
|
||||
},
|
||||
{
|
||||
name: "icon map",
|
||||
text: `a.icon: {
|
||||
# here
|
||||
}`,
|
||||
line: 1,
|
||||
column: 2,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "icon flat dot",
|
||||
text: `a.icon.`,
|
||||
line: 0,
|
||||
column: 7,
|
||||
want: getLabelCompletions(),
|
||||
},
|
||||
{
|
||||
name: "label flat dot",
|
||||
text: `a.label.`,
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: getLabelCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead completions - dot syntax",
|
||||
text: "a -> b: { source-arrowhead. }",
|
||||
line: 0,
|
||||
column: 27,
|
||||
want: getArrowheadCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead completions - colon syntax",
|
||||
text: "a -> b: { source-arrowhead: }",
|
||||
line: 0,
|
||||
column: 27,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "arrowhead completions - map syntax",
|
||||
text: `a -> b: {
|
||||
source-arrowhead: {
|
||||
# here
|
||||
}
|
||||
}`,
|
||||
line: 2,
|
||||
column: 4,
|
||||
want: getArrowheadCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead shape completions - flat dot syntax",
|
||||
text: "(a -> b)[0].source-arrowhead.shape:",
|
||||
line: 0,
|
||||
column: 35,
|
||||
want: getArrowheadShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead shape completions - dot syntax",
|
||||
text: "a -> b: { source-arrowhead.shape: }",
|
||||
line: 0,
|
||||
column: 33,
|
||||
want: getArrowheadShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead shape completions - map syntax",
|
||||
text: "a -> b: { source-arrowhead: { shape: } }",
|
||||
line: 0,
|
||||
column: 36,
|
||||
want: getArrowheadShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "width value hint",
|
||||
text: "a -> b: { width: }",
|
||||
line: 0,
|
||||
column: 16,
|
||||
want: getValueCompletions("width"),
|
||||
},
|
||||
{
|
||||
name: "height value hint",
|
||||
text: "a -> b: { height: }",
|
||||
line: 0,
|
||||
column: 17,
|
||||
want: getValueCompletions("height"),
|
||||
},
|
||||
{
|
||||
name: "tooltip markdown template",
|
||||
text: "a -> b: { tooltip: }",
|
||||
line: 0,
|
||||
column: 18,
|
||||
want: getTooltipCompletions(),
|
||||
},
|
||||
{
|
||||
name: "tooltip dot markdown template",
|
||||
text: "a.tooltip:",
|
||||
line: 0,
|
||||
column: 10,
|
||||
want: getTooltipCompletions(),
|
||||
},
|
||||
{
|
||||
name: "shape dot suggestions",
|
||||
text: "a.shape:",
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: getShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "shape suggestions",
|
||||
text: "a -> b: { shape: }",
|
||||
line: 0,
|
||||
column: 16,
|
||||
want: getShapeCompletions(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GetCompletionItems(tt.text, tt.line, tt.column)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetCompletionItems() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("GetCompletionItems() got %d completions, want %d", len(got), len(tt.want))
|
||||
return
|
||||
}
|
||||
|
||||
// Create maps for easy comparison
|
||||
gotMap := make(map[string]CompletionItem)
|
||||
wantMap := make(map[string]CompletionItem)
|
||||
for _, item := range got {
|
||||
gotMap[item.Label] = item
|
||||
}
|
||||
for _, item := range tt.want {
|
||||
wantMap[item.Label] = item
|
||||
}
|
||||
|
||||
// Check that each completion exists and has correct properties
|
||||
for label, wantItem := range wantMap {
|
||||
gotItem, exists := gotMap[label]
|
||||
if !exists {
|
||||
t.Errorf("missing completion for %q", label)
|
||||
continue
|
||||
}
|
||||
if gotItem.Kind != wantItem.Kind {
|
||||
t.Errorf("completion %q Kind = %v, want %v", label, gotItem.Kind, wantItem.Kind)
|
||||
}
|
||||
if gotItem.Detail != wantItem.Detail {
|
||||
t.Errorf("completion %q Detail = %v, want %v", label, gotItem.Detail, wantItem.Detail)
|
||||
}
|
||||
if gotItem.InsertText != wantItem.InsertText {
|
||||
t.Errorf("completion %q InsertText = %v, want %v", label, gotItem.InsertText, wantItem.InsertText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to compare CompletionItem slices
|
||||
func equalCompletions(a, b []CompletionItem) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Label != b[i].Label ||
|
||||
a[i].Kind != b[i].Kind ||
|
||||
a[i].Detail != b[i].Detail ||
|
||||
a[i].InsertText != b[i].InsertText {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestGetArrowheadShapeCompletions(t *testing.T) {
|
||||
got := getArrowheadShapeCompletions()
|
||||
|
||||
expectedLabels := []string{
|
||||
"triangle", "arrow", "diamond", "circle",
|
||||
"cf-one", "cf-one-required",
|
||||
"cf-many", "cf-many-required",
|
||||
}
|
||||
|
||||
if len(got) != len(expectedLabels) {
|
||||
t.Errorf("getArrowheadShapeCompletions() returned %d items, want %d", len(got), len(expectedLabels))
|
||||
return
|
||||
}
|
||||
|
||||
for i, label := range expectedLabels {
|
||||
if got[i].Label != label {
|
||||
t.Errorf("completion[%d].Label = %v, want %v", i, got[i].Label, label)
|
||||
}
|
||||
if got[i].Kind != ShapeCompletion {
|
||||
t.Errorf("completion[%d].Kind = %v, want ShapeCompletion", i, got[i].Kind)
|
||||
}
|
||||
if got[i].InsertText != label {
|
||||
t.Errorf("completion[%d].InsertText = %v, want %v", i, got[i].InsertText, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetValueCompletions(t *testing.T) {
|
||||
tests := []struct {
|
||||
property string
|
||||
wantLabel string
|
||||
wantDetail string
|
||||
}{
|
||||
{
|
||||
property: "opacity",
|
||||
wantLabel: "(number between 0.0 and 1.0)",
|
||||
wantDetail: "e.g. 0.4",
|
||||
},
|
||||
{
|
||||
property: "stroke-width",
|
||||
wantLabel: "(number between 0 and 15)",
|
||||
wantDetail: "e.g. 2",
|
||||
},
|
||||
{
|
||||
property: "font-size",
|
||||
wantLabel: "(number between 8 and 100)",
|
||||
wantDetail: "e.g. 14",
|
||||
},
|
||||
{
|
||||
property: "width",
|
||||
wantLabel: "(pixels)",
|
||||
wantDetail: "e.g. 400",
|
||||
},
|
||||
{
|
||||
property: "stroke",
|
||||
wantLabel: "(color name or hex code)",
|
||||
wantDetail: "e.g. blue, #ff0000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.property, func(t *testing.T) {
|
||||
got := getValueCompletions(tt.property)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("getValueCompletions(%s) returned %d items, want 1", tt.property, len(got))
|
||||
}
|
||||
if got[0].Label != tt.wantLabel {
|
||||
t.Errorf("completion.Label = %v, want %v", got[0].Label, tt.wantLabel)
|
||||
}
|
||||
if got[0].Detail != tt.wantDetail {
|
||||
t.Errorf("completion.Detail = %v, want %v", got[0].Detail, tt.wantDetail)
|
||||
}
|
||||
if got[0].InsertText != "" {
|
||||
t.Errorf("completion.InsertText = %v, want empty string", got[0].InsertText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue