Compare commits

..

13 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
Martino Ferrari
77fe3e9cac Improved LSP reactivity 2026-01-23 13:14:34 +01:00
Martino Ferrari
0ee44c0a27 Readme file added 2026-01-23 13:02:53 +01:00
Martino Ferrari
d450d358b4 add MIT Licensing 2026-01-23 13:02:34 +01:00
Martino Ferrari
2cdcfe2812 Updated specifications 2026-01-23 13:02:12 +01:00
13 changed files with 861 additions and 173 deletions

1
.gitignore vendored
View File

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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 MARTe Community
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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

96
README.md Normal file
View File

@@ -0,0 +1,96 @@
# MARTe Development Tools (mdt)
`mdt` is a comprehensive toolkit for developing, validating, and building configurations for the MARTe real-time framework. It provides a CLI and a Language Server Protocol (LSP) server to enhance the development experience.
## Features
- **LSP Server**: Real-time syntax checking, validation, autocomplete, hover documentation, and navigation (Go to Definition/References).
- **Builder**: Merges multiple configuration files into a single, ordered output file.
- **Formatter**: Standardizes configuration file formatting.
- **Validator**: Advanced semantic validation using [CUE](https://cuelang.org/) schemas, ensuring type safety and structural correctness.
## Installation
### From Source
Requirements: Go 1.21+
```bash
go install github.com/marte-community/marte-dev-tools/cmd/mdt@latest
```
## Usage
### CLI Commands
- **Check**: Run validation on a file or project.
```bash
mdt check path/to/project
```
- **Build**: Merge project files into a single output.
```bash
mdt build -o output.marte main.marte
```
- **Format**: Format configuration files.
```bash
mdt fmt path/to/file.marte
```
- **LSP**: Start the language server (used by editor plugins).
```bash
mdt lsp
```
### Editor Integration
`mdt lsp` implements the Language Server Protocol. You can use it with any LSP-compatible editor (VS Code, Neovim, Emacs, etc.).
## MARTe Configuration
The tools support the MARTe configuration format with extended features:
- **Objects**: `+Node = { Class = ... }`
- **Signals**: `Signal = { Type = ... }`
- **Namespaces**: `#package PROJECT.NODE` for organizing multi-file projects.
### Validation & Schema
Validation is fully schema-driven using CUE.
- **Built-in Schema**: Covers standard MARTe classes (`StateMachine`, `GAM`, `DataSource`, `RealTimeApplication`, etc.).
- **Custom Schema**: Add a `.marte_schema.cue` file to your project root to extend or override definitions.
**Example `.marte_schema.cue`:**
```cue
package schema
#Classes: {
MyCustomGAM: {
Param1: int
Param2?: string
...
}
}
```
### Pragmas (Suppressing Warnings)
Use comments starting with `//!` to control validation behavior:
- `//!unused: Reason` - Suppress "Unused GAM" or "Unused Signal" warnings.
- `//!implicit: Reason` - Suppress "Implicitly Defined Signal" warnings.
- `//!cast(DefinedType, UsageType)` - Allow type mismatch between definition and usage (e.g. `//!cast(uint32, int32)`).
- `//!allow(unused)` - Global suppression for the file.
## Development
### Building
```bash
go build ./cmd/mdt
```
### Running Tests
```bash
go test ./...
```
## License
MIT

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"regexp"
"strings"
"github.com/marte-community/marte-dev-tools/internal/formatter"
@@ -46,10 +47,10 @@ type CompletionList struct {
Items []CompletionItem `json:"items"`
}
var tree = index.NewProjectTree()
var documents = make(map[string]string)
var projectRoot string
var globalSchema *schema.Schema
var Tree = index.NewProjectTree()
var Documents = make(map[string]string)
var ProjectRoot string
var GlobalSchema *schema.Schema
type JsonRpcMessage struct {
Jsonrpc string `json:"jsonrpc"`
@@ -183,7 +184,7 @@ func RunServer() {
continue
}
handleMessage(msg)
HandleMessage(msg)
}
}
@@ -213,7 +214,7 @@ func readMessage(reader *bufio.Reader) (*JsonRpcMessage, error) {
return &msg, err
}
func handleMessage(msg *JsonRpcMessage) {
func HandleMessage(msg *JsonRpcMessage) {
switch msg.Method {
case "initialize":
var params InitializeParams
@@ -226,13 +227,13 @@ func handleMessage(msg *JsonRpcMessage) {
}
if root != "" {
projectRoot = root
ProjectRoot = 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)
}
tree.ResolveReferences()
globalSchema = schema.LoadFullSchema(projectRoot)
Tree.ResolveReferences()
GlobalSchema = schema.LoadFullSchema(ProjectRoot)
}
}
@@ -257,18 +258,18 @@ func handleMessage(msg *JsonRpcMessage) {
case "textDocument/didOpen":
var params DidOpenTextDocumentParams
if err := json.Unmarshal(msg.Params, &params); err == nil {
handleDidOpen(params)
HandleDidOpen(params)
}
case "textDocument/didChange":
var params DidChangeTextDocumentParams
if err := json.Unmarshal(msg.Params, &params); err == nil {
handleDidChange(params)
HandleDidChange(params)
}
case "textDocument/hover":
var params HoverParams
if err := json.Unmarshal(msg.Params, &params); err == nil {
logger.Printf("Hover: %s:%d", params.TextDocument.URI, params.Position.Line)
res := handleHover(params)
res := HandleHover(params)
if res != nil {
logger.Printf("Res: %v", res.Contents)
} else {
@@ -282,22 +283,22 @@ func handleMessage(msg *JsonRpcMessage) {
case "textDocument/definition":
var params DefinitionParams
if err := json.Unmarshal(msg.Params, &params); err == nil {
respond(msg.ID, handleDefinition(params))
respond(msg.ID, HandleDefinition(params))
}
case "textDocument/references":
var params ReferenceParams
if err := json.Unmarshal(msg.Params, &params); err == nil {
respond(msg.ID, handleReferences(params))
respond(msg.ID, HandleReferences(params))
}
case "textDocument/completion":
var params CompletionParams
if err := json.Unmarshal(msg.Params, &params); err == nil {
respond(msg.ID, handleCompletion(params))
respond(msg.ID, HandleCompletion(params))
}
case "textDocument/formatting":
var params DocumentFormattingParams
if err := json.Unmarshal(msg.Params, &params); err == nil {
respond(msg.ID, handleFormatting(params))
respond(msg.ID, HandleFormatting(params))
}
}
}
@@ -306,41 +307,51 @@ func uriToPath(uri string) string {
return strings.TrimPrefix(uri, "file://")
}
func handleDidOpen(params DidOpenTextDocumentParams) {
func HandleDidOpen(params DidOpenTextDocumentParams) {
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)
config, err := p.Parse()
if err != nil {
publishParserError(params.TextDocument.URI, err)
return
}
tree.AddFile(path, config)
tree.ResolveReferences()
runValidation(params.TextDocument.URI)
} else {
publishParserError(params.TextDocument.URI, nil)
}
func handleDidChange(params DidChangeTextDocumentParams) {
if config != nil {
Tree.AddFile(path, config)
Tree.ResolveReferences()
runValidation(params.TextDocument.URI)
}
}
func HandleDidChange(params DidChangeTextDocumentParams) {
if len(params.ContentChanges) == 0 {
return
}
text := params.ContentChanges[0].Text
documents[params.TextDocument.URI] = text
Documents[params.TextDocument.URI] = text
path := uriToPath(params.TextDocument.URI)
p := parser.NewParser(text)
config, err := p.Parse()
if err != nil {
publishParserError(params.TextDocument.URI, err)
return
}
tree.AddFile(path, config)
tree.ResolveReferences()
runValidation(params.TextDocument.URI)
} else {
publishParserError(params.TextDocument.URI, nil)
}
func handleFormatting(params DocumentFormattingParams) []TextEdit {
if config != nil {
Tree.AddFile(path, config)
Tree.ResolveReferences()
runValidation(params.TextDocument.URI)
}
}
func HandleFormatting(params DocumentFormattingParams) []TextEdit {
uri := params.TextDocument.URI
text, ok := documents[uri]
text, ok := Documents[uri]
if !ok {
return nil
}
@@ -372,7 +383,7 @@ func handleFormatting(params DocumentFormattingParams) []TextEdit {
}
func runValidation(uri string) {
v := validator.NewValidator(tree, projectRoot)
v := validator.NewValidator(Tree, ProjectRoot)
v.ValidateProject()
v.CheckUnused()
@@ -381,7 +392,7 @@ func runValidation(uri string) {
// Collect all known files to ensure we clear diagnostics for fixed files
knownFiles := make(map[string]bool)
collectFiles(tree.Root, knownFiles)
collectFiles(Tree.Root, knownFiles)
// Initialize all known files with empty diagnostics
for f := range knownFiles {
@@ -426,6 +437,19 @@ func runValidation(uri string) {
}
func publishParserError(uri string, err error) {
if err == nil {
notification := JsonRpcMessage{
Jsonrpc: "2.0",
Method: "textDocument/publishDiagnostics",
Params: mustMarshal(PublishDiagnosticsParams{
URI: uri,
Diagnostics: []LSPDiagnostic{},
}),
}
send(notification)
return
}
var line, col int
var msg string
// Try parsing "line:col: message"
@@ -477,12 +501,12 @@ func mustMarshal(v any) json.RawMessage {
return b
}
func handleHover(params HoverParams) *Hover {
func HandleHover(params HoverParams) *Hover {
path := uriToPath(params.TextDocument.URI)
line := params.Position.Line + 1
col := params.Position.Character + 1
res := tree.Query(path, line, col)
res := Tree.Query(path, line, col)
if res == nil {
logger.Printf("No object/node/reference found")
return nil
@@ -529,10 +553,10 @@ func handleHover(params HoverParams) *Hover {
}
}
func handleCompletion(params CompletionParams) *CompletionList {
func HandleCompletion(params CompletionParams) *CompletionList {
uri := params.TextDocument.URI
path := uriToPath(uri)
text, ok := documents[uri]
text, ok := Documents[uri]
if !ok {
return nil
}
@@ -552,22 +576,30 @@ func handleCompletion(params CompletionParams) *CompletionList {
// Case 1: Assigning a value (Ends with "=" or "= ")
if strings.Contains(prefix, "=") {
parts := strings.Split(prefix, "=")
key := strings.TrimSpace(parts[len(parts)-2])
lastIdx := strings.LastIndex(prefix, "=")
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" {
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 {
return suggestFieldValues(container, key)
return suggestFieldValues(container, key, path)
}
return nil
}
// 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 {
return suggestFields(container)
}
@@ -576,11 +608,11 @@ func handleCompletion(params CompletionParams) *CompletionList {
}
func suggestClasses() *CompletionList {
if globalSchema == nil {
if GlobalSchema == nil {
return nil
}
classesVal := globalSchema.Value.LookupPath(cue.ParsePath("#Classes"))
classesVal := GlobalSchema.Value.LookupPath(cue.ParsePath("#Classes"))
if classesVal.Err() != nil {
return nil
}
@@ -615,11 +647,11 @@ func suggestFields(container *index.ProjectNode) *CompletionList {
}}}
}
if globalSchema == nil {
if GlobalSchema == nil {
return nil
}
classPath := cue.ParsePath(fmt.Sprintf("#Classes.%s", cls))
classVal := globalSchema.Value.LookupPath(classPath)
classVal := GlobalSchema.Value.LookupPath(classPath)
if classVal.Err() != nil {
return nil
}
@@ -677,19 +709,107 @@ func suggestFields(container *index.ProjectNode) *CompletionList {
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" {
return suggestObjects("DataSource")
return suggestObjects(root, "DataSource")
}
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
}
func suggestObjects(filter string) *CompletionList {
func suggestObjects(root *index.ProjectNode, filter string) *CompletionList {
if root == nil {
return nil
}
var items []CompletionItem
tree.Walk(func(node *index.ProjectNode) {
var walk func(*index.ProjectNode)
walk = func(node *index.ProjectNode) {
match := false
if filter == "GAM" {
if isGAM(node) {
@@ -703,12 +823,18 @@ func suggestObjects(filter string) *CompletionList {
if match {
items = append(items, CompletionItem{
Label: node.RealName,
Label: node.Name,
Kind: 6, // Variable
Detail: node.Metadata["Class"],
})
}
})
for _, child := range node.Children {
walk(child)
}
}
walk(root)
return &CompletionList{Items: items}
}
@@ -729,12 +855,12 @@ func isDataSource(node *index.ProjectNode) bool {
return hasSignals
}
func handleDefinition(params DefinitionParams) any {
func HandleDefinition(params DefinitionParams) any {
path := uriToPath(params.TextDocument.URI)
line := params.Position.Line + 1
col := params.Position.Character + 1
res := tree.Query(path, line, col)
res := Tree.Query(path, line, col)
if res == nil {
return nil
}
@@ -769,12 +895,12 @@ func handleDefinition(params DefinitionParams) any {
return nil
}
func handleReferences(params ReferenceParams) []Location {
func HandleReferences(params ReferenceParams) []Location {
path := uriToPath(params.TextDocument.URI)
line := params.Position.Line + 1
col := params.Position.Character + 1
res := tree.Query(path, line, col)
res := Tree.Query(path, line, col)
if res == nil {
return nil
}
@@ -812,7 +938,7 @@ func handleReferences(params ReferenceParams) []Location {
}
// 1. References from index (Aliases)
for _, ref := range tree.References {
for _, ref := range Tree.References {
if ref.Target == canonical {
locations = append(locations, Location{
URI: "file://" + ref.File,
@@ -825,7 +951,7 @@ func handleReferences(params ReferenceParams) []Location {
}
// 2. References from Node Targets (Direct References)
tree.Walk(func(node *index.ProjectNode) {
Tree.Walk(func(node *index.ProjectNode) {
if node.Target == canonical {
for _, frag := range node.Fragments {
if frag.IsObject {
@@ -879,9 +1005,9 @@ func formatNodeInfo(node *index.ProjectNode) string {
// Find references
var refs []string
for _, ref := range tree.References {
for _, ref := range Tree.References {
if ref.Target == node {
container := tree.GetNodeContaining(ref.File, ref.Position)
container := Tree.GetNodeContaining(ref.File, ref.Position)
if container != nil {
threadName := ""
stateName := ""

View File

@@ -11,6 +11,7 @@ type Parser struct {
buf []Token
comments []Comment
pragmas []Pragma
errors []error
}
func NewParser(input string) *Parser {
@@ -19,6 +20,10 @@ func NewParser(input string) *Parser {
}
}
func (p *Parser) addError(pos Position, msg string) {
p.errors = append(p.errors, fmt.Errorf("%d:%d: %s", pos.Line, pos.Column, msg))
}
func (p *Parser) next() Token {
if len(p.buf) > 0 {
t := p.buf[0]
@@ -71,72 +76,82 @@ func (p *Parser) Parse() (*Configuration, error) {
continue
}
def, err := p.parseDefinition()
if err != nil {
return nil, err
}
def, ok := p.parseDefinition()
if ok {
config.Definitions = append(config.Definitions, def)
} else {
// Synchronization: skip token if not consumed to make progress
if p.peek() == tok {
p.next()
}
}
}
config.Comments = p.comments
config.Pragmas = p.pragmas
return config, nil
var err error
if len(p.errors) > 0 {
err = p.errors[0]
}
return config, err
}
func (p *Parser) parseDefinition() (Definition, error) {
func (p *Parser) parseDefinition() (Definition, bool) {
tok := p.next()
switch tok.Type {
case TokenIdentifier:
// Could be Field = Value OR Node = { ... }
name := tok.Value
if p.next().Type != TokenEqual {
return nil, fmt.Errorf("%d:%d: expected =", tok.Position.Line, tok.Position.Column)
if p.peek().Type != TokenEqual {
p.addError(tok.Position, "expected =")
return nil, false
}
p.next() // Consume =
// Disambiguate based on RHS
nextTok := p.peek()
if nextTok.Type == TokenLBrace {
// Check if it looks like a Subnode (contains definitions) or Array (contains values)
if p.isSubnodeLookahead() {
sub, err := p.parseSubnode()
if err != nil {
return nil, err
sub, ok := p.parseSubnode()
if !ok {
return nil, false
}
return &ObjectNode{
Position: tok.Position,
Name: name,
Subnode: sub,
}, nil
}, true
}
}
// Default to Field
val, err := p.parseValue()
if err != nil {
return nil, err
val, ok := p.parseValue()
if !ok {
return nil, false
}
return &Field{
Position: tok.Position,
Name: name,
Value: val,
}, nil
}, true
case TokenObjectIdentifier:
// node = subnode
name := tok.Value
if p.next().Type != TokenEqual {
return nil, fmt.Errorf("%d:%d: expected =", tok.Position.Line, tok.Position.Column)
if p.peek().Type != TokenEqual {
p.addError(tok.Position, "expected =")
return nil, false
}
sub, err := p.parseSubnode()
if err != nil {
return nil, err
p.next() // Consume =
sub, ok := p.parseSubnode()
if !ok {
return nil, false
}
return &ObjectNode{
Position: tok.Position,
Name: name,
Subnode: sub,
}, nil
}, true
default:
return nil, fmt.Errorf("%d:%d: unexpected token %v", tok.Position.Line, tok.Position.Column, tok.Value)
p.addError(tok.Position, fmt.Sprintf("unexpected token %v", tok.Value))
return nil, false
}
}
@@ -176,10 +191,11 @@ func (p *Parser) isSubnodeLookahead() bool {
return false
}
func (p *Parser) parseSubnode() (Subnode, error) {
func (p *Parser) parseSubnode() (Subnode, bool) {
tok := p.next()
if tok.Type != TokenLBrace {
return Subnode{}, fmt.Errorf("%d:%d: expected {", tok.Position.Line, tok.Position.Column)
p.addError(tok.Position, "expected {")
return Subnode{}, false
}
sub := Subnode{Position: tok.Position}
for {
@@ -190,18 +206,23 @@ func (p *Parser) parseSubnode() (Subnode, error) {
break
}
if t.Type == TokenEOF {
return sub, fmt.Errorf("%d:%d: unexpected EOF, expected }", t.Position.Line, t.Position.Column)
}
def, err := p.parseDefinition()
if err != nil {
return sub, err
p.addError(t.Position, "unexpected EOF, expected }")
sub.EndPosition = t.Position
return sub, true
}
def, ok := p.parseDefinition()
if ok {
sub.Definitions = append(sub.Definitions, def)
} else {
if p.peek() == t {
p.next()
}
return sub, nil
}
}
return sub, true
}
func (p *Parser) parseValue() (Value, error) {
func (p *Parser) parseValue() (Value, bool) {
tok := p.next()
switch tok.Type {
case TokenString:
@@ -209,24 +230,21 @@ func (p *Parser) parseValue() (Value, error) {
Position: tok.Position,
Value: strings.Trim(tok.Value, "\""),
Quoted: true,
}, nil
}, true
case TokenNumber:
// Simplistic handling
if strings.Contains(tok.Value, ".") || strings.Contains(tok.Value, "e") {
f, _ := strconv.ParseFloat(tok.Value, 64)
return &FloatValue{Position: tok.Position, Value: f, Raw: tok.Value}, nil
return &FloatValue{Position: tok.Position, Value: f, Raw: tok.Value}, true
}
i, _ := strconv.ParseInt(tok.Value, 0, 64)
return &IntValue{Position: tok.Position, Value: i, Raw: tok.Value}, nil
return &IntValue{Position: tok.Position, Value: i, Raw: tok.Value}, true
case TokenBool:
return &BoolValue{Position: tok.Position, Value: tok.Value == "true"},
nil
true
case TokenIdentifier:
// reference?
return &ReferenceValue{Position: tok.Position, Value: tok.Value}, nil
return &ReferenceValue{Position: tok.Position, Value: tok.Value}, true
case TokenLBrace:
// array
arr := &ArrayValue{Position: tok.Position}
for {
t := p.peek()
@@ -239,14 +257,15 @@ func (p *Parser) parseValue() (Value, error) {
p.next()
continue
}
val, err := p.parseValue()
if err != nil {
return nil, err
val, ok := p.parseValue()
if !ok {
return nil, false
}
arr.Elements = append(arr.Elements, val)
}
return arr, nil
return arr, true
default:
return nil, fmt.Errorf("%d:%d: unexpected value token %v", tok.Position.Line, tok.Position.Column, tok.Value)
p.addError(tok.Position, fmt.Sprintf("unexpected value token %v", tok.Value))
return nil, false
}
}

View File

@@ -29,7 +29,12 @@ The LSP server should provide the following capabilities:
- **Go to Definition**: Jump to the definition of a reference, supporting navigation across any file in the current project.
- **Go to References**: Find usages of a node or field, supporting navigation across any file in the current project.
- **Code Completion**: Autocomplete fields, values, and references.
- **Code Snippets**: Provide snippets for common patterns.
- **Context-Aware**: Suggestions depend on the cursor position (e.g., inside an object, assigning a value).
- **Schema-Driven**: Field suggestions are derived from the CUE schema for the current object's Class, indicating mandatory vs. optional fields.
- **Reference Suggestions**:
- `DataSource` fields suggest available DataSource objects.
- `Functions` (in Threads) suggest available GAM objects.
- **Code Snippets**: Provide snippets for common patterns (e.g., `+Object = { ... }`).
- **Formatting**: Format the document using the same rules and engine as the `fmt` command.
## Build System & File Structure
@@ -47,9 +52,9 @@ The LSP server should provide the following capabilities:
- **Namespace Consistency**: The build tool must verify that all input files belong to the same project namespace (the first segment of the `#package` URI). If multiple project namespaces are detected, the build must fail with an error.
- **Target**: The build output is written to a single target file (e.g., provided via CLI or API).
- **Multi-File Definitions**: Nodes and objects can be defined across multiple files. The build tool, validator, and LSP must merge these definitions (including all fields and sub-nodes) from the entire project to create a unified view before processing or validating.
- **Global References**: References to nodes, signals, or objects can point to definitions located in any file within the project.
- **Merging Order**: For objects defined across multiple files, the **first file** to be considered is the one containing the `Class` field definition.
- **Field Order**: Within a single file, the relative order of defined fields must be maintained.
- **Global References**: References to nodes, signals, or objects can point to definitions located in any file within the project. Support for dot-separated paths (e.g., `Node.SubNode`) is required.
- **Merging Order**: For objects defined across multiple files, definitions are merged. The build tool must preserve the relative order of fields and sub-nodes as they appear in the source files, interleaving them correctly in the final output.
- **Field Order**: Within a single file (and across merged files), the relative order of defined fields must be maintained in the output.
- The LSP indexes only files belonging to the same project/namespace scope.
- **Output**: The output format is the same as the input configuration but without the `#package` macro.
@@ -160,13 +165,13 @@ The tool must build an index of the configuration to support LSP features and va
- **Field Order**: Verification that specific fields appear in a prescribed order when required by the class definition.
- **Conditional Fields**: Validation of fields whose presence or value depends on the values of other fields within the same node or context.
- **Schema Definition**:
- Class validation rules must be defined in a separate schema file.
- Class validation rules must be defined in a separate schema file using the **CUE** language.
- **Project-Specific Classes**: Developers can define their own project-specific classes and corresponding validation rules, expanding the validation capabilities for their specific needs.
- **Schema Loading**:
- **Default Schema**: The tool should look for a default schema file `marte_schema.json` in standard system locations:
- `/usr/share/mdt/marte_schema.json`
- `$HOME/.local/share/mdt/marte_schema.json`
- **Project Schema**: If a file named `.marte_schema.json` exists in the project root, it must be loaded.
- **Default Schema**: The tool should look for a default schema file `marte_schema.cue` in standard system locations:
- `/usr/share/mdt/marte_schema.cue`
- `$HOME/.local/share/mdt/marte_schema.cue`
- **Project Schema**: If a file named `.marte_schema.cue` exists in the project root, it must be loaded.
- **Merging**: The final schema is a merge of the built-in schema, the system default schema (if found), and the project-specific schema. Rules in later sources (Project > System > Built-in) append to or override earlier ones.
- **Duplicate Fields**:
- **Constraint**: A field must not be defined more than once within the same object/node scope, even if those definitions are spread across different files.

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 (
"encoding/json"
@@ -8,6 +8,7 @@ import (
"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"
)
@@ -24,50 +25,38 @@ func TestInitProjectScan(t *testing.T) {
t.Fatal(err)
}
// 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 {
t.Fatal(err)
}
// 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)
msg := &JsonRpcMessage{
msg := &lsp.JsonRpcMessage{
Method: "initialize",
Params: paramsBytes,
ID: 1,
}
handleMessage(msg)
lsp.HandleMessage(msg)
// Query the reference in ref.marte at "Target"
// Target starts at index 29 (0-based) on Line 1
defParams := DefinitionParams{
TextDocument: TextDocumentIdentifier{URI: "file://" + filepath.Join(tmpDir, "ref.marte")},
Position: Position{Line: 1, Character: 29},
defParams := lsp.DefinitionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: "file://" + filepath.Join(tmpDir, "ref.marte")},
Position: lsp.Position{Line: 1, Character: 29},
}
res := handleDefinition(defParams)
res := lsp.HandleDefinition(defParams)
if res == nil {
t.Fatal("Definition not found via LSP after initialization")
}
locs, ok := res.([]Location)
locs, ok := res.([]lsp.Location)
if !ok {
t.Fatalf("Expected []Location, got %T", res)
t.Fatalf("Expected []lsp.Location, got %T", res)
}
if len(locs) == 0 {
@@ -83,7 +72,7 @@ func TestInitProjectScan(t *testing.T) {
func TestHandleDefinition(t *testing.T) {
// Reset tree for test
tree = index.NewProjectTree()
lsp.Tree = index.NewProjectTree()
content := `
+MyObject = {
@@ -100,28 +89,28 @@ func TestHandleDefinition(t *testing.T) {
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
tree.AddFile(path, config)
tree.ResolveReferences()
lsp.Tree.AddFile(path, config)
lsp.Tree.ResolveReferences()
t.Logf("Refs: %d", len(tree.References))
for _, r := range tree.References {
t.Logf("Refs: %d", len(lsp.Tree.References))
for _, r := range lsp.Tree.References {
t.Logf(" %s at %d:%d", r.Name, r.Position.Line, r.Position.Column)
}
// Test Go to Definition on MyObject reference
params := DefinitionParams{
TextDocument: TextDocumentIdentifier{URI: "file://" + path},
Position: Position{Line: 6, Character: 15}, // "MyObject" in RefField = MyObject
params := lsp.DefinitionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: "file://" + path},
Position: lsp.Position{Line: 6, Character: 15}, // "MyObject" in RefField = MyObject
}
result := handleDefinition(params)
result := lsp.HandleDefinition(params)
if result == nil {
t.Fatal("handleDefinition returned nil")
t.Fatal("HandleDefinition returned nil")
}
locations, ok := result.([]Location)
locations, ok := result.([]lsp.Location)
if !ok {
t.Fatalf("Expected []Location, got %T", result)
t.Fatalf("Expected []lsp.Location, got %T", result)
}
if len(locations) != 1 {
@@ -135,7 +124,7 @@ func TestHandleDefinition(t *testing.T) {
func TestHandleReferences(t *testing.T) {
// Reset tree for test
tree = index.NewProjectTree()
lsp.Tree = index.NewProjectTree()
content := `
+MyObject = {
@@ -155,17 +144,17 @@ func TestHandleReferences(t *testing.T) {
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
tree.AddFile(path, config)
tree.ResolveReferences()
lsp.Tree.AddFile(path, config)
lsp.Tree.ResolveReferences()
// Test Find References for MyObject (triggered from its definition)
params := ReferenceParams{
TextDocument: TextDocumentIdentifier{URI: "file://" + path},
Position: Position{Line: 1, Character: 1}, // "+MyObject"
Context: ReferenceContext{IncludeDeclaration: true},
params := lsp.ReferenceParams{
TextDocument: lsp.TextDocumentIdentifier{URI: "file://" + path},
Position: lsp.Position{Line: 1, Character: 1}, // "+MyObject"
Context: lsp.ReferenceContext{IncludeDeclaration: true},
}
locations := handleReferences(params)
locations := lsp.HandleReferences(params)
if len(locations) != 3 { // 1 declaration + 2 references
t.Fatalf("Expected 3 locations, got %d", len(locations))
}
@@ -181,15 +170,15 @@ Field=1
`
uri := "file:///test.marte"
// Open (populate documents map)
documents[uri] = content
// Open (populate Documents map)
lsp.Documents[uri] = content
// Format
params := DocumentFormattingParams{
TextDocument: TextDocumentIdentifier{URI: uri},
params := lsp.DocumentFormattingParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
}
edits := handleFormatting(params)
edits := lsp.HandleFormatting(params)
if len(edits) != 1 {
t.Fatalf("Expected 1 edit, got %d", len(edits))

View File

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

View File

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