Go to definition does not go to definition
How are you using the lua-language-server?
Visual Studio Code Extension (sumneko.lua)
Which OS are you using?
Linux
What is the issue affecting?
Libraries
Expected Behaviour
Following https://github.com/LuaLS/lua-language-server/discussions/3219 I've figured out that Go to definition does not act properly even if variable is set as local explicitly, see the video for details
Actual Behaviour
https://github.com/user-attachments/assets/cd98b077-bd11-461b-aa2c-78b375087dbf
Reproduction steps
I've created a repo with the code used in a video so you can clone and test it yourself https://github.com/Real-Gecko/lua-ls-test
Additional Notes
No response
Log File
No response
I am not sure but I guess assigning value to a field (setfield operation) is a kind of definition of that field? 😕
So this is the normal behavior? (at least I used to this behavior LOL)
this is also true for local var, a minimal example
local A
A = 1
print(A) --< use goto definition on `A` here
--> will list both lines of `local A` and `A = 1`
Hmm, I guess you're confusing Go to definition from class instance and Go to definition from actual class definition. I've recorded a video of working with Python "version" of the same scripts. But I can say for sure that this behavior is the same for any PL in VSCode.
https://github.com/user-attachments/assets/eeea6261-24e7-40b1-be43-47a94c5b8061
Same for Java
https://github.com/user-attachments/assets/f6798ec4-f9e1-4130-9a26-4af4f6a75e6f
Hmm, I guess you're confusing
Go to definitionfrom class instance andGo to definitionfrom actual class definition.
Yeah, maybe you are right 😕 I am actually quite confused now after viewing your screen recording 🙈 .
In particular, I see that you demoed both goto definition and goto declaration. 🤔
Just my point of view, seems what you want is a goto declaration => where the variable / field is first declared ?
However, I duno if LuaLS support this or not 😕
In my own usage experience, goto definition in LLS has long been like this: showing all setter as definitions.
I tried to ask an AI and here is how sonnet 4 replies
- prompt
In vscode's lua-language-server extension, when I use "goto definition" on a variable, it shows results including all assignment statement of this var as well. Is this reasonable?
- sonnet 4's reply
Click to show
This behavior is actually reasonable and typical for the Lua Language Server, though it might feel different if you're coming from statically-typed languages.
Why this happens in Lua:
-
Dynamic typing: In Lua, variables don't have fixed types, so any assignment could potentially change the "definition" of what a variable represents.
-
No explicit declarations: Unlike languages with
var,let, or type declarations, Lua variables are implicitly created on first assignment. -
Scope complexity: Lua's scoping rules mean that what looks like the "same" variable might actually be different variables in different scopes.
Example why this is useful:
local myVar = "initial string" -- Assignment 1
-- ... some code ...
myVar = {data = "table"} -- Assignment 2 - completely different type
-- ... more code ...
myVar = function() end -- Assignment 3 - now it's a function
When you "goto definition" on a usage of myVar, seeing all assignments helps you understand:
- What type the variable might be at that point
- How the variable's purpose has evolved
- Which assignment is most relevant to your current context
Many Lua developers find seeing all assignments helpful for understanding the code flow, especially in a dynamically-typed language where variable "meaning" can change throughout execution.
PS: I'm not maintainer of LLS, maybe this issue should be answered by authors of this extension 🤔 🤔
In particular, I see that you demoed both
goto definitionandgoto declaration. 🤔 Just my point of view, seems what you want is agoto declaration=> where the variable / field is first declared ?
Sorry, my screen capture software is dumb and sometimes skips frames, so after rewatching videos I've noticed the source of confusion: sometimes it seems as if I'm clicking Go to definition but in fact I was pressing Go to declaration(also F12 and Ctrl+Click).
Dynamic typing: In Lua, variables don't have fixed types, so any assignment could potentially change the "definition" of what a variable represents.
No explicit declarations: Unlike languages with
var,let, or type declarations, Lua variables are implicitly created on first assignment.Scope complexity: Lua's scoping rules mean that what looks like the "same" variable might actually be different variables in different scopes.
Yeah, all the same concepts both apply to Python and Javascript, but both Python LS and JS LS go to actual definition of the field if I Ctrl+Click on a field of class instance. And yes, if you Ctrl+Click on field declaration(like in place where your class is defined) it shows you all the places this var is used in, which can be really convenient when trying to understand where this particular field is used.
Here's JS example of the same code, note absence of type definitions
https://github.com/user-attachments/assets/022e68fe-114e-4d9d-9eb1-755b5e6337cc
I can understand your standpoint, that you are trying to use the behavior of LS of other languages to justify that the current behavior of LuaLS is wrong/incorrect. I have no experience in implementing a LS, so I cannot give comment on that 😕
Then I resorted to chatting with sonnet4 🙈
Here is the summary of my conversation with it
Details
Summary: LSP "Go to Definition" vs "Go to Declaration" Implementation
Core Question: How should a Lua language server handle "go to definition" and "go to declaration" for variables?
Key Learnings:
-
Basic Distinction:
- Declaration: Where variable enters scope (
local a) - Definition: Where variable gets its (initial) value (
a = 1)
- Declaration: Where variable enters scope (
-
Lua-Specific Behavior:
- When declaration includes initialization (
local a = 1): both commands → same location - When separate (
local athena = 1): declaration →local a, definition →a = 1 - When no initial value: definition falls back to declaration
- When declaration includes initialization (
-
Multiple Definitions:
- lua-language-server shows multiple locations (e.g., both
local a = 1anda = 2) - This is not wrong - it's sophisticated data-flow analysis
- Provides more context for understanding variable's value chain
- lua-language-server shows multiple locations (e.g., both
-
Implementation Options:
- Simple: Both commands go to declaration
- Standard: Declaration →
local, Definition → first assignment - Advanced: Show multiple relevant locations (like lua-language-server)
Takeaway: The LSP spec is flexible, and showing multiple definitions can be more helpful than forcing arbitrary choices.
Here is the full conversation if you are interested 👀
Click to show
LSP "Goto" Feature in Lua
User:
i am trying to implement a language-server following the lsp defined by vscode.
but i am confused about the "goto definition" and "goto declaration" feature.
what should be the behavior of the above, when performed on the variable a on line3 in the following given lua snippet?
local a = 1
a = 2
print(a) --< use goto xxx on `a` on this line
Assistant:
Great question! The distinction between "goto definition" and "goto declaration" can be confusing, especially in languages like Lua. Let me clarify the expected behavior for your example:
local a = 1 -- Line 1: Declaration AND definition
a = 2 -- Line 2: Assignment/reassignment
print(a) -- Line 3: Usage (where you're triggering goto)
Expected Behavior:
Both "Go to Definition" and "Go to Declaration" should go to Line 1 (local a = 1)
Why?
In Lua (and most dynamically typed languages), the distinction between declaration and definition is less meaningful than in languages like C/C++:
- Declaration: Where the variable is first introduced/declared in scope
- Definition: Where the variable gets its value/implementation
In your example:
- Line 1 is both the declaration (
local a) AND the initial definition (= 1) - Line 2 is just a reassignment, not a new declaration or definition
Language-Specific Considerations:
For C/C++, you might have:
extern int a; // Declaration
int a = 1; // Definition
But in Lua, variables are typically declared and defined simultaneously with local.
Implementation Recommendation:
For your Lua language server, I'd suggest:
- Both commands should navigate to the first occurrence where the variable enters the current scope
- For
localvariables, this is thelocalstatement - For global variables, this might be the first assignment in the file/project
This approach aligns with how most LSP implementations handle dynamically typed languages and provides the most useful behavior for developers.
User:
you said "Definition: Where the variable gets its value/implementation" and on line 2 of my snippet, the variable also gets its value through reassignment why isn't this line considered as a kind of definition?
further more, consider the following slightly modified snippet:
local a
a = 1
a = 2
print(a) --< use goto xxx on this line
what should be the behavior now?
Assistant:
Excellent point! You're absolutely right to question this. Let me clarify the nuanced distinction:
Terminology Clarification:
You're correct that technically, line 2 (a = 2) is also giving the variable a value. The key distinction in LSP context is:
- Declaration: Where the variable is first introduced into scope
- Definition: Where the variable gets its initial value/implementation
- Assignment: Subsequent value changes
For Your Modified Example:
local a -- Line 1: Declaration only
a = 1 -- Line 2: First assignment (initial definition)
a = 2 -- Line 3: Reassignment
print(a) -- Line 4: Usage
Expected behavior:
- Go to Declaration → Line 1 (
local a) - Go to Definition → Line 2 (
a = 1) - the first assignment that gives it a meaningful value
Why This Distinction Matters:
In your modified example, there's now a meaningful difference:
- Line 1 introduces the variable name into scope but doesn't give it a usable value
- Line 2 is where the variable first becomes "defined" with an actual value
- Line 3 is just another assignment
Real-World LSP Behavior:
Different language servers handle this differently:
- TypeScript/JavaScript: Often treats the first assignment as definition
- Python: Similar behavior with uninitialized variables
- Some LSPs: Make both commands go to declaration for simplicity
My Recommendation:
For your Lua LSP implementation:
- Go to Declaration: Always go to the
localstatement - Go to Definition: Go to the first assignment that gives it a value (if declaration doesn't include initialization)
This provides maximum utility - developers can choose whether they want to see where the variable was introduced or where it first got a meaningful value.
User:
more questions:
- if the variable doesn't get initial value, what would be the behavior of "goto definition" ?
local a
print(a) --< trigger goto definition of 'a'
- I used lua-language-server vscode's extension, where goto definition of
awill shows both locationslocal a = 1anda = 2does it implemented this feature wrongly?
Assistant:
Great questions! These highlight the complexity and varying interpretations of LSP behavior.
Question 1: Variable without initial value
local a
print(a) --< trigger goto definition of 'a'
Expected behavior: "Go to Definition" should fall back to the declaration (local a) since there's no separate definition.
Rationale: The variable is declared but never explicitly assigned, so the declaration IS the only definition available.
Question 2: lua-language-server showing multiple locations
The lua-language-server showing both local a = 1 and a = 2 is NOT wrong - it's actually a more sophisticated approach!
Why Multiple Results Make Sense:
local a = 1 -- Original definition
a = 2 -- Redefinition that changes the value
print(a) -- Which definition is relevant?
Arguments for showing multiple locations:
- Data flow analysis: Both assignments affect the value that
print(a)will see - Code understanding: Developers often want to see all places where a variable gets defined
- Debugging: Understanding the variable's "definition chain" is valuable
LSP Specification Flexibility:
The LSP spec doesn't strictly define what constitutes "the definition" - it allows for:
- Single location (most common)
- Multiple locations (like lua-language-server)
- Contextual decisions based on data flow
Industry Examples:
- Rust Analyzer: Sometimes shows multiple definitions for trait implementations
- TypeScript: May show multiple locations for overloaded functions
- Python: Some LSPs show all assignments in certain contexts
Conclusion:
lua-language-server's behavior is actually quite sophisticated and user-friendly. It's providing more information rather than forcing the LSP to make an arbitrary choice about which definition is "the one."
For your implementation, you could:
- Simple approach: Always go to declaration
- Advanced approach: Implement data-flow analysis like lua-language-server
- Hybrid: Provide configuration options for user preference
In short, the LSP spec doesn't strictly define what constitutes "the definition", so it says LuaLS returning multiple locations, including those assign/reassign statements, is NOT wrong 🤔
Moreover, it distinguishes definition as assignment to that variable, while declaration as introducing variable into scope. Thus your expected goto definition behavior is more like a goto declaration (location where this member field is declared) to me 🧐 .
Either way, as a LuaLS user I used to the current goto definition behavior. If this is going to be changed (or "fixed" if you wish to say it this way), I hope that there can be an option to toggle this behavior:
- the default one is including all assignment locations
- the other one is your expected behavior, which only shows the first declaration
That will be a win-win 😄
Moreover, it distinguishes
definitionas assignment to that variable, whiledeclarationas introducing variable into scope. Thus your expected goto definition behavior is more like a goto declaration (location where this member field is declared) to me 🧐 .
Totally makes sense 🤔 , however other LS work the way I showed, that's why I'm confused, initially I thought it's due to variable being global, but turns out it's not the case.