LSP support for HEEX ~H sigil contents & tree-sitter-heex - #75
LSP support for HEEX ~H sigil contents & tree-sitter-heex#75superhawk610 wants to merge 46 commits into
Conversation
|
Hey @superhawk610, nice work on this! However, I would suggest that we take a slightly different approach. I think that we should index HEEX (both inside sigils and heex files) as well using the tokenizer and parser. My reasoning:
I realize that this is more complex to build up front, but I think the end result would be well worth it. |
|
I'm working on indexing HEEX with the tokenizer/parser so it's incorporated with the existing process. I'm not confident I can write a full tokenizer for HEEX's grammar, so I'm going with a hybrid approach for now that still shells out to |
superhawk610
left a comment
There was a problem hiding this comment.
At this point go-to definition works, but find all references doesn't since the tree-sitter parsing in variables.go treats HEEX sigil contents as opaque. I've taken a pass at a possible approach through this using nested HEEX tree-sitter trees, but this has grown quite a bit in scope. I think a good next step would be to think this through a bit more thoroughly at a high level and plan the implementation more explicitly. What do you think?
I can take a stab at this part if you'd like - I can't promise a specific timeline though. Unfortunately, we can't ship something that shells out to tree-sitter during indexing. It's extremely important that we don't have performance regressions during indexing. There's too much overhead in calling out to tree-sitter. Remote's codebase has >57k files to parse, so we need indexing to be as fast as possible. |
|
No expectations at all on timeline! I've gotten this to handle what I need (go-to definition within HEEX sigils), and I don't mind just building from my fork if this ends up being too much work or too niche. Totally agree on avoiding performance regressions, Dexter's speed is its best selling point amongst my peers! 😎 Please feel free to contribute whatever you'd like to this branch, take over or use some/any of this PR, or close it out if it's not in the cards. |
|
I'll see if I can take a stab at it on this PR sometime this week and we can ship it together. In the meantime, feel free to keep shipping things to this branch and I'll add stuff when I can. |
|
I started on a barebones HEEX tokenizer. The line between tokenizer and parser is a bit blurry and this may lean a bit far into parsing. My aim is to get a simple replacement for |
|
OK, HEEX tokenizer is now on par with the
|
textDocument/definition support for HEEX ~H sigil via tree-sitter-heex4c40ca7 to
429f6bd
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 429f6bd. Configure here.
|
Alright @JesseHerrick updated the approach per your request. HEEX files and sigil contents are now fully indexed and the existing Let me know what you think! |
|
@JesseHerrick thanks for the thorough review! I'm working through addressing your feedback this week, I'll try to get what I can pushed up by this weekend. As I've been using this locally over the past month or so, I've realized another small gap - string interpolation doesn't output any interpolated tokens, so go-to definition like this doesn't work: defmodule MyModule do
def run do
"foo #{bar()} baz"
# ^^^^^
# go-to definition here should jump to `defp bar`
end
defp bar, do: ".."
end This isn't strictly related to the HEEX tokenization this PR does, but I was able to get it working with minor changes to This emits a single If it's out of scope for this PR no worries, just thought I'd mention here since I've been enjoying having it locally. EDIT: Alright, I've addressed most of the outstanding feedback and pushed up my work so far. It includes the above changes to string interpolation. I still need to address the comments about variable resolution in EDIT 2: Resolved 2/3 outstanding issues. Tracking whether a token is logically contained in another token required adding a EDIT 3: Alright, resolved the last issue around HEEX variable scoping. This was outside of my comfort zone so I got Claude's help, and also had it do a pass over everything we've implemented so far and it caught and resolved a few more issues. |
Curly interpolation {..} is disabled within script/style tags and when
the phx-no-curly-interpolation attribute is present on a parent tag.
This also fixes a latent issue with `scanInterpolation` where terminator
sequences would always be offset, even if they weren't encountered.
- references to local functions are no longer emitted - references contained in single-line `def` and module attributes are now emitted correctly - function component refs now correctly emit only when injectors are present - HEEX expressions now emit references properly - fixed failing tests for tokenizing string interpolation - `TokString` now properly comes before the interpolated tokens and contains the full Start:End range of the string
713f6f3 to
679fcab
Compare
Tokens now store a Parent which indicates the offset of the token which logically contains them, if they're part of an interpolation. This is now taken into account by TokenAtOffset.
Model HEEX bindings (EEx loops, case/cond clause arms, :for/:let) as byte ranges over the flat sibling directives they span, assign each occurrence to its smallest active range, and filter rename, collision, and autocomplete results to the cursor's range. Fixes conflation of same-named bindings across HEEX directive boundaries. - FindVariableOccurrences: post-pass range filter (shadowing-safe) - NameExistsInScopeOf: collision reach matches rename reach - FindVariablesInScope: prune out-of-scope loop/:let vars - add TreeNode.RawChild/RawChildCount/NamedChild navigation primitives Pure-Elixir behavior unchanged (no-op when no sub-trees in scope). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
visitTree only called onLeave for last-sibling nodes, so a
<script>/<style>/phx-no-curly-interpolation tag leaked its
interpolate=false state to following siblings, leaving their {expr}
unparsed. Fire onLeave once per node in post-order.
Also address review findings in the HEEX tokenizer: fix onNode
operator precedence, use startLine for empty sigils, bounds-guard
scanSigilCharacters, enforce tokenizeUntil opener/terminator prefix
invariant with a panic, and rename isStatementStart to couldBeginCall.
Add regression tests for sibling interpolation leakage and nested loops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an ordered branchesByStart index alongside Tree.Branches so collectHeexRangesInScope binary-searches overlapping sub-trees instead of linearly scanning the whole map. Branches within a tree are disjoint, non-nested siblings, so a sorted slice + sort.Search gives O(log n + k). The map is retained for the O(1) node-ID lookups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Sorry for being slow here @superhawk610! This is a major change so will need to spend some time properly digging in here. I'll review when I can. |

Resolves #74.
This PR extends the existing Elixir tokenizer to parse the contents of
~Hsigils and resolve any function components and expressions contained within so thattextDocument/definitionworks. This allows all common LSP functionality within~Hsigils, commonly found in Phoenix LiveViewrenderfunctions and function components. It also extends the existingtree-sitter-elixirparser withtree-sitter-heexin nested sub-trees within sigils.HEEX Parsing
TokenizeHEEXwill only output a minimal subset of HEEX contents:TokModule/TokIdentfor function components<% .. %>/{ .. }interpolationsTokHEEXOpenTag/TokHEEXCloseTagfor HTML<and</tagsTokEOLfor end-of-lineAll other HEEX contents are ignored during tokenization.
The parser has also been extended to read
.fooas a function call tofoo()when it occurs immediately after aTokHEEXOpenTag/TokHEEXCloseTag. Function components with a module prefix, e.g.<Foo.bar />, are already handled by the existing parser.Tree-sitter Integration
The existing tree-sitter tree stored by the document cache is a single tree with a single language. It's been extended with new
Tree/TreeNodetypes that support nested trees with different languages. A typical Elixir document will now be modeled with a 3-level Elixir->HEEX->Elixir tree:The
TreeNodetype is a light wrapper around*tree_sitter.Nodethat also tracks whichTreethe node is a member of, facilitates traversal between nested trees, and wraps common methods likeStartByte(),EndByte(),StartPosition(),EndPosition(), andUtf8Text()to correctly handle byte offsets from the root tree.Note
Medium Risk
Touches core document parsing, caching, and all tree-sitter-driven LSP paths; scope is large but covered by new HEEX and tree tests and an index version bump.
Overview
Adds Phoenix HEEX support inside
~Hsigils so LSP navigation (go-to-definition, references, hover, completion, rename, highlights) works on LiveView templates, not only plain Elixir.The tokenizer now lexes
~Hbodies viaTokenizeHeex(components like<.foo />,<Foo.bar />,{...}/<% %>interpolations) and maps<.nameafter HEEX open tags to function calls in the reference indexer.ExpressionAtCursorgains HEEX-aware cursor context tests.Tree-sitter is refactored from a single Elixir parse to a nested
treesitter.Tree: Elixir trunk with HEEX branches on~Hquoted_contentand Elixir branches on HEEXexpression_value, usingtree-sitter-heex.DocumentStorekeeps per-language parsers and caches this composite tree; LSP handlers call methods on*treesitter.Tree(e.g.FindVariableOccurrences) so positions resolve across nested languages.Index version is bumped to 13 for the parser/index changes.
Reviewed by Cursor Bugbot for commit 713f6f3. Bugbot is set up for automated code reviews on this repo. Configure here.