From bf6e8791806a477f428298dbe3868f30b03b2ccb Mon Sep 17 00:00:00 2001 From: Charlie Egan Date: Wed, 4 Sep 2024 18:19:23 +0100 Subject: [PATCH] lsp: Template new empty files & template on format (#1051) * lsp: Template new empty files & template on format Empty files will be created with a template content instead of being an error. New empty files will be immediately updated with the template content. Some client operations will not trigger the new file event, so in such cases, a save might be required to trigger the template update. Fixes https://github.com/StyraInc/regal/issues/1048 Signed-off-by: Charlie Egan * lsp: Add test for server templating Signed-off-by: Charlie Egan --------- Signed-off-by: Charlie Egan --- cmd/languageserver.go | 1 + internal/lsp/server.go | 137 ++++++++++++++++++++++++++- internal/lsp/server_template_test.go | 58 ++++++++++++ pkg/config/config.go | 6 +- 4 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 internal/lsp/server_template_test.go diff --git a/cmd/languageserver.go b/cmd/languageserver.go index 6699840b..6f4f27a5 100644 --- a/cmd/languageserver.go +++ b/cmd/languageserver.go @@ -45,6 +45,7 @@ func init() { go ls.StartCommandWorker(ctx) go ls.StartConfigWorker(ctx) go ls.StartWorkspaceStateWorker(ctx) + go ls.StartTemplateWorker(ctx) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index e968641d..4b8270ee 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -8,6 +8,8 @@ import ( "io" "os" "path/filepath" + "regexp" + "slices" "strconv" "strings" "sync" @@ -75,6 +77,7 @@ func NewLanguageServer(opts *LanguageServerOptions) *LanguageServer { diagnosticRequestWorkspace: make(chan string, 10), builtinsPositionFile: make(chan fileUpdateEvent, 10), commandRequest: make(chan types.ExecuteCommandParams, 10), + templateFile: make(chan fileUpdateEvent, 10), configWatcher: lsconfig.NewWatcher(&lsconfig.WatcherOpts{ErrorWriter: opts.ErrorLog}), completionsManager: completions.NewDefaultManager(c, store), } @@ -106,6 +109,7 @@ type LanguageServer struct { diagnosticRequestWorkspace chan string builtinsPositionFile chan fileUpdateEvent commandRequest chan types.ExecuteCommandParams + templateFile chan fileUpdateEvent } // fileUpdateEvent is sent to a channel when an update is required for a file. @@ -732,6 +736,113 @@ func (l *LanguageServer) StartWorkspaceStateWorker(ctx context.Context) { } } +// StartTemplateWorker runs the process of the server that templates newly +// created Rego files. +func (l *LanguageServer) StartTemplateWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case evt := <-l.templateFile: + newContents, err := l.templateContentsForFile(evt.URI) + if err != nil { + l.logError(fmt.Errorf("failed to template new file: %w", err)) + } + + // generate the edit params for the templating operation + templateParams := &types.ApplyWorkspaceEditParams{ + Label: "Template new Rego file", + Edit: types.WorkspaceEdit{ + DocumentChanges: []types.TextDocumentEdit{ + { + TextDocument: types.OptionalVersionedTextDocumentIdentifier{URI: evt.URI}, + Edits: ComputeEdits("", newContents), + }, + }, + }, + } + + err = l.conn.Call(ctx, methodWorkspaceApplyEdit, templateParams, nil) + if err != nil { + l.logError(fmt.Errorf("failed %s notify: %v", methodWorkspaceApplyEdit, err.Error())) + } + + // finally, update the cache contents and run diagnostics to clear + // empty module warning. + updateEvent := fileUpdateEvent{ + Reason: "internal/templateNewFile", + URI: evt.URI, + Content: newContents, + } + + l.diagnosticRequestFile <- updateEvent + } + } +} + +func (l *LanguageServer) templateContentsForFile(fileURI string) (string, error) { + content, ok := l.cache.GetFileContents(fileURI) + if !ok { + return "", fmt.Errorf("failed to get file contents for URI %q", fileURI) + } + + if content != "" { + return "", errors.New("file already has contents, templating not allowed") + } + + path := uri.ToPath(l.clientIdentifier, fileURI) + dir := filepath.Dir(path) + + roots, err := config.GetPotentialRoots(uri.ToPath(l.clientIdentifier, fileURI)) + if err != nil { + return "", fmt.Errorf("failed to get potential roots during templating of new file: %w", err) + } + + longestPrefixRoot := "" + + for _, root := range roots { + if strings.HasPrefix(dir, root) && len(root) > len(longestPrefixRoot) { + longestPrefixRoot = root + } + } + + if longestPrefixRoot == "" { + return "", fmt.Errorf("failed to find longest prefix root for templating of new file: %s", path) + } + + parts := slices.Compact(strings.Split(strings.TrimPrefix(dir, longestPrefixRoot), string(os.PathSeparator))) + + var pkg string + + validPathComponentPattern := regexp.MustCompile(`^\w+[\w\-]*\w+$`) + + for _, part := range parts { + if part == "" { + continue + } + + if !validPathComponentPattern.MatchString(part) { + return "", fmt.Errorf("failed to template new file as package path contained invalid part: %s", part) + } + + switch { + case strings.Contains(part, "-"): + pkg += fmt.Sprintf(`["%s"]`, part) + case pkg == "": + pkg += part + default: + pkg += "." + part + } + } + + // if we are in the root, then we can use main as a default + if pkg == "" { + pkg = "main" + } + + return fmt.Sprintf("package %s\n\nimport rego.v1\n", pkg), nil +} + func (l *LanguageServer) fixEditParams( label string, fix fixes.Fix, @@ -1208,8 +1319,6 @@ func (l *LanguageServer) handleTextDocumentCodeLens( module, ok := l.cache.GetModule(params.TextDocument.URI) if !ok { - l.logError(fmt.Errorf("failed to get module for uri %q", params.TextDocument.URI)) - // return a null response, as per the spec return nil, nil } @@ -1597,8 +1706,6 @@ func (l *LanguageServer) handleTextDocumentDocumentSymbol( module, ok := l.cache.GetModule(params.TextDocument.URI) if !ok { - l.logError(fmt.Errorf("failed to get module for uri %q", params.TextDocument.URI)) - return []types.DocumentSymbol{}, nil } @@ -1649,6 +1756,27 @@ func (l *LanguageServer) handleTextDocumentFormatting( oldContent, ok = l.cache.GetFileContents(params.TextDocument.URI) } + // if the file is empty, then the formatters will fail, so we template + // instead + if oldContent == "" { + newContent, err := l.templateContentsForFile(params.TextDocument.URI) + if err != nil { + return nil, fmt.Errorf("failed to template contents as a templating fallback: %w", err) + } + + l.cache.ClearFileDiagnostics() + + updateEvent := fileUpdateEvent{ + Reason: "internal/templateFormattingFallback", + URI: params.TextDocument.URI, + Content: newContent, + } + + l.diagnosticRequestFile <- updateEvent + + return ComputeEdits(oldContent, newContent), nil + } + if !ok { return nil, fmt.Errorf("failed to get file contents for uri %q", params.TextDocument.URI) } @@ -1773,6 +1901,7 @@ func (l *LanguageServer) handleWorkspaceDidCreateFiles( l.diagnosticRequestFile <- evt l.builtinsPositionFile <- evt + l.templateFile <- evt } return struct{}{}, nil diff --git a/internal/lsp/server_template_test.go b/internal/lsp/server_template_test.go new file mode 100644 index 00000000..8e60c294 --- /dev/null +++ b/internal/lsp/server_template_test.go @@ -0,0 +1,58 @@ +package lsp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/styrainc/regal/internal/lsp/clients" + "github.com/styrainc/regal/internal/lsp/uri" +) + +func TestServerTemplateContentsForFile(t *testing.T) { + t.Parallel() + + s := NewLanguageServer( + &LanguageServerOptions{ + ErrorLog: os.Stderr, + }, + ) + + td := t.TempDir() + + filePath := filepath.Join(td, "foo/bar/baz.rego") + regalPath := filepath.Join(td, ".regal/config.yaml") + + initialState := map[string]string{ + filePath: "", + regalPath: "", + } + + // create the initial state needed for the regal config root detection + for file := range initialState { + fileDir := filepath.Dir(file) + + err := os.MkdirAll(fileDir, 0o755) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = os.WriteFile(file, []byte(""), 0o600) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + fileURI := uri.FromPath(clients.IdentifierGeneric, filePath) + + s.cache.SetFileContents(fileURI, "") + + newContents, err := s.templateContentsForFile(fileURI) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if newContents != "package foo.bar\n\nimport rego.v1\n" { + t.Fatalf("unexpected contents: %v", newContents) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index d83968d4..88e5bca6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -195,7 +195,11 @@ func FindBundleRootDirectories(path string) ([]string, error) { // This will traverse the tree **downwards** searching for .regal directories // Not using rio.WalkFiles here as we're specifically looking for directories - if err := filepath.WalkDir(path, func(path string, info os.DirEntry, _ error) error { + if err := filepath.WalkDir(path, func(path string, info os.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("failed to walk path: %w", err) + } + if info.IsDir() && info.Name() == regalDirName { // Opening files as part of walking is generally not a good idea... // but I think we can assume the number of .regal directories in a project