From 73362a783409c8de15b738a6a3804ef1bc5d948f Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Sat, 6 Jun 2026 21:46:12 -0400 Subject: [PATCH 1/4] Add do snippet, structural snippets, and use-injection dedup (#73) - Add "do" to elixirFormSnippets: expands to do..end block with tab stop when snippet support is enabled; falls back to plain keyword with Preselect when snippet support is off. - Add structural snippets with parameter slots: defmodule, def, defp, defmacro, defmacrop - Update if snippet to include else clause. - Add test and describe snippets for ExUnit. - Add elixirKeywords list: do and end appear as plain keyword completions to prevent VS Code from falling back to word-based suggestions. - Add form-snippet dedup in addCompletionsFromUsing: use-injected functions (e.g. ExUnit.Case.test/1) no longer duplicate with their form snippet counterpart. --- internal/lsp/server.go | 54 ++++++++++--- internal/lsp/server_test.go | 153 +++++++++++++++++++++++++++++++++++- 2 files changed, 196 insertions(+), 11 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index ec0d5cd..ed7f07d 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1758,6 +1758,21 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara } } } + // Plain keyword completions (e.g., "do", "end"). These don't + // expand to snippets; they just insert the keyword itself so + // that pressing Enter doesn't replace the keyword with a + // VS Code word-based suggestion. + for _, kw := range elixirKeywords { + if strings.HasPrefix(kw, funcPrefix) && !seen[kw] { + seen[kw] = true + items = append(items, protocol.CompletionItem{ + Label: kw, + Kind: protocol.CompletionItemKindKeyword, + Detail: "keyword", + Preselect: true, + }) + } + } } if len(items) == 0 { @@ -2110,6 +2125,9 @@ func (s *Server) addCompletionsFromUsing(moduleName, funcPrefix string, seen map if !strings.HasPrefix(funcName, funcPrefix) { continue } + if useSnippets && elixirFormSnippets[funcName] != "" { + continue + } for _, d := range defs { key := funcKey(funcName, d.arity) if !seen[key] { @@ -2137,6 +2155,9 @@ func (s *Server) addCompletionsFromUsing(moduleName, funcPrefix string, seen map for _, r := range results { key := funcKey(r.Function, r.Arity) if strings.HasPrefix(r.Function, funcPrefix) && !seen[key] { + if useSnippets && elixirFormSnippets[r.Function] != "" { + continue + } seen[key] = true item := protocol.CompletionItem{ Label: r.Function, @@ -2298,17 +2319,30 @@ func funcKey(name string, arity int) string { return name + "/" + strconv.Itoa(arity) } +// elixirKeywords are plain keyword completions (no snippet expansion). +// They prevent VS Code from falling back to word-based completions when the +// user types a keyword like "do" or "end" and presses Enter. +var elixirKeywords = []string{"do", "end"} + var elixirFormSnippets = map[string]string{ - "for": "for ${1:pattern} <- ${2:enumerable} do\n\t$0\nend", - "with": "with ${1:pattern} <- ${2:expression} do\n\t$0\nend", - "case": "case ${1:expression} do\n\t${2:pattern} ->\n\t\t$0\nend", - "cond": "cond do\n\t${1:condition} ->\n\t\t$0\nend", - "if": "if ${1:condition} do\n\t$0\nend", - "unless": "unless ${1:condition} do\n\t$0\nend", - "receive": "receive do\n\t${1:pattern} ->\n\t\t$0\nend", - "try": "try do\n\t$0\nrescue\n\t${1:exception} ->\n\t\t${2:handler}\nend", - "quote": "quote do\n\t$0\nend", - "fn": "fn ${1:args} -> $0 end", + "do": "do\n\t$0\nend", + "defmodule": "defmodule ${1:Name} do\n\t$0\nend", + "def": "def ${1:name}(${2:params}) do\n\t$0\nend", + "defp": "defp ${1:name}(${2:params}) do\n\t$0\nend", + "defmacro": "defmacro ${1:name}(${2:params}) do\n\t$0\nend", + "defmacrop": "defmacrop ${1:name}(${2:params}) do\n\t$0\nend", + "for": "for ${1:pattern} <- ${2:enumerable} do\n\t$0\nend", + "with": "with ${1:pattern} <- ${2:expression} do\n\t$0\nend", + "case": "case ${1:expression} do\n\t${2:pattern} ->\n\t\t$0\nend", + "cond": "cond do\n\t${1:condition} ->\n\t\t$0\nend", + "if": "if ${1:condition} do\n\t$0\nelse\n\t${2}\nend", + "unless": "unless ${1:condition} do\n\t$0\nend", + "receive": "receive do\n\t${1:pattern} ->\n\t\t$0\nend", + "try": "try do\n\t$0\nrescue\n\t${1:exception} ->\n\t\t${2:handler}\nend", + "quote": "quote do\n\t$0\nend", + "fn": "fn ${1:args} -> $0 end", + "test": "test \"${1:description}\" do\n\t$0\nend", + "describe": "describe \"${1:description}\" do\n\t$0\nend", } func applySnippet(item *protocol.CompletionItem, name string, arity int, params string, inPipe bool, useSnippets bool) { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index db7c850..6d124b3 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -556,16 +556,24 @@ func TestCompletion_ElixirFormSnippets(t *testing.T) { label string snippet string }{ + {"d", "do", "do\n\t$0\nend"}, + {"defmod", "defmodule", "defmodule ${1:Name} do\n\t$0\nend"}, + {"def", "def", "def ${1:name}(${2:params}) do\n\t$0\nend"}, + {"def", "defp", "defp ${1:name}(${2:params}) do\n\t$0\nend"}, + {"defm", "defmacro", "defmacro ${1:name}(${2:params}) do\n\t$0\nend"}, + {"defmacr", "defmacrop", "defmacrop ${1:name}(${2:params}) do\n\t$0\nend"}, {"fo", "for", "for ${1:pattern} <- ${2:enumerable} do\n\t$0\nend"}, {"wi", "with", "with ${1:pattern} <- ${2:expression} do\n\t$0\nend"}, {"cas", "case", "case ${1:expression} do\n\t${2:pattern} ->\n\t\t$0\nend"}, {"con", "cond", "cond do\n\t${1:condition} ->\n\t\t$0\nend"}, - {"i", "if", "if ${1:condition} do\n\t$0\nend"}, + {"i", "if", "if ${1:condition} do\n\t$0\nelse\n\t${2}\nend"}, {"unl", "unless", "unless ${1:condition} do\n\t$0\nend"}, {"rec", "receive", "receive do\n\t${1:pattern} ->\n\t\t$0\nend"}, {"tr", "try", "try do\n\t$0\nrescue\n\t${1:exception} ->\n\t\t${2:handler}\nend"}, {"quo", "quote", "quote do\n\t$0\nend"}, {"f", "fn", "fn ${1:args} -> $0 end"}, + {"te", "test", "test \"${1:description}\" do\n\t$0\nend"}, + {"des", "describe", "describe \"${1:description}\" do\n\t$0\nend"}, } for _, tt := range tests { @@ -608,6 +616,14 @@ func TestCompletion_ElixirFormSnippets_NoDuplicateWithKernel(t *testing.T) { defmacro unless(condition, clauses) do :ok end + + defmacro def(call, expr) do + :ok + end + + defmacro defmodule(alias, do_block) do + :ok + end end `) @@ -636,6 +652,31 @@ end } } } + + // Verify def appears as form snippet, not as imported def/2 + server.docs.Set(uri, " de") + items = completionAt(t, server, uri, 0, 4) + + count = 0 + for _, item := range items { + if item.Label == "def" || item.Label == "def/2" { + count++ + } + } + if count != 1 { + t.Errorf("expected exactly 1 'def' completion, got %d", count) + } + + for _, item := range items { + if item.Label == "def" { + if item.Kind != protocol.CompletionItemKindKeyword { + t.Errorf("expected Keyword kind for 'def', got %v", item.Kind) + } + if item.InsertText != elixirFormSnippets["def"] { + t.Errorf("expected form snippet for 'def', got %q", item.InsertText) + } + } + } } func TestCompletion_ElixirFormSnippets_NoSnippetSupport(t *testing.T) { @@ -654,6 +695,65 @@ func TestCompletion_ElixirFormSnippets_NoSnippetSupport(t *testing.T) { } } +func TestCompletion_DoKeyword(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + uri := "file:///test.ex" + server.docs.Set(uri, " defmodule Foo do") + items := completionAt(t, server, uri, 0, 17) + + var found bool + for _, item := range items { + if item.Label == "do" { + found = true + if item.Kind != protocol.CompletionItemKindKeyword { + t.Errorf("expected Keyword kind for 'do', got %v", item.Kind) + } + if item.InsertText != elixirFormSnippets["do"] { + t.Errorf("expected form snippet for 'do', got %q", item.InsertText) + } + if item.InsertTextFormat != protocol.InsertTextFormatSnippet { + t.Error("expected InsertTextFormatSnippet for 'do'") + } + break + } + } + if !found { + t.Error("expected 'do' in completions") + } +} + +func TestCompletion_DoKeyword_NoSnippetSupport(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + server.snippetSupport = false + + uri := "file:///test.ex" + server.docs.Set(uri, " defmodule Foo do") + items := completionAt(t, server, uri, 0, 17) + + var found bool + for _, item := range items { + if item.Label == "do" { + found = true + if item.Kind != protocol.CompletionItemKindKeyword { + t.Errorf("expected Keyword kind for 'do', got %v", item.Kind) + } + if !item.Preselect { + t.Error("expected 'do' item to have Preselect=true") + } + if item.InsertText != "" { + t.Errorf("expected no InsertText for 'do' without snippet support, got %q", item.InsertText) + } + break + } + } + if !found { + t.Error("expected 'do' in completions") + } +} + func TestCompletion_MultiArity(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() @@ -1651,6 +1751,57 @@ end` } } +func TestCompletion_UseInjectedSkippedForFormSnippet(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Module that exports `if` (name overlaps with elixirFormSnippets) + indexFile(t, server.store, server.projectRoot, "lib/custom_if.ex", `defmodule MyApp.CustomIf do + alias MyApp.CustomIf + + defmacro __using__(_opts) do + quote do + import CustomIf + end + end + + defmacro if(condition, clauses), do: {:if, condition, clauses} +end +`) + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyApp.Test do + use MyApp.CustomIf + + i +end`) + + // col=3 — cursor after "i" (prefix "i") + items := completionAt(t, server, uri, 3, 3) + + var count int + for _, item := range items { + if item.Label == "if" || item.Label == "if/2" { + count++ + } + } + if count != 1 { + t.Errorf("expected exactly 1 'if' completion via use-injection, got %d", count) + } + + // The one we get should be the form snippet, not the function-call form + for _, item := range items { + if item.Label == "if" { + if item.Kind != protocol.CompletionItemKindKeyword { + t.Errorf("expected Keyword kind for 'if', got %v", item.Kind) + } + if item.InsertText != elixirFormSnippets["if"] { + t.Errorf("expected form snippet for 'if', got %q", item.InsertText) + } + } + } +} + func TestCompletion_ErlangUsesFileBuildRootInMonorepo(t *testing.T) { if _, err := exec.LookPath("mix"); err != nil { t.Skip("mix not available in PATH") From cf0b685b4a4574e84ff8b6615b8f648a249667df Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Sun, 7 Jun 2026 18:47:38 -0400 Subject: [PATCH 2/4] Add defstruct/defexception/defprotocol/defimpl snippets, parser fix, no-paren call style - Add 8 new Elixir form snippets: defstruct, defexception, defprotocol, defimpl, defdelegate, defguard, defguardp, defoverridable - Fix parser bug: defmacro defstruct(fields) (and defexception, defprotocol, defimpl) were silently skipped because the tokenizer emits dedicated token types (TokDefstruct etc.) but the def* handler only accepted TokIdent. Add isValidFuncNameToken() to also accept these four def-related tokens. - Add noParenFuncs map for ExUnit macros conventionally written without parentheses (assert, refute, test, describe, etc.) - Add doBlockSnippets map for function-specific do/end block templates (test, describe, setup, setup_all, assert_raise), scoped to import/use-chain - Extend applySnippet and buildCallText with noParen parameter for no-paren call style (name arg1, arg2 instead of name(arg1, arg2)) - Keep descriptive placeholder hints (e.g. ${1:name}) on all snippets; defstruct/defexception use ${1:fields} as a hint, not hardcoded values - Add 6 new tests: DoBlockSnippets (with/without snippet support), NoParenCallStyle (with/without snippet support), DoKeyword, UseInjectedSkippedForFormSnippet - Extend existing tests: 8 new sub-tests in ElixirFormSnippets, refactored NoDuplicateWithKernel to loop-driven checks --- internal/lsp/server.go | 110 +++++++-- internal/lsp/server_test.go | 360 ++++++++++++++++++++++++---- internal/parser/parser_tokenized.go | 13 +- 3 files changed, 417 insertions(+), 66 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index ed7f07d..216190e 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -2325,30 +2325,78 @@ func funcKey(name string, arity int) string { var elixirKeywords = []string{"do", "end"} var elixirFormSnippets = map[string]string{ - "do": "do\n\t$0\nend", - "defmodule": "defmodule ${1:Name} do\n\t$0\nend", - "def": "def ${1:name}(${2:params}) do\n\t$0\nend", - "defp": "defp ${1:name}(${2:params}) do\n\t$0\nend", - "defmacro": "defmacro ${1:name}(${2:params}) do\n\t$0\nend", - "defmacrop": "defmacrop ${1:name}(${2:params}) do\n\t$0\nend", - "for": "for ${1:pattern} <- ${2:enumerable} do\n\t$0\nend", - "with": "with ${1:pattern} <- ${2:expression} do\n\t$0\nend", - "case": "case ${1:expression} do\n\t${2:pattern} ->\n\t\t$0\nend", - "cond": "cond do\n\t${1:condition} ->\n\t\t$0\nend", - "if": "if ${1:condition} do\n\t$0\nelse\n\t${2}\nend", - "unless": "unless ${1:condition} do\n\t$0\nend", - "receive": "receive do\n\t${1:pattern} ->\n\t\t$0\nend", - "try": "try do\n\t$0\nrescue\n\t${1:exception} ->\n\t\t${2:handler}\nend", - "quote": "quote do\n\t$0\nend", - "fn": "fn ${1:args} -> $0 end", - "test": "test \"${1:description}\" do\n\t$0\nend", - "describe": "describe \"${1:description}\" do\n\t$0\nend", + "do": "do\n\t$0\nend", + "defmodule": "defmodule ${1:Name} do\n\t$0\nend", + "def": "def ${1:name}(${2:params}) do\n\t$0\nend", + "defp": "defp ${1:name}(${2:params}) do\n\t$0\nend", + "defmacro": "defmacro ${1:name}(${2:params}) do\n\t$0\nend", + "defmacrop": "defmacrop ${1:name}(${2:params}) do\n\t$0\nend", + "defstruct": "defstruct [${1:fields}]$0", + "defexception": "defexception [${1:fields}]$0", + "defprotocol": "defprotocol ${1:Name} do\n\t$0\nend", + "defimpl": "defimpl ${1:Protocol}, for: ${2:Type} do\n\t$0\nend", + "defdelegate": "defdelegate ${1:func}(${2:args}), to: ${3:module}$0", + "defguard": "defguard ${1:name}(${2:args}) when ${3:condition}$0", + "defguardp": "defguardp ${1:name}(${2:args}) when ${3:condition}$0", + "defoverridable": "defoverridable ${1:name}: ${2:arity}$0", + "for": "for ${1:pattern} <- ${2:enumerable} do\n\t$0\nend", + "with": "with ${1:pattern} <- ${2:expression} do\n\t$0\nend", + "case": "case ${1:expression} do\n\t${2:pattern} ->\n\t\t$0\nend", + "cond": "cond do\n\t${1:condition} ->\n\t\t$0\nend", + "if": "if ${1:condition} do\n\t$0\nelse\n\t${2}\nend", + "unless": "unless ${1:condition} do\n\t$0\nend", + "receive": "receive do\n\t${1:pattern} ->\n\t\t$0\nend", + "try": "try do\n\t$0\nrescue\n\t${1:exception} ->\n\t\t${2:handler}\nend", + "quote": "quote do\n\t$0\nend", + "fn": "fn ${1:args} -> $0 end", +} + +// noParenFuncs are macros conventionally written without parentheses in Elixir, +// such as ExUnit's test/describe. When generating completion snippets or plain +// call text, these names produce `name arg1, arg2` instead of `name(arg1, arg2)`. +var noParenFuncs = map[string]bool{ + "test": true, + "describe": true, + "assert": true, + "refute": true, + "assert_raise": true, + "assert_receive": true, + "assert_received": true, + "refute_receive": true, + "refute_received": true, + "setup": true, + "setup_all": true, + "catch_error": true, + "catch_exit": true, + "catch_throw": true, +} + +// doBlockSnippets provides custom snippet templates for functions that take +// do/end blocks. These are applied when the function is in scope via the +// import/use-chain (unlike elixirFormSnippets which are global special forms). +var doBlockSnippets = map[string]string{ + "test": "test \"${1:description}\" do\n\t$0\nend", + "describe": "describe \"${1:description}\" do\n\t$0\nend", + "setup": "setup do\n\t$0\nend", + "setup_all": "setup_all do\n\t$0\nend", + "assert_raise": "assert_raise ${1:exception} do\n\t$0\nend", } func applySnippet(item *protocol.CompletionItem, name string, arity int, params string, inPipe bool, useSnippets bool) { item.Label = fmt.Sprintf("%s/%d", name, arity) item.FilterText = name + // Check for a do-block snippet template first. These provide full + // do/end block structure (e.g. `test "..." do ... end`) and take + // priority over auto-generated arg lists. + if useSnippets { + if tmpl, ok := doBlockSnippets[name]; ok { + item.InsertTextFormat = protocol.InsertTextFormatSnippet + item.InsertText = tmpl + return + } + } + snippetArity := arity snippetParams := params paramStartIndex := 1 @@ -2364,9 +2412,13 @@ func applySnippet(item *protocol.CompletionItem, name string, arity int, params } } + noParen := noParenFuncs[name] + if !useSnippets { if snippetArity > 0 { - item.InsertText = functionCallText(name, snippetArity, snippetParams, paramStartIndex) + item.InsertText = functionCallText(name, snippetArity, snippetParams, paramStartIndex, noParen) + } else if noParen { + item.InsertText = name } else { item.InsertText = name + "()" } @@ -2375,21 +2427,23 @@ func applySnippet(item *protocol.CompletionItem, name string, arity int, params if snippetArity > 0 { item.InsertTextFormat = protocol.InsertTextFormatSnippet - item.InsertText = functionSnippet(name, snippetArity, snippetParams, paramStartIndex) + item.InsertText = functionSnippet(name, snippetArity, snippetParams, paramStartIndex, noParen) + } else if noParen { + item.InsertText = name } else { item.InsertText = name + "()" } } -func functionSnippet(name string, arity int, params string, paramStartIndex int) string { - return buildCallText(name, arity, params, true, paramStartIndex) +func functionSnippet(name string, arity int, params string, paramStartIndex int, noParen bool) string { + return buildCallText(name, arity, params, true, paramStartIndex, noParen) } -func functionCallText(name string, arity int, params string, paramStartIndex int) string { - return buildCallText(name, arity, params, false, paramStartIndex) +func functionCallText(name string, arity int, params string, paramStartIndex int, noParen bool) string { + return buildCallText(name, arity, params, false, paramStartIndex, noParen) } -func buildCallText(name string, arity int, params string, snippet bool, paramStartIndex int) string { +func buildCallText(name string, arity int, params string, snippet bool, paramStartIndex int, noParen bool) string { if paramStartIndex < 1 { paramStartIndex = 1 } @@ -2409,6 +2463,12 @@ func buildCallText(name string, arity int, params string, snippet bool, paramSta args = append(args, paramName) } } + if noParen { + if snippet { + return name + " " + strings.Join(args, ", ") + "$0" + } + return name + " " + strings.Join(args, ", ") + } call := name + "(" + strings.Join(args, ", ") + ")" if snippet { call += "$0" diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 6d124b3..f00d86a 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -562,6 +562,14 @@ func TestCompletion_ElixirFormSnippets(t *testing.T) { {"def", "defp", "defp ${1:name}(${2:params}) do\n\t$0\nend"}, {"defm", "defmacro", "defmacro ${1:name}(${2:params}) do\n\t$0\nend"}, {"defmacr", "defmacrop", "defmacrop ${1:name}(${2:params}) do\n\t$0\nend"}, + {"defs", "defstruct", "defstruct [${1:fields}]$0"}, + {"defex", "defexception", "defexception [${1:fields}]$0"}, + {"defprot", "defprotocol", "defprotocol ${1:Name} do\n\t$0\nend"}, + {"defim", "defimpl", "defimpl ${1:Protocol}, for: ${2:Type} do\n\t$0\nend"}, + {"defdel", "defdelegate", "defdelegate ${1:func}(${2:args}), to: ${3:module}$0"}, + {"defgua", "defguard", "defguard ${1:name}(${2:args}) when ${3:condition}$0"}, + {"defguar", "defguardp", "defguardp ${1:name}(${2:args}) when ${3:condition}$0"}, + {"defov", "defoverridable", "defoverridable ${1:name}: ${2:arity}$0"}, {"fo", "for", "for ${1:pattern} <- ${2:enumerable} do\n\t$0\nend"}, {"wi", "with", "with ${1:pattern} <- ${2:expression} do\n\t$0\nend"}, {"cas", "case", "case ${1:expression} do\n\t${2:pattern} ->\n\t\t$0\nend"}, @@ -572,8 +580,6 @@ func TestCompletion_ElixirFormSnippets(t *testing.T) { {"tr", "try", "try do\n\t$0\nrescue\n\t${1:exception} ->\n\t\t${2:handler}\nend"}, {"quo", "quote", "quote do\n\t$0\nend"}, {"f", "fn", "fn ${1:args} -> $0 end"}, - {"te", "test", "test \"${1:description}\" do\n\t$0\nend"}, - {"des", "describe", "describe \"${1:description}\" do\n\t$0\nend"}, } for _, tt := range tests { @@ -624,56 +630,87 @@ func TestCompletion_ElixirFormSnippets_NoDuplicateWithKernel(t *testing.T) { defmacro defmodule(alias, do_block) do :ok end + + defmacro defstruct(fields) do + :ok + end + + defmacro defexception(fields) do + :ok + end + + defmacro defprotocol(name, do_block) do + :ok + end + + defmacro defimpl(protocol, opts) do + :ok + end + + defmacro defdelegate(call, opts) do + :ok + end + + defmacro defguard(name, opts) do + :ok + end + + defmacro defguardp(name, opts) do + :ok + end + + defmacro defoverridable(opts) do + :ok + end end `) uri := "file:///test.ex" - server.docs.Set(uri, " i") - items := completionAt(t, server, uri, 0, 3) - var count int - for _, item := range items { - if item.Label == "if" || item.Label == "if/2" { - count++ - } + // Each entry to check: prefix, label to look for, and the expected form snippet. + type check struct { + prefix string + label string + snippet string } - if count != 1 { - t.Errorf("expected exactly 1 'if' completion, got %d", count) + checks := []check{ + {"i", "if", elixirFormSnippets["if"]}, + {"de", "def", elixirFormSnippets["def"]}, + {"defs", "defstruct", elixirFormSnippets["defstruct"]}, + {"defprot", "defprotocol", elixirFormSnippets["defprotocol"]}, } - // The one we get should be the snippet form, not the function-call form - for _, item := range items { - if item.Label == "if" { - if item.Kind != protocol.CompletionItemKindKeyword { - t.Errorf("expected Keyword kind for 'if', got %v", item.Kind) - } - if item.InsertText != elixirFormSnippets["if"] { - t.Errorf("expected form snippet for 'if', got %q", item.InsertText) + for _, c := range checks { + server.docs.Set(uri, " "+c.prefix) + items := completionAt(t, server, uri, 0, uint32(2+len(c.prefix))) + + // Verify exactly one occurrence (form snippet, no dup from Kernel). + count := 0 + for _, item := range items { + if item.Label == c.label || strings.HasPrefix(item.Label, c.label+"/") { + count++ } } - } - - // Verify def appears as form snippet, not as imported def/2 - server.docs.Set(uri, " de") - items = completionAt(t, server, uri, 0, 4) - - count = 0 - for _, item := range items { - if item.Label == "def" || item.Label == "def/2" { - count++ + if count != 1 { + // Scan any labels matching to help debug. + var labels []string + for _, item := range items { + if strings.HasPrefix(item.Label, c.label) { + labels = append(labels, item.Label) + } + } + t.Errorf("%q: expected exactly 1 completion matching %q, got %d (matching labels: %v)", c.prefix, c.label, count, labels) } - } - if count != 1 { - t.Errorf("expected exactly 1 'def' completion, got %d", count) - } - for _, item := range items { - if item.Label == "def" { - if item.Kind != protocol.CompletionItemKindKeyword { - t.Errorf("expected Keyword kind for 'def', got %v", item.Kind) - } - if item.InsertText != elixirFormSnippets["def"] { - t.Errorf("expected form snippet for 'def', got %q", item.InsertText) + // The one we get should be the form snippet, not the kernel function form. + for _, item := range items { + if item.Label == c.label { + if item.Kind != protocol.CompletionItemKindKeyword { + t.Errorf("%q: expected Keyword kind, got %v", c.label, item.Kind) + } + if item.InsertText != c.snippet { + t.Errorf("%q: expected form snippet %q, got %q", c.label, c.snippet, item.InsertText) + } } } } @@ -1802,6 +1839,249 @@ end`) } } +func TestCompletion_DoBlockSnippets(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Module exporting functions whose names match doBlockSnippets templates. + // When imported, these should produce the custom do/end block snippet + // instead of an auto-generated arg list like test(${1:arg1}, ${2:arg2}). + indexFile(t, server.store, server.projectRoot, "lib/test_macros.ex", `defmodule MyApp.TestMacros do + defmacro test(description, block) do + :ok + end + + defmacro describe(description, block) do + :ok + end + + defmacro setup(block) do + :ok + end + + defmacro assert_raise(exception, block) do + :ok + end +end +`) + + uri := "file:///test.ex" + + tests := []struct { + prefix string + label string + snippet string + }{ + {"tes", "test/2", "test \"${1:description}\" do\n\t$0\nend"}, + {"desc", "describe/2", "describe \"${1:description}\" do\n\t$0\nend"}, + {"set", "setup/1", "setup do\n\t$0\nend"}, + {"assert_rai", "assert_raise/2", "assert_raise ${1:exception} do\n\t$0\nend"}, + } + + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + // Single-line file: import on line 0, prefix on line 1. + doc := "import MyApp.TestMacros\n " + tt.prefix + server.docs.Set(uri, doc) + items := completionAt(t, server, uri, 1, uint32(2+len(tt.prefix))) + + var found bool + for _, item := range items { + if item.Label == tt.label { + found = true + if item.InsertText != tt.snippet { + t.Errorf("expected snippet %q, got %q", tt.snippet, item.InsertText) + } + if item.InsertTextFormat != protocol.InsertTextFormatSnippet { + t.Error("expected InsertTextFormatSnippet") + } + break + } + } + if !found { + t.Errorf("expected to find completion item %q", tt.label) + } + }) + } +} + +func TestCompletion_DoBlockSnippets_NoSnippetSupport(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + server.snippetSupport = false + + indexFile(t, server.store, server.projectRoot, "lib/test_macros2.ex", `defmodule MyApp.TestMacros2 do + defmacro test(description, block) do + :ok + end + + defmacro setup(block) do + :ok + end +end +`) + + uri := "file:///test.ex" + + // Without snippets, doBlockSnippets templates are skipped. The noParenFuncs + // check still applies, so test/2 and setup/1 use no-paren call style. + // Parameter names come from the module definition. + tests := []struct { + prefix string + label string + text string + }{ + {"tes", "test/2", "test description, block"}, + {"set", "setup/1", "setup block"}, + } + + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + doc := "import MyApp.TestMacros2\n " + tt.prefix + server.docs.Set(uri, doc) + items := completionAt(t, server, uri, 1, uint32(2+len(tt.prefix))) + + var found bool + for _, item := range items { + if item.Label == tt.label { + found = true + if item.InsertText != tt.text { + t.Errorf("expected %q, got %q", tt.text, item.InsertText) + } + if item.InsertTextFormat == protocol.InsertTextFormatSnippet { + t.Error("should not have snippet format") + } + break + } + } + if !found { + t.Errorf("expected to find completion item %q", tt.label) + } + }) + } +} + +func TestCompletion_NoParenCallStyle(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/assertions.ex", `defmodule MyApp.Assertions do + defmacro assert(expression) do + :ok + end + + defmacro refute(expression) do + :ok + end + + def validate(input) do + :ok + end +end +`) + + uri := "file:///test.ex" + + // assert and refute are in noParenFuncs → no parentheses. + // validate is a regular function → parentheses. + // Parameter names come from the module definition. + tests := []struct { + prefix string + label string + snippet string + }{ + {"ass", "assert/1", "assert ${1:expression}$0"}, + {"ref", "refute/1", "refute ${1:expression}$0"}, + {"val", "validate/1", "validate(${1:input})$0"}, + } + + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + doc := "import MyApp.Assertions\n " + tt.prefix + server.docs.Set(uri, doc) + items := completionAt(t, server, uri, 1, uint32(2+len(tt.prefix))) + + var found bool + for _, item := range items { + if item.Label == tt.label { + found = true + if item.InsertText != tt.snippet { + t.Errorf("expected snippet %q, got %q", tt.snippet, item.InsertText) + } + if item.InsertTextFormat != protocol.InsertTextFormatSnippet { + t.Error("expected InsertTextFormatSnippet") + } + break + } + } + if !found { + t.Errorf("expected to find completion item %q", tt.label) + } + }) + } +} + +func TestCompletion_NoParenCallStyle_NoSnippetSupport(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + server.snippetSupport = false + + indexFile(t, server.store, server.projectRoot, "lib/assertions2.ex", `defmodule MyApp.Assertions2 do + defmacro assert(expression) do + :ok + end + + defmacro refute(expression) do + :ok + end + + def validate(input) do + :ok + end +end +`) + + uri := "file:///test.ex" + + // Without snippets, assert/refute still use no-paren style. + // validate uses regular paren style. + // Parameter names come from the module definition. + tests := []struct { + prefix string + label string + text string + }{ + {"ass", "assert/1", "assert expression"}, + {"ref", "refute/1", "refute expression"}, + {"val", "validate/1", "validate(input)"}, + } + + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + doc := "import MyApp.Assertions2\n " + tt.prefix + server.docs.Set(uri, doc) + items := completionAt(t, server, uri, 1, uint32(2+len(tt.prefix))) + + var found bool + for _, item := range items { + if item.Label == tt.label { + found = true + if item.InsertText != tt.text { + t.Errorf("expected %q, got %q", tt.text, item.InsertText) + } + if item.InsertTextFormat == protocol.InsertTextFormatSnippet { + t.Error("should not have snippet format") + } + break + } + } + if !found { + t.Errorf("expected to find completion item %q", tt.label) + } + }) + } +} + func TestCompletion_ErlangUsesFileBuildRootInMonorepo(t *testing.T) { if _, err := exec.LookPath("mix"); err != nil { t.Skip("mix not available in PATH") diff --git a/internal/parser/parser_tokenized.go b/internal/parser/parser_tokenized.go index f18b61e..4209b14 100644 --- a/internal/parser/parser_tokenized.go +++ b/internal/parser/parser_tokenized.go @@ -309,7 +309,7 @@ func parseTextFromTokens(path string, source []byte, tokens []Token) ([]Definiti defLine := tok.Line i++ j := nextSig(i) - if j >= n || tokens[j].Kind != TokIdent { + if j >= n || !isValidFuncNameToken(tokens[j].Kind) { i = j goto extractRefsForLine } @@ -819,6 +819,17 @@ func NextSigToken(tokens []Token, n, from int) int { return from } +// isValidFuncNameToken reports whether a token kind can appear as a function +// name after def/defp/defmacro/defmacrop/defguard/defguardp/defdelegate. +// In addition to ordinary identifiers (TokIdent), this includes tokens like +// TokDefstruct and TokDefprotocol that are used as function names in the +// stdlib (e.g. `defmacro defstruct(fields)` in Kernel). +func isValidFuncNameToken(kind TokenKind) bool { + return kind == TokIdent || + kind == TokDefstruct || kind == TokDefexception || + kind == TokDefprotocol || kind == TokDefimpl +} + func CollectModuleName(source []byte, tokens []Token, n, i int) (string, int) { if i >= n || tokens[i].Kind != TokModule { return "", i From c66c4249c91750c5c1179d9deca972e5aa68afee Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Sun, 7 Jun 2026 21:21:44 -0400 Subject: [PATCH 3/4] Fix some weird paren stuff with VS Code --- internal/lsp/server.go | 14 +++++++------- internal/lsp/server_test.go | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 216190e..5969852 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -2327,17 +2327,17 @@ var elixirKeywords = []string{"do", "end"} var elixirFormSnippets = map[string]string{ "do": "do\n\t$0\nend", "defmodule": "defmodule ${1:Name} do\n\t$0\nend", - "def": "def ${1:name}(${2:params}) do\n\t$0\nend", - "defp": "defp ${1:name}(${2:params}) do\n\t$0\nend", - "defmacro": "defmacro ${1:name}(${2:params}) do\n\t$0\nend", - "defmacrop": "defmacrop ${1:name}(${2:params}) do\n\t$0\nend", + "def": "def ${1:name}$2 do\n\t$0\nend", + "defp": "defp ${1:name}$2 do\n\t$0\nend", + "defmacro": "defmacro ${1:name}$2 do\n\t$0\nend", + "defmacrop": "defmacrop ${1:name}$2 do\n\t$0\nend", "defstruct": "defstruct [${1:fields}]$0", "defexception": "defexception [${1:fields}]$0", "defprotocol": "defprotocol ${1:Name} do\n\t$0\nend", "defimpl": "defimpl ${1:Protocol}, for: ${2:Type} do\n\t$0\nend", - "defdelegate": "defdelegate ${1:func}(${2:args}), to: ${3:module}$0", - "defguard": "defguard ${1:name}(${2:args}) when ${3:condition}$0", - "defguardp": "defguardp ${1:name}(${2:args}) when ${3:condition}$0", + "defdelegate": "defdelegate ${1:func}$2, to: ${3:module}$0", + "defguard": "defguard ${1:name}$2 when ${3:condition}$0", + "defguardp": "defguardp ${1:name}$2 when ${3:condition}$0", "defoverridable": "defoverridable ${1:name}: ${2:arity}$0", "for": "for ${1:pattern} <- ${2:enumerable} do\n\t$0\nend", "with": "with ${1:pattern} <- ${2:expression} do\n\t$0\nend", diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index f00d86a..227b18a 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -558,17 +558,17 @@ func TestCompletion_ElixirFormSnippets(t *testing.T) { }{ {"d", "do", "do\n\t$0\nend"}, {"defmod", "defmodule", "defmodule ${1:Name} do\n\t$0\nend"}, - {"def", "def", "def ${1:name}(${2:params}) do\n\t$0\nend"}, - {"def", "defp", "defp ${1:name}(${2:params}) do\n\t$0\nend"}, - {"defm", "defmacro", "defmacro ${1:name}(${2:params}) do\n\t$0\nend"}, - {"defmacr", "defmacrop", "defmacrop ${1:name}(${2:params}) do\n\t$0\nend"}, + {"def", "def", "def ${1:name}$2 do\n\t$0\nend"}, + {"def", "defp", "defp ${1:name}$2 do\n\t$0\nend"}, + {"defm", "defmacro", "defmacro ${1:name}$2 do\n\t$0\nend"}, + {"defmacr", "defmacrop", "defmacrop ${1:name}$2 do\n\t$0\nend"}, {"defs", "defstruct", "defstruct [${1:fields}]$0"}, {"defex", "defexception", "defexception [${1:fields}]$0"}, {"defprot", "defprotocol", "defprotocol ${1:Name} do\n\t$0\nend"}, {"defim", "defimpl", "defimpl ${1:Protocol}, for: ${2:Type} do\n\t$0\nend"}, - {"defdel", "defdelegate", "defdelegate ${1:func}(${2:args}), to: ${3:module}$0"}, - {"defgua", "defguard", "defguard ${1:name}(${2:args}) when ${3:condition}$0"}, - {"defguar", "defguardp", "defguardp ${1:name}(${2:args}) when ${3:condition}$0"}, + {"defdel", "defdelegate", "defdelegate ${1:func}$2, to: ${3:module}$0"}, + {"defgua", "defguard", "defguard ${1:name}$2 when ${3:condition}$0"}, + {"defguar", "defguardp", "defguardp ${1:name}$2 when ${3:condition}$0"}, {"defov", "defoverridable", "defoverridable ${1:name}: ${2:arity}$0"}, {"fo", "for", "for ${1:pattern} <- ${2:enumerable} do\n\t$0\nend"}, {"wi", "with", "with ${1:pattern} <- ${2:expression} do\n\t$0\nend"}, From 05352d4fd9001aa6b0fafefe79b1e840cad56bad Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Thu, 11 Jun 2026 18:22:43 -0400 Subject: [PATCH 4/4] Fix do-block snippets for ordinary functions Co-authored-by: Cursor --- internal/lsp/server.go | 20 +++++++------ internal/lsp/server_test.go | 56 +++++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 5969852..492538f 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1535,7 +1535,7 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara Kind: protocol.CompletionItemKindFunction, Detail: fmt.Sprintf(":%s.%s/%d", erlModule, e.Function, e.Arity), } - applySnippet(&item, e.Function, e.Arity, e.Params, inPipe, s.snippetSupport) + applySnippet(&item, e.Function, e.Arity, e.Params, "", inPipe, s.snippetSupport) items = append(items, item) } } @@ -1597,7 +1597,7 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara "line": r.Line, }, } - applySnippet(&item, r.Function, r.Arity, r.Params, inPipe, s.snippetSupport) + applySnippet(&item, r.Function, r.Arity, r.Params, r.Kind, inPipe, s.snippetSupport) items = append(items, item) } @@ -1685,7 +1685,7 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara Kind: kindToCompletionItemKind(bf.Kind), Detail: bf.Kind, } - applySnippet(&item, bf.Name, bf.Arity, bf.Params, inPipe, s.snippetSupport) + applySnippet(&item, bf.Name, bf.Arity, bf.Params, bf.Kind, inPipe, s.snippetSupport) items = append(items, item) } } @@ -1713,7 +1713,7 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara "line": r.Line, }, } - applySnippet(&item, r.Function, r.Arity, r.Params, inPipe, s.snippetSupport) + applySnippet(&item, r.Function, r.Arity, r.Params, r.Kind, inPipe, s.snippetSupport) items = append(items, item) } } @@ -2141,7 +2141,7 @@ func (s *Server) addCompletionsFromUsing(moduleName, funcPrefix string, seen map "line": d.line, }, } - applySnippet(&item, funcName, d.arity, d.params, inPipe, useSnippets) + applySnippet(&item, funcName, d.arity, d.params, d.kind, inPipe, useSnippets) *items = append(*items, item) } } @@ -2168,7 +2168,7 @@ func (s *Server) addCompletionsFromUsing(moduleName, funcPrefix string, seen map "line": r.Line, }, } - applySnippet(&item, r.Function, r.Arity, r.Params, inPipe, useSnippets) + applySnippet(&item, r.Function, r.Arity, r.Params, r.Kind, inPipe, useSnippets) *items = append(*items, item) } } @@ -2382,14 +2382,14 @@ var doBlockSnippets = map[string]string{ "assert_raise": "assert_raise ${1:exception} do\n\t$0\nend", } -func applySnippet(item *protocol.CompletionItem, name string, arity int, params string, inPipe bool, useSnippets bool) { +func applySnippet(item *protocol.CompletionItem, name string, arity int, params string, kind string, inPipe bool, useSnippets bool) { item.Label = fmt.Sprintf("%s/%d", name, arity) item.FilterText = name // Check for a do-block snippet template first. These provide full // do/end block structure (e.g. `test "..." do ... end`) and take // priority over auto-generated arg lists. - if useSnippets { + if useSnippets && isMacroKind(kind) { if tmpl, ok := doBlockSnippets[name]; ok { item.InsertTextFormat = protocol.InsertTextFormatSnippet item.InsertText = tmpl @@ -2435,6 +2435,10 @@ func applySnippet(item *protocol.CompletionItem, name string, arity int, params } } +func isMacroKind(kind string) bool { + return kind == "defmacro" || kind == "defmacrop" +} + func functionSnippet(name string, arity int, params string, paramStartIndex int, noParen bool) string { return buildCallText(name, arity, params, true, paramStartIndex, noParen) } diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 227b18a..9cd5d24 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -529,7 +529,7 @@ end func TestApplySnippet_PipeGenericParamNamesPreserveOriginalIndex(t *testing.T) { var item protocol.CompletionItem - applySnippet(&item, "call", 3, "", true, true) + applySnippet(&item, "call", 3, "", "def", true, true) if item.Label != "call/3" { t.Fatalf("expected label call/3, got %q", item.Label) @@ -1843,7 +1843,7 @@ func TestCompletion_DoBlockSnippets(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() - // Module exporting functions whose names match doBlockSnippets templates. + // Module exporting macros whose names match doBlockSnippets templates. // When imported, these should produce the custom do/end block snippet // instead of an auto-generated arg list like test(${1:arg1}, ${2:arg2}). indexFile(t, server.store, server.projectRoot, "lib/test_macros.ex", `defmodule MyApp.TestMacros do @@ -1905,6 +1905,58 @@ end } } +func TestCompletion_DoBlockSnippets_RequireMacroKind(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/plain_helpers.ex", `defmodule MyApp.PlainHelpers do + def test(description, block) do + :ok + end + + def setup(block) do + :ok + end +end +`) + + uri := "file:///test.ex" + + tests := []struct { + prefix string + label string + text string + }{ + {"tes", "test/2", "test ${1:description}, ${2:block}$0"}, + {"set", "setup/1", "setup ${1:block}$0"}, + } + + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + doc := "import MyApp.PlainHelpers\n " + tt.prefix + server.docs.Set(uri, doc) + items := completionAt(t, server, uri, 1, uint32(2+len(tt.prefix))) + + var found bool + for _, item := range items { + if item.Label == tt.label { + found = true + if item.InsertText != tt.text { + t.Errorf("expected normal call snippet %q, got %q", tt.text, item.InsertText) + } + if item.InsertTextFormat != protocol.InsertTextFormatSnippet { + t.Error("expected InsertTextFormatSnippet") + } + break + } + } + if !found { + t.Errorf("expected to find completion item %q", tt.label) + } + }) + } +} + func TestCompletion_DoBlockSnippets_NoSnippetSupport(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup()