506 lines
13 KiB
Go
506 lines
13 KiB
Go
// 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:",
|
|
"style.shadow:", "style.3d:", "style.multiple:", "style.animated:", "style.bold:", "style.italic:", "style.underline:", "style.filled:", "style.double-border:":
|
|
return getBooleanCompletions(), nil
|
|
case "fill-pattern", "fill-pattern:", "style.fill-pattern:":
|
|
return getFillPatternCompletions(), nil
|
|
case "text-transform", "text-transform:", "style.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:",
|
|
"style.opacity:", "style.stroke-width:", "style.stroke-dash:", "style.border-radius:", "style.font-size:",
|
|
"style.stroke:", "style.fill:", "style.font-color:":
|
|
return getValueCompletions(strings.TrimSuffix(strings.TrimPrefix(keyword, "style."), ":")), 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 = ""
|
|
}
|
|
if firstPart == "classes" {
|
|
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
|
|
}
|