From e4664a1abef0a1a22ca88265ceb8fd82346c64bd Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sat, 15 Dec 2018 10:20:11 +0000 Subject: [PATCH] lang/langserver: Simple implementation of Formatting Mainly just to have a proof-of-concept of this doing something, this is a simple implementation of the Formatting method that just replaces the whole buffer with the hclwrite result. In future we should make this instead return surgical TextEdits, but we'll need more infrastructure in place here before that is possible. --- lang/langserver/files.go | 116 ++++++++++++++++++++++++++++++++------ lang/langserver/format.go | 21 +++++++ lang/langserver/server.go | 34 ++++++++++- lang/langserver/uri.go | 6 +- 4 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 lang/langserver/format.go 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 }