diff --git a/internal/lsp/elixir.go b/internal/lsp/elixir.go index 214f062..4eb32ed 100644 --- a/internal/lsp/elixir.go +++ b/internal/lsp/elixir.go @@ -148,6 +148,147 @@ func ExtractCompletionContext(line string, col int) (prefix string, afterDot boo return raw, false, start } +// ExtractAliasBlockParent detects whether the given 0-based line is inside +// a multi-line alias brace block (alias Parent.{ ... }). If so, it returns +// the resolved parent module name. This is used by the completion and hover +// handlers to resolve module names inside multi-line alias blocks. +func ExtractAliasBlockParent(lines []string, targetLine int) (string, bool) { + if targetLine < 0 || targetLine >= len(lines) { + return "", false + } + + // Quick pre-check: scan backward for an "alias ...{" line without a + // matching "}" on the same line. Pure string ops, no allocations in + // the fast path, so this is nearly free for the 99% of hover/definition + // requests that are not inside an alias block. + found := false + for i := targetLine; i >= 0; i-- { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, "alias ") && strings.Contains(trimmed, "{") && !strings.Contains(trimmed, "}") { + found = true + break + } + // Any def/defp/defmodule means we've left the possible alias context. + if strings.HasPrefix(trimmed, "def ") || strings.HasPrefix(trimmed, "defp ") || strings.HasPrefix(trimmed, "defmodule ") { + break + } + } + if !found { + return "", false + } + + // If the current line is just a closing brace (e.g. " }"), we're past the block. + // But if it has module content before the brace (e.g. " Services.MakePayment }"), + // we're still inside the alias block on the last line. + currentLine := strings.TrimSpace(parser.StripCommentsAndStrings(lines[targetLine])) + if strings.Contains(currentLine, "}") { + withoutBrace := strings.TrimSpace(strings.Replace(currentLine, "}", "", 1)) + withoutBrace = strings.TrimRight(withoutBrace, ", ") + if withoutBrace == "" { + return "", false + } + } + + // Scan backward from the current line looking for the opening "alias Parent.{" + for i := targetLine; i >= 0; i-- { + line := lines[i] + stripped := strings.TrimSpace(parser.StripCommentsAndStrings(line)) + + // If we encounter a closing brace scanning backward, we're not in an open block + if i < targetLine && strings.Contains(stripped, "}") { + return "", false + } + + // Look for "alias Module.{" pattern + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "alias ") { + // Skip blank/comment lines + if stripped == "" { + continue + } + // Any other statement means we've left the alias context + continue + } + + // Found an alias line — check if it opens a brace block + afterAlias := strings.TrimSpace(trimmed[6:]) + moduleName := parser.ScanModuleName(afterAlias) + if moduleName == "" { + return "", false + } + remaining := afterAlias[len(moduleName):] + remainingStripped := strings.TrimSpace(parser.StripCommentsAndStrings(remaining)) + + if !strings.HasPrefix(remainingStripped, "{") { + return "", false + } + // Has opening { — check that } is NOT on this same line + if strings.Contains(remainingStripped, "}") { + return "", false + } + + // We're inside a multi-line alias block — resolve the parent module + parent := strings.TrimRight(moduleName, ".") + + // Resolve __MODULE__ + enclosingModule := extractEnclosingModule(lines, i) + if enclosingModule != "" { + parent = strings.ReplaceAll(parent, "__MODULE__", enclosingModule) + } + if strings.Contains(parent, "__MODULE__") { + return "", false + } + return parent, true + } + + return "", false +} + +// extractEnclosingModule finds the innermost defmodule enclosing the given line. +func extractEnclosingModule(lines []string, targetLine int) string { + type moduleFrame struct { + name string + depth int + } + var stack []moduleFrame + depth := 0 + inHeredoc := false + + for i := 0; i <= targetLine && i < len(lines); i++ { + var skip bool + inHeredoc, skip = parser.CheckHeredoc(lines[i], inHeredoc) + if skip { + continue + } + stripped := strings.TrimSpace(parser.StripCommentsAndStrings(strings.TrimSpace(lines[i]))) + + if parser.IsEnd(stripped) { + if len(stack) > 0 && stack[len(stack)-1].depth == depth { + stack = stack[:len(stack)-1] + } + depth-- + if depth < 0 { + depth = 0 + } + } + if parser.OpensBlock(stripped) { + depth++ + } + if m := parser.DefmoduleRe.FindStringSubmatch(strings.TrimSpace(lines[i])); m != nil { + name := m[1] + if !strings.Contains(name, ".") && len(stack) > 0 { + name = stack[len(stack)-1].name + "." + name + } + stack = append(stack, moduleFrame{name, depth}) + } + } + + if len(stack) > 0 { + return stack[len(stack)-1].name + } + return "" +} + // IsPipeContext returns true if the text before prefixStartCol on this line // contains a pipe operator (|>), meaning the first argument is supplied by the // pipe and should be omitted from the completion snippet. @@ -275,6 +416,51 @@ func extractAliasesFromLines(lines []string, targetLine int) map[string]string { unscoped := targetLine < 0 inHeredoc := false + // Pending state for multi-line alias tracking + type pendingAliasAsState struct { + moduleName string + scope string + currentModule string + } + type pendingMultiAliasState struct { + parent string + scope string + currentModule string + children []string + } + var pendingAliasAs *pendingAliasAsState + var pendingMultiAlias *pendingMultiAliasState + pendingAlias := false + + resolveModuleStr := func(s, currentModule string) string { + if currentModule != "" { + return strings.ReplaceAll(s, "__MODULE__", currentModule) + } + return s + } + + // flushMultiAliasChildren processes accumulated children from a multi-line + // alias block and appends each as an alias entry. + flushMultiAliasChildren := func(scope, parent, currentModule string, children []string) { + base := resolveModuleStr(parent, currentModule) + if strings.Contains(base, "__MODULE__") { + return + } + for _, segment := range children { + segment = strings.TrimSpace(segment) + childName := parser.ScanModuleName(segment) + if childName != "" { + aliasKey := childName + if dot := strings.LastIndexByte(childName, '.'); dot >= 0 { + aliasKey = childName[dot+1:] + } + allAliases = append(allAliases, struct { + scope, short, full string + }{scope, aliasKey, base + "." + childName}) + } + } + } + for i, line := range lines { var skip bool inHeredoc, skip = parser.CheckHeredoc(line, inHeredoc) @@ -291,6 +477,68 @@ func extractAliasesFromLines(lines []string, targetLine int) map[string]string { trimmed := strings.TrimSpace(line) stripped := strings.TrimSpace(parser.StripCommentsAndStrings(trimmed)) + // Handle pending multi-line alias continuations (guarded by a single + // boolean so the common path — no pending alias — is one branch). + if pendingAlias { + if pendingAliasAs != nil { + // Skip blank and comment-only lines while waiting for as: + if stripped == "" || stripped[0] == '#' { + continue + } + if strings.HasPrefix(stripped, "as:") { + asStr := strings.TrimLeft(stripped[3:], " \t") + asName := parser.ScanModuleName(asStr) + if asName != "" { + resolved := resolveModuleStr(pendingAliasAs.moduleName, pendingAliasAs.currentModule) + if !strings.Contains(resolved, "__MODULE__") { + allAliases = append(allAliases, struct { + scope, short, full string + }{pendingAliasAs.scope, asName, resolved}) + } + } + pendingAliasAs = nil + pendingAlias = false + continue + } + // Not an as: line — bail out, register as simple alias, and reprocess + resolved := resolveModuleStr(pendingAliasAs.moduleName, pendingAliasAs.currentModule) + if !strings.Contains(resolved, "__MODULE__") { + parts := strings.Split(resolved, ".") + allAliases = append(allAliases, struct { + scope, short, full string + }{pendingAliasAs.scope, parts[len(parts)-1], resolved}) + } + pendingAliasAs = nil + pendingAlias = false + // Fall through to process this line normally + } else if pendingMultiAlias != nil { + if stripped == "" || stripped[0] == '#' { + continue + } + // Check for bail-out: line starts a new statement (not } or uppercase module name) + if stripped[0] != '}' && (stripped[0] < 'A' || stripped[0] > 'Z') { + flushMultiAliasChildren(pendingMultiAlias.scope, pendingMultiAlias.parent, pendingMultiAlias.currentModule, pendingMultiAlias.children) + pendingMultiAlias = nil + pendingAlias = false + // Fall through to process this line normally + } else { + braceEnd := strings.IndexByte(stripped, '}') + if braceEnd >= 0 { + inner := stripped[:braceEnd] + if inner != "" { + pendingMultiAlias.children = append(pendingMultiAlias.children, strings.Split(inner, ",")...) + } + flushMultiAliasChildren(pendingMultiAlias.scope, pendingMultiAlias.parent, pendingMultiAlias.currentModule, pendingMultiAlias.children) + pendingMultiAlias = nil + pendingAlias = false + } else { + pendingMultiAlias.children = append(pendingMultiAlias.children, strings.Split(stripped, ",")...) + } + continue + } + } + } + // Track do..end nesting if parser.IsEnd(stripped) { if len(stack) > 0 && stack[len(stack)-1].depth == depth { @@ -341,22 +589,58 @@ func extractAliasesFromLines(lines []string, targetLine int) map[string]string { } else if m := aliasMultiRe.FindStringSubmatch(line); m != nil { base := resolve(m[1]) if !strings.Contains(base, "__MODULE__") { - for _, name := range strings.Split(m[2], ",") { - name = strings.TrimSpace(name) - if len(name) > 0 && unicode.IsUpper(rune(name[0])) { + for _, segment := range strings.Split(m[2], ",") { + segment = strings.TrimSpace(segment) + childName := parser.ScanModuleName(segment) + if childName != "" { + aliasKey := childName + if dot := strings.LastIndexByte(childName, '.'); dot >= 0 { + aliasKey = childName[dot+1:] + } allAliases = append(allAliases, struct { scope, short, full string - }{currentModule, name, base + "." + name}) + }{currentModule, aliasKey, base + "." + childName}) } } } } else if m := parser.AliasRe.FindStringSubmatch(line); m != nil { fullMod := resolve(m[1]) if !strings.Contains(fullMod, "__MODULE__") { - parts := strings.Split(fullMod, ".") - allAliases = append(allAliases, struct { - scope, short, full string - }{currentModule, parts[len(parts)-1], fullMod}) + afterMod := line[strings.Index(line, m[1])+len(m[1]):] + afterModStripped := strings.TrimSpace(parser.StripCommentsAndStrings(afterMod)) + if afterModStripped == "," { + // Trailing comma — may be multi-line alias with as: on next line + pendingAliasAs = &pendingAliasAsState{ + moduleName: fullMod, + scope: currentModule, + currentModule: currentModule, + } + pendingAlias = true + } else if strings.HasPrefix(afterModStripped, "{") && !strings.Contains(afterModStripped, "}") { + // Opening { without closing } — multi-line multi-alias + parent := strings.TrimRight(fullMod, ".") + resolvedParent := resolve(parent) + if !strings.Contains(resolvedParent, "__MODULE__") { + // Collect any children on this same line after the { + inner := afterModStripped[1:] + var initialChildren []string + if strings.TrimSpace(inner) != "" { + initialChildren = strings.Split(inner, ",") + } + pendingMultiAlias = &pendingMultiAliasState{ + parent: resolvedParent, + scope: currentModule, + currentModule: currentModule, + children: initialChildren, + } + pendingAlias = true + } + } else { + parts := strings.Split(fullMod, ".") + allAliases = append(allAliases, struct { + scope, short, full string + }{currentModule, parts[len(parts)-1], fullMod}) + } } } } @@ -397,13 +681,18 @@ func extractAliasFromLine(line string, aliases map[string]string, resolveAlias f } if m := aliasMultiRe.FindStringSubmatch(line); m != nil { base := resolveAlias(m[1]) - for _, name := range strings.Split(m[2], ",") { - name = strings.TrimSpace(name) - if len(name) > 0 && unicode.IsUpper(rune(name[0])) { + for _, segment := range strings.Split(m[2], ",") { + segment = strings.TrimSpace(segment) + childName := parser.ScanModuleName(segment) + if childName != "" { if aliases == nil { aliases = make(map[string]string) } - aliases[name] = base + "." + name + aliasKey := childName + if dot := strings.LastIndexByte(childName, '.'); dot >= 0 { + aliasKey = childName[dot+1:] + } + aliases[aliasKey] = base + "." + childName } } return aliases, true @@ -425,6 +714,9 @@ func extractAliasFromLine(line string, aliases map[string]string, resolveAlias f // Returns nil slices if the function or its quote block can't be found. func parseHelperQuoteBlock(lines []string, helperName string, fileAliases map[string]string) (imported []string, inlineDefs map[string][]inlineDef, transUses []string, optBindings []optBinding, aliases map[string]string) { resolveAlias := func(modName string) string { + if resolved := parser.ResolveModuleRef(modName, aliases, ""); resolved != modName { + return resolved + } return parser.ResolveModuleRef(modName, fileAliases, "") } @@ -530,24 +822,57 @@ type UseCall struct { // ExtractUsesWithOpts parses all `use Module` and `use Module, key: Val` // declarations, returning each as a UseCall. Aliases are resolved using the -// provided map. +// provided map. Handles opts spanning multiple lines. func ExtractUsesWithOpts(text string, aliases map[string]string) []UseCall { var calls []UseCall - for _, line := range strings.Split(text, "\n") { - // use Module, key: Val + lines := strings.Split(text, "\n") + for i := 0; i < len(lines); i++ { + line := lines[i] + // use Module, key: Val (single line) if m := useWithOptsRe.FindStringSubmatch(line); m != nil { module := parser.ResolveModuleRef(m[1], aliases, "") calls = append(calls, UseCall{Module: module, Opts: ParseKeywordModuleOpts(m[2], aliases)}) continue } - // plain use Module + // plain use Module (possibly with trailing comma for multiline opts) if m := useRe.FindStringSubmatch(line); m != nil { - calls = append(calls, UseCall{Module: parser.ResolveModuleRef(m[1], aliases, "")}) + module := parser.ResolveModuleRef(m[1], aliases, "") + trimmed := strings.TrimRight(parser.StripCommentsAndStrings(line), " \t\r") + if strings.HasSuffix(trimmed, ",") { + // Multiline opts: collect continuation lines + var optsBuilder strings.Builder + for i+1 < len(lines) { + next := strings.TrimSpace(lines[i+1]) + if next == "" { + break + } + if next[0] == '#' { + i++ + continue + } + // Continuation lines are keyword opts (lowercase_key:) or + // known option patterns; stop on anything else. + if !parser.LooksLikeKeywordOpt(next) { + break + } + i++ + if optsBuilder.Len() > 0 { + optsBuilder.WriteString(", ") + } + optsBuilder.WriteString(strings.TrimRight(next, ",")) + } + calls = append(calls, UseCall{Module: module, Opts: ParseKeywordModuleOpts(optsBuilder.String(), aliases)}) + } else { + calls = append(calls, UseCall{Module: module}) + } } } return calls } +// looksLikeOptContinuation returns true if the trimmed line looks like a +// keyword list continuation (e.g. "name: \"foo\"", "permissions: :inherited"). + // ParseKeywordModuleOpts parses an Elixir keyword list string (e.g. "mod: Hammox, repo: MyRepo") // into a map of key → value. Only entries whose value starts with an uppercase letter // (module names) are included. Alias resolution is applied to module values. @@ -575,7 +900,7 @@ type inlineDef struct { // a Keyword.get on opts), and alias declarations that get injected into the // consumer module. func parseUsingBody(text string) (imported []string, inlineDefs map[string][]inlineDef, transUses []string, optBindings []optBinding, aliases map[string]string) { - lines := strings.Split(text, "\n") + lines := parser.JoinLines(strings.Split(text, "\n")) fileAliases := extractAliasesFromLines(lines, -1) // Check if this module uses ExUnit.CaseTemplate (which provides the `using` macro) @@ -630,6 +955,9 @@ func parseUsingBody(text string) (imported []string, inlineDefs map[string][]inl inlineDefs = make(map[string][]inlineDef) resolveAlias := func(modName string) string { + if resolved := parser.ResolveModuleRef(modName, aliases, ""); resolved != modName { + return resolved + } return parser.ResolveModuleRef(modName, fileAliases, "") } @@ -979,6 +1307,7 @@ func ExtractCallContext(text string, lineNum, col int) (funcExpr string, argInde // extractParamNames reads the function definition line at defIdx and returns // the parameter names. Falls back to positional names (arg1, arg2, ...) for // complex patterns. + func extractParamNames(lines []string, defIdx int) []string { if defIdx < 0 || defIdx >= len(lines) { return nil diff --git a/internal/lsp/elixir_test.go b/internal/lsp/elixir_test.go index 0c97a92..bd4af7c 100644 --- a/internal/lsp/elixir_test.go +++ b/internal/lsp/elixir_test.go @@ -1,6 +1,7 @@ package lsp import ( + "strings" "testing" ) @@ -178,6 +179,134 @@ func TestExtractModuleAndFunction(t *testing.T) { } } +func TestExtractAliasBlockParent(t *testing.T) { + t.Run("cursor inside multi-line block", func(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.Services.{ + Accounts, + + } +end` + parent, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 3) + if !ok { + t.Fatal("expected to be inside alias block") + } + if parent != "MyApp.Services" { + t.Errorf("got %q, want MyApp.Services", parent) + } + }) + + t.Run("cursor on line with children", func(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.Services.{ + Accounts, + } +end` + parent, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 2) + if !ok { + t.Fatal("expected to be inside alias block") + } + if parent != "MyApp.Services" { + t.Errorf("got %q, want MyApp.Services", parent) + } + }) + + t.Run("cursor after closing brace", func(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.Services.{ + Accounts + } + +end` + _, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 4) + if ok { + t.Error("should not be inside alias block after closing brace") + } + }) + + t.Run("cursor on normal alias line", func(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.Repo + +end` + _, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 2) + if ok { + t.Error("should not be inside alias block on a normal line") + } + }) + + t.Run("cursor on same line as opening brace", func(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.Handlers.{ +end` + parent, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 1) + if !ok { + t.Fatal("expected to be inside alias block") + } + if parent != "MyApp.Handlers" { + t.Errorf("got %q, want MyApp.Handlers", parent) + } + }) + + t.Run("resolves __MODULE__ in parent", func(t *testing.T) { + text := `defmodule MyApp.HRIS do + alias __MODULE__.{ + Services, + + } +end` + parent, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 3) + if !ok { + t.Fatal("expected to be inside alias block") + } + if parent != "MyApp.HRIS" { + t.Errorf("got %q, want MyApp.HRIS", parent) + } + }) + + t.Run("single-line block with closing brace", func(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.{Accounts, Users} + +end` + _, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 1) + if ok { + t.Error("should not be inside alias block when braces close on same line") + } + }) + + t.Run("trailing brace on content line", func(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.Billing.{ + Services.MakePayment } +end` + parent, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 2) + if !ok { + t.Fatal("expected to be inside alias block when } follows module content") + } + if parent != "MyApp.Billing" { + t.Errorf("got %q, want MyApp.Billing", parent) + } + }) + + t.Run("blank lines between alias and cursor", func(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.Services.{ + Accounts, + + + } +end` + parent, ok := ExtractAliasBlockParent(strings.Split(text, "\n"), 4) + if !ok { + t.Fatal("expected to be inside alias block") + } + if parent != "MyApp.Services" { + t.Errorf("got %q, want MyApp.Services", parent) + } + }) +} + func TestExtractAliases(t *testing.T) { t.Run("simple alias", func(t *testing.T) { aliases := ExtractAliases(" alias MyApp.Repo") @@ -253,6 +382,94 @@ func TestExtractAliases(t *testing.T) { } }) + t.Run("multi-line alias with as on next line", func(t *testing.T) { + text := "defmodule MyApp.Web do\n alias MyApp.Helpers.Paginator,\n as: Pages\nend" + aliases := ExtractAliases(text) + if aliases["Pages"] != "MyApp.Helpers.Paginator" { + t.Errorf("Pages: got %q, want MyApp.Helpers.Paginator", aliases["Pages"]) + } + // Should NOT also register as a simple alias under the last segment + if _, ok := aliases["Paginator"]; ok { + t.Error("should not register simple alias Paginator when as: is on next line") + } + }) + + t.Run("multi-line alias with as and extra whitespace before comma", func(t *testing.T) { + text := "defmodule MyApp.Web do\n alias MyApp.Billing.Services.MakePayment ,\n as: MakePaymentNow\nend" + aliases := ExtractAliases(text) + if aliases["MakePaymentNow"] != "MyApp.Billing.Services.MakePayment" { + t.Errorf("MakePaymentNow: got %q, want MyApp.Billing.Services.MakePayment", aliases["MakePaymentNow"]) + } + if _, ok := aliases["MakePayment"]; ok { + t.Error("should not register simple alias MakePayment when as: is on next line") + } + }) + + t.Run("multi-line multi-alias with braces spanning lines", func(t *testing.T) { + text := "defmodule MyApp.Web do\n alias MyApp.Handlers.{\n Accounts,\n Users,\n Profiles\n }\nend" + aliases := ExtractAliases(text) + if aliases["Accounts"] != "MyApp.Handlers.Accounts" { + t.Errorf("Accounts: got %q, want MyApp.Handlers.Accounts", aliases["Accounts"]) + } + if aliases["Users"] != "MyApp.Handlers.Users" { + t.Errorf("Users: got %q, want MyApp.Handlers.Users", aliases["Users"]) + } + if aliases["Profiles"] != "MyApp.Handlers.Profiles" { + t.Errorf("Profiles: got %q, want MyApp.Handlers.Profiles", aliases["Profiles"]) + } + }) + + t.Run("multi-line multi-alias with comments inside", func(t *testing.T) { + text := "defmodule MyApp.Web do\n alias MyApp.Services.{\n Accounts,\n # Users is deprecated\n Profiles\n }\nend" + aliases := ExtractAliases(text) + if aliases["Accounts"] != "MyApp.Services.Accounts" { + t.Errorf("Accounts: got %q, want MyApp.Services.Accounts", aliases["Accounts"]) + } + if aliases["Profiles"] != "MyApp.Services.Profiles" { + t.Errorf("Profiles: got %q, want MyApp.Services.Profiles", aliases["Profiles"]) + } + if len(aliases) != 2 { + t.Errorf("expected 2 aliases, got %d: %v", len(aliases), aliases) + } + }) + + t.Run("multi-line multi-alias with multiple children per line", func(t *testing.T) { + text := "defmodule MyApp.Web do\n alias MyApp.Handlers.{\n Accounts, Users,\n Profiles\n }\nend" + aliases := ExtractAliases(text) + if aliases["Accounts"] != "MyApp.Handlers.Accounts" { + t.Errorf("Accounts: got %q, want MyApp.Handlers.Accounts", aliases["Accounts"]) + } + if aliases["Users"] != "MyApp.Handlers.Users" { + t.Errorf("Users: got %q, want MyApp.Handlers.Users", aliases["Users"]) + } + if aliases["Profiles"] != "MyApp.Handlers.Profiles" { + t.Errorf("Profiles: got %q, want MyApp.Handlers.Profiles", aliases["Profiles"]) + } + }) + + t.Run("multi-line multi-alias with trailing comma", func(t *testing.T) { + text := "defmodule MyApp.Web do\n alias MyApp.Handlers.{\n Accounts,\n Users,\n }\nend" + aliases := ExtractAliases(text) + if aliases["Accounts"] != "MyApp.Handlers.Accounts" { + t.Errorf("Accounts: got %q, want MyApp.Handlers.Accounts", aliases["Accounts"]) + } + if aliases["Users"] != "MyApp.Handlers.Users" { + t.Errorf("Users: got %q, want MyApp.Handlers.Users", aliases["Users"]) + } + if len(aliases) != 2 { + t.Errorf("expected 2 aliases, got %d: %v", len(aliases), aliases) + } + }) + + t.Run("multi-line alias bail-out on new statement", func(t *testing.T) { + text := "defmodule MyApp.Web do\n alias MyApp.Handlers.{\n Accounts,\n def foo, do: :ok\nend" + aliases := ExtractAliases(text) + // Key assertion: no alias for "foo" or anything weird — the def line must not be swallowed + if _, ok := aliases["foo"]; ok { + t.Error("should not register 'foo' as an alias") + } + }) + t.Run("partial __MODULE__ alias resolves in lookup", func(t *testing.T) { // Simulates: alias __MODULE__.Services -> Services = MyApp.HRIS.Services // Then a lookup for "Services.AssociateWithTeamV2" should resolve @@ -1384,6 +1601,34 @@ func TestExtractUsesWithOpts(t *testing.T) { t.Errorf("alias not resolved: got %q", calls[0].Opts["mod"]) } }) + + t.Run("multiline opts", func(t *testing.T) { + text := "defmodule Foo do\n use Tool,\n name: \"mock\",\n controller: CompanyController,\n action: :show\nend" + calls := ExtractUsesWithOpts(text, nil) + if len(calls) != 1 { + t.Fatalf("expected 1 use call, got %d", len(calls)) + } + if calls[0].Module != "Tool" { + t.Errorf("module: want Tool, got %q", calls[0].Module) + } + if calls[0].Opts["controller"] != "CompanyController" { + t.Errorf("controller: want CompanyController, got %q", calls[0].Opts["controller"]) + } + }) + + t.Run("multiline opts with module values", func(t *testing.T) { + text := "defmodule Foo do\n use Remote.Mox,\n mod: Hammox,\n repo: MyRepo\nend" + calls := ExtractUsesWithOpts(text, nil) + if len(calls) != 1 { + t.Fatalf("expected 1 use call, got %d", len(calls)) + } + if calls[0].Opts["mod"] != "Hammox" { + t.Errorf("mod: want Hammox, got %q", calls[0].Opts["mod"]) + } + if calls[0].Opts["repo"] != "MyRepo" { + t.Errorf("repo: want MyRepo, got %q", calls[0].Opts["repo"]) + } + }) } func TestFindBufferFunctions(t *testing.T) { @@ -1630,3 +1875,111 @@ func TestExtractParamNames(t *testing.T) { }) } } + +func TestExtractAliasesInScope_AliasInString(t *testing.T) { + text := `defmodule MyApp.Foo do + def bar do + x = "alias MyApp.Helpers, as: H" + H.help() + end +end` + aliases := ExtractAliasesInScope(text, 3) + if _, ok := aliases["H"]; ok { + t.Error("should not extract alias from string content") + } +} + +func TestExtractAliasesInScope_AliasInHeredoc(t *testing.T) { + text := `defmodule MyApp.Foo do + @doc """ + alias MyApp.Helpers, as: H + """ + def bar do + H.help() + end +end` + aliases := ExtractAliasesInScope(text, 5) + if _, ok := aliases["H"]; ok { + t.Error("should not extract alias from heredoc content") + } +} + +func TestExtractAliasesInScope_MultilineAliasWithComment(t *testing.T) { + text := `defmodule MyApp.Foo do + alias MyApp.Helpers.Paginator, + # Short name for convenience + as: Pages + + def bar, do: Pages.paginate() +end` + aliases := ExtractAliasesInScope(text, 5) + if aliases["Pages"] != "MyApp.Helpers.Paginator" { + t.Errorf("expected Pages -> MyApp.Helpers.Paginator, got %q", aliases["Pages"]) + } +} + +func TestExtractAliasesInScope_NestedModuleScope(t *testing.T) { + text := `defmodule MyApp.Outer do + alias MyApp.Helpers + + defmodule Inner do + def bar, do: Helpers.help() + end +end` + outerAliases := ExtractAliasesInScope(text, 1) + innerAliases := ExtractAliasesInScope(text, 4) + + if outerAliases["Helpers"] != "MyApp.Helpers" { + t.Error("outer module should have the alias") + } + if _, ok := innerAliases["Helpers"]; ok { + t.Error("inner module should NOT inherit outer alias") + } +} + +func TestExtractAliasesInScope_MultilineBlockTrailingComma(t *testing.T) { + text := `defmodule MyApp.Web do + alias MyApp.{ + Accounts, + Users, + } + + def foo, do: Accounts.list() +end` + aliases := ExtractAliasesInScope(text, 6) + if aliases["Accounts"] != "MyApp.Accounts" { + t.Errorf("Accounts: got %q, want MyApp.Accounts", aliases["Accounts"]) + } + if aliases["Users"] != "MyApp.Users" { + t.Errorf("Users: got %q, want MyApp.Users", aliases["Users"]) + } +} + +func TestExtractUsesWithOpts_StringContent(t *testing.T) { + text := `defmodule MyApp.Foo do + def bar do + x = "use Tool," + y = "name: mock" + end +end` + calls := ExtractUsesWithOpts(text, nil) + for _, c := range calls { + if c.Module == "Tool" { + t.Error("should not extract use from string content") + } + } +} + +func TestExtractAliasBlockParent_NotConfusedByMapBraces(t *testing.T) { + lines := strings.Split(`defmodule MyApp.Foo do + def bar do + map = %{ + key: "value" + } + end +end`, "\n") + _, inBlock := ExtractAliasBlockParent(lines, 3) + if inBlock { + t.Error("map literal brace should not be detected as alias block") + } +} diff --git a/internal/lsp/hover_test.go b/internal/lsp/hover_test.go index 2f8b336..e82b62f 100644 --- a/internal/lsp/hover_test.go +++ b/internal/lsp/hover_test.go @@ -1026,3 +1026,119 @@ end` t.Errorf("expected submodule in hover, got %q", hover.Contents.Value) } } + +func TestHover_MultiLineAliasBlock(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", `defmodule MyApp.Accounts do + @moduledoc "The Accounts context." + def list, do: [] +end +`) + + indexFile(t, server.store, server.projectRoot, "lib/users.ex", `defmodule MyApp.Users do + @moduledoc "User management." + def get(id), do: nil +end +`) + + src := `defmodule MyApp.Web do + alias MyApp.{ + Accounts, + Users + } + + def run, do: Accounts.list() +end` + uri := "file:///test.ex" + server.docs.Set(uri, src) + + // Hover on "Accounts" inside the multi-line alias block (line 2, col 4) + hover := hoverAt(t, server, uri, 2, 4) + if hover == nil { + t.Fatal("expected hover for Accounts inside multi-line alias block") + } + if !strings.Contains(hover.Contents.Value, "The Accounts context") { + t.Errorf("expected moduledoc in hover, got %q", hover.Contents.Value) + } + + // Hover on "Users" inside the multi-line alias block (line 3, col 4) + hover = hoverAt(t, server, uri, 3, 4) + if hover == nil { + t.Fatal("expected hover for Users inside multi-line alias block") + } + if !strings.Contains(hover.Contents.Value, "User management") { + t.Errorf("expected moduledoc in hover, got %q", hover.Contents.Value) + } + + // Trailing brace on content line: alias MyApp.{ Users } + src2 := `defmodule MyApp.Web do + alias MyApp.{ + Accounts } +end` + uri2 := "file:///test2.ex" + server.docs.Set(uri2, src2) + + hover = hoverAt(t, server, uri2, 2, 6) + if hover == nil { + t.Fatal("expected hover for module on line with trailing brace") + } + if !strings.Contains(hover.Contents.Value, "The Accounts context") { + t.Errorf("expected moduledoc in hover, got %q", hover.Contents.Value) + } +} + +func TestHover_UseInjectedMultilineAlias(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/helpers.ex", `defmodule MyApp.Helpers do + @moduledoc "Helper utilities." + def help, do: :ok +end +`) + + // Module with __using__ that has a multiline alias ... as: + // AND uses the alias in an import within the same quote block. + indexFile(t, server.store, server.projectRoot, "lib/base.ex", `defmodule MyApp.Base do + defmacro __using__(_opts) do + quote do + alias MyApp.Helpers, + as: H + + import H + end + end +end +`) + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyApp.Consumer do + use MyApp.Base + + def run, do: help() +end`) + + // help() should resolve via import H → MyApp.Helpers + hover := hoverAt(t, server, uri, 3, 15) + if hover == nil { + t.Fatal("expected hover for help() injected via multiline alias + import in __using__") + } + if !strings.Contains(hover.Contents.Value, "def help") { + t.Errorf("expected help signature in hover, got %q", hover.Contents.Value) + } +} + +func TestHover_DefKeywordNoCrash(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyApp.Foo do + def bar, do: :ok +end`) + + hover := hoverAt(t, server, uri, 1, 3) + _ = hover +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 72c78de..d023242 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -255,6 +255,19 @@ func (s *Server) watchGitHead() { }() } +// periodicReindex runs backgroundReindex on a fixed interval to catch files +// created or deleted outside the editor. +func (s *Server) periodicReindex() { + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + s.backgroundReindex() + } + }() +} + // notifyOTPMismatch checks stderr output for an OTP version mismatch and // sends a one-time warning to the editor so the user doesn't have to dig // through logs. @@ -342,6 +355,7 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara s.initialized = true s.backgroundReindex() s.watchGitHead() + s.periodicReindex() } if params.Capabilities.Window != nil && params.Capabilities.Window.ShowDocument != nil { @@ -553,6 +567,13 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara } moduleRef, functionName := ExtractModuleAndFunction(expr) + + if moduleRef != "" { + if aliasParent, inBlock := ExtractAliasBlockParent(lines, lineNum); inBlock { + moduleRef = aliasParent + "." + moduleRef + } + } + aliases := ExtractAliasesInScope(text, lineNum) s.mergeAliasesFromUse(text, aliases) s.debugf("Definition: expr=%q module=%q function=%q", expr, moduleRef, functionName) @@ -949,6 +970,46 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara } prefix, afterDot, prefixStartCol := ExtractCompletionContext(lines[lineNum], col) + + // Inside a multi-line alias block: complete child module segments under the parent. + if aliasParent, inBlock := ExtractAliasBlockParent(lines, lineNum); inBlock { + searchParent := aliasParent + segmentPrefix := prefix + labelPrefix := "" + + if afterDot && prefix != "" { + searchParent = aliasParent + "." + prefix + segmentPrefix = "" + labelPrefix = prefix + "." + } else if prefix != "" { + if dotIdx := strings.LastIndexByte(prefix, '.'); dotIdx >= 0 { + searchParent = aliasParent + "." + prefix[:dotIdx] + segmentPrefix = prefix[dotIdx+1:] + labelPrefix = prefix[:dotIdx+1] + } + } + + segments, err := s.store.SearchSubmoduleSegments(searchParent, segmentPrefix) + if err != nil { + return nil, nil + } + var items []protocol.CompletionItem + for _, segment := range segments { + items = append(items, protocol.CompletionItem{ + Label: labelPrefix + segment, + Kind: protocol.CompletionItemKindModule, + Detail: searchParent + "." + segment, + }) + } + if len(items) == 0 { + return nil, nil + } + return &protocol.CompletionList{ + IsIncomplete: len(items) >= 100, + Items: items, + }, nil + } + if prefix == "" && !afterDot { return nil, nil } @@ -963,6 +1024,12 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara moduleRef, funcPrefix := ExtractModuleAndFunction(prefix) inPipe := IsPipeContext(lines[lineNum], prefixStartCol) + // "Module.func." or "variable." — dot after a function call result or + // map/struct field access. We have no type info to complete the result. + if afterDot && (funcPrefix != "" || moduleRef == "") { + return nil, nil + } + var items []protocol.CompletionItem if moduleRef != "" && (afterDot || funcPrefix != "") { @@ -2643,6 +2710,15 @@ func (s *Server) Hover(ctx context.Context, params *protocol.HoverParams) (*prot } moduleRef, functionName := ExtractModuleAndFunction(expr) + + // Inside a multi-line alias block like "alias MyModule.{ Something }", + // prepend the parent so "Something" resolves to "MyModule.Something". + if moduleRef != "" { + if aliasParent, inBlock := ExtractAliasBlockParent(lines, lineNum); inBlock { + moduleRef = aliasParent + "." + moduleRef + } + } + aliases := ExtractAliasesInScope(text, lineNum) s.mergeAliasesFromUse(text, aliases) @@ -2975,6 +3051,13 @@ func (s *Server) References(ctx context.Context, params *protocol.ReferenceParam } moduleRef, functionName := ExtractModuleAndFunction(expr) + + if moduleRef != "" { + if aliasParent, inBlock := ExtractAliasBlockParent(lines, lineNum); inBlock { + moduleRef = aliasParent + "." + moduleRef + } + } + aliases := ExtractAliasesInScope(text, lineNum) s.mergeAliasesFromUse(text, aliases) s.debugf("References: expr=%q module=%q function=%q", expr, moduleRef, functionName) @@ -3172,6 +3255,7 @@ func (s *Server) References(ctx context.Context, params *protocol.ReferenceParam s.debugf("References: returning %d locations", len(locations)) return locations, nil } + func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) { docURI := string(params.TextDocument.URI) text, ok := s.docs.Get(docURI) @@ -4009,6 +4093,7 @@ func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string) * applyTokenEdits := func(origLines []string, fileSites []renameSite) []string { lines := make([]string, len(origLines)) copy(lines, origLines) + for _, site := range fileSites { if site.line-1 >= len(lines) { continue diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 89ccfb3..05e697b 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -752,6 +752,130 @@ end`) } } +func TestCompletion_AliasBlock_SimplePrefix(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/services.ex", `defmodule MyApp.Services.Accounts do +end + +defmodule MyApp.Services.Analytics do +end + +defmodule MyApp.Services.Billing do +end +`) + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyModule do + alias MyApp.Services.{ + Ac + } +end`) + + // cursor at "Ac" → line 2, col 6 + items := completionAt(t, server, uri, 2, 6) + if !hasCompletionItem(items, "Accounts") { + t.Error("expected 'Accounts' in alias block completions") + } + if hasCompletionItem(items, "Analytics") { + t.Error("should not include 'Analytics' — doesn't match prefix 'Ac'") + } + if hasCompletionItem(items, "Billing") { + t.Error("should not include 'Billing' — doesn't match prefix 'Ac'") + } +} + +func TestCompletion_AliasBlock_DottedPrefix(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/ecto.ex", `defmodule MyApp.Ecto.Paginator do +end + +defmodule MyApp.Ecto.ChangesetHelpers do +end + +defmodule MyApp.Accounts do +end +`) + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyModule do + alias MyApp.{ + Ecto. + } +end`) + + // cursor after "Ecto." → line 2, col 9 + items := completionAt(t, server, uri, 2, 9) + if !hasCompletionItem(items, "Ecto.Paginator") { + t.Error("expected 'Ecto.Paginator' in alias block completions") + } + if !hasCompletionItem(items, "Ecto.ChangesetHelpers") { + t.Error("expected 'Ecto.ChangesetHelpers' in alias block completions") + } + if hasCompletionItem(items, "Accounts") { + t.Error("should not include 'Accounts' — not a child of MyApp.Ecto") + } +} + +func TestCompletion_AliasBlock_DottedPrefixWithPartial(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/ecto.ex", `defmodule MyApp.Ecto.Paginator do +end + +defmodule MyApp.Ecto.ChangesetHelpers do +end +`) + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyModule do + alias MyApp.{ + Ecto.Pag + } +end`) + + // cursor at "Ecto.Pag" → line 2, col 12 + items := completionAt(t, server, uri, 2, 12) + if !hasCompletionItem(items, "Ecto.Paginator") { + t.Error("expected 'Ecto.Paginator' in alias block completions") + } + if hasCompletionItem(items, "Ecto.ChangesetHelpers") { + t.Error("should not include 'Ecto.ChangesetHelpers' — doesn't match prefix 'Pag'") + } +} + +func TestCompletion_AliasBlock_EmptyPrefix(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/services.ex", `defmodule MyApp.Accounts do +end + +defmodule MyApp.Billing do +end +`) + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyModule do + alias MyApp.{ + + } +end`) + + // cursor on blank line inside the block → line 2, col 4 + items := completionAt(t, server, uri, 2, 4) + if !hasCompletionItem(items, "Accounts") { + t.Error("expected 'Accounts' in alias block completions with empty prefix") + } + if !hasCompletionItem(items, "Billing") { + t.Error("expected 'Billing' in alias block completions with empty prefix") + } +} + func TestCompletion_ImportedFunctions(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() @@ -947,6 +1071,46 @@ func TestCompletion_NoResults(t *testing.T) { } } +func TestCompletion_FunctionResultDotNoResults(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", `defmodule MyApp.Accounts do + def list, do: [] +end +`) + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyApp.Web do + alias MyApp.Accounts + Accounts.list. +end`) + + // col 16 = right after "Accounts.list." on line 2 + items := completionAt(t, server, uri, 2, 16) + if len(items) != 0 { + t.Errorf("expected no completions after function result dot, got %d: %v", len(items), items) + } +} + +func TestCompletion_VariableDotNoResults(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + uri := "file:///test.ex" + server.docs.Set(uri, `defmodule MyApp.Web do + def run(config) do + config. + end +end`) + + // col 11 = right after "config." on line 2 + items := completionAt(t, server, uri, 2, 11) + if len(items) != 0 { + t.Errorf("expected no completions after variable dot, got %d: %v", len(items), items) + } +} + func TestCompletionResolve_WithDoc(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() @@ -1257,6 +1421,47 @@ end`) } } +func TestCompletion_MultilineUseOpts(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/custom_mock.ex", `defmodule MyApp.CustomMock do + def mock_func, do: :ok +end +`) + + indexFile(t, server.store, server.projectRoot, "lib/mox_base.ex", `defmodule MyApp.MoxBase do + defmacro __using__(opts) do + mod = Keyword.get(opts, :mod, MyApp.DefaultMod) + quote do + import unquote(mod) + end + end +end +`) + + uri := "file:///test.ex" + src := `defmodule MyApp.Test do + use MyApp.MoxBase, + mod: MyApp.CustomMock + + def test, do: mock_func() +end` + server.docs.Set(uri, src) + + aliases := map[string]string{} + calls := ExtractUsesWithOpts(src, aliases) + found := false + for _, c := range calls { + if c.Module == "MyApp.MoxBase" && c.Opts["mod"] == "MyApp.CustomMock" { + found = true + } + } + if !found { + t.Errorf("expected use MyApp.MoxBase with mod: MyApp.CustomMock; got %+v", calls) + } +} + func TestDefinition_ModuleKeyword(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 78ea6a1..2c45d13 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -75,6 +75,358 @@ func ParseFile(path string) ([]Definition, []Reference, error) { return ParseText(path, string(data)) } +// Line represents a line of source code with its content and original line number. +// The original line number is the line (1-indexed) where this line appears in the source file, +// or for joined lines, the line where the joined group begins. +type Line struct { + Content string + OrigNum int // 1-indexed original line number +} + +// joinContinuedLines joins lines ending with a bare backslash (line continuation) +// with the next line. A trailing \\ only counts as continuation when it is outside +// string literals and comments. Returns a slice of Line structs with original line numbers. +func joinContinuedLines(lines []string) []Line { + var result []Line + i := 0 + for i < len(lines) { + origNum := i + 1 // 1-indexed + line := lines[i] + for hasTrailingBackslash(line) && i+1 < len(lines) { + i++ + trimmed := strings.TrimRight(line, " \t\r") + line = trimmed[:len(trimmed)-1] + " " + lines[i] + } + result = append(result, Line{Content: line, OrigNum: origNum}) + i++ + } + return result +} + +// JoinContinuedLines is the exported version that returns just strings. +// Used by tests and other code that doesn't need line number tracking. +// JoinLines applies all three joining passes (continued lines, bracket lines, +// trailing comma) and returns the joined content strings. This is the same +// pipeline used by ParseText. +func JoinLines(raw []string) []string { + joined := joinContinuedLines(raw) + joined = joinBracketLines(joined) + joined = joinTrailingComma(joined) + result := make([]string, len(joined)) + for i, l := range joined { + result[i] = l.Content + } + return result +} + +func JoinContinuedLines(lines []string) []string { + joined := joinContinuedLines(lines) + result := make([]string, len(joined)) + for i, l := range joined { + result[i] = l.Content + } + return result +} + +// hasTrailingBackslash returns true if line ends with a \\ that is outside strings/comments. +func hasTrailingBackslash(line string) bool { + j := len(line) - 1 + for j >= 0 && (line[j] == ' ' || line[j] == '\t' || line[j] == '\r') { + j-- + } + if j < 0 || line[j] != '\\' { + return false + } + inSingle := false + inDouble := false + for k := 0; k < j; k++ { + ch := line[k] + if ch == '\\' && (inSingle || inDouble) { + k++ + continue + } + if ch == '"' && !inSingle { + inDouble = !inDouble + } else if ch == '\'' && !inDouble { + inSingle = !inSingle + } + if ch == '#' && !inSingle && !inDouble { + return false + } + } + return !inSingle && !inDouble +} + +// joinBracketLines joins lines that have unclosed brackets (parentheses, square +// brackets, or curly braces). Brackets inside string literals, charlists, +// comments, and sigils are ignored. The joined line retains the line number of +// the first line in the group. +func joinBracketLines(lines []Line) []Line { + var result []Line + i := 0 + for i < len(lines) { + origNum := lines[i].OrigNum // Keep the first line's original number + line := lines[i].Content + depth := bracketDepth(line) + for depth > 0 && i+1 < len(lines) { + i++ + line = line + " " + lines[i].Content + depth = bracketDepth(line) + } + result = append(result, Line{Content: line, OrigNum: origNum}) + i++ + } + return result +} + +// JoinBracketLines is the exported version that takes and returns strings. +// Used by tests and other code that doesn't need line number tracking. +func JoinBracketLines(lines []string) []string { + lineObjs := make([]Line, len(lines)) + for i, l := range lines { + lineObjs[i] = Line{Content: l, OrigNum: i + 1} + } + joined := joinBracketLines(lineObjs) + result := make([]string, len(joined)) + for i, l := range joined { + result[i] = l.Content + } + return result +} + +// joinTrailingComma joins lines where a directive (alias, import, use, require) +// ends with a trailing comma and the next line is a continuation (starts with +// whitespace followed by a keyword or keyword-like argument). This handles +// multi-line constructs like: +// +// alias MyModule.MySubModule, +// as: Something +// +// use SomeModule, +// key: Val +// +// These have no unclosed brackets, so joinBracketLines doesn't catch them. +func joinTrailingComma(lines []Line) []Line { + var result []Line + i := 0 + for i < len(lines) { + origNum := lines[i].OrigNum + content := lines[i].Content + + if isDirectiveWithTrailingComma(content) { + // Join with subsequent continuation lines + for i+1 < len(lines) { + next := lines[i+1].Content + trimmed := strings.TrimSpace(next) + // Skip blank lines — they signal a new statement + if trimmed == "" { + break + } + // Skip comment-only lines inside the continuation + if strings.HasPrefix(trimmed, "#") { + i++ + continue + } + if !LooksLikeKeywordOpt(trimmed) { + break + } + content = content + " " + trimmed + i++ + } + } + + result = append(result, Line{Content: content, OrigNum: origNum}) + i++ + } + return result +} + +// directivePrefixes are the keywords that can have multi-line comma continuations. +var directivePrefixes = []string{"alias ", "import ", "use ", "require "} + +// isDirectiveWithTrailingComma returns true if the line starts with a directive +// keyword and ends with a comma (outside strings/comments), with no unclosed brackets. +func isDirectiveWithTrailingComma(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + for _, prefix := range directivePrefixes { + if strings.HasPrefix(trimmed, prefix) { + // Check the line ends with a trailing comma (outside strings/comments) + stripped := StripCommentsAndStrings(trimmed) + stripped = strings.TrimRight(stripped, " \t\r") + return strings.HasSuffix(stripped, ",") + } + } + return false +} + +// LooksLikeKeywordOpt returns true if trimmed starts with a lowercase +// identifier followed by ':' (e.g. "as: Something", "name: \"foo\""). +// Used by both the parser's line joining and the LSP's multiline use opts. +func LooksLikeKeywordOpt(trimmed string) bool { + for i := 0; i < len(trimmed); i++ { + ch := trimmed[i] + if ch == ':' && i > 0 { + return true + } + if !IsLowerIdentChar(ch) { + return false + } + } + return false +} + +// bracketDepth returns the net bracket depth of a line: positive if there are +// unclosed brackets. Only counts (, [, { outside strings, charlists, sigils, +// and comments. Handles <<>> bitstring syntax. +func bracketDepth(line string) int { + depth := 0 + i := 0 + for i < len(line) { + ch := line[i] + // Skip escaped characters inside strings/charlists + if ch == '\\' && i+1 < len(line) { + i += 2 + continue + } + // Comment — stop counting + if ch == '#' { + break + } + // Double-quoted string + if ch == '"' { + i = skipQuoted(line, i+1, '"') + continue + } + // Single-quoted charlist + if ch == '\'' { + i = skipQuoted(line, i+1, '\'') + continue + } + // Sigil + if ch == '~' && i+1 < len(line) { + next := line[i+1] + if (next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z') { + i = skipSigil(line, i+2) + continue + } + } + // Bitstring << >> + if ch == '<' && i+1 < len(line) && line[i+1] == '<' { + // Skip over <<, but don't count as bracket + i += 2 + continue + } + if ch == '>' && i+1 < len(line) && line[i+1] == '>' { + i += 2 + continue + } + switch ch { + case '(', '[', '{': + depth++ + case ')', ']', '}': + depth-- + } + i++ + } + return depth +} + +// skipQuoted advances past a quoted literal content until the closing quote. +func skipQuoted(line string, i int, quote byte) int { + for i < len(line) { + if line[i] == '\\' && i+1 < len(line) { + i += 2 + continue + } + if line[i] == quote { + return i + 1 + } + // Interpolation in double-quoted strings + if quote == '"' && line[i] == '#' && i+1 < len(line) && line[i+1] == '{' { + i += 2 + // Track nested braces in interpolation + braceDepth := 1 + for i < len(line) && braceDepth > 0 { + if line[i] == '\\' && i+1 < len(line) { + i += 2 + continue + } + switch line[i] { + case '{': + braceDepth++ + case '}': + braceDepth-- + } + if braceDepth > 0 { + i++ + } + } + continue + } + i++ + } + return i +} + +// skipSigil advances past a sigil's content until the closing delimiter. +func skipSigil(line string, i int) int { + if i >= len(line) { + return i + } + // Determine delimiter + ch := line[i] + var openDelim, closeDelim byte + switch ch { + case '(', '[', '{', '<': + openDelim = ch + switch ch { + case '(': + closeDelim = ')' + case '[': + closeDelim = ']' + case '{': + closeDelim = '}' + case '<': + closeDelim = '>' + } + default: + // Non-bracket delimiter (e.g. /, |, ") + closeDelim = ch + openDelim = ch + } + i++ // skip opening delimiter + depth := 1 + for i < len(line) && depth > 0 { + if line[i] == '\\' && i+1 < len(line) { + i += 2 + continue + } + if openDelim != closeDelim { + switch line[i] { + case openDelim: + depth++ + case closeDelim: + depth-- + } + } else { + if line[i] == closeDelim { + depth-- + } + } + if depth > 0 { + i++ + } + } + if i < len(line) { + i++ // skip closing delimiter + } + // Skip optional modifiers (e.g. ~r/pattern/s) + for i < len(line) && ((line[i] >= 'a' && line[i] <= 'z') || (line[i] >= 'A' && line[i] <= 'Z')) { + i++ + } + return i +} + // ParseText parses Elixir source text and returns definitions and references. // The path is used to populate FilePath fields but the text is not read from disk. func ParseText(path, text string) ([]Definition, []Reference, error) { @@ -85,7 +437,10 @@ func ParseText(path, text string) ([]Definition, []Reference, error) { savedInjectors map[string]bool } - lines := strings.Split(text, "\n") + origLines := strings.Split(text, "\n") + lines := joinContinuedLines(origLines) + lines = joinBracketLines(lines) + lines = joinTrailingComma(lines) var defs []Definition var refs []Reference var moduleStack []moduleFrame @@ -94,25 +449,64 @@ func ParseText(path, text string) ([]Definition, []Reference, error) { injectors := map[string]bool{} // modules from use/import that inject bare functions inHeredoc := false + // Multi-line sigil tracking: when a sigil opener (~X with bracket delimiter) + // doesn't close on the same line, we track the closing delimiter and depth + // so subsequent lines inside the sigil are skipped. + var sigilCloser byte + var sigilDepth int + for lineIdx, line := range lines { - lineNum := lineIdx + 1 + lineNum := line.OrigNum // Use original line number, not joined index + content := line.Content + + // Skip lines inside a multi-line sigil + if sigilCloser != 0 { + for j := 0; j < len(content); j++ { + if content[j] == '\\' && j+1 < len(content) { + j++ + continue + } + if content[j] == sigilCloser { + sigilDepth-- + if sigilDepth == 0 { + sigilCloser = 0 + break + } + } else if isSigilBracketOpener(content[j], sigilCloser) { + sigilDepth++ + } + } + if sigilCloser != 0 { + continue // sigil still open, skip this line + } + // Sigil closed on this line — it's just a closing delimiter, skip it + continue + } + + // Check for a sigil opener that doesn't close on this line. + // Must run before CheckHeredoc so ~s""" is treated as a sigil, not heredoc. + if opener, ok := findUnclosedSigil(content); ok { + sigilCloser = opener.closer + sigilDepth = opener.depth + continue + } var skip bool - inHeredoc, skip = CheckHeredoc(line, inHeredoc) + inHeredoc, skip = CheckHeredoc(content, inHeredoc) if skip { continue } // Find first non-whitespace character for fast pre-filtering. trimStart := 0 - for trimStart < len(line) && (line[trimStart] == ' ' || line[trimStart] == '\t') { + for trimStart < len(content) && (content[trimStart] == ' ' || content[trimStart] == '\t') { trimStart++ } - if trimStart >= len(line) { + if trimStart >= len(content) { continue } - first := line[trimStart] - rest := line[trimStart:] // line content from first non-whitespace char + first := content[trimStart] + rest := content[trimStart:] // line content from first non-whitespace char strippedRest := strings.TrimRight(StripCommentsAndStrings(rest), " \t\r") @@ -276,7 +670,7 @@ func ParseText(path, text string) ([]Definition, []Reference, error) { defs = append(defs, Definition{ Module: currentModule, Function: name, - Arity: ExtractArity(line, name), + Arity: ExtractArity(content, name), Line: lineNum, FilePath: path, Kind: kind, @@ -313,7 +707,7 @@ func ParseText(path, text string) ([]Definition, []Reference, error) { defs = append(defs, Definition{ Module: currentModule, Function: name, - Arity: ExtractArity(line, name), + Arity: ExtractArity(content, name), Line: lineNum, FilePath: path, Kind: callbackKind, @@ -377,7 +771,7 @@ func ParseText(path, text string) ([]Definition, []Reference, error) { if currentModule != "" { if kind, funcName, ok := ScanFuncDef(rest); ok { - paramContent := FindParamContent(line, funcName) + paramContent := FindParamContent(content, funcName) maxArity := ArityFromParams(paramContent) defaultCount := DefaultsFromParams(paramContent) minArity := maxArity - defaultCount @@ -387,7 +781,7 @@ func ParseText(path, text string) ([]Definition, []Reference, error) { delegateTo, delegateAs = findDelegateToAndAs(lines, lineIdx, aliases, currentModule) } - allParamNames := ExtractParamNames(line, funcName) + allParamNames := ExtractParamNames(content, funcName) for arity := minArity; arity <= maxArity; arity++ { params := JoinParams(allParamNames, arity) @@ -467,11 +861,11 @@ func ParseText(path, text string) ([]Definition, []Reference, error) { } // Extract Module.function calls and %Module{} struct literals from any line. - if !hasUppercase(line) { + if !hasUppercase(content) { continue } { - codeLine := StripCommentsAndStrings(line) + codeLine := StripCommentsAndStrings(content) // Module.function calls (including type refs like User.t()) for _, match := range moduleCallRe.FindAllStringSubmatch(codeLine, -1) { @@ -648,16 +1042,19 @@ func ScanFuncDef(rest string) (string, string, bool) { // inHeredoc state and whether this line is a heredoc boundary or content that // should be skipped by callers doing line-by-line analysis. func CheckHeredoc(line string, inHeredoc bool) (newState bool, skip bool) { - if strings.IndexByte(line, '"') >= 0 { - if c := strings.Count(line, `"""`); c > 0 { + // Strip sigil content before checking for heredoc markers so that + // """ or ''' inside ~s(...) doesn't toggle heredoc mode. + stripped := stripSigils(line) + if strings.IndexByte(stripped, '"') >= 0 { + if c := countHeredocMarkers(stripped, '"'); c > 0 { if c < 2 { inHeredoc = !inHeredoc } return inHeredoc, true } } - if strings.IndexByte(line, '\'') >= 0 { - if c := strings.Count(line, `'''`); c > 0 { + if strings.IndexByte(stripped, '\'') >= 0 { + if c := countHeredocMarkers(stripped, '\''); c > 0 { if c < 2 { inHeredoc = !inHeredoc } @@ -667,6 +1064,208 @@ func CheckHeredoc(line string, inHeredoc bool) (newState bool, skip bool) { return inHeredoc, inHeredoc } +// stripSigils replaces sigil content with spaces, preserving the opening ~X +// and closing delimiter so that heredoc detection only sees code, not string +// content. This is a simplified version of StripCommentsAndStrings that only +// handles sigils. +func stripSigils(line string) string { + if !strings.ContainsRune(line, '~') { + return line + } + buf := []byte(line) + i := 0 + for i < len(buf) { + if buf[i] == '\\' && i+1 < len(buf) { + i += 2 + continue + } + if buf[i] == '"' { + // Skip string content so we don't match ~ inside strings + i++ + for i < len(buf) { + if buf[i] == '\\' && i+1 < len(buf) { + i += 2 + continue + } + if buf[i] == '"' { + i++ + break + } + i++ + } + continue + } + if buf[i] == '~' && i+1 < len(buf) { + next := buf[i+1] + if (next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z') { + sigilStart := i + 2 + if sigilStart < len(buf) { + i = blankSigilForHeredoc(buf, sigilStart) + continue + } + } + } + i++ + } + return string(buf) +} + +// blankSigilForHeredoc blanks sigil content (replacing with spaces) starting +// at the opening delimiter. Returns the index after the closing delimiter + +// modifiers. +func blankSigilForHeredoc(buf []byte, i int) int { + if i >= len(buf) { + return i + } + opener := buf[i] + var closer byte + switch opener { + case '(': + closer = ')' + case '[': + closer = ']' + case '{': + closer = '}' + case '<': + closer = '>' + default: + closer = opener + } + i++ // skip opening delimiter + depth := 1 + for i < len(buf) && depth > 0 { + if buf[i] == '\\' && i+1 < len(buf) { + buf[i] = ' ' + buf[i+1] = ' ' + i += 2 + continue + } + if buf[i] == closer { + depth-- + if depth == 0 { + i++ + // skip modifiers + for i < len(buf) && ((buf[i] >= 'a' && buf[i] <= 'z') || (buf[i] >= 'A' && buf[i] <= 'Z')) { + i++ + } + return i + } + } else if closer != opener && buf[i] == opener { + depth++ + } + if depth > 0 { + buf[i] = ' ' + i++ + } + } + return i +} + +// sigilBracketPairs maps bracket openers to their closers. +var sigilBracketPairs = map[byte]byte{ + '(': ')', + '[': ']', + '{': '}', + '<': '>', +} + +// sigilResult holds the closer byte and remaining depth after scanning a line +// for an unclosed sigil. +type sigilResult struct { + closer byte + depth int +} + +// findUnclosedSigil scans content for a sigil (~X followed by a bracket delimiter) +// that doesn't close on the same line. Returns the closer and remaining depth. +func findUnclosedSigil(content string) (sigilResult, bool) { + for i := 0; i < len(content); i++ { + // Skip past strings so we don't match ~ inside them + if content[i] == '"' { + i = skipQuoted(content, i+1, '"') + i-- // outer loop will i++ + continue + } + if content[i] == '\'' { + i = skipQuoted(content, i+1, '\'') + i-- + continue + } + if content[i] == '~' && i+1 < len(content) { + next := content[i+1] + if (next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z') { + sigilStart := i + 2 + if sigilStart >= len(content) { + continue + } + delim := content[sigilStart] + closer, isBracket := sigilBracketPairs[delim] + if !isBracket { + continue + } + // Scan to end of line tracking bracket depth + depth := 1 + for j := sigilStart + 1; j < len(content); j++ { + if content[j] == '\\' && j+1 < len(content) { + j++ + continue + } + if content[j] == closer { + depth-- + if depth == 0 { + break + } + } else if content[j] == delim { + depth++ + } + } + if depth > 0 { + return sigilResult{closer: closer, depth: depth}, true + } + } + } + } + return sigilResult{}, false +} + +// isSigilBracketOpener returns true if ch is a bracket opener that matches +// the given closer (i.e. closer ')' matches opener '('). +func isSigilBracketOpener(ch byte, closer byte) bool { + for o, c := range sigilBracketPairs { + if c == closer && o == ch { + return true + } + } + return false +} + +// countHeredocMarkers counts occurrences of triple-quote markers (\"\"\" or \”'\) +// in the line, but only when they appear outside of single-line string literals. +func countHeredocMarkers(line string, quote byte) int { + inString := false + count := 0 + for i := 0; i < len(line); i++ { + if line[i] == '\\' && inString { + i++ // skip escaped char + continue + } + if line[i] == quote { + if inString { + inString = false + continue + } + // Check for triple quote + if i+2 < len(line) && line[i+1] == quote && line[i+2] == quote { + count++ + i += 2 + continue + } + inString = true + } + } + return count +} + // ContainsDo returns true if the trimmed line ends with a block-opening " do" // (not an inline "do:" keyword argument). func ContainsDo(trimmed string) bool { @@ -732,12 +1331,20 @@ func containsFnKeyword(code string) bool { } func isIdentChar(b byte) bool { + return IsIdentChar(b) +} + +func IsIdentChar(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' } +func IsLowerIdentChar(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_' +} + // findDelegateTo searches the current line and up to 5 subsequent lines for a to: target, // then resolves it via aliases. -func findDelegateToAndAs(lines []string, startIdx int, aliases map[string]string, currentModule string) (string, string) { +func findDelegateToAndAs(lines []Line, startIdx int, aliases map[string]string, currentModule string) (string, string) { end := startIdx + 6 if end > len(lines) { end = len(lines) @@ -746,10 +1353,10 @@ func findDelegateToAndAs(lines []string, startIdx int, aliases map[string]string var targetModule, targetFunc string for i := startIdx; i < end; i++ { // A new statement on any line after the first means the current defdelegate ended - if i > startIdx && newStatementRe.MatchString(lines[i]) { + if i > startIdx && newStatementRe.MatchString(lines[i].Content) { break } - if m := delegateToRe.FindStringSubmatch(lines[i]); m != nil && targetModule == "" { + if m := delegateToRe.FindStringSubmatch(lines[i].Content); m != nil && targetModule == "" { target := m[1] // Resolve __MODULE__ directly in to: field if currentModule != "" { @@ -769,7 +1376,7 @@ func findDelegateToAndAs(lines []string, startIdx int, aliases map[string]string targetModule = target } } - if m := delegateAsRe.FindStringSubmatch(lines[i]); m != nil && targetFunc == "" { + if m := delegateAsRe.FindStringSubmatch(lines[i].Content); m != nil && targetFunc == "" { targetFunc = m[1] } } @@ -1083,11 +1690,21 @@ func StripCommentsAndStrings(line string) string { } // String literal (double-quoted) if ch == '"' { + // Check for char literal: ?\" is the integer value of ", not a string start + if i > 0 && buf[i-1] == '?' { + i++ + continue + } i = blankQuoted(buf, i, '"') continue } // Single-quoted charlist if ch == '\'' { + // Check for char literal: ?' is also a char literal + if i > 0 && buf[i-1] == '?' { + i++ + continue + } i = blankQuoted(buf, i, '\'') continue } @@ -1115,6 +1732,55 @@ func blankQuoted(buf []byte, i int, quote byte) int { j += 2 continue } + // Handle interpolation: #{...} can contain nested strings/braces + if quote == '"' && buf[j] == '#' && j+1 < len(buf) && buf[j+1] == '{' { + buf[j] = ' ' + buf[j+1] = ' ' + j += 2 + braceDepth := 1 + for j < len(buf) && braceDepth > 0 { + if buf[j] == '\\' && j+1 < len(buf) { + buf[j] = ' ' + buf[j+1] = ' ' + j += 2 + continue + } + if buf[j] == '"' || buf[j] == '\'' { + // Nested string inside interpolation — blank it recursively + nestedQuote := buf[j] + j++ + for j < len(buf) { + if buf[j] == '\\' && j+1 < len(buf) { + buf[j] = ' ' + buf[j+1] = ' ' + j += 2 + continue + } + if buf[j] == nestedQuote { + buf[j] = ' ' + j++ + break + } + buf[j] = ' ' + j++ + } + continue + } + if buf[j] == '{' { + braceDepth++ + } else if buf[j] == '}' { + braceDepth-- + if braceDepth == 0 { + buf[j] = ' ' + j++ + break + } + } + buf[j] = ' ' + j++ + } + continue + } if buf[j] == quote { i = j + 1 return i diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 99548ae..aa3bfa1 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -2047,3 +2048,762 @@ end t.Errorf("expected String.t ref from callback type annotation, refs: %v", refs) } } + +// --- Regression tests for parser edge cases --- + +func TestStripCommentsAndStrings_CharLiteralQuote(t *testing.T) { + // Bug 1: ?" is a char literal, should not start a string. + input := `x = ?"; Foo.bar()` + result := StripCommentsAndStrings(input) + if !strings.Contains(result, "Foo.bar") { + t.Errorf("Foo.bar should survive stripping, got %q", result) + } +} + +func TestParseFile_CharLiteralDoesNotConfuseStringBlanking(t *testing.T) { + // Bug 1: char literal ?" should not eat the module ref on the same line + path := writeTempFile(t, "defmodule MyApp.Foo do\n def bar do\n x = ?\"\n Real.Module.call()\n end\nend\n") + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + found := false + for _, r := range refs { + if r.Module == "Real.Module" && r.Function == "call" { + found = true + } + } + if !found { + t.Errorf("expected Real.Module.call ref; got refs: %+v", refs) + } +} + +func TestStripCommentsAndStrings_Interpolation(t *testing.T) { + // Bug 2: interpolation #{...} with nested quotes should not terminate string blanking early + input := `foo "hello #{bar("world")}"` + result := StripCommentsAndStrings(input) + if strings.Contains(result, "world") { + t.Errorf("interpolation content should be blanked, got %q", result) + } + if strings.Contains(result, "bar") { + t.Errorf("interpolation content should be blanked, got %q", result) + } +} + +func TestParseFile_InterpolationDoesNotConfuseRefExtraction(t *testing.T) { + // Bug 2: string interpolation with nested quotes containing module refs + path := writeTempFile(t, "defmodule MyApp.Foo do\n def bar do\n x = \"hello #{Real.Module.call(\\\"arg\\\"}\"\n Other.Module.work()\n end\nend\n") + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + for _, r := range refs { + if r.Module == "Real.Module" { + t.Errorf("should not extract refs from inside string interpolation, got %+v", r) + } + } + + found := false + for _, r := range refs { + if r.Module == "Other.Module" && r.Function == "work" { + found = true + } + } + if !found { + t.Errorf("expected Other.Module.work ref; got refs: %+v", refs) + } +} + +func TestCheckHeredoc_TripleQuoteInsideString(t *testing.T) { + // Bug 3: """ inside a string literal should not toggle heredoc state + line := `x = "contains triple quote: """ and more"` + newState, skip := CheckHeredoc(line, false) + if newState { + t.Error(`""" inside string should not toggle heredoc on`) + } + if skip { + t.Error(`line with """ inside string should not be skipped`) + } +} + +func TestParseFile_TripleQuoteInStringDoesNotToggleHeredoc(t *testing.T) { + // Bug 3: """ inside a string should not cause subsequent lines to be skipped + path := writeTempFile(t, "defmodule MyApp.Foo do\n def bar do\n x = \"contains \\\"\\\"\\\" triple quotes\"\n Real.Module.call()\n end\nend\n") + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + found := false + for _, r := range refs { + if r.Module == "Real.Module" && r.Function == "call" { + found = true + } + } + if !found { + t.Errorf("Real.Module.call should be found; got refs: %+v", refs) + } +} + +func TestParseText_LineContinuation(t *testing.T) { + // Bug 4: backslash at EOL joins with next line. + // Use a case where the module name spans the continuation boundary. + text := "defmodule MyApp.Foo\n alias Some.\\\n Module\n def bar, do: Some.Module.call()\nend\n" + + _, refs, err := ParseText("test.ex", text) + if err != nil { + t.Fatal(err) + } + + found := false + for _, r := range refs { + if r.Module == "Some.Module" && r.Function == "call" { + found = true + } + } + if !found { + t.Errorf("Some.Module.call should be resolved after line continuation; got refs: %+v", refs) + } +} + +func TestJoinContinuedLines_BareBackslash(t *testing.T) { + result := JoinContinuedLines([]string{"def foo,\\", " do: :bar"}) + if len(result) != 1 { + t.Fatalf("expected 1 line, got %d: %v", len(result), result) + } + if !strings.Contains(result[0], "do: :bar") { + t.Errorf("lines should be joined, got %q", result[0]) + } +} + +func TestJoinContinuedLines_BackslashInString(t *testing.T) { + // Backslash inside a string at EOL should NOT join lines + result := JoinContinuedLines([]string{`x = "hello\"`, "y = 1"}) + if len(result) != 2 { + t.Errorf("backslash in string should not join lines, got %d: %v", len(result), result) + } +} + +func TestJoinContinuedLines_MultipleContinuations(t *testing.T) { + result := JoinContinuedLines([]string{"def foo,\\", " arg1,\\", " do: :bar"}) + if len(result) != 1 { + t.Fatalf("expected 1 line after 3 continuations, got %d: %v", len(result), result) + } +} + +func TestJoinBracketLines_MultiLineAlias(t *testing.T) { + result := JoinBracketLines([]string{ + "alias MyApp.{", + " Accounts,", + " Users,", + " Billing", + "}", + }) + if len(result) != 1 { + t.Fatalf("expected 1 joined line, got %d: %v", len(result), result) + } + if !strings.Contains(result[0], "Accounts") || !strings.Contains(result[0], "Billing") { + t.Errorf("joined line should contain all segments, got %q", result[0]) + } +} + +func TestJoinBracketLines_MultiLineDef(t *testing.T) { + result := JoinBracketLines([]string{ + "def foo(arg1,", + " arg2)", + }) + if len(result) != 1 { + t.Fatalf("expected 1 joined line, got %d: %v", len(result), result) + } + if !strings.Contains(result[0], "arg1") || !strings.Contains(result[0], "arg2") { + t.Errorf("joined line should contain both args, got %q", result[0]) + } +} + +func TestJoinBracketLines_NestedBrackets(t *testing.T) { + result := JoinBracketLines([]string{ + "alias MyApp.{Accounts.{Admin, User}}", + }) + if len(result) != 1 { + t.Fatalf("single line with balanced brackets should stay 1 line, got %d", len(result)) + } +} + +func TestJoinBracketLines_BracketInString(t *testing.T) { + result := JoinBracketLines([]string{ + `foo("bar { baz")`, + "next_line()", + }) + if len(result) != 2 { + t.Errorf("bracket in string should not cause joining, got %d: %v", len(result), result) + } +} + +func TestJoinBracketLines_NoOpenBrackets(t *testing.T) { + result := JoinBracketLines([]string{ + "def foo do", + " :ok", + "end", + }) + if len(result) != 3 { + t.Errorf("lines without open brackets should pass through, got %d: %v", len(result), result) + } +} + +func TestJoinBracketLines_BitstringNotDoubleAngle(t *testing.T) { + // << and >> are bitstring delimiters, not double < or > + result := JoinBracketLines([]string{ + `x = <<1, 2, 3>>`, + "y = 1", + }) + if len(result) != 2 { + t.Errorf("bitstring on one line should not join, got %d: %v", len(result), result) + } +} + +func TestParseText_MultiLineAlias(t *testing.T) { + text := "defmodule MyApp do\n" + + " alias MyApp.{\n" + + " Accounts,\n" + + " Users\n" + + " }\n" + + "\n" + + " def foo do\n" + + " Accounts.list()\n" + + " end\n" + + "end\n" + + _, refs, err := ParseText("test.ex", text) + if err != nil { + t.Fatal(err) + } + + // Should have alias refs for MyApp.Accounts and MyApp.Users + aliasRefs := filterRefs(refs, "alias") + found := map[string]bool{} + for _, r := range aliasRefs { + found[r.Module] = true + } + if !found["MyApp.Accounts"] { + t.Error("expected alias ref for MyApp.Accounts from multi-line alias") + } + if !found["MyApp.Users"] { + t.Error("expected alias ref for MyApp.Users from multi-line alias") + } + + // Accounts.list() should resolve via the alias + callRefs := filterRefs(refs, "call") + foundCall := false + for _, r := range callRefs { + if r.Module == "MyApp.Accounts" && r.Function == "list" { + foundCall = true + } + } + if !foundCall { + t.Error("expected Accounts.list() to resolve to MyApp.Accounts.list via alias") + } +} + +func TestParseText_MultiLineDefArgs(t *testing.T) { + text := "defmodule MyApp do\n" + + " def foo(arg1,\n" + + " arg2) do\n" + + " :ok\n" + + " end\n" + + "end\n" + + defs, _, err := ParseText("test.ex", text) + if err != nil { + t.Fatal(err) + } + + found := false + for _, d := range defs { + if d.Function == "foo" { + found = true + if d.Arity != 2 { + t.Errorf("foo should have arity 2, got %d", d.Arity) + } + if d.Line != 2 { + t.Errorf("foo should be on line 2 (first line of joined group), got %d", d.Line) + } + } + } + if !found { + t.Error("missing def foo from multi-line definition") + } +} + +func TestJoinBracketLines_CommentWithBackslash(t *testing.T) { + // Backslash in a comment should NOT join lines + result := JoinContinuedLines([]string{"x = 1 # trailing \\", "y = 2"}) + if len(result) != 2 { + t.Errorf("backslash in comment should not join lines, got %d: %v", len(result), result) + } +} + +// --- Regression tests for multi-line construct bugs --- + +func TestParseFile_MultiLineAliasAs(t *testing.T) { + // Bug: alias with multi-line as: was silently lost because the trailing + // comma didn't trigger bracket joining and the parser saw two separate lines. + path := writeTempFile(t, `defmodule MyApp do + alias MyModule.MySubModule, + as: Something + + def foo do + Something.call() + end +end +`) + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + // The alias ref should be recorded + foundAlias := false + for _, r := range refs { + if r.Kind == "alias" && r.Module == "MyModule.MySubModule" { + foundAlias = true + } + } + if !foundAlias { + t.Error("expected alias ref for MyModule.MySubModule") + } + + // Something.call() should resolve via the as: alias + foundCall := false + for _, r := range refs { + if r.Kind == "call" && r.Module == "MyModule.MySubModule" && r.Function == "call" { + foundCall = true + } + } + if !foundCall { + t.Error("expected Something.call() to resolve to MyModule.MySubModule.call via as: alias") + } +} + +func TestParseFile_MultiLineAliasAs_Defdelegate(t *testing.T) { + // Multi-line alias ... as: must resolve for defdelegate targets too. + path := writeTempFile(t, `defmodule MyApp do + alias MyApp.Serializer.Date, + as: DateSerializer + + defdelegate format(date), to: DateSerializer +end +`) + + defs, _, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + for _, d := range defs { + if d.Function == "format" { + if d.DelegateTo != "MyApp.Serializer.Date" { + t.Errorf("expected DelegateTo MyApp.Serializer.Date, got %q", d.DelegateTo) + } + return + } + } + t.Error("missing defdelegate format") +} + +func TestParseFile_SigilTripleQuoteDoesNotToggleHeredoc(t *testing.T) { + // Bug: """ inside ~s(...) toggled heredoc mode on, causing subsequent + // lines to be silently skipped by the parser. + path := writeTempFile(t, `defmodule MyApp do + def foo do + x = ~s(this has """ inside parens) + Real.Module.call() + end +end +`) + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + found := false + for _, r := range refs { + if r.Module == "Real.Module" && r.Function == "call" { + found = true + } + } + if !found { + t.Error("expected Real.Module.call ref — triple quote inside sigil may have toggled heredoc") + } +} + +func TestParseFile_SigilTripleQuoteDoesNotToggleHeredoc_Bracket(t *testing.T) { + // Same bug with ~s[...] delimiter. + path := writeTempFile(t, `defmodule MyApp do + def foo do + x = ~s[this has """ inside brackets] + Real.Module.call() + end +end +`) + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + found := false + for _, r := range refs { + if r.Module == "Real.Module" && r.Function == "call" { + found = true + } + } + if !found { + t.Error("expected Real.Module.call ref") + } +} + +func TestParseFile_MultiLineSigilNoFalseRefs(t *testing.T) { + // Bug: multi-line sigil content was indexed as real references because the + // parser had no "inside sigil" tracking (only heredoc tracking). + path := writeTempFile(t, `defmodule MyApp do + @doc ~S( + Fake.Module.ref() inside sigil + ) + def foo do + Real.Module.call() + end +end +`) + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + for _, r := range refs { + if r.Module == "Fake.Module" { + t.Errorf("should not extract refs from inside multi-line sigil, got %+v", r) + } + } + + found := false + for _, r := range refs { + if r.Module == "Real.Module" && r.Function == "call" { + found = true + } + } + if !found { + t.Error("expected Real.Module.call ref after multi-line sigil") + } +} + +func TestParseFile_MultiLineSigilNoFalseDefs(t *testing.T) { + // Multi-line sigil should not swallow subsequent function definitions. + path := writeTempFile(t, `defmodule MyApp do + @doc ~S( + multi-line sigil content + ) + def foo do + :ok + end + + def bar do + :ok + end +end +`) + + defs, _, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + funcs := map[string]bool{} + for _, d := range defs { + if d.Function != "" { + funcs[d.Function] = true + } + } + if !funcs["foo"] { + t.Error("missing def foo after multi-line sigil") + } + if !funcs["bar"] { + t.Error("missing def bar after multi-line sigil") + } +} + +func TestFindUnclosedSigil(t *testing.T) { + tests := []struct { + line string + found bool + }{ + {` @doc ~S(`, true}, + {` )`, false}, + {` x = ~s(foo)`, false}, + {` x = ~s(foo`, true}, + {` Fake.Module.ref() inside sigil`, false}, + } + for _, tt := range tests { + _, ok := findUnclosedSigil(tt.line) + if ok != tt.found { + t.Errorf("findUnclosedSigil(%q) = %v, want %v", tt.line, ok, tt.found) + } + } +} + +func TestBracketDepth_SigilOpen(t *testing.T) { + tests := []struct { + line string + expect int + }{ + {` @doc ~S(`, 0}, + {` @doc ~S( Fake.Module.ref() inside sigil`, 0}, + {` @doc ~S( Fake.Module.ref() inside sigil )`, 0}, + {` def foo(arg1,`, 1}, + } + for _, tt := range tests { + got := bracketDepth(tt.line) + if got != tt.expect { + t.Errorf("bracketDepth(%q) = %d, want %d", tt.line, got, tt.expect) + } + } +} + +// --- Additional regression tests for edge cases --- + +func TestParseFile_MultiLineUseWithOpts(t *testing.T) { + // use with opts spanning multiple lines must produce correct refs + path := writeTempFile(t, `defmodule MyApp.Worker do + use GenServer, + restart: :transient + + def init(state), do: {:ok, state} +end +`) + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + found := false + for _, r := range refs { + if r.Module == "GenServer" && r.Kind == "use" { + found = true + } + } + if !found { + t.Errorf("expected use ref for GenServer; refs: %+v", refs) + } +} + +func TestParseFile_MultilineDefWithDefaults(t *testing.T) { + // Function head with params spanning lines AND \\\\ defaults + path := writeTempFile(t, `defmodule MyApp.Accounts do + def fetch( + slug, + opts \\ [] + ) do + :ok + end +end +`) + + defs, _, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + found := map[string]bool{} + for _, d := range defs { + if d.Function == "fetch" { + found[fmt.Sprintf("fetch/%d", d.Arity)] = true + } + } + if !found["fetch/1"] || !found["fetch/2"] { + t.Errorf("expected fetch/1 and fetch/2 from multiline def with defaults; got %v", found) + } +} + +func TestParseFile_StringContainingDirectiveComma(t *testing.T) { + // A string literal that looks like "alias Foo," must NOT trigger joining + path := writeTempFile(t, `defmodule MyApp.Foo do + def bar do + x = "alias Fake.Module," + Real.Module.call() + end +end +`) + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + foundReal := false + for _, r := range refs { + if r.Module == "Real.Module" && r.Function == "call" { + foundReal = true + } + if r.Module == "Fake.Module" { + t.Errorf("should not extract refs from string content, got %+v", r) + } + } + if !foundReal { + t.Errorf("Real.Module.call should be found; refs: %+v", refs) + } +} + +func TestJoinTrailingComma_CommentBetweenLines(t *testing.T) { + // Comment between directive and continuation should not break joining + lines := []Line{ + {Content: " alias MyModule.MySubModule,", OrigNum: 1}, + {Content: " # the short name", OrigNum: 2}, + {Content: " as: Something", OrigNum: 3}, + } + result := joinTrailingComma(lines) + joined := false + for _, l := range result { + if strings.Contains(l.Content, "as: Something") && strings.Contains(l.Content, "alias") { + joined = true + } + } + if !joined { + t.Errorf("comment between directive and continuation should not break joining; got %+v", result) + } +} + +func TestJoinTrailingComma_BlankLineBetween(t *testing.T) { + // Blank line between directive and continuation should stop joining + // (a blank line indicates a new statement in Elixir) + lines := []Line{ + {Content: " use Tool,", OrigNum: 1}, + {Content: "", OrigNum: 2}, + {Content: " name: \"foo\"", OrigNum: 3}, + } + result := joinTrailingComma(lines) + if len(result) < 2 { + t.Errorf("blank line should stop joining; got %d lines: %+v", len(result), result) + } +} + +func TestJoinTrailingComma_NewStatementNotSwallowed(t *testing.T) { + // A new def on the next line should NOT be joined as a continuation + lines := []Line{ + {Content: " use Module,", OrigNum: 1}, + {Content: " def foo, do: :ok", OrigNum: 2}, + } + result := joinTrailingComma(lines) + if len(result) != 2 { + t.Errorf("new statement should not be swallowed as continuation; got %d lines: %+v", len(result), result) + } +} + +func TestParseFile_MultiLineAliasAs_PreservesLineNumber(t *testing.T) { + // Verify that joining preserves the original line number for definitions + path := writeTempFile(t, `defmodule MyApp.Foo do + alias MyModule.MySubModule, + as: Something + + def bar do + :ok + end +end +`) + + defs, _, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + for _, d := range defs { + if d.Function == "bar" { + if d.Line != 5 { + t.Errorf("def bar should be on original line 5, got %d", d.Line) + } + } + } +} + +func TestParseFile_SigilContainingDirective(t *testing.T) { + // Sigil content containing alias/use keywords should not produce refs + path := writeTempFile(t, `defmodule MyApp.Foo do + def bar do + x = ~s(alias Fake.Module) + y = ~s(use Fake.Module, key: val) + Real.Module.call() + end +end +`) + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + for _, r := range refs { + if r.Module == "Fake.Module" { + t.Errorf("should not extract refs from sigil content, got %+v", r) + } + } + + foundReal := false + for _, r := range refs { + if r.Module == "Real.Module" && r.Function == "call" { + foundReal = true + } + } + if !foundReal { + t.Errorf("Real.Module.call should be found; refs: %+v", refs) + } +} + +func TestParseFile_TrailingCommaInAliasBlock(t *testing.T) { + // Trailing comma after last child in alias block (common formatter output) + path := writeTempFile(t, `defmodule MyApp.Web do + alias MyApp.{ + Accounts, + Users, + } + + def foo do + Accounts.list() + end +end +`) + + _, refs, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + + aliasRefs := filterRefs(refs, "alias") + found := map[string]bool{} + for _, r := range aliasRefs { + found[r.Module] = true + } + if !found["MyApp.Accounts"] { + t.Error("expected alias ref for MyApp.Accounts from block with trailing comma") + } + if !found["MyApp.Users"] { + t.Error("expected alias ref for MyApp.Users from block with trailing comma") + } + + callRefs := filterRefs(refs, "call") + foundCall := false + for _, r := range callRefs { + if r.Module == "MyApp.Accounts" && r.Function == "list" { + foundCall = true + } + } + if !foundCall { + t.Error("expected Accounts.list() to resolve to MyApp.Accounts.list via alias") + } +} diff --git a/internal/treesitter/variables_test.go b/internal/treesitter/variables_test.go index e183fa3..083d965 100644 --- a/internal/treesitter/variables_test.go +++ b/internal/treesitter/variables_test.go @@ -1358,3 +1358,25 @@ end`) t.Fatalf("expected 1 occurrence of 'process' (not atom), got %d: %+v", len(occs), occs) } } + +func TestFindVariableOccurrences_DefpLine(t *testing.T) { + src := []byte(`defmodule MyApp.Worker do + def enqueue(resource) do + %{ + resource_type: resource_type(resource) + } + end + + defp resource_type(%{type: t}), do: t +end`) + + // Line 7 is "defp resource_type(%{type: t}), do: t" + // Col 7 is on the 'r' in 'resource_type' + occs := FindVariableOccurrences(src, 7, 7) + if occs != nil { + t.Errorf("expected nil on defp line, got %d occurrences", len(occs)) + for _, occ := range occs { + t.Logf(" Line %d, col %d-%d", occ.Line, occ.StartCol, occ.EndCol) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 53f1c83..a7ec31a 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,4 +5,4 @@ const Version = "0.5.3" // IndexVersion is incremented whenever the index schema or parser changes in a // way that requires a full rebuild. Bump this alongside Version when releasing // a change that makes existing indexes stale. -const IndexVersion = 9 +const IndexVersion = 10