Skip to content
Draft
407 changes: 407 additions & 0 deletions bundle/lsp/completion.go

Large diffs are not rendered by default.

442 changes: 442 additions & 0 deletions bundle/lsp/completion_test.go

Large diffs are not rendered by default.

130 changes: 130 additions & 0 deletions bundle/lsp/definition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package lsp

import (
"fmt"
"regexp"
"strings"

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

// InterpolationRe matches ${...} interpolation expressions in strings.
// 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.Positive(t, loc.Line)
}

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)
}
80 changes: 80 additions & 0 deletions bundle/lsp/diagnostics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package lsp

import (
"fmt"
"strings"

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

const diagnosticSource = "databricks-bundle-lsp"

// computedPrefixes are path prefixes that are populated at deploy time and
// should not be flagged as unresolved references. Prefixes ending in "."
// match any sub-path; others require an exact match.
var computedPrefixes = []string{
"bundle.target",
"bundle.environment",
"bundle.git.",
"workspace.current_user.",
"workspace.root_path",
"workspace.file_path",
"workspace.resource_path",
"workspace.artifact_path",
"workspace.state_path",
}

// DiagnoseInterpolations checks all ${...} interpolation references in the document
// and returns diagnostics for references that cannot be resolved in the merged tree.
func DiagnoseInterpolations(lines []string, tree dyn.Value) []Diagnostic {
var diags []Diagnostic
for lineIdx, line := range lines {
matches := InterpolationRe.FindAllStringSubmatchIndex(line, -1)
for _, m := range matches {
// m[0]:m[1] is the full "${...}" match.
// m[2]:m[3] is the captured path inside ${}.
path := line[m[2]:m[3]]

if isComputedPath(path) {
continue
}

_, found := ResolveDefinition(tree, path)
if found {
continue
}

diags = append(diags, Diagnostic{
Range: Range{
Start: Position{Line: lineIdx, Character: m[0]},
End: Position{Line: lineIdx, Character: m[1]},
},
Severity: DiagnosticSeverityWarning,
Source: diagnosticSource,
Message: fmt.Sprintf("Cannot resolve reference %q", path),
})
}
}
return diags
}

// isComputedPath returns true if the path is known to be populated at deploy
// time and won't appear in the static merged tree.
func isComputedPath(path string) bool {
// var.* references are rewritten to variables.* by ResolveDefinition,
// so we only need to handle the other computed prefixes here.
for _, prefix := range computedPrefixes {
if strings.HasSuffix(prefix, ".") {
// Dot-terminated: match any sub-path.
if strings.HasPrefix(path, prefix) {
return true
}
} else {
// Exact match only.
if path == prefix {
return true
}
}
}
return false
}
Loading
Loading