diff --git a/d2ast/keywords.go b/d2ast/keywords.go index 7ad1fae651..f894045072 100644 --- a/d2ast/keywords.go +++ b/d2ast/keywords.go @@ -8,7 +8,6 @@ var ReservedKeywords map[string]struct{} // Non Style/Holder keywords. var SimpleReservedKeywords = map[string]struct{}{ "label": {}, - "desc": {}, "shape": {}, "icon": {}, "constraint": {}, diff --git a/d2js/d2wasm/functions.go b/d2js/d2wasm/functions.go index 10706fb750..25250880f0 100644 --- a/d2js/d2wasm/functions.go +++ b/d2js/d2wasm/functions.go @@ -159,3 +159,37 @@ func Decode(args []js.Value) (interface{}, error) { func GetVersion(args []js.Value) (interface{}, error) { return version.Version, nil } + +func GetCompletions(args []js.Value) (interface{}, error) { + if len(args) < 3 { + return nil, &WASMError{Message: "missing required arguments", Code: 400} + } + + text := args[0].String() + line := args[1].Int() + column := args[2].Int() + + completions, err := d2lsp.GetCompletionItems(text, line, column) + if err != nil { + return nil, &WASMError{Message: err.Error(), Code: 500} + } + + // Convert to map for JSON serialization + items := make([]map[string]interface{}, len(completions)) + for i, completion := range completions { + items[i] = map[string]interface{}{ + "label": completion.Label, + "kind": int(completion.Kind), + "detail": completion.Detail, + "insertText": completion.InsertText, + } + } + + return CompletionResponse{ + Items: items, + }, nil +} + +type CompletionResponse struct { + Items []map[string]interface{} `json:"items"` +} diff --git a/d2js/js.go b/d2js/js.go index c1461b5df8..50280194c6 100644 --- a/d2js/js.go +++ b/d2js/js.go @@ -11,6 +11,7 @@ import ( func main() { api := d2wasm.NewD2API() + api.Register("getCompletions", d2wasm.GetCompletions) api.Register("getParentID", d2wasm.GetParentID) api.Register("getObjOrder", d2wasm.GetObjOrder) api.Register("getRefRanges", d2wasm.GetRefRanges) diff --git a/d2lsp/completion.go b/d2lsp/completion.go new file mode 100644 index 0000000000..08b84d16ae --- /dev/null +++ b/d2lsp/completion.go @@ -0,0 +1,492 @@ +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 + "." + } + } + } + } + + _, 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 +} diff --git a/d2lsp/completion_test.go b/d2lsp/completion_test.go new file mode 100644 index 0000000000..a25c417b33 --- /dev/null +++ b/d2lsp/completion_test.go @@ -0,0 +1,398 @@ +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: "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) + } + }) + } +}