diff --git a/handler/builder.go b/handler/builder.go index db03d53..bf1ae83 100644 --- a/handler/builder.go +++ b/handler/builder.go @@ -2,6 +2,7 @@ package handler import ( "bytes" + "context" "encoding/json" "log" "strings" @@ -10,6 +11,7 @@ import ( "github.com/arduino/arduino-cli/arduino/libraries" "github.com/arduino/arduino-cli/executils" "github.com/arduino/go-paths-helper" + "github.com/bcmi-labs/arduino-language-server/lsp" "github.com/bcmi-labs/arduino-language-server/streams" "github.com/pkg/errors" ) @@ -50,9 +52,53 @@ func (handler *InoHandler) rebuildEnvironmentLoop() { } // Regenerate preprocessed sketch! + done := make(chan bool) + go func() { + { + // Request a new progress token + req := &lsp.WorkDoneProgressCreateParams{Token: "arduinoLanguageServerRebuild"} + var resp lsp.WorkDoneProgressCreateResult + if err := handler.StdioConn.Call(context.Background(), "window/workDoneProgress/create", req, &resp, nil); err != nil { + log.Printf(" !!! could not create report progress: %s", err) + <-done + return + } + } + + req := &lsp.ProgressParams{Token: "arduinoLanguageServerRebuild"} + req.Value = lsp.WorkDoneProgressBegin{ + Title: "Building sketch", + } + if err := handler.StdioConn.Notify(context.Background(), "$/progress", req, nil); err != nil { + log.Printf(" !!! could not report progress: %s", err) + } + count := 0 + dots := []string{".", "..", "..."} + for { + select { + case <-time.After(time.Millisecond * 400): + msg := "compiling" + dots[count%3] + count++ + req.Value = lsp.WorkDoneProgressReport{Message: &msg} + if err := handler.StdioConn.Notify(context.Background(), "$/progress", req, nil); err != nil { + log.Printf(" !!! could not report progress: %s", err) + } + case <-done: + msg := "done" + req.Value = lsp.WorkDoneProgressEnd{Message: &msg} + if err := handler.StdioConn.Notify(context.Background(), "$/progress", req, nil); err != nil { + log.Printf(" !!! could not report progress: %s", err) + } + return + } + } + }() + handler.synchronizer.DataMux.Lock() handler.initializeWorkbench(nil) handler.synchronizer.DataMux.Unlock() + done <- true + close(done) } } @@ -72,13 +118,14 @@ func (handler *InoHandler) generateBuildEnvironment() (*paths.Path, error) { } data.Overrides[rel.String()] = trackedFile.Text } - var overridesJSON string + var overridesJSON *paths.Path if jsonBytes, err := json.MarshalIndent(data, "", " "); err != nil { return nil, errors.WithMessage(err, "dumping tracked files") } else if tmpFile, err := paths.WriteToTempFile(jsonBytes, nil, ""); err != nil { return nil, errors.WithMessage(err, "dumping tracked files") } else { - overridesJSON = tmpFile.String() + overridesJSON = tmpFile + defer tmpFile.Remove() } // XXX: do this from IDE or via gRPC @@ -87,7 +134,7 @@ func (handler *InoHandler) generateBuildEnvironment() (*paths.Path, error) { "--fqbn", fqbn, "--only-compilation-database", "--clean", - "--source-override", overridesJSON, + "--source-override", overridesJSON.String(), "--format", "json", sketchDir.String(), } diff --git a/handler/handler.go b/handler/handler.go index 49bf826..78400b9 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -279,9 +279,9 @@ func (handler *InoHandler) HandleMessageFromIDE(ctx context.Context, conn *jsonr log.Printf("--> %s(%s:%s)", req.Method, p.TextDocument.URI, p.Position) inoURI = p.TextDocument.URI if res, e := handler.ino2cppTextDocumentPositionParams(p); e == nil { - cppURI = p.TextDocument.URI + cppURI = res.TextDocument.URI params = res - log.Printf(" --> %s(%s:%s)", req.Method, p.TextDocument.URI, p.Position) + log.Printf(" --> %s(%s:%s)", req.Method, res.TextDocument.URI, res.Position) } else { err = e } @@ -453,7 +453,7 @@ func (handler *InoHandler) refreshCppDocumentSymbols() error { if err != nil { return errors.WithMessage(err, "quering source code symbols") } - result = handler.transformClangdResult("textDocument/documentSymbol", cppURI, "", result) + result = handler.transformClangdResult("textDocument/documentSymbol", cppURI, lsp.NilURI, result) if symbols, ok := result.([]lsp.DocumentSymbol); !ok { return errors.WithMessage(err, "quering source code symbols (2)") } else { @@ -748,7 +748,7 @@ func (handler *InoHandler) ino2cppDocumentURI(inoURI lsp.DocumentURI) (lsp.Docum inside, err := inoPath.IsInsideDir(handler.sketchRoot) if err != nil { log.Printf(" could not determine if '%s' is inside '%s'", inoPath, handler.sketchRoot) - return "", unknownURI(inoURI) + return lsp.NilURI, unknownURI(inoURI) } if !inside { log.Printf(" passing doc identifier to '%s' as-is", inoPath) @@ -763,7 +763,7 @@ func (handler *InoHandler) ino2cppDocumentURI(inoURI lsp.DocumentURI) (lsp.Docum } log.Printf(" could not determine rel-path of '%s' in '%s': %s", inoPath, handler.sketchRoot, err) - return "", err + return lsp.NilURI, err } func (handler *InoHandler) cpp2inoDocumentURI(cppURI lsp.DocumentURI, cppRange lsp.Range) (lsp.DocumentURI, lsp.Range, error) { @@ -791,7 +791,7 @@ func (handler *InoHandler) cpp2inoDocumentURI(cppURI lsp.DocumentURI, cppRange l inside, err := cppPath.IsInsideDir(handler.buildSketchRoot) if err != nil { log.Printf(" could not determine if '%s' is inside '%s'", cppPath, handler.buildSketchRoot) - return "", lsp.Range{}, err + return lsp.NilURI, lsp.Range{}, err } if !inside { log.Printf(" keep doc identifier to '%s' as-is", cppPath) @@ -806,7 +806,7 @@ func (handler *InoHandler) cpp2inoDocumentURI(cppURI lsp.DocumentURI, cppRange l } log.Printf(" could not determine rel-path of '%s' in '%s': %s", cppPath, handler.buildSketchRoot, err) - return "", lsp.Range{}, err + return lsp.NilURI, lsp.Range{}, err } func (handler *InoHandler) ino2cppTextDocumentPositionParams(inoParams *lsp.TextDocumentPositionParams) (*lsp.TextDocumentPositionParams, error) { @@ -833,7 +833,7 @@ func (handler *InoHandler) ino2cppTextDocumentPositionParams(inoParams *lsp.Text func (handler *InoHandler) ino2cppRange(inoURI lsp.DocumentURI, inoRange lsp.Range) (lsp.DocumentURI, lsp.Range, error) { cppURI, err := handler.ino2cppDocumentURI(inoURI) if err != nil { - return "", lsp.Range{}, err + return lsp.NilURI, lsp.Range{}, err } if cppURI.AsPath().EquivalentTo(handler.buildSketchCpp) { cppRange := handler.sketchMapper.InoToCppLSPRange(inoURI, inoRange) @@ -919,7 +919,7 @@ func (handler *InoHandler) ino2cppWorkspaceEdit(origEdit *lsp.WorkspaceEdit) *ls } func (handler *InoHandler) transformClangdResult(method string, inoURI, cppURI lsp.DocumentURI, result interface{}) interface{} { - cppToIno := inoURI != "" && inoURI.AsPath().EquivalentTo(handler.buildSketchCpp) + cppToIno := inoURI != lsp.NilURI && inoURI.AsPath().EquivalentTo(handler.buildSketchCpp) switch r := result.(type) { case *lsp.Hover: @@ -1423,5 +1423,5 @@ func (handler *InoHandler) showMessage(ctx context.Context, msgType lsp.MessageT } func unknownURI(uri lsp.DocumentURI) error { - return errors.New("Document is not available: " + string(uri)) + return errors.New("Document is not available: " + uri.String()) } diff --git a/lsp/protocol_test.go b/lsp/protocol_test.go index fc213ce..e1095e2 100644 --- a/lsp/protocol_test.go +++ b/lsp/protocol_test.go @@ -66,6 +66,16 @@ func TestDocumentSymbolParse(t *testing.T) { } func TestVariousMessages(t *testing.T) { + x := &ProgressParams{ + Token: "token", + Value: WorkDoneProgressBegin{ + Title: "some work", + }, + } + data, err := json.Marshal(&x) + require.NoError(t, err) + require.JSONEq(t, `{"token":"token", "value":{"kind":"begin","title":"some work"}}`, string(data)) + msg := `{ "capabilities":{ "codeActionProvider":{ @@ -107,6 +117,6 @@ func TestVariousMessages(t *testing.T) { }, "serverInfo":{"name":"clangd","version":"clangd version 11.0.0 (https://github.com/llvm/llvm-project 176249bd6732a8044d457092ed932768724a6f06)"}}` var init InitializeResult - err := json.Unmarshal([]byte(msg), &init) + err = json.Unmarshal([]byte(msg), &init) require.NoError(t, err) } diff --git a/lsp/service.go b/lsp/service.go index 06d91c3..84b258a 100644 --- a/lsp/service.go +++ b/lsp/service.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "errors" "strings" ) @@ -27,19 +28,6 @@ type InitializeParams struct { type InitializedParams struct{} -// Root returns the RootURI if set, or otherwise the RootPath with 'file://' prepended. -func (p *InitializeParams) Root() DocumentURI { - if p.RootURI != "" { - return p.RootURI - } - if strings.HasPrefix(p.RootPath, "file://") { - return DocumentURI(p.RootPath) - } - return DocumentURI("file://" + p.RootPath) -} - -type DocumentURI string - type ClientInfo struct { Name string `json:"name,omitempty"` Version string `json:"version,omitempty"` @@ -552,16 +540,42 @@ var completionItemKindName = map[CompletionItemKind]string{ } type CompletionItem struct { - Label string `json:"label"` - Kind CompletionItemKind `json:"kind,omitempty"` - Detail string `json:"detail,omitempty"` - Documentation string `json:"documentation,omitempty"` - SortText string `json:"sortText,omitempty"` - FilterText string `json:"filterText,omitempty"` - InsertText string `json:"insertText,omitempty"` - InsertTextFormat InsertTextFormat `json:"insertTextFormat,omitempty"` - TextEdit *TextEdit `json:"textEdit,omitempty"` - Data interface{} `json:"data,omitempty"` + Label string `json:"label"` + Kind CompletionItemKind `json:"kind,omitempty"` + Detail string `json:"detail,omitempty"` + Documentation *StringOrMarkupContent `json:"documentation,omitempty"` + SortText string `json:"sortText,omitempty"` + FilterText string `json:"filterText,omitempty"` + InsertText string `json:"insertText,omitempty"` + InsertTextFormat InsertTextFormat `json:"insertTextFormat,omitempty"` + TextEdit *TextEdit `json:"textEdit,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type StringOrMarkupContent struct { + String *string + MarkupContent *MarkupContent +} + +// MarshalJSON implements json.Marshaler. +func (v *StringOrMarkupContent) MarshalJSON() ([]byte, error) { + if v.String != nil { + return json.Marshal(v.String) + } + return json.Marshal(v.MarkupContent) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *StringOrMarkupContent) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err == nil { + v.String = &s + v.MarkupContent = nil + return nil + } + v.String = nil + err := json.Unmarshal(data, &v.MarkupContent) + return err } type CompletionList struct { @@ -1042,3 +1056,124 @@ type SemanticHighlightingToken struct { Length uint16 Scope uint16 } + +type ProgressParams struct { + Token string `json:"token"` + Value interface{} `json:"value"` +} + +type WorkDoneProgressCreateParams struct { + Token string `json:"token"` +} + +type WorkDoneProgressCreateResult struct{} + +// MarshalJSON implements json.Marshaler. +func (v *WorkDoneProgressCreateResult) MarshalJSON() ([]byte, error) { + return []byte("null"), nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *WorkDoneProgressCreateResult) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte("null")) { + return nil + } + return errors.New("expected null") +} + +type WorkDoneProgressBegin struct { + Title string `json:"title"` + Cancellable *bool `json:"cancellable,omitempty"` + Message *string `json:"message,omitempty"` + Percentage *int `json:"percentage,omitempty"` +} + +// MarshalJSON implements json.Marshaler. +func (v WorkDoneProgressBegin) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Title string `json:"title"` + Cancellable *bool `json:"cancellable,omitempty"` + Message *string `json:"message,omitempty"` + Percentage *int `json:"percentage,omitempty"` + Kind string `json:"kind"` + }{v.Title, v.Cancellable, v.Message, v.Percentage, "begin"}) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *WorkDoneProgressBegin) UnmarshalJSON(data []byte) error { + type ProgressBegin struct { + WorkDoneProgressBegin + Kind string `json:"kind"` + } + var x ProgressBegin + if err := json.Unmarshal(data, &x); err != nil { + return err + } + if x.Kind != "begin" { + return errors.New(`expected kind == "begin"`) + } + *v = x.WorkDoneProgressBegin + return nil +} + +type WorkDoneProgressReport struct { + Cancellable *bool `json:"cancellable,omitempty"` + Message *string `json:"message,omitempty"` + Percentage *int `json:"percentage,omitempty"` +} + +// MarshalJSON implements json.Marshaler. +func (v WorkDoneProgressReport) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Cancellable *bool `json:"cancellable,omitempty"` + Message *string `json:"message,omitempty"` + Percentage *int `json:"percentage,omitempty"` + Kind string `json:"kind"` + }{v.Cancellable, v.Message, v.Percentage, "report"}) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *WorkDoneProgressReport) UnmarshalJSON(data []byte) error { + type ProgressReport struct { + WorkDoneProgressReport + Kind string `json:"kind"` + } + var x ProgressReport + if err := json.Unmarshal(data, &x); err != nil { + return err + } + if x.Kind != "report" { + return errors.New(`expected kind == "report"`) + } + *v = x.WorkDoneProgressReport + return nil +} + +type WorkDoneProgressEnd struct { + Message *string `json:"message,omitempty"` +} + +// MarshalJSON implements json.Marshaler. +func (v WorkDoneProgressEnd) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Message *string `json:"message,omitempty"` + Kind string `json:"kind"` + }{v.Message, "end"}) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *WorkDoneProgressEnd) UnmarshalJSON(data []byte) error { + type ProgressEnd struct { + WorkDoneProgressEnd + Kind string `json:"kind"` + } + var x ProgressEnd + if err := json.Unmarshal(data, &x); err != nil { + return err + } + if x.Kind != "end" { + return errors.New(`expected kind == "end"`) + } + *v = x.WorkDoneProgressEnd + return nil +} diff --git a/lsp/uri.go b/lsp/uri.go index 9c84adf..a5ae960 100644 --- a/lsp/uri.go +++ b/lsp/uri.go @@ -1,16 +1,23 @@ package lsp import ( + "encoding/json" "net/url" "path/filepath" "regexp" - "runtime" - "strings" "github.com/arduino/go-paths-helper" + "github.com/pkg/errors" ) -var expDriveID = regexp.MustCompile("[a-zA-Z]:") +type DocumentURI struct { + url url.URL +} + +// NilURI is the empty DocumentURI +var NilURI = DocumentURI{} + +var expDriveID = regexp.MustCompile("^/[a-zA-Z]:") // AsPath convert the DocumentURI to a paths.Path func (uri DocumentURI) AsPath() *paths.Path { @@ -19,33 +26,20 @@ func (uri DocumentURI) AsPath() *paths.Path { // Unbox convert the DocumentURI to a file path string func (uri DocumentURI) Unbox() string { - urlObj, err := url.Parse(string(uri)) - if err != nil { - return string(uri) - } - path := "" - segments := strings.Split(urlObj.Path, "/") - for _, segment := range segments { - decoded, err := url.PathUnescape(segment) - if err != nil { - decoded = segment - } - if runtime.GOOS == "windows" && expDriveID.MatchString(decoded) { - path += strings.ToUpper(decoded) - } else if len(decoded) > 0 { - path += string(filepath.Separator) + decoded - } + path := uri.url.Path + if expDriveID.MatchString(path) { + return path[1:] } return path } func (uri DocumentURI) String() string { - return string(uri) + return uri.url.String() } // Ext returns the extension of the file pointed by the URI func (uri DocumentURI) Ext() string { - return filepath.Ext(string(uri)) + return filepath.Ext(uri.Unbox()) } // NewDocumentURIFromPath create a DocumentURI from the given Path object @@ -53,17 +47,48 @@ func NewDocumentURIFromPath(path *paths.Path) DocumentURI { return NewDocumentURI(path.String()) } +var toSlash = filepath.ToSlash + // NewDocumentURI create a DocumentURI from the given string path func NewDocumentURI(path string) DocumentURI { - urlObj, err := url.Parse("file://") + // tranform path into URI + path = toSlash(path) + if len(path) == 0 || path[0] != '/' { + path = "/" + path + } + uri, err := NewDocumentURIFromURL("file://" + path) if err != nil { panic(err) } - segments := strings.Split(path, string(filepath.Separator)) - for _, segment := range segments { - if len(segment) > 0 { - urlObj.Path += "/" + url.PathEscape(segment) - } + return uri +} + +// NewDocumentURIFromURL converts an URL into a DocumentURI +func NewDocumentURIFromURL(inURL string) (DocumentURI, error) { + uri, err := url.Parse(inURL) + if err != nil { + return NilURI, err } - return DocumentURI(urlObj.String()) + return DocumentURI{url: *uri}, nil +} + +// UnmarshalJSON implements json.Unmarshaller interface +func (uri *DocumentURI) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return errors.WithMessage(err, "expoected JSON string for DocumentURI") + } + + newDocURI, err := NewDocumentURIFromURL(s) + if err != nil { + return errors.WithMessage(err, "parsing DocumentURI") + } + + *uri = newDocURI + return nil +} + +// MarshalJSON implements json.Marshaller interface +func (uri DocumentURI) MarshalJSON() ([]byte, error) { + return json.Marshal(uri.url.String()) } diff --git a/lsp/uri_test.go b/lsp/uri_test.go index 98e5fb3..b09a649 100644 --- a/lsp/uri_test.go +++ b/lsp/uri_test.go @@ -1,49 +1,83 @@ package lsp import ( - "path/filepath" - "runtime" + "encoding/json" + "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestUriToPath(t *testing.T) { - var path string - if runtime.GOOS == "windows" { - path = DocumentURI("file:///C:/Users/test/Sketch.ino").Unbox() - if path != "C:\\Users\\test\\Sketch.ino" { - t.Error(path) - } - path = DocumentURI("file:///c%3A/Users/test/Sketch.ino").Unbox() - if path != "C:\\Users\\test\\Sketch.ino" { - t.Error(path) - } - } else { - path = DocumentURI("file:///Users/test/Sketch.ino").Unbox() - if path != "/Users/test/Sketch.ino" { - t.Error(path) - } - } - path = DocumentURI("file:///%25F0%259F%2598%259B").Unbox() - if path != string(filepath.Separator)+"\U0001F61B" { - t.Error(path) - } + d, err := NewDocumentURIFromURL("file:///C:/Users/test/Sketch.ino") + require.NoError(t, err) + require.Equal(t, "C:/Users/test/Sketch.ino", d.Unbox()) + + d, err = NewDocumentURIFromURL("file:///c%3A/Users/test/Sketch.ino") + require.NoError(t, err) + require.Equal(t, "c:/Users/test/Sketch.ino", d.Unbox()) + + d, err = NewDocumentURIFromURL("file:///Users/test/Sketch.ino") + require.NoError(t, err) + require.Equal(t, "/Users/test/Sketch.ino", d.Unbox()) + + d, err = NewDocumentURIFromURL("file:///c%3A/Users/USERNA~1/AppData/Local/Temp/.arduinoProIDE-unsaved202108-10416-j28c17.lru6k/sketch_jan8a/sketch_jan8a.ino") + require.NoError(t, err) + require.Equal(t, "c:/Users/USERNA~1/AppData/Local/Temp/.arduinoProIDE-unsaved202108-10416-j28c17.lru6k/sketch_jan8a/sketch_jan8a.ino", d.Unbox()) + + d, err = NewDocumentURIFromURL("file:///%F0%9F%98%9B") + require.NoError(t, err) + require.Equal(t, "/\U0001F61B", d.Unbox()) } func TestPathToUri(t *testing.T) { - var uri DocumentURI - if runtime.GOOS == "windows" { - uri = NewDocumentURI("C:\\Users\\test\\Sketch.ino") - if uri != "file:///C:/Users/test/Sketch.ino" { - t.Error(uri) - } - } else { - uri = NewDocumentURI("/Users/test/Sketch.ino") - if uri != "file:///Users/test/Sketch.ino" { - t.Error(uri) - } - } - uri = NewDocumentURI("\U0001F61B") - if uri != "file:///%25F0%259F%2598%259B" { - t.Error(uri) - } + toSlash = windowsToSlash // Emulate windows cases + + d := NewDocumentURI("C:\\Users\\test\\Sketch.ino") + require.Equal(t, "file:///C:/Users/test/Sketch.ino", d.String()) + d = NewDocumentURI("/Users/test/Sketch.ino") + require.Equal(t, "file:///Users/test/Sketch.ino", d.String()) + d = NewDocumentURI("\U0001F61B") + require.Equal(t, "file:///%F0%9F%98%9B", d.String()) +} + +func TestJSONMarshalUnmarshal(t *testing.T) { + toSlash = windowsToSlash // Emulate windows cases + + var d DocumentURI + err := json.Unmarshal([]byte(`"file:///Users/test/Sketch.ino"`), &d) + require.NoError(t, err) + require.Equal(t, "/Users/test/Sketch.ino", d.Unbox()) + + err = json.Unmarshal([]byte(`"file:///%F0%9F%98%9B"`), &d) + require.NoError(t, err) + require.Equal(t, "/\U0001F61B", d.Unbox()) + + d = NewDocumentURI("C:\\Users\\test\\Sketch.ino") + data, err := json.Marshal(d) + require.NoError(t, err) + require.Equal(t, `"file:///C:/Users/test/Sketch.ino"`, string(data)) + + d = NewDocumentURI("/Users/test/Sketch.ino") + data, err = json.Marshal(d) + require.NoError(t, err) + require.Equal(t, `"file:///Users/test/Sketch.ino"`, string(data)) + + d = NewDocumentURI("/User nàmé/test/Sketch.ino") + data, err = json.Marshal(d) + require.NoError(t, err) + require.Equal(t, `"file:///User%20n%C3%A0m%C3%A9/test/Sketch.ino"`, string(data)) + + d = NewDocumentURI("\U0001F61B") + data, err = json.Marshal(d) + require.NoError(t, err) + require.Equal(t, `"file:///%F0%9F%98%9B"`, string(data)) +} + +func windowsToSlash(path string) string { + return strings.ReplaceAll(path, `\`, "/") +} + +func windowsFromSlash(path string) string { + return strings.ReplaceAll(path, "/", `\`) }