Compare commits

..

9 Commits

Author SHA1 Message Date
Martino Ferrari
0ffcecf19e simple makefile 2026-01-23 14:30:17 +01:00
Martino Ferrari
761cf83b8e Added *.out rule 2026-01-23 14:30:02 +01:00
Martino Ferrari
7caf3a5da5 Renamed files 2026-01-23 14:24:43 +01:00
Martino Ferrari
94ee7e4880 added support to enum in completion 2026-01-23 14:18:41 +01:00
Martino Ferrari
ce9b68200e More tests 2026-01-23 14:09:17 +01:00
Martino Ferrari
e3c84fcf60 Moved tests in test folder (and made methods public in server.go) 2026-01-23 14:04:24 +01:00
Martino Ferrari
4a515fd6c3 completion test 2026-01-23 14:01:35 +01:00
Martino Ferrari
14cba1b530 Working 2026-01-23 14:01:26 +01:00
Martino Ferrari
462c832651 improved suggestions 2026-01-23 13:20:22 +01:00
10 changed files with 636 additions and 111 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
build build
*.log *.log
mdt mdt
*.out

24
Makefile Normal file
View File

@@ -0,0 +1,24 @@
BINARY_NAME=mdt
BUILD_DIR=build
.PHONY: all build test coverage clean install
all: test build
build:
mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/mdt
test:
go test -v ./...
coverage:
go test -cover -coverprofile=coverage.out ./test/... -coverpkg=./internal/...
go tool cover -func=coverage.out
clean:
rm -rf $(BUILD_DIR)
rm -f coverage.out
install:
go install ./cmd/mdt

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"regexp"
"strings" "strings"
"github.com/marte-community/marte-dev-tools/internal/formatter" "github.com/marte-community/marte-dev-tools/internal/formatter"
@@ -46,10 +47,10 @@ type CompletionList struct {
Items []CompletionItem `json:"items"` Items []CompletionItem `json:"items"`
} }
var tree = index.NewProjectTree() var Tree = index.NewProjectTree()
var documents = make(map[string]string) var Documents = make(map[string]string)
var projectRoot string var ProjectRoot string
var globalSchema *schema.Schema var GlobalSchema *schema.Schema
type JsonRpcMessage struct { type JsonRpcMessage struct {
Jsonrpc string `json:"jsonrpc"` Jsonrpc string `json:"jsonrpc"`
@@ -183,7 +184,7 @@ func RunServer() {
continue continue
} }
handleMessage(msg) HandleMessage(msg)
} }
} }
@@ -213,7 +214,7 @@ func readMessage(reader *bufio.Reader) (*JsonRpcMessage, error) {
return &msg, err return &msg, err
} }
func handleMessage(msg *JsonRpcMessage) { func HandleMessage(msg *JsonRpcMessage) {
switch msg.Method { switch msg.Method {
case "initialize": case "initialize":
var params InitializeParams var params InitializeParams
@@ -226,13 +227,13 @@ func handleMessage(msg *JsonRpcMessage) {
} }
if root != "" { if root != "" {
projectRoot = root ProjectRoot = root
logger.Printf("Scanning workspace: %s\n", root) logger.Printf("Scanning workspace: %s\n", root)
if err := tree.ScanDirectory(root); err != nil { if err := Tree.ScanDirectory(root); err != nil {
logger.Printf("ScanDirectory failed: %v\n", err) logger.Printf("ScanDirectory failed: %v\n", err)
} }
tree.ResolveReferences() Tree.ResolveReferences()
globalSchema = schema.LoadFullSchema(projectRoot) GlobalSchema = schema.LoadFullSchema(ProjectRoot)
} }
} }
@@ -257,18 +258,18 @@ func handleMessage(msg *JsonRpcMessage) {
case "textDocument/didOpen": case "textDocument/didOpen":
var params DidOpenTextDocumentParams var params DidOpenTextDocumentParams
if err := json.Unmarshal(msg.Params, &params); err == nil { if err := json.Unmarshal(msg.Params, &params); err == nil {
handleDidOpen(params) HandleDidOpen(params)
} }
case "textDocument/didChange": case "textDocument/didChange":
var params DidChangeTextDocumentParams var params DidChangeTextDocumentParams
if err := json.Unmarshal(msg.Params, &params); err == nil { if err := json.Unmarshal(msg.Params, &params); err == nil {
handleDidChange(params) HandleDidChange(params)
} }
case "textDocument/hover": case "textDocument/hover":
var params HoverParams var params HoverParams
if err := json.Unmarshal(msg.Params, &params); err == nil { if err := json.Unmarshal(msg.Params, &params); err == nil {
logger.Printf("Hover: %s:%d", params.TextDocument.URI, params.Position.Line) logger.Printf("Hover: %s:%d", params.TextDocument.URI, params.Position.Line)
res := handleHover(params) res := HandleHover(params)
if res != nil { if res != nil {
logger.Printf("Res: %v", res.Contents) logger.Printf("Res: %v", res.Contents)
} else { } else {
@@ -282,22 +283,22 @@ func handleMessage(msg *JsonRpcMessage) {
case "textDocument/definition": case "textDocument/definition":
var params DefinitionParams var params DefinitionParams
if err := json.Unmarshal(msg.Params, &params); err == nil { if err := json.Unmarshal(msg.Params, &params); err == nil {
respond(msg.ID, handleDefinition(params)) respond(msg.ID, HandleDefinition(params))
} }
case "textDocument/references": case "textDocument/references":
var params ReferenceParams var params ReferenceParams
if err := json.Unmarshal(msg.Params, &params); err == nil { if err := json.Unmarshal(msg.Params, &params); err == nil {
respond(msg.ID, handleReferences(params)) respond(msg.ID, HandleReferences(params))
} }
case "textDocument/completion": case "textDocument/completion":
var params CompletionParams var params CompletionParams
if err := json.Unmarshal(msg.Params, &params); err == nil { if err := json.Unmarshal(msg.Params, &params); err == nil {
respond(msg.ID, handleCompletion(params)) respond(msg.ID, HandleCompletion(params))
} }
case "textDocument/formatting": case "textDocument/formatting":
var params DocumentFormattingParams var params DocumentFormattingParams
if err := json.Unmarshal(msg.Params, &params); err == nil { if err := json.Unmarshal(msg.Params, &params); err == nil {
respond(msg.ID, handleFormatting(params)) respond(msg.ID, HandleFormatting(params))
} }
} }
} }
@@ -306,9 +307,9 @@ func uriToPath(uri string) string {
return strings.TrimPrefix(uri, "file://") return strings.TrimPrefix(uri, "file://")
} }
func handleDidOpen(params DidOpenTextDocumentParams) { func HandleDidOpen(params DidOpenTextDocumentParams) {
path := uriToPath(params.TextDocument.URI) path := uriToPath(params.TextDocument.URI)
documents[params.TextDocument.URI] = params.TextDocument.Text Documents[params.TextDocument.URI] = params.TextDocument.Text
p := parser.NewParser(params.TextDocument.Text) p := parser.NewParser(params.TextDocument.Text)
config, err := p.Parse() config, err := p.Parse()
@@ -319,18 +320,18 @@ func handleDidOpen(params DidOpenTextDocumentParams) {
} }
if config != nil { if config != nil {
tree.AddFile(path, config) Tree.AddFile(path, config)
tree.ResolveReferences() Tree.ResolveReferences()
runValidation(params.TextDocument.URI) runValidation(params.TextDocument.URI)
} }
} }
func handleDidChange(params DidChangeTextDocumentParams) { func HandleDidChange(params DidChangeTextDocumentParams) {
if len(params.ContentChanges) == 0 { if len(params.ContentChanges) == 0 {
return return
} }
text := params.ContentChanges[0].Text text := params.ContentChanges[0].Text
documents[params.TextDocument.URI] = text Documents[params.TextDocument.URI] = text
path := uriToPath(params.TextDocument.URI) path := uriToPath(params.TextDocument.URI)
p := parser.NewParser(text) p := parser.NewParser(text)
config, err := p.Parse() config, err := p.Parse()
@@ -342,15 +343,15 @@ func handleDidChange(params DidChangeTextDocumentParams) {
} }
if config != nil { if config != nil {
tree.AddFile(path, config) Tree.AddFile(path, config)
tree.ResolveReferences() Tree.ResolveReferences()
runValidation(params.TextDocument.URI) runValidation(params.TextDocument.URI)
} }
} }
func handleFormatting(params DocumentFormattingParams) []TextEdit { func HandleFormatting(params DocumentFormattingParams) []TextEdit {
uri := params.TextDocument.URI uri := params.TextDocument.URI
text, ok := documents[uri] text, ok := Documents[uri]
if !ok { if !ok {
return nil return nil
} }
@@ -382,7 +383,7 @@ func handleFormatting(params DocumentFormattingParams) []TextEdit {
} }
func runValidation(uri string) { func runValidation(uri string) {
v := validator.NewValidator(tree, projectRoot) v := validator.NewValidator(Tree, ProjectRoot)
v.ValidateProject() v.ValidateProject()
v.CheckUnused() v.CheckUnused()
@@ -391,7 +392,7 @@ func runValidation(uri string) {
// Collect all known files to ensure we clear diagnostics for fixed files // Collect all known files to ensure we clear diagnostics for fixed files
knownFiles := make(map[string]bool) knownFiles := make(map[string]bool)
collectFiles(tree.Root, knownFiles) collectFiles(Tree.Root, knownFiles)
// Initialize all known files with empty diagnostics // Initialize all known files with empty diagnostics
for f := range knownFiles { for f := range knownFiles {
@@ -500,12 +501,12 @@ func mustMarshal(v any) json.RawMessage {
return b return b
} }
func handleHover(params HoverParams) *Hover { func HandleHover(params HoverParams) *Hover {
path := uriToPath(params.TextDocument.URI) path := uriToPath(params.TextDocument.URI)
line := params.Position.Line + 1 line := params.Position.Line + 1
col := params.Position.Character + 1 col := params.Position.Character + 1
res := tree.Query(path, line, col) res := Tree.Query(path, line, col)
if res == nil { if res == nil {
logger.Printf("No object/node/reference found") logger.Printf("No object/node/reference found")
return nil return nil
@@ -552,10 +553,10 @@ func handleHover(params HoverParams) *Hover {
} }
} }
func handleCompletion(params CompletionParams) *CompletionList { func HandleCompletion(params CompletionParams) *CompletionList {
uri := params.TextDocument.URI uri := params.TextDocument.URI
path := uriToPath(uri) path := uriToPath(uri)
text, ok := documents[uri] text, ok := Documents[uri]
if !ok { if !ok {
return nil return nil
} }
@@ -575,22 +576,30 @@ func handleCompletion(params CompletionParams) *CompletionList {
// Case 1: Assigning a value (Ends with "=" or "= ") // Case 1: Assigning a value (Ends with "=" or "= ")
if strings.Contains(prefix, "=") { if strings.Contains(prefix, "=") {
parts := strings.Split(prefix, "=") lastIdx := strings.LastIndex(prefix, "=")
key := strings.TrimSpace(parts[len(parts)-2]) beforeEqual := prefix[:lastIdx]
// Find the last identifier before '='
key := ""
re := regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_\-]*`)
matches := re.FindAllString(beforeEqual, -1)
if len(matches) > 0 {
key = matches[len(matches)-1]
}
if key == "Class" { if key == "Class" {
return suggestClasses() return suggestClasses()
} }
container := tree.GetNodeContaining(path, parser.Position{Line: params.Position.Line + 1, Column: col + 1}) container := Tree.GetNodeContaining(path, parser.Position{Line: params.Position.Line + 1, Column: col + 1})
if container != nil { if container != nil {
return suggestFieldValues(container, key) return suggestFieldValues(container, key, path)
} }
return nil return nil
} }
// Case 2: Typing a key inside an object // Case 2: Typing a key inside an object
container := tree.GetNodeContaining(path, parser.Position{Line: params.Position.Line + 1, Column: col + 1}) container := Tree.GetNodeContaining(path, parser.Position{Line: params.Position.Line + 1, Column: col + 1})
if container != nil { if container != nil {
return suggestFields(container) return suggestFields(container)
} }
@@ -599,11 +608,11 @@ func handleCompletion(params CompletionParams) *CompletionList {
} }
func suggestClasses() *CompletionList { func suggestClasses() *CompletionList {
if globalSchema == nil { if GlobalSchema == nil {
return nil return nil
} }
classesVal := globalSchema.Value.LookupPath(cue.ParsePath("#Classes")) classesVal := GlobalSchema.Value.LookupPath(cue.ParsePath("#Classes"))
if classesVal.Err() != nil { if classesVal.Err() != nil {
return nil return nil
} }
@@ -638,11 +647,11 @@ func suggestFields(container *index.ProjectNode) *CompletionList {
}}} }}}
} }
if globalSchema == nil { if GlobalSchema == nil {
return nil return nil
} }
classPath := cue.ParsePath(fmt.Sprintf("#Classes.%s", cls)) classPath := cue.ParsePath(fmt.Sprintf("#Classes.%s", cls))
classVal := globalSchema.Value.LookupPath(classPath) classVal := GlobalSchema.Value.LookupPath(classPath)
if classVal.Err() != nil { if classVal.Err() != nil {
return nil return nil
} }
@@ -700,19 +709,107 @@ func suggestFields(container *index.ProjectNode) *CompletionList {
return &CompletionList{Items: items} return &CompletionList{Items: items}
} }
func suggestFieldValues(container *index.ProjectNode, field string) *CompletionList { func suggestFieldValues(container *index.ProjectNode, field string, path string) *CompletionList {
var root *index.ProjectNode
if iso, ok := Tree.IsolatedFiles[path]; ok {
root = iso
} else {
root = Tree.Root
}
if field == "DataSource" { if field == "DataSource" {
return suggestObjects("DataSource") return suggestObjects(root, "DataSource")
} }
if field == "Functions" { if field == "Functions" {
return suggestObjects("GAM") return suggestObjects(root, "GAM")
}
if field == "Type" {
return suggestSignalTypes()
}
if list := suggestCUEEnums(container, field); list != nil {
return list
}
return nil
}
func suggestSignalTypes() *CompletionList {
types := []string{
"uint8", "int8", "uint16", "int16", "uint32", "int32", "uint64", "int64",
"float32", "float64", "string", "bool", "char8",
}
var items []CompletionItem
for _, t := range types {
items = append(items, CompletionItem{
Label: t,
Kind: 13, // EnumMember
Detail: "Signal Type",
})
}
return &CompletionList{Items: items}
}
func suggestCUEEnums(container *index.ProjectNode, field string) *CompletionList {
if GlobalSchema == nil {
return nil
}
cls := container.Metadata["Class"]
if cls == "" {
return nil
}
classPath := cue.ParsePath(fmt.Sprintf("#Classes.%s.%s", cls, field))
val := GlobalSchema.Value.LookupPath(classPath)
if val.Err() != nil {
return nil
}
op, args := val.Expr()
var values []cue.Value
if op == cue.OrOp {
values = args
} else {
values = []cue.Value{val}
}
var items []CompletionItem
for _, v := range values {
if !v.IsConcrete() {
continue
}
str, err := v.String() // Returns quoted string for string values?
if err != nil {
continue
}
// Ensure strings are quoted
if v.Kind() == cue.StringKind && !strings.HasPrefix(str, "\"") {
str = fmt.Sprintf("\"%s\"", str)
}
items = append(items, CompletionItem{
Label: str,
Kind: 13, // EnumMember
Detail: "Enum Value",
})
}
if len(items) > 0 {
return &CompletionList{Items: items}
} }
return nil return nil
} }
func suggestObjects(filter string) *CompletionList { func suggestObjects(root *index.ProjectNode, filter string) *CompletionList {
if root == nil {
return nil
}
var items []CompletionItem var items []CompletionItem
tree.Walk(func(node *index.ProjectNode) {
var walk func(*index.ProjectNode)
walk = func(node *index.ProjectNode) {
match := false match := false
if filter == "GAM" { if filter == "GAM" {
if isGAM(node) { if isGAM(node) {
@@ -726,12 +823,18 @@ func suggestObjects(filter string) *CompletionList {
if match { if match {
items = append(items, CompletionItem{ items = append(items, CompletionItem{
Label: node.RealName, Label: node.Name,
Kind: 6, // Variable Kind: 6, // Variable
Detail: node.Metadata["Class"], Detail: node.Metadata["Class"],
}) })
} }
})
for _, child := range node.Children {
walk(child)
}
}
walk(root)
return &CompletionList{Items: items} return &CompletionList{Items: items}
} }
@@ -752,12 +855,12 @@ func isDataSource(node *index.ProjectNode) bool {
return hasSignals return hasSignals
} }
func handleDefinition(params DefinitionParams) any { func HandleDefinition(params DefinitionParams) any {
path := uriToPath(params.TextDocument.URI) path := uriToPath(params.TextDocument.URI)
line := params.Position.Line + 1 line := params.Position.Line + 1
col := params.Position.Character + 1 col := params.Position.Character + 1
res := tree.Query(path, line, col) res := Tree.Query(path, line, col)
if res == nil { if res == nil {
return nil return nil
} }
@@ -792,12 +895,12 @@ func handleDefinition(params DefinitionParams) any {
return nil return nil
} }
func handleReferences(params ReferenceParams) []Location { func HandleReferences(params ReferenceParams) []Location {
path := uriToPath(params.TextDocument.URI) path := uriToPath(params.TextDocument.URI)
line := params.Position.Line + 1 line := params.Position.Line + 1
col := params.Position.Character + 1 col := params.Position.Character + 1
res := tree.Query(path, line, col) res := Tree.Query(path, line, col)
if res == nil { if res == nil {
return nil return nil
} }
@@ -835,7 +938,7 @@ func handleReferences(params ReferenceParams) []Location {
} }
// 1. References from index (Aliases) // 1. References from index (Aliases)
for _, ref := range tree.References { for _, ref := range Tree.References {
if ref.Target == canonical { if ref.Target == canonical {
locations = append(locations, Location{ locations = append(locations, Location{
URI: "file://" + ref.File, URI: "file://" + ref.File,
@@ -848,7 +951,7 @@ func handleReferences(params ReferenceParams) []Location {
} }
// 2. References from Node Targets (Direct References) // 2. References from Node Targets (Direct References)
tree.Walk(func(node *index.ProjectNode) { Tree.Walk(func(node *index.ProjectNode) {
if node.Target == canonical { if node.Target == canonical {
for _, frag := range node.Fragments { for _, frag := range node.Fragments {
if frag.IsObject { if frag.IsObject {
@@ -902,9 +1005,9 @@ func formatNodeInfo(node *index.ProjectNode) string {
// Find references // Find references
var refs []string var refs []string
for _, ref := range tree.References { for _, ref := range Tree.References {
if ref.Target == node { if ref.Target == node {
container := tree.GetNodeContaining(ref.File, ref.Position) container := Tree.GetNodeContaining(ref.File, ref.Position)
if container != nil { if container != nil {
threadName := "" threadName := ""
stateName := "" stateName := ""

View File

@@ -207,7 +207,8 @@ func (p *Parser) parseSubnode() (Subnode, bool) {
} }
if t.Type == TokenEOF { if t.Type == TokenEOF {
p.addError(t.Position, "unexpected EOF, expected }") p.addError(t.Position, "unexpected EOF, expected }")
return sub, false sub.EndPosition = t.Position
return sub, true
} }
def, ok := p.parseDefinition() def, ok := p.parseDefinition()
if ok { if ok {

View File

@@ -277,12 +277,12 @@ package schema
} }
} }
// Definition for any Object. // Definition for any Object.
// It must have a Class field. // It must have a Class field.
// Based on Class, it validates against #Classes. // Based on Class, it validates against #Classes.
#Object: { #Object: {
Class: string Class: string
// Allow any other field by default (extensibility), // Allow any other field by default (extensibility),
// unless #Classes definition is closed. // unless #Classes definition is closed.
// We allow open structs now. // We allow open structs now.
... ...

320
test/lsp_completion_test.go Normal file
View File

@@ -0,0 +1,320 @@
package integration
import (
"strings"
"testing"
"github.com/marte-community/marte-dev-tools/internal/index"
"github.com/marte-community/marte-dev-tools/internal/lsp"
"github.com/marte-community/marte-dev-tools/internal/parser"
"github.com/marte-community/marte-dev-tools/internal/schema"
)
func TestHandleCompletion(t *testing.T) {
setup := func() {
lsp.Tree = index.NewProjectTree()
lsp.Documents = make(map[string]string)
lsp.ProjectRoot = "."
lsp.GlobalSchema = schema.NewSchema()
}
uri := "file://test.marte"
path := "test.marte"
t.Run("Suggest Classes", func(t *testing.T) {
setup()
content := "+Obj = { Class = "
lsp.Documents[uri] = content
params := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Position: lsp.Position{Line: 0, Character: len(content)},
}
list := lsp.HandleCompletion(params)
if list == nil || len(list.Items) == 0 {
t.Fatal("Expected class suggestions, got none")
}
found := false
for _, item := range list.Items {
if item.Label == "RealTimeApplication" {
found = true
break
}
}
if !found {
t.Error("Expected RealTimeApplication in class suggestions")
}
})
t.Run("Suggest Fields", func(t *testing.T) {
setup()
content := `
+MyApp = {
Class = RealTimeApplication
}
`
lsp.Documents[uri] = content
p := parser.NewParser(content)
cfg, _ := p.Parse()
lsp.Tree.AddFile(path, cfg)
// Position at line 3 (empty line inside MyApp)
params := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Position: lsp.Position{Line: 3, Character: 4},
}
list := lsp.HandleCompletion(params)
if list == nil || len(list.Items) == 0 {
t.Fatal("Expected field suggestions, got none")
}
foundData := false
for _, item := range list.Items {
if item.Label == "Data" {
foundData = true
if item.Detail != "Mandatory" {
t.Errorf("Expected Data to be Mandatory, got %s", item.Detail)
}
}
}
if !foundData {
t.Error("Expected 'Data' in field suggestions for RealTimeApplication")
}
})
t.Run("Suggest References (DataSource)", func(t *testing.T) {
setup()
content := `
$App = {
$Data = {
+InDS = {
Class = FileReader
+Signals = {
Sig1 = { Type = uint32 }
}
}
}
}
+MyGAM = {
Class = IOGAM
+InputSignals = {
S1 = { DataSource = }
}
}
`
lsp.Documents[uri] = content
p := parser.NewParser(content)
cfg, _ := p.Parse()
lsp.Tree.AddFile(path, cfg)
lsp.Tree.ResolveReferences()
// Position at end of "DataSource = "
params := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Position: lsp.Position{Line: 14, Character: 28},
}
list := lsp.HandleCompletion(params)
if list == nil || len(list.Items) == 0 {
t.Fatal("Expected DataSource suggestions, got none")
}
foundDS := false
for _, item := range list.Items {
if item.Label == "InDS" {
foundDS = true
break
}
}
if !foundDS {
t.Error("Expected 'InDS' in suggestions for DataSource field")
}
})
t.Run("Filter Existing Fields", func(t *testing.T) {
setup()
content := `
+MyThread = {
Class = RealTimeThread
Functions = { }
}
`
lsp.Documents[uri] = content
p := parser.NewParser(content)
cfg, _ := p.Parse()
lsp.Tree.AddFile(path, cfg)
// Position at line 4
params := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Position: lsp.Position{Line: 4, Character: 4},
}
list := lsp.HandleCompletion(params)
for _, item := range list.Items {
if item.Label == "Functions" || item.Label == "Class" {
t.Errorf("Did not expect already defined field %s in suggestions", item.Label)
}
}
})
t.Run("Scope-aware suggestions", func(t *testing.T) {
setup()
// Define a project DataSource in one file
cfg1, _ := parser.NewParser("#package MYPROJ.Data\n+ProjectDS = { Class = FileReader +Signals = { S1 = { Type = int32 } } }").Parse()
lsp.Tree.AddFile("project_ds.marte", cfg1)
// Define an isolated file
contentIso := "+MyGAM = { Class = IOGAM +InputSignals = { S1 = { DataSource = } } }"
lsp.Documents["file://iso.marte"] = contentIso
cfg2, _ := parser.NewParser(contentIso).Parse()
lsp.Tree.AddFile("iso.marte", cfg2)
lsp.Tree.ResolveReferences()
// Completion in isolated file
params := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: "file://iso.marte"},
Position: lsp.Position{Line: 0, Character: strings.Index(contentIso, "DataSource = ") + len("DataSource = ") + 1},
}
list := lsp.HandleCompletion(params)
foundProjectDS := false
if list != nil {
for _, item := range list.Items {
if item.Label == "ProjectDS" {
foundProjectDS = true
break
}
}
}
if foundProjectDS {
t.Error("Did not expect ProjectDS in isolated file suggestions")
}
// Completion in a project file
lineContent := "+MyGAM = { Class = IOGAM +InputSignals = { S1 = { DataSource = Dummy } } }"
contentPrj := "#package MYPROJ.App\n" + lineContent
lsp.Documents["file://prj.marte"] = contentPrj
pPrj := parser.NewParser(contentPrj)
cfg3, err := pPrj.Parse()
if err != nil {
t.Logf("Parser error in contentPrj: %v", err)
}
lsp.Tree.AddFile("prj.marte", cfg3)
lsp.Tree.ResolveReferences()
paramsPrj := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: "file://prj.marte"},
Position: lsp.Position{Line: 1, Character: strings.Index(lineContent, "Dummy")},
}
listPrj := lsp.HandleCompletion(paramsPrj)
foundProjectDS = false
if listPrj != nil {
for _, item := range listPrj.Items {
if item.Label == "ProjectDS" {
foundProjectDS = true
break
}
}
}
if !foundProjectDS {
t.Error("Expected ProjectDS in project file suggestions")
}
})
t.Run("Suggest Signal Types", func(t *testing.T) {
setup()
content := `
+DS = {
Class = FileReader
Signals = {
S1 = { Type = }
}
}
`
lsp.Documents[uri] = content
p := parser.NewParser(content)
cfg, _ := p.Parse()
lsp.Tree.AddFile(path, cfg)
params := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Position: lsp.Position{Line: 4, Character: strings.Index(content, "Type = ") + len("Type = ") + 1},
}
list := lsp.HandleCompletion(params)
if list == nil {
t.Fatal("Expected signal type suggestions")
}
foundUint32 := false
for _, item := range list.Items {
if item.Label == "uint32" {
foundUint32 = true
break
}
}
if !foundUint32 {
t.Error("Expected uint32 in suggestions")
}
})
t.Run("Suggest CUE Enums", func(t *testing.T) {
setup()
// Inject custom schema with enum
custom := []byte(`
package schema
#Classes: {
TestEnumClass: {
Mode: "Auto" | "Manual"
}
}
`)
val := lsp.GlobalSchema.Context.CompileBytes(custom)
lsp.GlobalSchema.Value = lsp.GlobalSchema.Value.Unify(val)
content := `
+Obj = {
Class = TestEnumClass
Mode =
}
`
lsp.Documents[uri] = content
p := parser.NewParser(content)
cfg, _ := p.Parse()
lsp.Tree.AddFile(path, cfg)
params := lsp.CompletionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Position: lsp.Position{Line: 3, Character: strings.Index(content, "Mode = ") + len("Mode = ") + 1},
}
list := lsp.HandleCompletion(params)
if list == nil {
t.Fatal("Expected enum suggestions")
}
foundAuto := false
for _, item := range list.Items {
if item.Label == "\"Auto\"" { // CUE string value includes quotes
foundAuto = true
break
}
}
if !foundAuto {
// Check if it returned without quotes?
// v.String() returns quoted for string.
t.Error("Expected \"Auto\" in suggestions")
for _, item := range list.Items {
t.Logf("Suggestion: %s", item.Label)
}
}
})
}

View File

@@ -1,4 +1,4 @@
package lsp package integration
import ( import (
"encoding/json" "encoding/json"
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"github.com/marte-community/marte-dev-tools/internal/index" "github.com/marte-community/marte-dev-tools/internal/index"
"github.com/marte-community/marte-dev-tools/internal/lsp"
"github.com/marte-community/marte-dev-tools/internal/parser" "github.com/marte-community/marte-dev-tools/internal/parser"
) )
@@ -24,50 +25,38 @@ func TestInitProjectScan(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// File 2: Reference // File 2: Reference
// +Source = { Class = C Link = Target }
// Link = Target starts at index ...
// #package Test.Common (21 chars including newline)
// +Source = { Class = C Link = Target }
// 012345678901234567890123456789012345
// Previous offset was 29.
// Now add 21?
// #package Test.Common\n
// +Source = ...
// So add 21 to Character? Or Line 1?
// It's on Line 1 (0-based 1).
if err := os.WriteFile(filepath.Join(tmpDir, "ref.marte"), []byte("#package Test.Common\n+Source = { Class = C Link = Target }"), 0644); err != nil { if err := os.WriteFile(filepath.Join(tmpDir, "ref.marte"), []byte("#package Test.Common\n+Source = { Class = C Link = Target }"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// 2. Initialize // 2. Initialize
tree = index.NewProjectTree() // Reset global tree lsp.Tree = index.NewProjectTree() // Reset global tree
initParams := InitializeParams{RootPath: tmpDir} initParams := lsp.InitializeParams{RootPath: tmpDir}
paramsBytes, _ := json.Marshal(initParams) paramsBytes, _ := json.Marshal(initParams)
msg := &JsonRpcMessage{ msg := &lsp.JsonRpcMessage{
Method: "initialize", Method: "initialize",
Params: paramsBytes, Params: paramsBytes,
ID: 1, ID: 1,
} }
handleMessage(msg) lsp.HandleMessage(msg)
// Query the reference in ref.marte at "Target" // Query the reference in ref.marte at "Target"
// Target starts at index 29 (0-based) on Line 1 defParams := lsp.DefinitionParams{
defParams := DefinitionParams{ TextDocument: lsp.TextDocumentIdentifier{URI: "file://" + filepath.Join(tmpDir, "ref.marte")},
TextDocument: TextDocumentIdentifier{URI: "file://" + filepath.Join(tmpDir, "ref.marte")}, Position: lsp.Position{Line: 1, Character: 29},
Position: Position{Line: 1, Character: 29},
} }
res := handleDefinition(defParams) res := lsp.HandleDefinition(defParams)
if res == nil { if res == nil {
t.Fatal("Definition not found via LSP after initialization") t.Fatal("Definition not found via LSP after initialization")
} }
locs, ok := res.([]Location) locs, ok := res.([]lsp.Location)
if !ok { if !ok {
t.Fatalf("Expected []Location, got %T", res) t.Fatalf("Expected []lsp.Location, got %T", res)
} }
if len(locs) == 0 { if len(locs) == 0 {
@@ -83,7 +72,7 @@ func TestInitProjectScan(t *testing.T) {
func TestHandleDefinition(t *testing.T) { func TestHandleDefinition(t *testing.T) {
// Reset tree for test // Reset tree for test
tree = index.NewProjectTree() lsp.Tree = index.NewProjectTree()
content := ` content := `
+MyObject = { +MyObject = {
@@ -100,28 +89,28 @@ func TestHandleDefinition(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Parse failed: %v", err) t.Fatalf("Parse failed: %v", err)
} }
tree.AddFile(path, config) lsp.Tree.AddFile(path, config)
tree.ResolveReferences() lsp.Tree.ResolveReferences()
t.Logf("Refs: %d", len(tree.References)) t.Logf("Refs: %d", len(lsp.Tree.References))
for _, r := range tree.References { for _, r := range lsp.Tree.References {
t.Logf(" %s at %d:%d", r.Name, r.Position.Line, r.Position.Column) t.Logf(" %s at %d:%d", r.Name, r.Position.Line, r.Position.Column)
} }
// Test Go to Definition on MyObject reference // Test Go to Definition on MyObject reference
params := DefinitionParams{ params := lsp.DefinitionParams{
TextDocument: TextDocumentIdentifier{URI: "file://" + path}, TextDocument: lsp.TextDocumentIdentifier{URI: "file://" + path},
Position: Position{Line: 6, Character: 15}, // "MyObject" in RefField = MyObject Position: lsp.Position{Line: 6, Character: 15}, // "MyObject" in RefField = MyObject
} }
result := handleDefinition(params) result := lsp.HandleDefinition(params)
if result == nil { if result == nil {
t.Fatal("handleDefinition returned nil") t.Fatal("HandleDefinition returned nil")
} }
locations, ok := result.([]Location) locations, ok := result.([]lsp.Location)
if !ok { if !ok {
t.Fatalf("Expected []Location, got %T", result) t.Fatalf("Expected []lsp.Location, got %T", result)
} }
if len(locations) != 1 { if len(locations) != 1 {
@@ -135,7 +124,7 @@ func TestHandleDefinition(t *testing.T) {
func TestHandleReferences(t *testing.T) { func TestHandleReferences(t *testing.T) {
// Reset tree for test // Reset tree for test
tree = index.NewProjectTree() lsp.Tree = index.NewProjectTree()
content := ` content := `
+MyObject = { +MyObject = {
@@ -155,17 +144,17 @@ func TestHandleReferences(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Parse failed: %v", err) t.Fatalf("Parse failed: %v", err)
} }
tree.AddFile(path, config) lsp.Tree.AddFile(path, config)
tree.ResolveReferences() lsp.Tree.ResolveReferences()
// Test Find References for MyObject (triggered from its definition) // Test Find References for MyObject (triggered from its definition)
params := ReferenceParams{ params := lsp.ReferenceParams{
TextDocument: TextDocumentIdentifier{URI: "file://" + path}, TextDocument: lsp.TextDocumentIdentifier{URI: "file://" + path},
Position: Position{Line: 1, Character: 1}, // "+MyObject" Position: lsp.Position{Line: 1, Character: 1}, // "+MyObject"
Context: ReferenceContext{IncludeDeclaration: true}, Context: lsp.ReferenceContext{IncludeDeclaration: true},
} }
locations := handleReferences(params) locations := lsp.HandleReferences(params)
if len(locations) != 3 { // 1 declaration + 2 references if len(locations) != 3 { // 1 declaration + 2 references
t.Fatalf("Expected 3 locations, got %d", len(locations)) t.Fatalf("Expected 3 locations, got %d", len(locations))
} }
@@ -181,15 +170,15 @@ Field=1
` `
uri := "file:///test.marte" uri := "file:///test.marte"
// Open (populate documents map) // Open (populate Documents map)
documents[uri] = content lsp.Documents[uri] = content
// Format // Format
params := DocumentFormattingParams{ params := lsp.DocumentFormattingParams{
TextDocument: TextDocumentIdentifier{URI: uri}, TextDocument: lsp.TextDocumentIdentifier{URI: uri},
} }
edits := handleFormatting(params) edits := lsp.HandleFormatting(params)
if len(edits) != 1 { if len(edits) != 1 {
t.Fatalf("Expected 1 edit, got %d", len(edits)) t.Fatalf("Expected 1 edit, got %d", len(edits))

View File

@@ -1,4 +1,4 @@
package parser_test package integration
import ( import (
"testing" "testing"

View File

@@ -1,7 +1,9 @@
package parser package integration
import ( import (
"testing" "testing"
"github.com/marte-community/marte-dev-tools/internal/parser"
) )
func TestParseBasic(t *testing.T) { func TestParseBasic(t *testing.T) {
@@ -22,7 +24,7 @@ $Node2 = {
Array = {1 2 3} Array = {1 2 3}
} }
` `
p := NewParser(input) p := parser.NewParser(input)
config, err := p.Parse() config, err := p.Parse()
if err != nil { if err != nil {
t.Fatalf("Parse error: %v", err) t.Fatalf("Parse error: %v", err)

View File

@@ -0,0 +1,85 @@
package integration
import (
"strings"
"testing"
"github.com/marte-community/marte-dev-tools/internal/index"
"github.com/marte-community/marte-dev-tools/internal/parser"
"github.com/marte-community/marte-dev-tools/internal/validator"
)
func TestGAMSignalDirectionality(t *testing.T) {
content := `
$App = {
$Data = {
+InDS = { Class = FileReader Filename="f" +Signals = { S1 = { Type = uint32 } } }
+OutDS = { Class = FileWriter Filename="f" +Signals = { S1 = { Type = uint32 } } }
+InOutDS = { Class = FileDataSource Filename="f" +Signals = { S1 = { Type = uint32 } } }
}
+ValidGAM = {
Class = IOGAM
InputSignals = {
S1 = { DataSource = InDS }
S2 = { DataSource = InOutDS Alias = S1 }
}
OutputSignals = {
S3 = { DataSource = OutDS Alias = S1 }
S4 = { DataSource = InOutDS Alias = S1 }
}
}
+InvalidGAM = {
Class = IOGAM
InputSignals = {
BadIn = { DataSource = OutDS Alias = S1 }
}
OutputSignals = {
BadOut = { DataSource = InDS Alias = S1 }
}
}
}
`
p := parser.NewParser(content)
config, err := p.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
idx := index.NewProjectTree()
idx.AddFile("dir.marte", config)
idx.ResolveReferences()
v := validator.NewValidator(idx, ".")
v.ValidateProject()
// Check ValidGAM has NO directionality errors
for _, d := range v.Diagnostics {
if strings.Contains(d.Message, "is Output-only but referenced in InputSignals") ||
strings.Contains(d.Message, "is Input-only but referenced in OutputSignals") {
if strings.Contains(d.Message, "ValidGAM") {
t.Errorf("Unexpected direction error for ValidGAM: %s", d.Message)
}
}
}
// Check InvalidGAM HAS errors
foundBadIn := false
foundBadOut := false
for _, d := range v.Diagnostics {
if strings.Contains(d.Message, "InvalidGAM") {
if strings.Contains(d.Message, "is Output-only but referenced in InputSignals") {
foundBadIn = true
}
if strings.Contains(d.Message, "is Input-only but referenced in OutputSignals") {
foundBadOut = true
}
}
}
if !foundBadIn {
t.Error("Expected error for OutDS in InputSignals of InvalidGAM")
}
if !foundBadOut {
t.Error("Expected error for InDS in OutputSignals of InvalidGAM")
}
}