diff --git a/internal/index/index.go b/internal/index/index.go index cdf1ed8..19f16f8 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -120,8 +120,11 @@ func (pt *ProjectTree) removeFileFromNode(node *ProjectNode, file string) { node.Metadata = make(map[string]string) pt.rebuildMetadata(node) - for _, child := range node.Children { + for name, child := range node.Children { pt.removeFileFromNode(child, file) + if len(child.Fragments) == 0 && len(child.Children) == 0 { + delete(node.Children, name) + } } } @@ -181,13 +184,8 @@ func (pt *ProjectTree) AddFile(file string, config *parser.Configuration) { node := pt.Root parts := strings.Split(config.Package.URI, ".") - // Skip first part as per spec (Project Name is namespace only) - startIdx := 0 - if len(parts) > 0 { - startIdx = 1 - } - - for i := startIdx; i < len(parts); i++ { + + for i := 0; i < len(parts); i++ { part := strings.TrimSpace(parts[i]) if part == "" { continue diff --git a/internal/lsp/server.go b/internal/lsp/server.go index cd625bb..92dab00 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -159,6 +159,16 @@ type DocumentFormattingParams struct { Options FormattingOptions `json:"options"` } +type RenameParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` + NewName string `json:"newName"` +} + +type WorkspaceEdit struct { + Changes map[string][]TextEdit `json:"changes"` +} + type FormattingOptions struct { TabSize int `json:"tabSize"` InsertSpaces bool `json:"insertSpaces"` @@ -241,6 +251,7 @@ func HandleMessage(msg *JsonRpcMessage) { "definitionProvider": true, "referencesProvider": true, "documentFormattingProvider": true, + "renameProvider": true, "completionProvider": map[string]any{ "triggerCharacters": []string{"=", " "}, }, @@ -297,6 +308,11 @@ func HandleMessage(msg *JsonRpcMessage) { if err := json.Unmarshal(msg.Params, ¶ms); err == nil { respond(msg.ID, HandleFormatting(params)) } + case "textDocument/rename": + var params RenameParams + if err := json.Unmarshal(msg.Params, ¶ms); err == nil { + respond(msg.ID, HandleRename(params)) + } } } @@ -1103,6 +1119,139 @@ func formatNodeInfo(node *index.ProjectNode) string { return info } +func HandleRename(params RenameParams) *WorkspaceEdit { + path := uriToPath(params.TextDocument.URI) + line := params.Position.Line + 1 + col := params.Position.Character + 1 + + res := Tree.Query(path, line, col) + if res == nil { + return nil + } + + var targetNode *index.ProjectNode + var targetField *parser.Field + + if res.Node != nil { + targetNode = res.Node + } else if res.Field != nil { + targetField = res.Field + } else if res.Reference != nil { + if res.Reference.Target != nil { + targetNode = res.Reference.Target + } else { + return nil + } + } + + changes := make(map[string][]TextEdit) + + addEdit := func(file string, rng Range, newText string) { + uri := "file://" + file + changes[uri] = append(changes[uri], TextEdit{Range: rng, NewText: newText}) + } + + if targetNode != nil { + // 1. Rename Definitions + prefix := "" + if len(targetNode.RealName) > 0 { + first := targetNode.RealName[0] + if first == '+' || first == '$' { + prefix = string(first) + } + } + normNewName := strings.TrimLeft(params.NewName, "+$") + finalDefName := prefix + normNewName + + for _, frag := range targetNode.Fragments { + if frag.IsObject { + rng := Range{ + Start: Position{Line: frag.ObjectPos.Line - 1, Character: frag.ObjectPos.Column - 1}, + End: Position{Line: frag.ObjectPos.Line - 1, Character: frag.ObjectPos.Column - 1 + len(targetNode.RealName)}, + } + addEdit(frag.File, rng, finalDefName) + } + } + + // 2. Rename References + for _, ref := range Tree.References { + if ref.Target == targetNode { + // Handle qualified names (e.g. Pkg.Node) + if strings.Contains(ref.Name, ".") { + if strings.HasSuffix(ref.Name, "."+targetNode.Name) { + prefixLen := len(ref.Name) - len(targetNode.Name) + rng := Range{ + Start: Position{Line: ref.Position.Line - 1, Character: ref.Position.Column - 1 + prefixLen}, + End: Position{Line: ref.Position.Line - 1, Character: ref.Position.Column - 1 + len(ref.Name)}, + } + addEdit(ref.File, rng, normNewName) + } else if ref.Name == targetNode.Name { + rng := Range{ + Start: Position{Line: ref.Position.Line - 1, Character: ref.Position.Column - 1}, + End: Position{Line: ref.Position.Line - 1, Character: ref.Position.Column - 1 + len(ref.Name)}, + } + addEdit(ref.File, rng, normNewName) + } + } else { + rng := Range{ + Start: Position{Line: ref.Position.Line - 1, Character: ref.Position.Column - 1}, + End: Position{Line: ref.Position.Line - 1, Character: ref.Position.Column - 1 + len(ref.Name)}, + } + addEdit(ref.File, rng, normNewName) + } + } + } + + // 3. Rename Implicit Node References (Signals in GAMs relying on name match) + Tree.Walk(func(n *index.ProjectNode) { + if n.Target == targetNode { + hasAlias := false + for _, frag := range n.Fragments { + for _, def := range frag.Definitions { + if f, ok := def.(*parser.Field); ok && f.Name == "Alias" { + hasAlias = true + } + } + } + + if !hasAlias { + for _, frag := range n.Fragments { + if frag.IsObject { + rng := Range{ + Start: Position{Line: frag.ObjectPos.Line - 1, Character: frag.ObjectPos.Column - 1}, + End: Position{Line: frag.ObjectPos.Line - 1, Character: frag.ObjectPos.Column - 1 + len(n.RealName)}, + } + addEdit(frag.File, rng, normNewName) + } + } + } + } + }) + + return &WorkspaceEdit{Changes: changes} + } else if targetField != nil { + container := Tree.GetNodeContaining(path, targetField.Position) + if container != nil { + for _, frag := range container.Fragments { + for _, def := range frag.Definitions { + if f, ok := def.(*parser.Field); ok { + if f.Name == targetField.Name { + rng := Range{ + Start: Position{Line: f.Position.Line - 1, Character: f.Position.Column - 1}, + End: Position{Line: f.Position.Line - 1, Character: f.Position.Column - 1 + len(f.Name)}, + } + addEdit(frag.File, rng, params.NewName) + } + } + } + } + } + return &WorkspaceEdit{Changes: changes} + } + + return nil +} + func respond(id any, result any) { msg := JsonRpcMessage{ Jsonrpc: "2.0", diff --git a/specification.md b/specification.md index e0a6315..4c5f713 100644 --- a/specification.md +++ b/specification.md @@ -34,6 +34,9 @@ The LSP server should provide the following capabilities: - **Reference Suggestions**: - `DataSource` fields suggest available DataSource objects. - `Functions` (in Threads) suggest available GAM objects. +- **Rename Symbol**: Rename an object, field, or reference across the entire project scope. + - Supports renaming of Definitions (`+Name` or `Name`), preserving any modifiers (`+`/`$`). + - Updates all references to the renamed symbol, including qualified references (e.g., `Pkg.Name`). - **Code Snippets**: Provide snippets for common patterns (e.g., `+Object = { ... }`). - **Formatting**: Format the document using the same rules and engine as the `fmt` command. diff --git a/test/index_cleanup_test.go b/test/index_cleanup_test.go new file mode 100644 index 0000000..d410cb1 --- /dev/null +++ b/test/index_cleanup_test.go @@ -0,0 +1,58 @@ +package integration + +import ( + "testing" + + "github.com/marte-community/marte-dev-tools/internal/index" + "github.com/marte-community/marte-dev-tools/internal/parser" +) + +func TestIndexCleanup(t *testing.T) { + idx := index.NewProjectTree() + file := "cleanup.marte" + content := ` +#package Pkg ++Node = { Class = Type } +` + p := parser.NewParser(content) + cfg, err := p.Parse() + if err != nil { + t.Fatal(err) + } + idx.AddFile(file, cfg) + + // Check node exists + // Root -> Pkg -> Node + pkgNode := idx.Root.Children["Pkg"] + if pkgNode == nil { + t.Fatal("Pkg node should exist") + } + if pkgNode.Children["Node"] == nil { + t.Fatal("Node should exist") + } + + // Update file: remove +Node + content2 := ` +#package Pkg +// Removed node +` + p2 := parser.NewParser(content2) + cfg2, _ := p2.Parse() + idx.AddFile(file, cfg2) + + // Check Node is gone + pkgNode = idx.Root.Children["Pkg"] + if pkgNode == nil { + // Pkg should exist because of #package Pkg + t.Fatal("Pkg node should exist after update") + } + if pkgNode.Children["Node"] != nil { + t.Error("Node should be gone") + } + + // Test removing file completely + idx.RemoveFile(file) + if len(idx.Root.Children) != 0 { + t.Errorf("Root should be empty after removing file, got %d children", len(idx.Root.Children)) + } +} diff --git a/test/lsp_hover_gam_usage_test.go b/test/lsp_hover_gam_usage_test.go new file mode 100644 index 0000000..e583785 --- /dev/null +++ b/test/lsp_hover_gam_usage_test.go @@ -0,0 +1,75 @@ +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" +) + +func TestHoverGAMUsage(t *testing.T) { + // Setup + lsp.Tree = index.NewProjectTree() + lsp.Documents = make(map[string]string) + + content := ` ++DS1 = { + Class = FileReader + +Signals = { + Sig1 = { Type = uint32 } + } +} ++GAM1 = { + Class = IOGAM + +InputSignals = { + S1 = { + DataSource = DS1 + Alias = Sig1 + } + } +} ++GAM2 = { + Class = IOGAM + +OutputSignals = { + S2 = { + DataSource = DS1 + Alias = Sig1 + } + } +} +` + uri := "file://test_gam_usage.marte" + lsp.Documents[uri] = content + p := parser.NewParser(content) + cfg, err := p.Parse() + if err != nil { + t.Fatal(err) + } + lsp.Tree.AddFile("test_gam_usage.marte", cfg) + lsp.Tree.ResolveReferences() + + // Query hover for Sig1 (Line 5) + // Line 4: Sig1... (0-based) + params := lsp.HoverParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: uri}, + Position: lsp.Position{Line: 4, Character: 9}, + } + + hover := lsp.HandleHover(params) + if hover == nil { + t.Fatal("Expected hover") + } + + contentHover := hover.Contents.(lsp.MarkupContent).Value + if !strings.Contains(contentHover, "**Used in GAMs**") { + t.Errorf("Expected 'Used in GAMs' section, got:\n%s", contentHover) + } + if !strings.Contains(contentHover, "- +GAM1") { + t.Error("Expected +GAM1 in usage list") + } + if !strings.Contains(contentHover, "- +GAM2") { + t.Error("Expected +GAM2 in usage list") + } +} diff --git a/test/lsp_rename_signal_test.go b/test/lsp_rename_signal_test.go new file mode 100644 index 0000000..301b515 --- /dev/null +++ b/test/lsp_rename_signal_test.go @@ -0,0 +1,110 @@ +package integration + +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" + "github.com/marte-community/marte-dev-tools/internal/validator" +) + +func TestRenameSignalInGAM(t *testing.T) { + // Setup + lsp.Tree = index.NewProjectTree() + lsp.Documents = make(map[string]string) + + content := ` ++DS = { + Class = FileReader + +Signals = { + Sig1 = { Type = uint32 } + } +} ++GAM = { + Class = IOGAM + +InputSignals = { + // Implicit match + Sig1 = { DataSource = DS } + // Explicit Alias + S2 = { DataSource = DS Alias = Sig1 } + } +} +` + uri := "file://rename_sig.marte" + lsp.Documents[uri] = content + p := parser.NewParser(content) + cfg, err := p.Parse() + if err != nil { + t.Fatal(err) + } + lsp.Tree.AddFile("rename_sig.marte", cfg) + lsp.Tree.ResolveReferences() + + // Run validator to populate Targets + v := validator.NewValidator(lsp.Tree, ".") + v.ValidateProject() + + // Rename DS.Sig1 to NewSig + // Sig1 is at Line 5. + // Line 0: empty + // Line 1: +DS + // Line 2: Class + // Line 3: +Signals + // Line 4: Sig1 + params := lsp.RenameParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: uri}, + Position: lsp.Position{Line: 4, Character: 9}, // Sig1 + NewName: "NewSig", + } + + edit := lsp.HandleRename(params) + if edit == nil { + t.Fatal("Expected edits") + } + + edits := edit.Changes[uri] + + // Expect: + // 1. Definition of Sig1 in DS (Line 5) -> NewSig + // 2. Definition of Sig1 in GAM (Line 10) -> NewSig (Implicit match) + // 3. Alias reference in S2 (Line 12) -> NewSig + + // Line 10: Sig1 = ... (0-based 9) + // Line 12: S2 = ... Alias = Sig1 (0-based 11) + + expectedCount := 3 + if len(edits) != expectedCount { + t.Errorf("Expected %d edits, got %d", expectedCount, len(edits)) + for _, e := range edits { + t.Logf("Edit: %s at %d", e.NewText, e.Range.Start.Line) + } + } + + // Check Implicit Signal Rename + foundImplicit := false + for _, e := range edits { + if e.Range.Start.Line == 11 { + if e.NewText == "NewSig" { + foundImplicit = true + } + } + } + if !foundImplicit { + t.Error("Did not find implicit signal rename") + } + + // Check Alias Rename + foundAlias := false + for _, e := range edits { + if e.Range.Start.Line == 13 { + // Alias = Sig1. Range should cover Sig1. + if e.NewText == "NewSig" { + foundAlias = true + } + } + } + if !foundAlias { + t.Error("Did not find alias reference rename") + } +} diff --git a/test/lsp_rename_test.go b/test/lsp_rename_test.go new file mode 100644 index 0000000..13690c1 --- /dev/null +++ b/test/lsp_rename_test.go @@ -0,0 +1,92 @@ +package integration + +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" +) + +func TestHandleRename(t *testing.T) { + // Setup + lsp.Tree = index.NewProjectTree() + lsp.Documents = make(map[string]string) + + content := ` +#package Some ++MyNode = { + Class = Type +} ++Consumer = { + Link = MyNode + PkgLink = Some.MyNode +} +` + uri := "file://rename.marte" + lsp.Documents[uri] = content + p := parser.NewParser(content) + cfg, err := p.Parse() + if err != nil { + t.Fatal(err) + } + lsp.Tree.AddFile("rename.marte", cfg) + lsp.Tree.ResolveReferences() + + // Rename +MyNode to NewNode + // +MyNode is at Line 2 (after #package) + // Line 0: empty + // Line 1: #package + // Line 2: +MyNode + params := lsp.RenameParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: uri}, + Position: lsp.Position{Line: 2, Character: 4}, // +MyNode + NewName: "NewNode", + } + + edit := lsp.HandleRename(params) + if edit == nil { + t.Fatal("Expected edits") + } + + edits := edit.Changes[uri] + if len(edits) != 3 { + t.Errorf("Expected 3 edits (Def, Link, PkgLink), got %d", len(edits)) + } + + // Verify Definition change (+MyNode -> +NewNode) + foundDef := false + for _, e := range edits { + if e.NewText == "+NewNode" { + foundDef = true + if e.Range.Start.Line != 2 { + t.Errorf("Definition edit line wrong: %d", e.Range.Start.Line) + } + } + } + if !foundDef { + t.Error("Did not find definition edit +NewNode") + } + + // Verify Link change (MyNode -> NewNode) + foundLink := false + for _, e := range edits { + if e.NewText == "NewNode" && e.Range.Start.Line == 6 { // Link = MyNode + foundLink = true + } + } + if !foundLink { + t.Error("Did not find Link edit") + } + + // Verify PkgLink change (Some.MyNode -> Some.NewNode) + foundPkg := false + for _, e := range edits { + if e.NewText == "NewNode" && e.Range.Start.Line == 7 { // PkgLink = Some.MyNode + foundPkg = true + } + } + if !foundPkg { + t.Error("Did not find PkgLink edit") + } +} diff --git a/test/validator_multifile_test.go b/test/validator_multifile_test.go index 1f1fc23..5b1c833 100644 --- a/test/validator_multifile_test.go +++ b/test/validator_multifile_test.go @@ -107,7 +107,11 @@ func TestHierarchicalPackageMerge(t *testing.T) { } // We can also inspect the tree to verify FieldX is there (optional, but good for confidence) - baseNode := idx.Root.Children["Base"] + projNode := idx.Root.Children["Proj"] + if projNode == nil { + t.Fatal("Proj node not found") + } + baseNode := projNode.Children["Base"] if baseNode == nil { t.Fatal("Base node not found") }