diff --git a/lang/langserver/files.go b/lang/langserver/files.go index 82d378ae15..b85e08ab5b 100644 --- a/lang/langserver/files.go +++ b/lang/langserver/files.go @@ -3,6 +3,12 @@ package langserver import ( "fmt" "sync" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" + + "github.com/hashicorp/terraform/tfdiags" ) type filesystem struct { @@ -15,12 +21,18 @@ type dir struct { } type file struct { - content []byte - open bool + fullPath string + content []byte + open bool // TODO: use a piece table to track edits and flatten into content // only when we need to produce a contiguous buffer to parse it. // (That'll let us implement the incremental sync mode in the LSP.) + + errs bool + diags tfdiags.Diagnostics + ast *hcl.File + wrAST *hclwrite.File } func newFilesystem() *filesystem { @@ -37,7 +49,7 @@ func (fs *filesystem) Open(u uri, s []byte) error { return fmt.Errorf("invalid URL to open") } - dn, fn := u.DirFilename() + fullName, dn, fn := u.PathParts() d, ok := fs.dirs[dn] if !ok { d = newDir() @@ -45,7 +57,7 @@ func (fs *filesystem) Open(u uri, s []byte) error { } f, ok := d.files[fn] if !ok { - f = newFile() + f = newFile(fullName) } f.content = s f.open = true @@ -57,10 +69,6 @@ func (fs *filesystem) Change(u uri, s []byte) error { fs.mu.Lock() defer fs.mu.Unlock() - if !u.Valid() { - return fmt.Errorf("invalid URL to change") - } - f := fs.file(u) if f == nil || !f.open { return fmt.Errorf("file %q is not open", u) @@ -73,24 +81,50 @@ func (fs *filesystem) Close(u uri) error { fs.mu.Lock() defer fs.mu.Unlock() - if !u.Valid() { - return fmt.Errorf("invalid URL to close") - } - - dn, fn := u.DirFilename() f := fs.file(u) if f == nil || !f.open { return fmt.Errorf("file %q is not open", u) } + _, dn, fn := u.PathParts() delete(fs.dirs[dn].files, fn) return nil } +func (fs *filesystem) Format(u uri) ([]byte, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + f := fs.file(u) + if f == nil || !f.open { + return nil, fmt.Errorf("file %q is not open", u) + } + + s, changed := formatSource(f.content) + if changed { + f.change(s) + } + return s, nil +} + +func (fs *filesystem) FileAST(u uri) *hcl.File { + fs.mu.Lock() + defer fs.mu.Unlock() + + // FIXME: This method should work with non-open files too, reading them + // from disk and caching them. + f := fs.file(u) + if f == nil { + return nil + } + + return f.hclAST() +} + func (fs *filesystem) file(u uri) *file { if !u.Valid() { return nil } - dn, fn := u.DirFilename() + _, dn, fn := u.PathParts() d, ok := fs.dirs[dn] if !ok { return nil @@ -104,10 +138,60 @@ func newDir() *dir { } } -func newFile() *file { - return &file{} +func newFile(fullPath string) *file { + return &file{fullPath: fullPath} +} + +func (f *file) diagnostics() tfdiags.Diagnostics { + if f.diags != nil { + return f.diags + } + // FIXME: Unfortunate that we just keep re-parsing every time + // if there are no errors. + _ = f.hclAST() + return f.diags +} + +func (f *file) hclAST() *hcl.File { + if f.errs { + return nil + } + if f.ast != nil { + return f.ast + } + hf, diags := hclsyntax.ParseConfig(f.content, f.fullPath, hcl.Pos{Line: 1, Column: 1}) + f.diags = nil + f.diags = f.diags.Append(diags) + if diags.HasErrors() { + f.errs = true + return nil + } + f.ast = hf + return hf +} + +func (f *file) hclWriteAST() *hclwrite.File { + if f.errs { + return nil + } + if f.wrAST != nil { + return f.wrAST + } + hf, diags := hclwrite.ParseConfig(f.content, f.fullPath, hcl.Pos{Line: 1, Column: 1}) + f.diags = nil + f.diags = f.diags.Append(diags) + if diags.HasErrors() { + f.errs = true + return nil + } + f.wrAST = hf + return hf } func (f *file) change(s []byte) { f.content = s + f.wrAST = nil + f.ast = nil + f.diags = nil + f.errs = false } diff --git a/lang/langserver/format.go b/lang/langserver/format.go new file mode 100644 index 0000000000..405c828815 --- /dev/null +++ b/lang/langserver/format.go @@ -0,0 +1,21 @@ +package langserver + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" +) + +// formatSource will apply hclwrite formatting to the given source code +// if it has valid syntax, or just return it verbatim if not. The second +// argument is true if the returned buffer might be different than the given +// buffer, or false if it's guaranteed identical (allowing the caller to skip +// invalidating caches, etc.) +func formatSource(in []byte) ([]byte, bool) { + _, diags := hclsyntax.ParseConfig(in, "", hcl.Pos{}) + if diags.HasErrors() { + return in, false + } + + return hclwrite.Format(in), true +} diff --git a/lang/langserver/server.go b/lang/langserver/server.go index 9c8f00ceb6..60a47eb056 100644 --- a/lang/langserver/server.go +++ b/lang/langserver/server.go @@ -46,6 +46,7 @@ func (s *server) Initialize(context.Context, *lsp.InitializeParams) (*lsp.Initia log.Printf("[DEBUG] langserver: Initialize") return &lsp.InitializeResult{ Capabilities: lsp.ServerCapabilities{ + DocumentFormattingProvider: true, TextDocumentSync: lsp.TextDocumentSyncOptions{ OpenClose: true, @@ -228,9 +229,38 @@ func (s *server) ColorPresentation(context.Context, *lsp.ColorPresentationParams return nil, notImplemented("ColorPresentation") } -func (s *server) Formatting(context.Context, *lsp.DocumentFormattingParams) ([]lsp.TextEdit, error) { +func (s *server) Formatting(ctx context.Context, req *lsp.DocumentFormattingParams) ([]lsp.TextEdit, error) { log.Printf("[DEBUG] langserver: Formatting") - return nil, notImplemented("Formatting") + u := uri(req.TextDocument.URI) + new, err := s.fs.Format(u) + if err != nil { + return nil, err + } + // For now we just replace the entire file, but that means we need + // the whole file's source range. + astF := s.fs.FileAST(u) + if astF == nil { + // Shouldn't happen if formatting succeeded, but we'll accept it + // anyway to avoid a panic. + return nil, fmt.Errorf("failed to parse %q", u) + } + return []lsp.TextEdit{ + { + Range: lsp.Range{ + Start: lsp.Position{Line: 0, Character: 0}, + + // Since HCL's line numbers are 1-based but LSP is 0-based, + // this is intentionally pointing to one line _after_ the + // end of our file, so we don't need to convert the + // column number from grapheme clusters to UTF-16 code units. + // This is a cheat and we should implement incremental + // edits here rather than just overwriting the whole thing + // every time. + End: lsp.Position{Line: float64(astF.Body.MissingItemRange().End.Line), Character: 0}, + }, + NewText: string(new), + }, + }, nil } func (s *server) RangeFormatting(context.Context, *lsp.DocumentRangeFormattingParams) ([]lsp.TextEdit, error) { diff --git a/lang/langserver/uri.go b/lang/langserver/uri.go index 42ff545e59..0ee37b28cf 100644 --- a/lang/langserver/uri.go +++ b/lang/langserver/uri.go @@ -39,9 +39,9 @@ func (u uri) Filename() string { return filepath.Base(u.FullPath()) } -func (u uri) DirFilename() (dir, filename string) { - full := u.FullPath() +func (u uri) PathParts() (full, dir, filename string) { + full = u.FullPath() dir = filepath.Dir(full) filename = filepath.Base(full) - return dir, filename + return full, dir, filename }