Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ebe1a97
wip: heex treesitter
superhawk610 Jun 5, 2026
44d103a
wip: page_live.ex with example heex
superhawk610 Jun 5, 2026
23b3aca
expression parsing and LSP definition for HEEX
superhawk610 Jun 5, 2026
d10bdc4
review feedback
superhawk610 Jun 7, 2026
1214aea
review feedback
superhawk610 Jun 7, 2026
0c961b4
ignore trailing modifer chars for sigil contents
superhawk610 Jun 7, 2026
bc3b072
index HEEX template contents via tree-sitter-heex
superhawk610 Jun 7, 2026
be3848d
wip: nested tree-sitter-heex while parsing tree-sitter-elixir
superhawk610 Jun 7, 2026
341f290
remove unused test
superhawk610 Jun 7, 2026
6dfc7ea
review feedback
superhawk610 Jun 7, 2026
19f913a
first-class Tree type container for Elixir tree and HEEX sub-trees
superhawk610 Jun 8, 2026
328320b
review feedback
superhawk610 Jun 8, 2026
c6a739a
wip: HEEX tokenizer
superhawk610 Jun 9, 2026
9c82cc4
scan interpolations in standard HTML tag attrs
superhawk610 Jun 9, 2026
0657d5b
perform bounds check before access
superhawk610 Jun 9, 2026
b8d299b
wip: continue HEEX tokenizer impl
superhawk610 Jun 9, 2026
80d3f1b
wip: tokenize HEEX - fix infinite loops
superhawk610 Jun 10, 2026
7e1bfc5
don't duplicate lineStarts when parsing sigil contents
superhawk610 Jun 10, 2026
270c640
fix test timeout, improve guards for sigil parsing
superhawk610 Jun 10, 2026
eda0690
replace scan/tokenize with tokenizeUntil
superhawk610 Jun 10, 2026
a52a0cd
wip: recursive Elixir/HEEX tree-sitter nested trees
superhawk610 Jun 10, 2026
a3968e9
[skip ci] wip: use resolvedNode during variable resolution
superhawk610 Jun 10, 2026
03c9f75
wip: variables.go tree_sitter.Node -> TreeNode
superhawk610 Jun 11, 2026
7eb56c3
parse HEEX function components as references
superhawk610 Jun 11, 2026
3fde085
check variable occurences within sigils
superhawk610 Jun 11, 2026
f8decb4
fix StartPosition/EndPosition on first line
superhawk610 Jun 11, 2026
d446dbf
fuzz TokenizeHeex, add tests for Tree/TreeNode
superhawk610 Jun 11, 2026
31467ad
fix tag attr quote handling
superhawk610 Jun 11, 2026
4216225
bump index version
superhawk610 Jun 11, 2026
429f6bd
*tree_sitter.Node -> *TreeNode after rebase
superhawk610 Jun 11, 2026
713f6f3
findEnclosingScope traverse up through multiple trees
superhawk610 Jun 11, 2026
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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ module github.com/remoteoss/dexter
go 1.26.1

require (
github.com/google/go-cmp v0.5.6
github.com/mattn/go-sqlite3 v1.14.38
github.com/phoenixframework/tree-sitter-heex v0.9.0
github.com/spf13/cobra v1.10.2
github.com/tree-sitter/go-tree-sitter v0.25.0
github.com/tree-sitter/tree-sitter-elixir v0.3.5
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/phoenixframework/tree-sitter-heex v0.9.0 h1:19d/KenCYoturUoMq+fY5LXTwPhe5msaOx9cHGnPUj0=
github.com/phoenixframework/tree-sitter-heex v0.9.0/go.mod h1:ul+VP/WJ7qS+DPlkr15hyBrzYd1D1rvmyEKmw/7lGOQ=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
22 changes: 11 additions & 11 deletions internal/lsp/documents.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import (
"sync"

tree_sitter "github.com/tree-sitter/go-tree-sitter"
tree_sitter_elixir "github.com/tree-sitter/tree-sitter-elixir/bindings/go"
"go.lsp.dev/protocol"

"github.com/remoteoss/dexter/internal/parser"
"github.com/remoteoss/dexter/internal/treesitter"
)

// defaultMaxTransient caps how many disk-loaded buffers may live in the
Expand Down Expand Up @@ -45,7 +45,7 @@ type cachedDoc struct {
// tree for free and only triggers ts_tree_delete if no handler still
// holds a reference.
type refTree struct {
tree *tree_sitter.Tree
tree *treesitter.Tree
refs int
retired bool
}
Expand Down Expand Up @@ -76,9 +76,9 @@ func (rt *refTree) retireLocked() {
// (e.g. Claude Code) can still query references/hover/definition without
// causing unbounded memory growth.
type DocumentStore struct {
mu sync.RWMutex
docs map[string]*cachedDoc
parser *tree_sitter.Parser
mu sync.RWMutex
docs map[string]*cachedDoc
parsers map[treesitter.Language]*tree_sitter.Parser

// LRU bookkeeping for transient (disk-loaded) entries only. The list
// holds URIs in access-order, newest at the front. transientIdx maps
Expand All @@ -89,11 +89,9 @@ type DocumentStore struct {
}

func NewDocumentStore() *DocumentStore {
p := tree_sitter.NewParser()
_ = p.SetLanguage(tree_sitter.NewLanguage(tree_sitter_elixir.Language()))
return &DocumentStore{
docs: make(map[string]*cachedDoc),
parser: p,
parsers: treesitter.AllParsers(),
transientList: list.New(),
transientIdx: make(map[string]*list.Element),
maxTransient: defaultMaxTransient,
Expand Down Expand Up @@ -149,7 +147,9 @@ func (ds *DocumentStore) CloseAll() {
ds.docs = nil
ds.transientList = nil
ds.transientIdx = nil
ds.parser.Close()
for _, p := range ds.parsers {
p.Close()
}
}

func (ds *DocumentStore) Get(uri string) (string, bool) {
Expand Down Expand Up @@ -300,7 +300,7 @@ func (ds *DocumentStore) evictTransientLocked() {
// Callers must not close the returned tree directly.
//
// When ok is false, release is nil and must not be called.
func (ds *DocumentStore) GetTree(uri string) (*tree_sitter.Tree, []byte, func(), bool) {
func (ds *DocumentStore) GetTree(uri string) (*treesitter.Tree, []byte, func(), bool) {
ds.mu.Lock()
defer ds.mu.Unlock()
doc, ok := ds.docs[uri]
Expand All @@ -309,7 +309,7 @@ func (ds *DocumentStore) GetTree(uri string) (*tree_sitter.Tree, []byte, func(),
}
if doc.tree == nil {
doc.src = []byte(doc.text)
doc.tree = &refTree{tree: ds.parser.Parse(doc.src, nil)}
doc.tree = &refTree{tree: treesitter.NewTreeWithParsers(doc.src, ds.parsers)}
}
rt := doc.tree
rt.refs++
Expand Down
10 changes: 5 additions & 5 deletions internal/lsp/documents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,8 @@ func TestDocumentStore_GetTree_DiskLoaded(t *testing.T) {
if string(src) != contents {
t.Fatalf("GetTree src mismatch: got %q want %q", src, contents)
}
if tree.RootNode().Kind() != "source" {
t.Fatalf("expected root node kind 'source', got %q", tree.RootNode().Kind())
if tree.Trunk.RootNode().Kind() != "source" {
t.Fatalf("expected root node kind 'source', got %q", tree.Trunk.RootNode().Kind())
}
}

Expand Down Expand Up @@ -470,7 +470,7 @@ func TestDocumentStore_GetTree_SurvivesEviction(t *testing.T) {
// Capture the root node kind so we can re-read it after eviction.
// Pre-fix, the eviction below would call ts_tree_delete on this tree
// and the second RootNode() call would read freed C memory.
rootKindBefore := tree.RootNode().Kind()
rootKindBefore := tree.Trunk.RootNode().Kind()

// Force eviction of this URI while we still hold a ref.
ds.SetMaxTransient(0)
Expand All @@ -480,7 +480,7 @@ func TestDocumentStore_GetTree_SurvivesEviction(t *testing.T) {

// Walking the tree after eviction must still work - this is the UAF
// the refcounting prevents.
rootKindAfter := tree.RootNode().Kind()
rootKindAfter := tree.Trunk.RootNode().Kind()
if rootKindAfter != rootKindBefore {
t.Fatalf("tree root kind changed across eviction: got %q want %q", rootKindAfter, rootKindBefore)
}
Expand Down Expand Up @@ -537,7 +537,7 @@ func TestDocumentStore_GetTree_ConcurrentEvictionStress(t *testing.T) {
if !ok {
continue
}
root := tree.RootNode()
root := tree.Trunk.RootNode()
_ = root.Kind()
_ = root.ChildCount()
release()
Expand Down
42 changes: 41 additions & 1 deletion internal/lsp/elixir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/remoteoss/dexter/internal/parser"
)

Expand Down Expand Up @@ -436,6 +437,45 @@ func TestExpressionAtCursor_ExprBounds(t *testing.T) {
}
}

func TestExpressionAtCursor_HEEX(t *testing.T) {
tests := []struct {
code string
line, col int
want CursorContext
}{
// all delimiter styles should be supported
{"~H\"\"\"\n<.foo />\n\"\"\"", 1, 2, CursorContext{FunctionName: "foo", ExprStart: 2, ExprEnd: 5}},
{"~H'''\n<.foo />\n'''", 1, 2, CursorContext{FunctionName: "foo", ExprStart: 2, ExprEnd: 5}},
{"~H\"<.foo />\"", 0, 5, CursorContext{FunctionName: "foo", ExprStart: 5, ExprEnd: 8}},
{"~H'<.foo />'", 0, 5, CursorContext{FunctionName: "foo", ExprStart: 5, ExprEnd: 8}},
{"~H[<.foo />]", 0, 5, CursorContext{FunctionName: "foo", ExprStart: 5, ExprEnd: 8}},
// newline after delimiter is optional
{"~H\"\"\"<.foo />\"\"\"", 0, 7, CursorContext{FunctionName: "foo", ExprStart: 7, ExprEnd: 10}},
{"~H[<Foo.bar />]", 0, 5, CursorContext{ModuleRef: "Foo", ExprStart: 4, ExprEnd: 7}},
{"~H[<Foo.bar />]", 0, 9, CursorContext{ModuleRef: "Foo", FunctionName: "bar", ExprStart: 4, ExprEnd: 11}},
{"~H[<.live_component module={Foo.Bar} />]", 0, 28, CursorContext{ModuleRef: "Foo", ExprStart: 28, ExprEnd: 31}},
{"~H[<.live_component module={Foo.Bar} />]", 0, 32, CursorContext{ModuleRef: "Foo.Bar", ExprStart: 28, ExprEnd: 35}},
{"~H'''\n<.live_component module={Foo.Bar} />\n'''", 1, 29, CursorContext{ModuleRef: "Foo.Bar", ExprStart: 25, ExprEnd: 32}},
// interpolated expressions that aren't module/function should be ignored
{"~H[<div n={1} />]", 0, 11, CursorContext{}},
// HTML tags should be ignored
{"~H[<div n={1} />]", 0, 4, CursorContext{}},
// custom sigils should be parsed correctly but ignored
{"~x[_]", 0, 3, CursorContext{}},
{"~X[_]", 0, 3, CursorContext{}},
{"~XXX[_]", 0, 5, CursorContext{}},
{"~X12[_]", 0, 5, CursorContext{}},
}

for _, tt := range tests {
tokens, source, lineStarts := tokenize(tt.code)
got := ExpressionAtCursor(tokens, source, lineStarts, tt.line, tt.col)
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("ExpressionAtCursor(_, %#v, _, %d, %d)\nparse mismatch (-want +got):\n%s", tt.code, tt.line, tt.col, diff)
}
}
}

func TestCursorContext_Expr(t *testing.T) {
tests := []struct {
mod, fn, want string
Expand Down Expand Up @@ -583,7 +623,7 @@ end`
text := `defmodule MyApp.Web do
alias MyApp.Services.{
Accounts,

def foo do
# missing close brace
end
Expand Down
21 changes: 11 additions & 10 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
"github.com/remoteoss/dexter/internal/parser"
"github.com/remoteoss/dexter/internal/stdlib"
"github.com/remoteoss/dexter/internal/store"
"github.com/remoteoss/dexter/internal/treesitter"
"github.com/remoteoss/dexter/internal/version"
)

Expand Down Expand Up @@ -661,7 +660,8 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara
// The first occurrence in scope is the definition (pattern/assignment).
if tree, src, release, ok := s.docs.GetTree(docURI); ok {
defer release()
if occs := treesitter.FindVariableOccurrencesWithTree(tree.RootNode(), src, uint(lineNum), uint(col)); len(occs) > 0 {

if occs := tree.FindVariableOccurrences(src, uint(lineNum), uint(col)); len(occs) > 0 {
s.debugf("Definition: returning variable definition at line %d", occs[0].Line)
return []protocol.Location{{
URI: params.TextDocument.URI,
Expand Down Expand Up @@ -1731,7 +1731,7 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara
var varsInScope []string
if tree, src, release, ok := s.docs.GetTree(docURI); ok {
defer release()
varsInScope = treesitter.FindVariablesInScopeWithTree(tree.RootNode(), src, uint(lineNum), uint(col))
varsInScope = tree.FindVariablesInScope(src, uint(lineNum), uint(col))
}
for _, varName := range varsInScope {
if strings.HasPrefix(varName, funcPrefix) && !seen[varName] {
Expand Down Expand Up @@ -2687,10 +2687,9 @@ func (s *Server) DocumentHighlight(ctx context.Context, params *protocol.Documen
return nil, nil
}
defer release()
root := tree.RootNode()

// Try scope-aware variable highlight first
if occs := treesitter.FindVariableOccurrencesWithTree(root, src, uint(lineNum), uint(col)); len(occs) > 0 {
if occs := tree.FindVariableOccurrences(src, uint(lineNum), uint(col)); len(occs) > 0 {
var highlights []protocol.DocumentHighlight
for _, occ := range occs {
highlights = append(highlights, protocol.DocumentHighlight{
Expand Down Expand Up @@ -2722,7 +2721,7 @@ func (s *Server) DocumentHighlight(ctx context.Context, params *protocol.Documen
}

// Reuse the same parsed tree for token occurrences
occs := treesitter.FindTokenOccurrencesWithTree(root, src, token)
occs := tree.FindTokenOccurrences(src, token)
if len(occs) == 0 {
return nil, nil
}
Expand Down Expand Up @@ -3606,7 +3605,7 @@ func (s *Server) PrepareRename(ctx context.Context, params *protocol.PrepareRena
if moduleRef == "" {
if tree, src, release, ok := s.docs.GetTree(docURI); ok {
defer release()
if occs := treesitter.FindVariableOccurrencesWithTree(tree.RootNode(), src, uint(lineNum), uint(col)); len(occs) > 0 {
if occs := tree.FindVariableOccurrences(src, uint(lineNum), uint(col)); len(occs) > 0 {
for _, occ := range occs {
if occ.Line == uint(lineNum) && uint(col) >= occ.StartCol && uint(col) < occ.EndCol {
return &protocol.Range{
Expand Down Expand Up @@ -3804,7 +3803,7 @@ func (s *Server) References(ctx context.Context, params *protocol.ReferenceParam
// function reference lookup.
if tree, src, release, ok := s.docs.GetTree(docURI); ok {
defer release()
if occs := treesitter.FindVariableOccurrencesWithTree(tree.RootNode(), src, uint(lineNum), uint(col)); len(occs) > 0 {
if occs := tree.FindVariableOccurrences(src, uint(lineNum), uint(col)); len(occs) > 0 {
var locations []protocol.Location
for _, occ := range occs {
locations = append(locations, protocol.Location{
Expand Down Expand Up @@ -4010,8 +4009,8 @@ func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*pr
if moduleRef == "" {
if tree, src, release, ok := s.docs.GetTree(docURI); ok {
defer release()
if occs := treesitter.FindVariableOccurrencesWithTree(tree.RootNode(), src, uint(lineNum), uint(col)); len(occs) > 0 {
if treesitter.NameExistsInScopeOf(tree.RootNode(), src, uint(lineNum), uint(col), params.NewName) {
if occs := tree.FindVariableOccurrences(src, uint(lineNum), uint(col)); len(occs) > 0 {
if tree.NameExistsInScopeOf(src, uint(lineNum), uint(col), params.NewName) {
return nil, fmt.Errorf("variable %q already exists in this scope", params.NewName)
}
changes := make(map[protocol.DocumentURI][]protocol.TextEdit)
Expand Down Expand Up @@ -5021,6 +5020,8 @@ func (s *Server) getFileLine(filePath string, lineNum int) (string, bool) {
return scanner.Text(), true
}
}
// ignore any scan error
_ = scanner.Err()
return "", false
}

Expand Down
Loading
Loading