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.
|
// Non Style/Holder keywords.
|
||||||
var SimpleReservedKeywords = map[string]struct{}{
|
var SimpleReservedKeywords = map[string]struct{}{
|
||||||
"label": {},
|
"label": {},
|
||||||
"desc": {},
|
|
||||||
"shape": {},
|
"shape": {},
|
||||||
"icon": {},
|
"icon": {},
|
||||||
"constraint": {},
|
"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