Skip to content

Commit

Permalink
lsp: Template new empty files & template on format (#1051)
Browse files Browse the repository at this point in the history
* 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 #1048

Signed-off-by: Charlie Egan <charlie@styra.com>

* lsp: Add test for server templating

Signed-off-by: Charlie Egan <charlie@styra.com>

---------

Signed-off-by: Charlie Egan <charlie@styra.com>
  • Loading branch information
charlieegan3 authored Sep 4, 2024
1 parent ced7c70 commit bf6e879
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 5 deletions.
1 change: 1 addition & 0 deletions cmd/languageserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
137 changes: 133 additions & 4 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -1773,6 +1901,7 @@ func (l *LanguageServer) handleWorkspaceDidCreateFiles(

l.diagnosticRequestFile <- evt
l.builtinsPositionFile <- evt
l.templateFile <- evt
}

return struct{}{}, nil
Expand Down
58 changes: 58 additions & 0 deletions internal/lsp/server_template_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 5 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit bf6e879

Please sign in to comment.