diff --git a/.changes/unreleased/ENHANCEMENTS-20251209-175542.yaml b/.changes/unreleased/ENHANCEMENTS-20251209-175542.yaml new file mode 100644 index 000000000..51eedbca0 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20251209-175542.yaml @@ -0,0 +1,6 @@ +kind: ENHANCEMENTS +body: Terraform Graph Visualization in VS Code Extension +time: 2025-12-09T17:55:42.954862+05:30 +custom: + Issue: "2056" + Repository: terraform-ls diff --git a/go.mod b/go.mod index b6b07553d..0efc46e25 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/hc-install v0.9.2 - github.com/hashicorp/hcl-lang v0.0.0-20250613065305-ef4e1a57cead + github.com/hashicorp/hcl-lang v0.0.0-20251223113758-c9e9d0ec101a github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform-exec v0.24.0 github.com/hashicorp/terraform-json v0.27.2 diff --git a/go.sum b/go.sum index ddb224184..c263c15e5 100644 --- a/go.sum +++ b/go.sum @@ -116,8 +116,8 @@ github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+l github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= -github.com/hashicorp/hcl-lang v0.0.0-20250613065305-ef4e1a57cead h1:Nthdz3YLW98zGxDKgkBj6lTXWKhp1tWDg8ecyLkYe94= -github.com/hashicorp/hcl-lang v0.0.0-20250613065305-ef4e1a57cead/go.mod h1:lUY+oHKrbuViZqM+HHqmKE/18umUBPLAIoVT0WSZjKE= +github.com/hashicorp/hcl-lang v0.0.0-20251223113758-c9e9d0ec101a h1:k8sRuiYS612tDfIGZiAsAXYcjRsfnkaW2u47IoL/6JU= +github.com/hashicorp/hcl-lang v0.0.0-20251223113758-c9e9d0ec101a/go.mod h1:2SQEYnpcouuNOR8bjKyWuh82bawbZgoesfHZgqVSTjg= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/terraform-exec v0.24.0 h1:mL0xlk9H5g2bn0pPF6JQZk5YlByqSqrO5VoaNtAf8OE= diff --git a/internal/langserver/handlers/command/display_graph.go b/internal/langserver/handlers/command/display_graph.go new file mode 100644 index 000000000..97296b107 --- /dev/null +++ b/internal/langserver/handlers/command/display_graph.go @@ -0,0 +1,201 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package command + +import ( + "context" + "fmt" + "path/filepath" + "sort" + + "github.com/creachadair/jrpc2" + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform-ls/internal/langserver/cmd" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + lsp "github.com/hashicorp/terraform-ls/internal/protocol" + "github.com/hashicorp/terraform-ls/internal/uri" +) + +const displayGraphVersion = 0 + +type displayGraphResponse struct { + FormatVersion int `json:"v"` + Nodes []node `json:"nodes"` + Edges []edge `json:"edges"` +} + +type node struct { + ID int `json:"id"` + lsp.Location + Type string `json:"type"` + Labels []string `json:"labels"` +} + +type edge struct { + From int `json:"from"` + To int `json:"to"` +} + +type blockInfo struct { + filename string + block *hclsyntax.Block +} + +func (h *CmdHandler) DisplayGraphHandler(ctx context.Context, args cmd.CommandArgs) (interface{}, error) { + response := newDisplayGraphResponse() + + docUri, ok := args.GetString("uri") + if !ok || docUri == "" { + return response, fmt.Errorf("%w: expected uri argument to be set", jrpc2.InvalidParams.Err()) + } + + dh := ilsp.HandleFromDocumentURI(lsp.DocumentURI(uri.FromPath(docUri))) + doc, err := h.StateStore.DocumentStore.GetDocument(dh) + if err != nil { + return response, err + } + + // Wait for incomplete jobs to ensure references are computed + jobIds, err := h.StateStore.JobStore.ListIncompleteJobsForDir(dh.Dir) + if err != nil { + return response, err + } + h.StateStore.JobStore.WaitForJobs(ctx, jobIds...) + + path := lang.Path{ + Path: dh.Dir.Path(), + LanguageID: doc.LanguageID, + } + + pathDecoder, err := h.Decoder.Path(path) + if err != nil { + return response, err + } + + nodes, nodeMap, err := getNodes(pathDecoder, path) + if err != nil { + return response, err + } + + edges, err := getEdges(pathDecoder, path, h.Decoder, nodeMap) + if err != nil { + return response, err + } + + response.Nodes = nodes + response.Edges = edges + return response, nil +} + +func newDisplayGraphResponse() displayGraphResponse { + return displayGraphResponse{ + FormatVersion: displayGraphVersion, + Nodes: make([]node, 0), + Edges: make([]edge, 0), + } +} + +func getNodes(pathDecoder *decoder.PathDecoder, path lang.Path) ([]node, map[string]int, error) { + var blocks []blockInfo + for _, file := range pathDecoder.Files() { + body := file.Body.(*hclsyntax.Body) + for _, block := range body.Blocks { + blocks = append(blocks, blockInfo{ + filename: block.DefRange().Filename, + block: block, + }) + } + } + + sort.Slice(blocks, func(i, j int) bool { + if blocks[i].filename != blocks[j].filename { + return blocks[i].filename < blocks[j].filename + } + return blocks[i].block.DefRange().Start.Line < blocks[j].block.DefRange().Start.Line + }) + + nodes := make([]node, 0) + nodeMap := make(map[string]int) + idCounter := 0 + for _, bi := range blocks { + loc := pathRangetoLocation(path, bi.block.DefRange()) + key := locationKey(loc) + nodeMap[key] = idCounter + nodes = append(nodes, + node{ + ID: idCounter, + Location: loc, + Type: bi.block.Type, + Labels: bi.block.Labels, + }) + idCounter++ + } + return nodes, nodeMap, nil +} + +func getEdges(pathDecoder *decoder.PathDecoder, path lang.Path, decoder *decoder.Decoder, nodeMap map[string]int) ([]edge, error) { + edges := make([]edge, 0) + refTargets := pathDecoder.RefTargets() + seen := make(map[string]bool) + + for _, refTarget := range refTargets { + if refTarget.RootBlockRange != nil { + fromLoc := pathRangetoLocation(path, *refTarget.RootBlockRange) + fromKey := locationKey(fromLoc) + fromID, fromExists := nodeMap[fromKey] + if !fromExists { + continue + } + origins := decoder.ReferenceOriginsByTargetInPath(context.Background(), refTarget, path, path) + for _, refOrigin := range origins { + toLoc := pathRangetoLocation(path, refOrigin.RootBlockRange) + toKey := locationKey(toLoc) + toID, toExists := nodeMap[toKey] + if !toExists { + continue + } + edge := edge{ + From: fromID, + To: toID, + } + edgeKey := edgeKey(edge) + if isASelfEdge(edge) || isSeenEdge(&seen, edgeKey) { + continue + } + + edges = append(edges, edge) + seen[edgeKey] = true + } + + } + } + return edges, nil +} + +func edgeKey(e edge) string { + return fmt.Sprintf("%d->%d", e.From, e.To) +} + +func locationKey(loc lsp.Location) string { + return fmt.Sprintf("%s#%d:%d#%d:%d", loc.URI, loc.Range.Start.Line, loc.Range.Start.Character, loc.Range.End.Line, loc.Range.End.Character) +} + +func isSeenEdge(seen *map[string]bool, edgeKey string) bool { + _, ok := (*seen)[edgeKey] + return ok +} + +func isASelfEdge(edge edge) bool { + return edge.From == edge.To +} + +func pathRangetoLocation(path lang.Path, rng hcl.Range) lsp.Location { + return lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath(filepath.Join(path.Path, rng.Filename))), + Range: ilsp.HCLRangeToLSP(rng), + } +} diff --git a/internal/langserver/handlers/command/display_graph_test.go b/internal/langserver/handlers/command/display_graph_test.go new file mode 100644 index 000000000..fd93ee618 --- /dev/null +++ b/internal/langserver/handlers/command/display_graph_test.go @@ -0,0 +1,432 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package command + +import ( + "context" + "fmt" + "testing" + + "path/filepath" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + lsp "github.com/hashicorp/terraform-ls/internal/protocol" + "github.com/hashicorp/terraform-ls/internal/uri" +) + +func Test_GetNodes(t *testing.T) { + tests := []struct { + name string + pathDecoder *decoder.PathDecoder + path lang.Path + expectedNodes []node + expectedNodeMap map[string]int + }{ + { + name: "single file", + pathDecoder: createTestPathDecoder(t, map[string]string{ + "main.tf": ` +resource "aws_instance" "example" { + ami = "ami-0c55b159cbfafe1d0" + instance_type = "t2.micro" +} + +variable "region" { + type = string +} +`, + }, &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, + {Name: "name"}, + }, + Body: &schema.BodySchema{}, + }, + "variable": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{}, + }, + }, + }), + path: lang.Path{Path: "/test", LanguageID: "terraform"}, + expectedNodes: []node{ + { + ID: 0, + Location: lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/main.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "main.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 34, Byte: 33}, + }), + }, + Type: "resource", + Labels: []string{"aws_instance", "example"}, + }, + { + ID: 1, + Location: lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/main.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "main.tf", + Start: hcl.Pos{Line: 7, Column: 1, Byte: 90}, + End: hcl.Pos{Line: 7, Column: 18, Byte: 107}, + }), + }, + Type: "variable", + Labels: []string{"region"}, + }, + }, + expectedNodeMap: map[string]int{ + locationKey(lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/main.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "main.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 34, Byte: 33}, + }), + }): 0, + locationKey(lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/main.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "main.tf", + Start: hcl.Pos{Line: 7, Column: 1, Byte: 90}, + End: hcl.Pos{Line: 7, Column: 18, Byte: 107}, + }), + }): 1, + }, + }, + { + name: "multiple files", + pathDecoder: createTestPathDecoder(t, map[string]string{ + "main.tf": ` +resource "aws_instance" "example" { + ami = "ami-0c55b159cbfafe1d0" + instance_type = "t2.micro" +} +`, + "variables.tf": ` +variable "region" { + type = string +} + +variable "instance_type" { + type = string +} +`, + }, &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, + {Name: "name"}, + }, + Body: &schema.BodySchema{}, + }, + "variable": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{}, + }, + }, + }), + path: lang.Path{Path: "/test", LanguageID: "terraform"}, + expectedNodes: []node{ + { + ID: 0, + Location: lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/main.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "main.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 34, Byte: 33}, + }), + }, + Type: "resource", + Labels: []string{"aws_instance", "example"}, + }, + { + ID: 1, + Location: lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/variables.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "variables.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 18, Byte: 17}, + }), + }, + Type: "variable", + Labels: []string{"region"}, + }, + { + ID: 2, + Location: lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/variables.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "variables.tf", + Start: hcl.Pos{Line: 6, Column: 1, Byte: 34}, + End: hcl.Pos{Line: 6, Column: 25, Byte: 58}, + }), + }, + Type: "variable", + Labels: []string{"instance_type"}, + }, + }, + expectedNodeMap: map[string]int{ + locationKey(lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/main.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "main.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 34, Byte: 33}, + }), + }): 0, + locationKey(lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/variables.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "variables.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 18, Byte: 17}, + }), + }): 1, + locationKey(lsp.Location{ + URI: lsp.DocumentURI(uri.FromPath("/test/variables.tf")), + Range: ilsp.HCLRangeToLSP(hcl.Range{ + Filename: "variables.tf", + Start: hcl.Pos{Line: 6, Column: 1, Byte: 34}, + End: hcl.Pos{Line: 6, Column: 25, Byte: 58}, + }), + }): 2, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nodes, nodeMap, err := getNodes(tt.pathDecoder, tt.path) + if err != nil { + t.Fatalf("getNodes() error = %v", err) + } + + if diff := cmp.Diff(tt.expectedNodes, nodes); diff != "" { + t.Errorf("nodes mismatch (-want +got):\n%s", diff) + } + + if diff := cmp.Diff(tt.expectedNodeMap, nodeMap); diff != "" { + t.Errorf("nodeMap mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func Test_GetEdges(t *testing.T) { + tests := []struct { + name string + files map[string]string + schema *schema.BodySchema + expectedEdges []edge + }{ + { + name: "variable and local referenced in output", + files: map[string]string{ + "main.tf": ` +variable "region" { + type = string +} + +locals { + region = var.region +} + +output "region" { + value = local.region +} +`, + }, + schema: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "variable": { + Address: &schema.BlockAddrSchema{ + Steps: []schema.AddrStep{ + schema.StaticStep{Name: "var"}, + schema.LabelStep{Index: 0}, + }, + AsReference: true, + ScopeId: lang.ScopeId("variable"), + }, + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "type": { + Constraint: schema.TypeDeclaration{}, + IsOptional: true, + }, + }, + }, + }, + "locals": { + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "region": { + Address: &schema.AttributeAddrSchema{ + Steps: []schema.AddrStep{ + schema.StaticStep{Name: "local"}, + schema.AttrNameStep{}, + }, + ScopeId: lang.ScopeId("local"), + AsExprType: true, + AsReference: true, + }, + Constraint: schema.Reference{ + OfScopeId: lang.ScopeId("variable"), + }, + }, + }, + }, + }, + "output": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "value": { + Constraint: schema.Reference{ + OfScopeId: lang.ScopeId("local"), + }, + IsOptional: true, + }, + }, + }, + }, + }, + }, + expectedEdges: []edge{ + { + From: 1, + To: 2, + }, + { + From: 0, + To: 1, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pathDecoder, d, path := createTestDecoder(t, tt.files, tt.schema) + + _, nodeMap, err := getNodes(pathDecoder, path) + if err != nil { + t.Fatalf("getNodes() error = %v", err) + } + + edges, err := getEdges(pathDecoder, path, d, nodeMap) + if err != nil { + t.Fatalf("getEdges() error = %v", err) + } + + if diff := cmp.Diff(tt.expectedEdges, edges); diff != "" { + t.Errorf("edges mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func createTestDecoder(t *testing.T, files map[string]string, schema *schema.BodySchema) (*decoder.PathDecoder, *decoder.Decoder, lang.Path) { + pathCtx := &decoder.PathContext{ + Schema: schema, + Files: make(map[string]*hcl.File), + } + + p := hclparse.NewParser() + dirPath := "/test" + for filename, content := range files { + file, diags := p.ParseHCL([]byte(content), filename) + if len(diags) > 0 { + t.Fatalf("failed to parse HCL for %s: %v", filename, diags) + } + pathCtx.Files[filepath.Join(dirPath, filename)] = file + } + dirs := map[string]*decoder.PathContext{ + dirPath: pathCtx, + } + + d := decoder.NewDecoder(&testPathReader{ + paths: dirs, + }) + d.SetContext(decoder.NewDecoderContext()) + + path := lang.Path{Path: dirPath, LanguageID: "terraform"} + + // First create a temporary PathDecoder to collect reference targets and origins + tempPathDecoder, err := d.Path(path) + if err != nil { + t.Fatal(err) + } + refTargets, err := tempPathDecoder.CollectReferenceTargets() + if err != nil { + t.Fatal(err) + } + refOrigins, err := tempPathDecoder.CollectReferenceOrigins() + if err != nil { + t.Fatal(err) + } + + // Set the collected reference targets and origins on the path context + dirs[dirPath].ReferenceTargets = refTargets + dirs[dirPath].ReferenceOrigins = refOrigins + + // Now create the final PathDecoder with the populated reference targets + pathDecoder, err := d.Path(path) + if err != nil { + t.Fatal(err) + } + + return pathDecoder, d, path +} + +func createTestPathDecoder(t *testing.T, files map[string]string, schema *schema.BodySchema) *decoder.PathDecoder { + pathDecoder, _, _ := createTestDecoder(t, files, schema) + return pathDecoder +} + +type testPathReader struct { + paths map[string]*decoder.PathContext +} + +func (r *testPathReader) Paths(ctx context.Context) []lang.Path { + paths := make([]lang.Path, len(r.paths)) + + i := 0 + for path := range r.paths { + paths[i] = lang.Path{Path: path, LanguageID: "terraform"} + i++ + } + + return paths +} + +func (r *testPathReader) PathContext(path lang.Path) (*decoder.PathContext, error) { + if ctx, ok := r.paths[path.Path]; ok { + return ctx, nil + } + + return nil, fmt.Errorf("path not found: %q", path.Path) +} diff --git a/internal/langserver/handlers/command/handler.go b/internal/langserver/handlers/command/handler.go index 0bf088df3..0c7b5b4cb 100644 --- a/internal/langserver/handlers/command/handler.go +++ b/internal/langserver/handlers/command/handler.go @@ -6,6 +6,7 @@ package command import ( "log" + "github.com/hashicorp/hcl-lang/decoder" fmodules "github.com/hashicorp/terraform-ls/internal/features/modules" frootmodules "github.com/hashicorp/terraform-ls/internal/features/rootmodules" "github.com/hashicorp/terraform-ls/internal/state" @@ -18,4 +19,5 @@ type CmdHandler struct { // the features here? ModulesFeature *fmodules.ModulesFeature RootModulesFeature *frootmodules.RootModulesFeature + Decoder *decoder.Decoder } diff --git a/internal/langserver/handlers/execute_command.go b/internal/langserver/handlers/execute_command.go index 5e5465b71..37cb7d098 100644 --- a/internal/langserver/handlers/execute_command.go +++ b/internal/langserver/handlers/execute_command.go @@ -18,19 +18,21 @@ func cmdHandlers(svc *service) cmd.Handlers { cmdHandler := &command.CmdHandler{ StateStore: svc.stateStore, Logger: svc.logger, + Decoder: svc.decoder, } if svc.features != nil { cmdHandler.ModulesFeature = svc.features.Modules cmdHandler.RootModulesFeature = svc.features.RootModules } return cmd.Handlers{ - cmd.Name("rootmodules"): removedHandler("use module.callers instead"), - cmd.Name("module.callers"): cmdHandler.ModuleCallersHandler, - cmd.Name("terraform.init"): cmdHandler.TerraformInitHandler, - cmd.Name("terraform.validate"): cmdHandler.TerraformValidateHandler, - cmd.Name("module.calls"): cmdHandler.ModuleCallsHandler, - cmd.Name("module.providers"): cmdHandler.ModuleProvidersHandler, - cmd.Name("module.terraform"): cmdHandler.TerraformVersionRequestHandler, + cmd.Name("rootmodules"): removedHandler("use module.callers instead"), + cmd.Name("module.callers"): cmdHandler.ModuleCallersHandler, + cmd.Name("terraform.init"): cmdHandler.TerraformInitHandler, + cmd.Name("terraform.validate"): cmdHandler.TerraformValidateHandler, + cmd.Name("module.calls"): cmdHandler.ModuleCallsHandler, + cmd.Name("module.providers"): cmdHandler.ModuleProvidersHandler, + cmd.Name("module.terraform"): cmdHandler.TerraformVersionRequestHandler, + cmd.Name("terraform.display-graph"): cmdHandler.DisplayGraphHandler, } }