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