Skip to content
Draft
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
129 changes: 129 additions & 0 deletions bundle/lsp/definition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package lsp

import (
"fmt"
"regexp"
"strings"

"github.com/databricks/cli/libs/dyn"
)

// Interpolation regex copied from libs/dyn/dynvar/ref.go to avoid coupling LSP to dynvar internals.
var interpolationRe = regexp.MustCompile(
fmt.Sprintf(`\$\{(%s(\.%s(\[[0-9]+\])*)*(\[[0-9]+\])*)\}`,
`[a-zA-Z]+([-_]*[a-zA-Z0-9]+)*`,
`[a-zA-Z]+([-_]*[a-zA-Z0-9]+)*`,
),
)

// InterpolationRef represents a ${...} reference found at a cursor position.
type InterpolationRef struct {
Path string // e.g., "resources.jobs.my_job.name"
Range Range // range of the full "${...}" token
}

// FindInterpolationAtPosition finds the ${...} expression the cursor is inside.
func FindInterpolationAtPosition(lines []string, pos Position) (InterpolationRef, bool) {
if pos.Line < 0 || pos.Line >= len(lines) {
return InterpolationRef{}, false
}

line := lines[pos.Line]
matches := interpolationRe.FindAllStringSubmatchIndex(line, -1)
for _, m := range matches {
// m[0]:m[1] is the full match "${...}"
// m[2]:m[3] is the first capture group (the path inside ${})
start := m[0]
end := m[1]
if pos.Character >= start && pos.Character < end {
path := line[m[2]:m[3]]
return InterpolationRef{
Path: path,
Range: Range{
Start: Position{Line: pos.Line, Character: start},
End: Position{Line: pos.Line, Character: end},
},
}, true
}
}
return InterpolationRef{}, false
}

// ResolveDefinition resolves a path string against the merged tree and returns its source location.
func ResolveDefinition(tree dyn.Value, pathStr string) (dyn.Location, bool) {
if !tree.IsValid() {
return dyn.Location{}, false
}

// Handle var.X shorthand: rewrite to variables.X.
if strings.HasPrefix(pathStr, "var.") {
pathStr = "variables." + strings.TrimPrefix(pathStr, "var.")
}

p, err := dyn.NewPathFromString(pathStr)
if err != nil {
return dyn.Location{}, false
}

v, err := dyn.GetByPath(tree, p)
if err != nil {
return dyn.Location{}, false
}

loc := v.Location()
if loc.File == "" {
return dyn.Location{}, false
}
return loc, true
}

// InterpolationReference records a ${...} reference found in the merged tree.
type InterpolationReference struct {
Path string // dyn path where the reference was found
Location dyn.Location // source location of the string containing the reference
RefStr string // the full "${...}" expression
}

// FindInterpolationReferences walks the merged tree to find all ${...} string values
// whose reference path starts with the given resource path prefix.
func FindInterpolationReferences(tree dyn.Value, resourcePath string) []InterpolationReference {
if !tree.IsValid() {
return nil
}

var refs []InterpolationReference
dyn.Walk(tree, func(p dyn.Path, v dyn.Value) (dyn.Value, error) { //nolint:errcheck
s, ok := v.AsString()
if !ok {
return v, nil
}

matches := interpolationRe.FindAllStringSubmatch(s, -1)
for _, m := range matches {
refPath := m[1]
if refPath == resourcePath || strings.HasPrefix(refPath, resourcePath+".") {
refs = append(refs, InterpolationReference{
Path: p.String(),
Location: v.Location(),
RefStr: m[0],
})
}
}
return v, nil
})

return refs
}

// DynLocationToLSPLocation converts a 1-based dyn.Location to a 0-based LSPLocation.
func DynLocationToLSPLocation(loc dyn.Location) LSPLocation {
line := max(loc.Line-1, 0)
col := max(loc.Column-1, 0)
return LSPLocation{
URI: PathToURI(loc.File),
Range: Range{
Start: Position{Line: line, Character: col},
End: Position{Line: line, Character: col},
},
}
}
142 changes: 142 additions & 0 deletions bundle/lsp/definition_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package lsp_test

import (
"strings"
"testing"

"github.com/databricks/cli/bundle/lsp"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/yamlloader"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFindInterpolationAtPositionBasic(t *testing.T) {
lines := []string{` name: "${resources.jobs.my_job.name}"`}
// Cursor inside the interpolation.
ref, ok := lsp.FindInterpolationAtPosition(lines, lsp.Position{Line: 0, Character: 15})
require.True(t, ok)
assert.Equal(t, "resources.jobs.my_job.name", ref.Path)
}

func TestFindInterpolationAtPositionMultiple(t *testing.T) {
lines := []string{`value: "${a.b} and ${c.d}"`}
// Cursor on the second interpolation.
ref, ok := lsp.FindInterpolationAtPosition(lines, lsp.Position{Line: 0, Character: 21})
require.True(t, ok)
assert.Equal(t, "c.d", ref.Path)
}

func TestFindInterpolationAtPositionOutside(t *testing.T) {
lines := []string{`value: "${a.b} plain text ${c.d}"`}
// Cursor on "plain text" between the two interpolations.
_, ok := lsp.FindInterpolationAtPosition(lines, lsp.Position{Line: 0, Character: 16})
assert.False(t, ok)
}

func TestFindInterpolationAtPositionAtDollar(t *testing.T) {
lines := []string{`name: "${var.foo}"`}
// Cursor on the "$" character.
idx := strings.Index(lines[0], "$")
ref, ok := lsp.FindInterpolationAtPosition(lines, lsp.Position{Line: 0, Character: idx})
require.True(t, ok)
assert.Equal(t, "var.foo", ref.Path)
}

func TestFindInterpolationAtPositionNone(t *testing.T) {
lines := []string{`name: "plain string"`}
_, ok := lsp.FindInterpolationAtPosition(lines, lsp.Position{Line: 0, Character: 10})
assert.False(t, ok)
}

func TestResolveDefinition(t *testing.T) {
yaml := `
resources:
jobs:
my_job:
name: "ETL"
`
tree, err := yamlloader.LoadYAML("test.yml", strings.NewReader(yaml))
require.NoError(t, err)

loc, ok := lsp.ResolveDefinition(tree, "resources.jobs.my_job")
require.True(t, ok)
assert.Equal(t, "test.yml", loc.File)
assert.Greater(t, loc.Line, 0)

Check failure on line 65 in bundle/lsp/definition_test.go

View workflow job for this annotation

GitHub Actions / lint

negative-positive: use assert.Positive (testifylint)
}

func TestResolveDefinitionVarShorthand(t *testing.T) {
yaml := `
variables:
foo:
default: "bar"
`
tree, err := yamlloader.LoadYAML("test.yml", strings.NewReader(yaml))
require.NoError(t, err)

loc, ok := lsp.ResolveDefinition(tree, "var.foo")
require.True(t, ok)
assert.Equal(t, "test.yml", loc.File)
}

func TestResolveDefinitionInvalid(t *testing.T) {
yaml := `
resources:
jobs:
my_job:
name: "ETL"
`
tree, err := yamlloader.LoadYAML("test.yml", strings.NewReader(yaml))
require.NoError(t, err)

_, ok := lsp.ResolveDefinition(tree, "resources.jobs.nonexistent")
assert.False(t, ok)
}

func TestFindInterpolationReferences(t *testing.T) {
yaml := `
resources:
jobs:
my_job:
name: "ETL"
pipelines:
my_pipeline:
name: "${resources.jobs.my_job.name}"
settings:
target: "${resources.jobs.my_job.id}"
`
tree, err := yamlloader.LoadYAML("test.yml", strings.NewReader(yaml))
require.NoError(t, err)

refs := lsp.FindInterpolationReferences(tree, "resources.jobs.my_job")
require.Len(t, refs, 2)
assert.Contains(t, refs[0].RefStr, "resources.jobs.my_job")
assert.Contains(t, refs[1].RefStr, "resources.jobs.my_job")
}

func TestFindInterpolationReferencesNoMatch(t *testing.T) {
yaml := `
resources:
jobs:
my_job:
name: "${var.name}"
`
tree, err := yamlloader.LoadYAML("test.yml", strings.NewReader(yaml))
require.NoError(t, err)

refs := lsp.FindInterpolationReferences(tree, "resources.jobs.my_job")
assert.Empty(t, refs)
}

func TestDynLocationToLSPLocation(t *testing.T) {
loc := dyn.Location{
File: "/path/to/file.yml",
Line: 5,
Column: 10,
}

lspLoc := lsp.DynLocationToLSPLocation(loc)
assert.Equal(t, "file:///path/to/file.yml", lspLoc.URI)
assert.Equal(t, 4, lspLoc.Range.Start.Line)
assert.Equal(t, 9, lspLoc.Range.Start.Character)
}
105 changes: 105 additions & 0 deletions bundle/lsp/documents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package lsp

import (
"net/url"
"path/filepath"
"runtime"
"strings"
"sync"

"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/yamlloader"
)

// Document tracks the state of an open text document.
type Document struct {
URI string
Version int
Content string
Lines []string // split by newline for position lookup
Value dyn.Value // parsed YAML (may be invalid)
}

// DocumentStore manages open text documents.
type DocumentStore struct {
mu sync.RWMutex
docs map[string]*Document
}

// NewDocumentStore creates an empty document store.
func NewDocumentStore() *DocumentStore {
return &DocumentStore{docs: make(map[string]*Document)}
}

// Open registers a newly opened document.
func (s *DocumentStore) Open(uri string, version int, content string) {
doc := &Document{
URI: uri,
Version: version,
Content: content,
Lines: strings.Split(content, "\n"),
}
doc.parse()
s.mu.Lock()
s.docs[uri] = doc
s.mu.Unlock()
}

// Change updates the content of an already-open document.
func (s *DocumentStore) Change(uri string, version int, content string) {
s.mu.Lock()
doc, ok := s.docs[uri]
if ok {
doc.Version = version
doc.Content = content
doc.Lines = strings.Split(content, "\n")
doc.parse()
}
s.mu.Unlock()
}

// Close removes a document from the store.
func (s *DocumentStore) Close(uri string) {
s.mu.Lock()
delete(s.docs, uri)
s.mu.Unlock()
}

// Get returns the document for the given URI, or nil if not found.
func (s *DocumentStore) Get(uri string) *Document {
s.mu.RLock()
defer s.mu.RUnlock()
return s.docs[uri]
}

func (doc *Document) parse() {
path := URIToPath(doc.URI)
v, err := yamlloader.LoadYAML(path, strings.NewReader(doc.Content))
if err != nil {
doc.Value = dyn.InvalidValue
return
}
doc.Value = v
}

// URIToPath converts a file:// URI to a filesystem path.
func URIToPath(uri string) string {
u, err := url.Parse(uri)
if err != nil {
return uri
}
p := u.Path
// On Windows, file URIs look like file:///C:/path
if runtime.GOOS == "windows" && len(p) > 0 && p[0] == '/' {
p = p[1:]
}
return p
}

// PathToURI converts a filesystem path to a file:// URI.
func PathToURI(path string) string {
if runtime.GOOS == "windows" {
path = "/" + filepath.ToSlash(path)
}
return "file://" + path
}
Loading
Loading