Compare commits

...

3 Commits

Author SHA1 Message Date
Martino Ferrari
d2b2750833 Full expression and validation support 2026-02-02 14:53:35 +01:00
Martino Ferrari
55ca313b73 added suggestion for variables 2026-02-02 14:37:03 +01:00
Martino Ferrari
ff19fef779 Fixed isolated file indexing 2026-02-02 14:26:19 +01:00
12 changed files with 455 additions and 35 deletions

View File

@@ -182,7 +182,13 @@ func (pt *ProjectTree) AddFile(file string, config *parser.Configuration) {
} }
if config.Package == nil { if config.Package == nil {
pt.populateNode(pt.Root, file, config) node := &ProjectNode{
Children: make(map[string]*ProjectNode),
Metadata: make(map[string]string),
Variables: make(map[string]VariableInfo),
}
pt.IsolatedFiles[file] = node
pt.populateNode(node, file, config)
return return
} }

View File

@@ -265,7 +265,7 @@ func HandleMessage(msg *JsonRpcMessage) {
"documentFormattingProvider": true, "documentFormattingProvider": true,
"renameProvider": true, "renameProvider": true,
"completionProvider": map[string]any{ "completionProvider": map[string]any{
"triggerCharacters": []string{"=", " "}, "triggerCharacters": []string{"=", " ", "@"},
}, },
}, },
}) })
@@ -675,6 +675,20 @@ func HandleCompletion(params CompletionParams) *CompletionList {
prefix := lineStr[:col] prefix := lineStr[:col]
// Case 3: Variable completion
varRegex := regexp.MustCompile(`([@$])([a-zA-Z0-9_]*)$`)
if matches := varRegex.FindStringSubmatch(prefix); matches != nil {
container := Tree.GetNodeContaining(path, parser.Position{Line: params.Position.Line + 1, Column: col + 1})
if container == nil {
if iso, ok := Tree.IsolatedFiles[path]; ok {
container = iso
} else {
container = Tree.Root
}
}
return suggestVariables(container)
}
// Case 1: Assigning a value (Ends with "=" or "= ") // Case 1: Assigning a value (Ends with "=" or "= ")
if strings.Contains(prefix, "=") { if strings.Contains(prefix, "=") {
lastIdx := strings.LastIndex(prefix, "=") lastIdx := strings.LastIndex(prefix, "=")
@@ -716,9 +730,16 @@ func HandleCompletion(params CompletionParams) *CompletionList {
return nil return nil
} }
func suggestGAMSignals(_ *index.ProjectNode, direction string) *CompletionList { func suggestGAMSignals(container *index.ProjectNode, direction string) *CompletionList {
var items []CompletionItem var items []CompletionItem
// Find scope root
root := container
for root.Parent != nil {
root = root.Parent
}
var walk func(*index.ProjectNode)
processNode := func(node *index.ProjectNode) { processNode := func(node *index.ProjectNode) {
if !isDataSource(node) { if !isDataSource(node) {
return return
@@ -776,7 +797,13 @@ func suggestGAMSignals(_ *index.ProjectNode, direction string) *CompletionList {
} }
} }
Tree.Walk(processNode) walk = func(n *index.ProjectNode) {
processNode(n)
for _, child := range n.Children {
walk(child)
}
}
walk(root)
if len(items) > 0 { if len(items) > 0 {
return &CompletionList{Items: items} return &CompletionList{Items: items}
@@ -894,20 +921,41 @@ func suggestFieldValues(container *index.ProjectNode, field string, path string)
root = Tree.Root root = Tree.Root
} }
var items []CompletionItem
if field == "DataSource" { if field == "DataSource" {
return suggestObjects(root, "DataSource") if list := suggestObjects(root, "DataSource"); list != nil {
items = append(items, list.Items...)
} }
if field == "Functions" { } else if field == "Functions" {
return suggestObjects(root, "GAM") if list := suggestObjects(root, "GAM"); list != nil {
items = append(items, list.Items...)
} }
if field == "Type" { } else if field == "Type" {
return suggestSignalTypes() if list := suggestSignalTypes(); list != nil {
items = append(items, list.Items...)
} }
} else {
if list := suggestCUEEnums(container, field); list != nil { if list := suggestCUEEnums(container, field); list != nil {
return list items = append(items, list.Items...)
}
} }
// Add variables
vars := suggestVariables(container)
if vars != nil {
for _, item := range vars.Items {
// Create copy to modify label
newItem := item
newItem.Label = "@" + newItem.Label
newItem.InsertText = "@" + item.Label
items = append(items, newItem)
}
}
if len(items) > 0 {
return &CompletionList{Items: items}
}
return nil return nil
} }
@@ -1505,3 +1553,31 @@ func send(msg any) {
body, _ := json.Marshal(msg) body, _ := json.Marshal(msg)
fmt.Fprintf(Output, "Content-Length: %d\r\n\r\n%s", len(body), body) fmt.Fprintf(Output, "Content-Length: %d\r\n\r\n%s", len(body), body)
} }
func suggestVariables(container *index.ProjectNode) *CompletionList {
items := []CompletionItem{}
seen := make(map[string]bool)
curr := container
for curr != nil {
for name, info := range curr.Variables {
if !seen[name] {
seen[name] = true
doc := ""
if info.Def.DefaultValue != nil {
doc = fmt.Sprintf("Default: %s", valueToString(info.Def.DefaultValue))
}
items = append(items, CompletionItem{
Label: name,
Kind: 6, // Variable
Detail: fmt.Sprintf("Variable (%s)", info.Def.TypeExpr),
Documentation: doc,
})
}
}
curr = curr.Parent
}
return &CompletionList{Items: items}
}

View File

@@ -153,3 +153,12 @@ type BinaryExpression struct {
func (b *BinaryExpression) Pos() Position { return b.Position } func (b *BinaryExpression) Pos() Position { return b.Position }
func (b *BinaryExpression) isValue() {} func (b *BinaryExpression) isValue() {}
type UnaryExpression struct {
Position Position
Operator Token
Right Value
}
func (u *UnaryExpression) Pos() Position { return u.Position }
func (u *UnaryExpression) isValue() {}

View File

@@ -147,18 +147,12 @@ func (l *Lexer) NextToken() Token {
case ']': case ']':
return l.emit(TokenRBracket) return l.emit(TokenRBracket)
case '+': case '+':
if unicode.IsSpace(l.peek()) { if unicode.IsSpace(l.peek()) || unicode.IsDigit(l.peek()) {
return l.emit(TokenPlus) return l.emit(TokenPlus)
} }
return l.lexObjectIdentifier() return l.lexObjectIdentifier()
case '-': case '-':
if unicode.IsDigit(l.peek()) {
return l.lexNumber()
}
if unicode.IsSpace(l.peek()) {
return l.emit(TokenMinus) return l.emit(TokenMinus)
}
return l.lexIdentifier()
case '*': case '*':
return l.emit(TokenStar) return l.emit(TokenStar)
case '/': case '/':
@@ -242,13 +236,28 @@ func (l *Lexer) lexString() Token {
} }
func (l *Lexer) lexNumber() Token { func (l *Lexer) lexNumber() Token {
for { // Consume initial digits (already started)
r := l.next() l.lexDigits()
if unicode.IsDigit(r) || unicode.IsLetter(r) || r == '.' || r == '-' || r == '+' {
continue if l.peek() == '.' {
l.next()
l.lexDigits()
} }
l.backup()
if r := l.peek(); r == 'e' || r == 'E' {
l.next()
if p := l.peek(); p == '+' || p == '-' {
l.next()
}
l.lexDigits()
}
return l.emit(TokenNumber) return l.emit(TokenNumber)
}
func (l *Lexer) lexDigits() {
for unicode.IsDigit(l.peek()) {
l.next()
} }
} }
@@ -318,7 +327,7 @@ func (l *Lexer) lexHashIdentifier() Token {
func (l *Lexer) lexVariableReference() Token { func (l *Lexer) lexVariableReference() Token {
for { for {
r := l.next() r := l.next()
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' { if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
continue continue
} }
l.backup() l.backup()

View File

@@ -299,8 +299,27 @@ func (p *Parser) parseAtom() (Value, bool) {
return &ReferenceValue{Position: tok.Position, Value: tok.Value}, true return &ReferenceValue{Position: tok.Position, Value: tok.Value}, true
case TokenVariableReference: case TokenVariableReference:
return &VariableReferenceValue{Position: tok.Position, Name: tok.Value}, true return &VariableReferenceValue{Position: tok.Position, Name: tok.Value}, true
case TokenMinus:
val, ok := p.parseAtom()
if !ok {
return nil, false
}
return &UnaryExpression{Position: tok.Position, Operator: tok, Right: val}, true
case TokenObjectIdentifier: case TokenObjectIdentifier:
return &VariableReferenceValue{Position: tok.Position, Name: tok.Value}, true return &VariableReferenceValue{Position: tok.Position, Name: tok.Value}, true
case TokenSymbol:
if tok.Value == "(" {
val, ok := p.parseExpression(0)
if !ok {
return nil, false
}
if next := p.next(); next.Type != TokenSymbol || next.Value != ")" {
p.addError(next.Position, "expected )")
return nil, false
}
return val, true
}
fallthrough
case TokenLBrace: case TokenLBrace:
arr := &ArrayValue{Position: tok.Position} arr := &ArrayValue{Position: tok.Position}
for { for {

View File

@@ -236,6 +236,108 @@ func (v *Validator) valueToInterface(val parser.Value, ctx *index.ProjectNode) i
arr = append(arr, v.valueToInterface(e, ctx)) arr = append(arr, v.valueToInterface(e, ctx))
} }
return arr return arr
case *parser.BinaryExpression:
left := v.valueToInterface(t.Left, ctx)
right := v.valueToInterface(t.Right, ctx)
return v.evaluateBinary(left, t.Operator.Type, right)
case *parser.UnaryExpression:
val := v.valueToInterface(t.Right, ctx)
return v.evaluateUnary(t.Operator.Type, val)
}
return nil
}
func (v *Validator) evaluateBinary(left interface{}, op parser.TokenType, right interface{}) interface{} {
if left == nil || right == nil {
return nil
}
if op == parser.TokenConcat {
return fmt.Sprintf("%v%v", left, right)
}
toInt := func(val interface{}) (int64, bool) {
switch v := val.(type) {
case int64:
return v, true
case int:
return int64(v), true
}
return 0, false
}
toFloat := func(val interface{}) (float64, bool) {
switch v := val.(type) {
case float64:
return v, true
case int64:
return float64(v), true
case int:
return float64(v), true
}
return 0, false
}
if l, ok := toInt(left); ok {
if r, ok := toInt(right); ok {
switch op {
case parser.TokenPlus:
return l + r
case parser.TokenMinus:
return l - r
case parser.TokenStar:
return l * r
case parser.TokenSlash:
if r != 0 {
return l / r
}
case parser.TokenPercent:
if r != 0 {
return l % r
}
}
}
}
if l, ok := toFloat(left); ok {
if r, ok := toFloat(right); ok {
switch op {
case parser.TokenPlus:
return l + r
case parser.TokenMinus:
return l - r
case parser.TokenStar:
return l * r
case parser.TokenSlash:
if r != 0 {
return l / r
}
}
}
}
return nil
}
func (v *Validator) evaluateUnary(op parser.TokenType, val interface{}) interface{} {
if val == nil {
return nil
}
switch op {
case parser.TokenMinus:
switch v := val.(type) {
case int64:
return -v
case float64:
return -v
}
case parser.TokenSymbol: // ! is Symbol?
// Parser uses TokenSymbol for ! ?
// Lexer: '!' -> Symbol.
if b, ok := val.(bool); ok {
return !b
}
} }
return nil return nil
} }

View File

@@ -0,0 +1,60 @@
package integration
import (
"os"
"strings"
"testing"
"github.com/marte-community/marte-dev-tools/internal/builder"
)
func TestExpressionParsing(t *testing.T) {
content := `
#var A: int = 10
#var B: int = 2
+Obj = {
// 1. Multiple variables
Expr1 = @A + @B + @A
// 2. Brackets
Expr2 = (@A + 2) * @B
// 3. No space operator (variable name strictness)
Expr3 = @A-2
}
`
f, _ := os.CreateTemp("", "expr_test.marte")
f.WriteString(content)
f.Close()
defer os.Remove(f.Name())
b := builder.NewBuilder([]string{f.Name()}, nil)
outF, _ := os.CreateTemp("", "out.marte")
defer os.Remove(outF.Name())
err := b.Build(outF)
if err != nil {
t.Fatalf("Build failed: %v", err)
}
outF.Close()
outContent, _ := os.ReadFile(outF.Name())
outStr := string(outContent)
// Expr1: 10 + 2 + 10 = 22
if !strings.Contains(outStr, "Expr1 = 22") {
t.Errorf("Expr1 failed. Got:\n%s", outStr)
}
// Expr2: (10 + 2) * 2 = 24
if !strings.Contains(outStr, "Expr2 = 24") {
t.Errorf("Expr2 failed. Got:\n%s", outStr)
}
// Expr3: 10 - 2 = 8
if !strings.Contains(outStr, "Expr3 = 8") {
t.Errorf("Expr3 failed. Got:\n%s", outStr)
}
}

View File

@@ -0,0 +1,39 @@
package integration
import (
"os"
"strings"
"testing"
"github.com/marte-community/marte-dev-tools/internal/builder"
)
func TestExpressionWhitespace(t *testing.T) {
content := `
+Obj = {
NoSpace = 2+2
WithSpace = 2 + 2
}
`
f, _ := os.CreateTemp("", "expr_ws.marte")
f.WriteString(content)
f.Close()
defer os.Remove(f.Name())
b := builder.NewBuilder([]string{f.Name()}, nil)
outF, _ := os.CreateTemp("", "out.marte")
defer os.Remove(outF.Name())
b.Build(outF)
outF.Close()
outContent, _ := os.ReadFile(outF.Name())
outStr := string(outContent)
if !strings.Contains(outStr, "NoSpace = 4") {
t.Errorf("NoSpace failed. Got:\n%s", outStr)
}
if !strings.Contains(outStr, "WithSpace = 4") {
t.Errorf("WithSpace failed. Got:\n%s", outStr)
}
}

38
test/isolation_test.go Normal file
View File

@@ -0,0 +1,38 @@
package integration
import (
"testing"
"github.com/marte-community/marte-dev-tools/internal/index"
"github.com/marte-community/marte-dev-tools/internal/parser"
)
func TestIsolatedFileIsolation(t *testing.T) {
pt := index.NewProjectTree()
// File 1: Project file
f1 := "#package P\n+A = { Class = C }"
p1 := parser.NewParser(f1)
c1, _ := p1.Parse()
pt.AddFile("f1.marte", c1)
// File 2: Isolated file
f2 := "+B = { Class = C }"
p2 := parser.NewParser(f2)
c2, _ := p2.Parse()
pt.AddFile("f2.marte", c2)
pt.ResolveReferences()
// Try finding A from f2
isoNode := pt.IsolatedFiles["f2.marte"]
if pt.ResolveName(isoNode, "A", nil) != nil {
t.Error("Isolated file f2 should not see global A")
}
// Try finding B from f1
pNode := pt.Root.Children["P"]
if pt.ResolveName(pNode, "B", nil) != nil {
t.Error("Project file f1 should not see isolated B")
}
}

View File

@@ -193,8 +193,8 @@ $App = {
} }
} }
} }
if !foundProjectDS { if foundProjectDS {
t.Error("Expected ProjectDS in isolated file suggestions (now shared root)") t.Error("Did not expect ProjectDS in isolated file suggestions (isolation)")
} }
// Completion in a project file // Completion in a project file
@@ -317,4 +317,66 @@ package schema
} }
} }
}) })
t.Run("Suggest Variables", func(t *testing.T) {
setup()
content := `
#var MyVar: uint = 10
+App = {
Field =
}
`
lsp.Documents[uri] = content
p := parser.NewParser(content)
cfg, _ := p.Parse()
lsp.Tree.AddFile(path, cfg)
// 1. Triggered by =
params := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Position: lsp.Position{Line: 3, Character: 12}, // After "Field = "
}
list := lsp.HandleCompletion(params)
if list == nil {
t.Fatal("Expected suggestions")
}
found := false
for _, item := range list.Items {
if item.Label == "@MyVar" {
found = true
break
}
}
if !found {
t.Error("Expected @MyVar in suggestions for =")
}
// 2. Triggered by $
// "Field = $"
lsp.Documents[uri] = `
#var MyVar: uint = 10
+App = {
Field = $
}
`
params2 := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Position: lsp.Position{Line: 3, Character: 13}, // After "Field = $"
}
list2 := lsp.HandleCompletion(params2)
if list2 == nil {
t.Fatal("Expected suggestions for $")
}
found = false
for _, item := range list2.Items {
if item.Label == "MyVar" { // suggestVariables returns "MyVar"
found = true
break
}
}
if !found {
t.Error("Expected MyVar in suggestions for $")
}
})
} }

View File

@@ -45,9 +45,9 @@ func TestLSPSignalReferences(t *testing.T) {
v.ValidateProject() v.ValidateProject()
// Find definition of MySig in MyDS // Find definition of MySig in MyDS
root := idx.Root root := idx.IsolatedFiles["signal_refs.marte"]
if root == nil { if root == nil {
t.Fatal("Root node not found") t.Fatal("Root node not found (isolated)")
} }
// Traverse to MySig // Traverse to MySig

View File

@@ -194,7 +194,7 @@ func TestIsolatedFileValidation(t *testing.T) {
t.Fatal("Reference SharedObj not found in index") t.Fatal("Reference SharedObj not found in index")
} }
if ref.Target == nil { if ref.Target != nil {
t.Errorf("Expected reference in root file (iso.marte) to resolve to global SharedObj") t.Errorf("Isolation failure: reference in isolated file resolved to global object")
} }
} }