Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions internal/lsp/rename_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,49 @@ end
}
}

func TestRename_Module_PlainAliasUpdatesRequireInSameFile(t *testing.T) {
server, cleanup := setupTestServer(t)
defer cleanup()

defContent := `defmodule SharedLib.Values.ServiceEndpointConfig do
defmacro __using__(_), do: :ok
end
`
// A config script that aliases the module by its plain (last-segment) name
// and then refers to it by that short name in a `require`.
callerContent := `import Config

alias SharedLib.Values.ServiceEndpointConfig

require ServiceEndpointConfig
`
callerPath := filepath.Join(server.projectRoot, "config", "runtime.exs")
indexFile(t, server.store, server.projectRoot, "lib/service_endpoint_config.ex", defContent)
indexFile(t, server.store, server.projectRoot, "config/runtime.exs", callerContent)

callerURI := "file://" + callerPath
server.docs.Set(callerURI, callerContent)

// Rename the alias on the alias line (line 2). Column 20 is the start of
// "ServiceEndpointConfig" in "alias SharedLib.Values.ServiceEndpointConfig".
edit := renameAt(t, server, callerURI, 2, uint32(len("alias SharedLib.Values.")), "PublicEndpointConfig")
if edit == nil {
t.Fatal("expected non-nil edit")
}

edits := collectEdits(edit, callerPath)

// The alias declaration's full module reference must be updated.
if !hasEdit(edits, "SharedLib.Values.PublicEndpointConfig") {
t.Errorf("expected alias line full-module edit to SharedLib.Values.PublicEndpointConfig; edits=%+v", edits)
}
// The `require` referencing the short alias name must also be updated, and
// only its short-name segment (line 4) — not turned into the full module.
if !editsContainLine(edits, 4) || !hasEdit(edits, "PublicEndpointConfig") {
t.Errorf("expected `require` short-name on line 4 to be renamed to PublicEndpointConfig; edits=%+v", edits)
}
}

func TestRename_Module_AliasAsPreserved(t *testing.T) {
server, cleanup := setupTestServer(t)
defer cleanup()
Expand Down
16 changes: 8 additions & 8 deletions internal/parser/parser_tokenized.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,8 @@ func parseTextFromTokens(path string, source []byte, tokens []Token) ([]Definiti
j := nextSig(i)
modName, k := collectModuleName(j)
if modName != "" {
resolved := resolveModule(modName, currentModule())
if !strings.Contains(resolved, "__MODULE__") {
resolved := ResolveModuleRef(modName, aliases, currentModule())
if resolved != "" {
refs = append(refs, Reference{Module: resolved, Line: importLine, FilePath: path, Kind: "import"})
injectors[resolved] = true
}
Expand All @@ -442,8 +442,8 @@ func parseTextFromTokens(path string, source []byte, tokens []Token) ([]Definiti
j := nextSig(i)
modName, k := collectModuleName(j)
if modName != "" {
resolved := resolveModule(modName, currentModule())
if !strings.Contains(resolved, "__MODULE__") {
resolved := ResolveModuleRef(modName, aliases, currentModule())
if resolved != "" {
refs = append(refs, Reference{Module: resolved, Line: useLine, FilePath: path, Kind: "use"})
injectors[resolved] = true
}
Expand All @@ -464,8 +464,8 @@ func parseTextFromTokens(path string, source []byte, tokens []Token) ([]Definiti

// Check for require Module, as: Name
if asName, nextPos, ok := ScanKeywordOptionValue(source, tokens, n, k, "as"); ok {
resolved := resolveModule(modName, cm)
if !strings.Contains(resolved, "__MODULE__") {
resolved := ResolveModuleRef(modName, aliases, cm)
if resolved != "" {
aliases[asName] = resolved
refs = append(refs, Reference{Module: resolved, Line: requireLine, FilePath: path, Kind: "require"})
}
Expand All @@ -474,8 +474,8 @@ func parseTextFromTokens(path string, source []byte, tokens []Token) ([]Definiti
}

// Simple require (no as:) — still emit reference but no alias
resolved := resolveModule(modName, cm)
if !strings.Contains(resolved, "__MODULE__") {
resolved := ResolveModuleRef(modName, aliases, cm)
if resolved != "" {
refs = append(refs, Reference{Module: resolved, Line: requireLine, FilePath: path, Kind: "require"})
}
i = k
Expand Down
93 changes: 86 additions & 7 deletions internal/treesitter/variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ func FindVariableOccurrencesWithTree(root *tree_sitter.Node, src []byte, line, c
return occurrences
}

skipRoot := resolved.scope.Kind() == "stab_clause"
// A def-family call (scope.Kind() == "call" here, since with/for calls are
// handled above) is the scope root, so skip the def-boundary check on it —
// otherwise collection would bail immediately on the very scope it chose.
skipRoot := resolved.scope.Kind() == "stab_clause" || resolved.scope.Kind() == "call"
collectVariableOccurrences(resolved.scope, src, resolved.varName, &occurrences, skipRoot)
return occurrences
}
Expand Down Expand Up @@ -114,16 +117,27 @@ func NameExistsInScopeOf(root *tree_sitter.Node, src []byte, line, col uint, new
}

// findFirstNonCallIdentifier returns the first identifier node in the subtree
// matching name that is not a function name in a call expression.
// matching name that is not a function name in a call expression. Nested
// function definitions (def/defp/etc.) are independent scopes, so they are not
// descended into — a same-named binding inside one is not a collision in the
// scope rooted at node. (The root itself may be such a def call when renaming a
// function-local; that is the chosen scope and is always searched.)
func findFirstNonCallIdentifier(node *tree_sitter.Node, src []byte, name string) *tree_sitter.Node {
return findFirstNonCallIdentifierInScope(node, src, name, true)
}

func findFirstNonCallIdentifierInScope(node *tree_sitter.Node, src []byte, name string, isRoot bool) *tree_sitter.Node {
if node == nil {
return nil
}
if !isRoot && definesNestedScope(node, src) {
return nil
}
if node.Kind() == "identifier" && node.Utf8Text(src) == name && !isFunctionNameInCall(node, src) {
return node
}
for i := uint(0); i < uint(node.ChildCount()); i++ {
if found := findFirstNonCallIdentifier(node.Child(i), src, name); found != nil {
if found := findFirstNonCallIdentifierInScope(node.Child(i), src, name, false); found != nil {
return found
}
}
Expand Down Expand Up @@ -289,6 +303,44 @@ func isDefinitionKeyword(name string) bool {
return defKeywords[name]
}

// moduleKeywords are the keywords that open a module body — an independent
// variable scope. Variables bound directly in a module body belong only to
// that module, not to sibling modules or the surrounding script.
var moduleKeywords = map[string]bool{
"defmodule": true, "defprotocol": true, "defimpl": true,
}

// isFunctionDefinitionCall reports whether node is a def/defp/defmacro/etc.
// call — the boundary of an independent variable scope. Variables inside a
// function definition do not leak to (and cannot reference) an enclosing
// module/script scope, so traversals rooted at an outer scope must not descend
// into these.
func isFunctionDefinitionCall(node *tree_sitter.Node, src []byte) bool {
if node.Kind() != "call" || node.ChildCount() == 0 {
return false
}
first := node.Child(0)
return first.Kind() == "identifier" && functionKeywords[first.Utf8Text(src)]
}

// isModuleDefinitionCall reports whether node is a defmodule/defprotocol/defimpl
// call, which opens a module-body scope.
func isModuleDefinitionCall(node *tree_sitter.Node, src []byte) bool {
if node.Kind() != "call" || node.ChildCount() == 0 {
return false
}
first := node.Child(0)
return first.Kind() == "identifier" && moduleKeywords[first.Utf8Text(src)]
}

// definesNestedScope reports whether node is a call that introduces its own
// variable scope — a function or module definition. A traversal rooted at an
// outer scope (a module body, or the whole file) must not descend into these,
// or a rename/collision check would wrongly reach into an unrelated scope.
func definesNestedScope(node *tree_sitter.Node, src []byte) bool {
return isFunctionDefinitionCall(node, src) || isModuleDefinitionCall(node, src)
}

// isAssignmentTarget returns true if node is on the left-hand side of a `=`
// binary operator, meaning it is unambiguously a variable binding.
func isAssignmentTarget(node *tree_sitter.Node, src []byte) bool {
Expand All @@ -309,24 +361,31 @@ func isAssignmentTarget(node *tree_sitter.Node, src []byte) bool {
// at the cursor position is ambiguous (could be a zero-arity function call)
// and should not be treated as a variable.
func variableDefinedInScope(scope *tree_sitter.Node, src []byte, varName string, cursorLine, cursorCol uint) bool {
return identifierExistsElsewhere(scope, src, varName, cursorLine, cursorCol)
return identifierExistsElsewhere(scope, src, varName, cursorLine, cursorCol, true)
}

// identifierExistsElsewhere returns true if an identifier matching name
// exists anywhere in the subtree at a position different from (line, col).
// It skips function names in calls and definition keywords.
func identifierExistsElsewhere(node *tree_sitter.Node, src []byte, name string, line, col uint) bool {
// It skips function names in calls and definition keywords. Nested function
// definitions are independent scopes and are not descended into (isRoot guards
// the chosen scope itself, which may be such a def call) — otherwise a bare
// top-level call sharing a name with a function-local would be misread as a
// variable.
func identifierExistsElsewhere(node *tree_sitter.Node, src []byte, name string, line, col uint, isRoot bool) bool {
if node == nil {
return false
}
if !isRoot && definesNestedScope(node, src) {
return false
}
if node.Kind() == "identifier" && node.Utf8Text(src) == name && !isFunctionNameInCall(node, src) {
pos := node.StartPosition()
if uint(pos.Row) != line || uint(pos.Column) != col {
return true
}
}
for i := uint(0); i < uint(node.ChildCount()); i++ {
if identifierExistsElsewhere(node.Child(i), src, name, line, col) {
if identifierExistsElsewhere(node.Child(i), src, name, line, col, false) {
return true
}
}
Expand Down Expand Up @@ -363,13 +422,24 @@ func findEnclosingScope(node *tree_sitter.Node, src []byte, varName string) *tre
if firstChild.Kind() == "identifier" && functionKeywords[firstChild.Utf8Text(src)] {
return current
}
// A module body (defmodule/defprotocol/defimpl) is its own scope:
// module-level bindings belong to this module, not to sibling
// modules or the surrounding script.
if firstChild.Kind() == "identifier" && moduleKeywords[firstChild.Utf8Text(src)] {
return current
}
// with/for/etc.: scope boundary unless cursor is on clause 0's rhs (outer scope).
if callHasDoBlock(current) && callArgumentPatternsBindVariable(current, src, varName) {
if cursorNeedsWithScope(current, prev, node, src, varName) {
return current
}
}
}
// Reached the file root without an inner scope: top-level script
// bindings (e.g. config/runtime.exs) are scoped to the whole file.
if current.Kind() == "source" {
return current
}
Comment thread
cursor[bot] marked this conversation as resolved.
prev = current
current = current.Parent()
}
Expand Down Expand Up @@ -463,6 +533,15 @@ func collectVariableOccurrences(node *tree_sitter.Node, src []byte, varName stri
collectPatternExpressionOccurrences(node, src, varName, out)
return
}

// Function and module definitions introduce their own variable scope.
// When collecting from an outer scope — e.g. the whole file for a
// top-level script binding, or a module body — do not descend into a
// nested definition, or a rename would wrongly touch same-named bindings
// that live in that separate scope.
if definesNestedScope(node, src) {
return
}
}

for i := uint(0); i < uint(node.ChildCount()); i++ {
Expand Down
Loading
Loading