diff --git a/cmd/mdt/main.go b/cmd/mdt/main.go index a87d04b..74b46d9 100644 --- a/cmd/mdt/main.go +++ b/cmd/mdt/main.go @@ -86,7 +86,6 @@ func runCheck(args []string) { // Legacy loop removed as ValidateProject covers it via recursion - v.CheckUnused() for _, diag := range v.Diagnostics { level := "ERROR" diff --git a/internal/lsp/server.go b/internal/lsp/server.go index cd7f97c..d5ec5b2 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -49,6 +49,7 @@ var Tree = index.NewProjectTree() var Documents = make(map[string]string) var ProjectRoot string var GlobalSchema *schema.Schema +var Output io.Writer = os.Stdout type JsonRpcMessage struct { Jsonrpc string `json:"jsonrpc"` @@ -404,7 +405,6 @@ func HandleFormatting(params DocumentFormattingParams) []TextEdit { func runValidation(_ string) { v := validator.NewValidator(Tree, ProjectRoot) v.ValidateProject() - v.CheckUnused() // Group diagnostics by file fileDiags := make(map[string][]LSPDiagnostic) @@ -646,7 +646,7 @@ func suggestGAMSignals(_ *index.ProjectNode, direction string) *CompletionList { dir := "NIL" if GlobalSchema != nil { - classPath := cue.ParsePath(fmt.Sprintf("#Classes.%s.direction", cls)) + classPath := cue.ParsePath(fmt.Sprintf("#Classes.%s.#direction", cls)) val := GlobalSchema.Value.LookupPath(classPath) if val.Err() == nil { var s string @@ -1342,5 +1342,5 @@ func respond(id any, result any) { func send(msg any) { body, _ := json.Marshal(msg) - fmt.Printf("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) } diff --git a/internal/parser/lexer.go b/internal/parser/lexer.go index 5a539c3..ee012c5 100644 --- a/internal/parser/lexer.go +++ b/internal/parser/lexer.go @@ -129,7 +129,7 @@ func (l *Lexer) NextToken() Token { case '/': return l.lexComment() case '#': - return l.lexPackage() + return l.lexHashIdentifier() case '+': fallthrough case '$': @@ -243,18 +243,19 @@ func (l *Lexer) lexUntilNewline(t TokenType) Token { } } -func (l *Lexer) lexPackage() Token { +func (l *Lexer) lexHashIdentifier() Token { // We are at '#', l.start is just before it for { r := l.next() - if unicode.IsLetter(r) { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' || r == ':' || r == '#' { continue } l.backup() break } - if l.input[l.start:l.pos] == "#package" { + val := l.input[l.start:l.pos] + if val == "#package" { return l.lexUntilNewline(TokenPackage) } - return l.emit(TokenError) + return l.emit(TokenIdentifier) } diff --git a/internal/schema/marte.cue b/internal/schema/marte.cue index 0504056..2a7209a 100644 --- a/internal/schema/marte.cue +++ b/internal/schema/marte.cue @@ -43,7 +43,8 @@ package schema ... } TimingDataSource: { - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } IOGAM: { @@ -64,73 +65,86 @@ package schema ... } FileDataSource: { - Filename: string - Format?: string - direction: "INOUT" + Filename: string + Format?: string + #multithreaded: bool | *false + #direction: "INOUT" ... } LoggerDataSource: { - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" ... } DANStream: { - Timeout?: int - direction: "OUT" + Timeout?: int + #multithreaded: bool | *false + #direction: "OUT" ... } EPICSCAInput: { - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } EPICSCAOutput: { - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" ... } EPICSPVAInput: { - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } EPICSPVAOutput: { - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" ... } SDNSubscriber: { - Address: string - Port: int - Interface?: string - direction: "IN" + Address: string + Port: int + Interface?: string + #multithreaded: bool | *false + #direction: "IN" ... } SDNPublisher: { - Address: string - Port: int - Interface?: string - direction: "OUT" + Address: string + Port: int + Interface?: string + #multithreaded: bool | *false + #direction: "OUT" ... } UDPReceiver: { - Port: int - Address?: string - direction: "IN" + Port: int + Address?: string + #multithreaded: bool | *false + #direction: "IN" ... } UDPSender: { - Destination: string - direction: "OUT" + Destination: string + #multithreaded: bool | *false + #direction: "OUT" ... } FileReader: { - Filename: string - Format?: string - Interpolate?: string - direction: "IN" + Filename: string + Format?: string + Interpolate?: string + #multithreaded: bool | *false + #direction: "IN" ... } FileWriter: { Filename: string Format?: string StoreOnTrigger?: int - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" ... } OrderedClass: { @@ -173,7 +187,8 @@ package schema TriggeredIOGAM: {...} WaveformGAM: {...} DAN: { - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" ... } LinuxTimer: { @@ -184,11 +199,13 @@ package schema CPUMask?: int TimeProvider?: {...} Signals: {...} - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } LinkDataSource: { - direction: "INOUT" + #multithreaded: bool | *false + #direction: "INOUT" ... } MDSReader: { @@ -196,7 +213,8 @@ package schema ShotNumber: int Frequency: float | int Signals: {...} - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } MDSWriter: { @@ -212,57 +230,74 @@ package schema NumberOfPostTriggers?: int Signals: {...} Messages?: {...} - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" ... } NI1588TimeStamp: { - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } NI6259ADC: { - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } NI6259DAC: { - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" ... } NI6259DIO: { - direction: "INOUT" + #multithreaded: bool | *false + #direction: "INOUT" ... } NI6368ADC: { - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } NI6368DAC: { - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" ... } NI6368DIO: { - direction: "INOUT" + #multithreaded: bool | *false + #direction: "INOUT" ... } NI9157CircularFifoReader: { - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } NI9157MxiDataSource: { - direction: "INOUT" + #multithreaded: bool | *false + #direction: "INOUT" ... } OPCUADSInput: { - direction: "IN" + #multithreaded: bool | *false + #direction: "IN" ... } OPCUADSOutput: { - direction: "OUT" + #multithreaded: bool | *false + #direction: "OUT" + ... + } + RealTimeThreadAsyncBridge: { + #direction: "INOUT" + #multithreaded: bool | true ... } - RealTimeThreadAsyncBridge: {...} RealTimeThreadSynchronisation: {...} UARTDataSource: { - direction: "INOUT" + #multithreaded: bool | *false + #direction: "INOUT" ... } BaseLib2Wrapper: {...} @@ -272,7 +307,8 @@ package schema OPCUA: {...} SysLogger: {...} GAMDataSource: { - direction: "INOUT" + #multithreaded: bool | *false + #direction: "INOUT" ... } } diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 9017983..d6ce5e0 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -53,6 +53,8 @@ func (v *Validator) ValidateProject() { for _, node := range v.Tree.IsolatedFiles { v.validateNode(node) } + v.CheckUnused() + v.CheckDataSourceThreading() } func (v *Validator) validateNode(node *index.ProjectNode) { @@ -314,7 +316,7 @@ func (v *Validator) validateGAMSignal(gamNode, signalNode *index.ProjectNode, di if dsClass != "" { // Lookup class definition in Schema // path: #Classes.ClassName.direction - path := cue.ParsePath(fmt.Sprintf("#Classes.%s.direction", dsClass)) + path := cue.ParsePath(fmt.Sprintf("#Classes.%s.#direction", dsClass)) val := v.Schema.Value.LookupPath(path) if val.Err() == nil { @@ -509,6 +511,8 @@ func (v *Validator) getFieldValue(f *parser.Field) string { return val.Raw case *parser.FloatValue: return val.Raw + case *parser.BoolValue: + return strconv.FormatBool(val.Value) } return "" } @@ -741,3 +745,140 @@ func (v *Validator) isGloballyAllowed(warningType string, contextFile string) bo } return false } + +func (v *Validator) CheckDataSourceThreading() { + if v.Tree.Root == nil { + return + } + + // 1. Find RealTimeApplication + var appNode *index.ProjectNode + findApp := func(n *index.ProjectNode) { + if cls, ok := n.Metadata["Class"]; ok && cls == "RealTimeApplication" { + appNode = n + } + } + v.Tree.Walk(findApp) + + if appNode == nil { + return + } + + // 2. Find States + var statesNode *index.ProjectNode + if s, ok := appNode.Children["States"]; ok { + statesNode = s + } else { + for _, child := range appNode.Children { + if cls, ok := child.Metadata["Class"]; ok && cls == "StateMachine" { + statesNode = child + break + } + } + } + + if statesNode == nil { + return + } + + // 3. Iterate States + for _, state := range statesNode.Children { + dsUsage := make(map[*index.ProjectNode]string) // DS Node -> Thread Name + var threads []*index.ProjectNode + + // Search for threads in the state (either direct children or inside "Threads" container) + for _, child := range state.Children { + if child.RealName == "Threads" { + for _, t := range child.Children { + if cls, ok := t.Metadata["Class"]; ok && cls == "RealTimeThread" { + threads = append(threads, t) + } + } + } else { + if cls, ok := child.Metadata["Class"]; ok && cls == "RealTimeThread" { + threads = append(threads, child) + } + } + } + + for _, thread := range threads { + gams := v.getThreadGAMs(thread) + for _, gam := range gams { + dss := v.getGAMDataSources(gam) + for _, ds := range dss { + if existingThread, ok := dsUsage[ds]; ok { + if existingThread != thread.RealName { + if !v.isMultithreaded(ds) { + v.Diagnostics = append(v.Diagnostics, Diagnostic{ + Level: LevelError, + Message: fmt.Sprintf("DataSource '%s' is not multithreaded but used in multiple threads (%s, %s) in state '%s'", ds.RealName, existingThread, thread.RealName, state.RealName), + Position: v.getNodePosition(gam), + File: v.getNodeFile(gam), + }) + } + } + } else { + dsUsage[ds] = thread.RealName + } + } + } + } + } +} + +func (v *Validator) getThreadGAMs(thread *index.ProjectNode) []*index.ProjectNode { + var gams []*index.ProjectNode + fields := v.getFields(thread) + if funcs, ok := fields["Functions"]; ok && len(funcs) > 0 { + f := funcs[0] + if arr, ok := f.Value.(*parser.ArrayValue); ok { + for _, elem := range arr.Elements { + if ref, ok := elem.(*parser.ReferenceValue); ok { + target := v.resolveReference(ref.Value, v.getNodeFile(thread), isGAM) + if target != nil { + gams = append(gams, target) + } + } + } + } + } + return gams +} + +func (v *Validator) getGAMDataSources(gam *index.ProjectNode) []*index.ProjectNode { + dsMap := make(map[*index.ProjectNode]bool) + + processSignals := func(container *index.ProjectNode) { + if container == nil { + return + } + for _, sig := range container.Children { + fields := v.getFields(sig) + if dsFields, ok := fields["DataSource"]; ok && len(dsFields) > 0 { + dsName := v.getFieldValue(dsFields[0]) + dsNode := v.resolveReference(dsName, v.getNodeFile(sig), isDataSource) + if dsNode != nil { + dsMap[dsNode] = true + } + } + } + } + + processSignals(gam.Children["InputSignals"]) + processSignals(gam.Children["OutputSignals"]) + + var dss []*index.ProjectNode + for ds := range dsMap { + dss = append(dss, ds) + } + return dss +} + +func (v *Validator) isMultithreaded(ds *index.ProjectNode) bool { + fields := v.getFields(ds) + if mt, ok := fields["#multithreaded"]; ok && len(mt) > 0 { + val := v.getFieldValue(mt[0]) + return val == "true" + } + return false +} diff --git a/test/lsp_completion_signals_robustness_test.go b/test/lsp_completion_signals_robustness_test.go index a5f2283..284ee10 100644 --- a/test/lsp_completion_signals_robustness_test.go +++ b/test/lsp_completion_signals_robustness_test.go @@ -20,7 +20,7 @@ func TestSuggestSignalsRobustness(t *testing.T) { custom := []byte(` package schema #Classes: { - InOutReader: { direction: "INOUT" } + InOutReader: { #direction: "INOUT" } } `) val := lsp.GlobalSchema.Context.CompileBytes(custom) diff --git a/test/lsp_validation_threading_test.go b/test/lsp_validation_threading_test.go new file mode 100644 index 0000000..423e894 --- /dev/null +++ b/test/lsp_validation_threading_test.go @@ -0,0 +1,75 @@ +package integration + +import ( + "bytes" + "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/schema" +) + +func TestLSPValidationThreading(t *testing.T) { + // Setup + lsp.Tree = index.NewProjectTree() + lsp.Documents = make(map[string]string) + lsp.ProjectRoot = "." + lsp.GlobalSchema = schema.NewSchema() // Empty schema but not nil + + // Capture Output + var buf bytes.Buffer + lsp.Output = &buf + + content := ` ++Data = { + Class = ReferenceContainer + +SharedDS = { + Class = GAMDataSource + #direction = "INOUT" + #multithreaded = false + Signals = { + Sig1 = { Type = uint32 } + } + } +} ++GAM1 = { Class = IOGAM InputSignals = { Sig1 = { DataSource = SharedDS Type = uint32 } } } ++GAM2 = { Class = IOGAM OutputSignals = { Sig1 = { DataSource = SharedDS Type = uint32 } } } ++App = { + Class = RealTimeApplication + +States = { + Class = ReferenceContainer + +State1 = { + Class = RealTimeState + +Thread1 = { Class = RealTimeThread Functions = { GAM1 } } + +Thread2 = { Class = RealTimeThread Functions = { GAM2 } } + } + } +} +` + uri := "file://threading.marte" + + // Call HandleDidOpen directly + params := lsp.DidOpenTextDocumentParams{ + TextDocument: lsp.TextDocumentItem{ + URI: uri, + Text: content, + }, + } + + lsp.HandleDidOpen(params) + + // Check output + output := buf.String() + + // We look for publishDiagnostics notification + if !strings.Contains(output, "textDocument/publishDiagnostics") { + t.Fatal("Did not receive publishDiagnostics") + } + + // We look for the specific error message + expectedError := "DataSource '+SharedDS' is not multithreaded but used in multiple threads" + if !strings.Contains(output, expectedError) { + t.Errorf("Expected error '%s' not found in LSP output. Output:\n%s", expectedError, output) + } +} diff --git a/test/validator_datasource_threading_test.go b/test/validator_datasource_threading_test.go new file mode 100644 index 0000000..0a43a77 --- /dev/null +++ b/test/validator_datasource_threading_test.go @@ -0,0 +1,120 @@ +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 TestDataSourceThreadingValidation(t *testing.T) { + content := ` ++Data = { + Class = ReferenceContainer + +SharedDS = { + Class = GAMDataSource + #direction = "INOUT" + #multithreaded = false + Signals = { + Sig1 = { Type = uint32 } + } + } + +MultiDS = { + Class = GAMDataSource + #direction = "INOUT" + #multithreaded = true + Signals = { + Sig1 = { Type = uint32 } + } + } +} ++GAM1 = { + Class = IOGAM + InputSignals = { + Sig1 = { DataSource = SharedDS Type = uint32 } + } +} ++GAM2 = { + Class = IOGAM + OutputSignals = { + Sig1 = { DataSource = SharedDS Type = uint32 } + } +} ++GAM3 = { + Class = IOGAM + InputSignals = { + Sig1 = { DataSource = MultiDS Type = uint32 } + } +} ++GAM4 = { + Class = IOGAM + OutputSignals = { + Sig1 = { DataSource = MultiDS Type = uint32 } + } +} ++App = { + Class = RealTimeApplication + +States = { + Class = ReferenceContainer + +State1 = { + Class = RealTimeState + +Thread1 = { + Class = RealTimeThread + Functions = { GAM1 } + } + +Thread2 = { + Class = RealTimeThread + Functions = { GAM2 } + } + } + +State2 = { + Class = RealTimeState + +Thread1 = { + Class = RealTimeThread + Functions = { GAM3 } + } + +Thread2 = { + Class = RealTimeThread + Functions = { GAM4 } + } + } + } +} +` + pt := index.NewProjectTree() + p := parser.NewParser(content) + cfg, err := p.Parse() + if err != nil { + t.Fatal(err) + } + pt.AddFile("main.marte", cfg) + + // Since we don't load schema here (empty path), it won't validate classes via CUE, + // but CheckDataSourceThreading relies on parsing logic, not CUE schema unification. + // So it should work. + + v := validator.NewValidator(pt, "") + v.ValidateProject() + + foundError := false + for _, d := range v.Diagnostics { + if strings.Contains(d.Message, "not multithreaded but used in multiple threads") { + if strings.Contains(d.Message, "SharedDS") { + foundError = true + } + if strings.Contains(d.Message, "MultiDS") { + t.Error("Unexpected threading error for MultiDS") + } + } + } + + if !foundError { + t.Error("Expected threading error for SharedDS") + // Debug + for _, d := range v.Diagnostics { + t.Logf("Diag: %s", d.Message) + } + } +} diff --git a/test/validator_gam_signals_linking_test.go b/test/validator_gam_signals_linking_test.go index e4e8838..e553598 100644 --- a/test/validator_gam_signals_linking_test.go +++ b/test/validator_gam_signals_linking_test.go @@ -23,6 +23,7 @@ func TestGAMSignalLinking(t *testing.T) { +MyGAM = { Class = IOGAM + //! ignore(unused) InputSignals = { MySig = { DataSource = MyDS