diff --git a/cmd/editor/diagnostics.go b/cmd/editor/diagnostics.go new file mode 100644 index 0000000..6e953e6 --- /dev/null +++ b/cmd/editor/diagnostics.go @@ -0,0 +1,69 @@ +package editor + +import ( + "encoding/json" + "fmt" + "io" + + editoripc "github.com/kloudkit/ws-cli/internals/editor" + "github.com/kloudkit/ws-cli/internals/styles" + "github.com/spf13/cobra" +) + +var diagnosticsCmd = &cobra.Command{ + Use: "diagnostics", + Short: "Show language diagnostics for the workspace (or a single file)", + RunE: func(cmd *cobra.Command, args []string) error { + uri, _ := cmd.Flags().GetString("uri") + + body, err := editoripc.FetchDiagnostics(uri) + if err != nil { + return err + } + + return emit(cmd, body, renderDiagnostics) + }, +} + +func renderDiagnostics(out io.Writer, body []byte) error { + var files []editoripc.DiagnosticFile + if err := json.Unmarshal(body, &files); err != nil { + return fmt.Errorf("error parsing diagnostics response: %w", err) + } + + total := 0 + for _, file := range files { + total += len(file.Items) + } + + if total == 0 { + styles.PrintSuccess(out, "No diagnostics") + return nil + } + + fmt.Fprintf(out, "%s\n", styles.TitleWithCount("Diagnostics", total)) + + for _, file := range files { + if len(file.Items) == 0 { + continue + } + + fmt.Fprintf(out, "%s\n", styles.SubHeader().Render(file.URI)) + + rows := make([][]string, len(file.Items)) + for i, item := range file.Items { + location := fmt.Sprintf("%d:%d", item.Range.Start.Line+1, item.Range.Start.Character+1) + rows[i] = []string{item.Severity.Label, location, item.Source, item.Message} + } + + fmt.Fprintf(out, "%s\n", styles.Table("Severity", "Location", "Source", "Message").Rows(rows...).Render()) + } + + return nil +} + +func init() { + diagnosticsCmd.Flags().String("uri", "", "Filter to a single file (URI or absolute path)") + + EditorCmd.AddCommand(diagnosticsCmd) +} diff --git a/cmd/editor/editor.go b/cmd/editor/editor.go new file mode 100644 index 0000000..b8a96f5 --- /dev/null +++ b/cmd/editor/editor.go @@ -0,0 +1,46 @@ +package editor + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/kloudkit/ws-cli/internals/config" + "github.com/kloudkit/ws-cli/internals/env" + "github.com/spf13/cobra" +) + +var errNoEditorOverSSH = errors.New( + "the editor commands are not available over SSH — there is no editor session to act on", +) + +var EditorCmd = &cobra.Command{ + Use: "editor", + Short: "Inspect and drive the active editor session", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if env.IsSSHSession() { + return errNoEditorOverSSH + } + + return config.Bootstrap() + }, +} + +func emit(cmd *cobra.Command, body []byte, render func(io.Writer, []byte) error) error { + out := cmd.OutOrStdout() + + if raw, _ := cmd.Flags().GetBool("raw"); raw { + if len(body) > 0 { + fmt.Fprintln(out, strings.TrimSpace(string(body))) + } + + return nil + } + + return render(out, body) +} + +func init() { + EditorCmd.PersistentFlags().Bool("raw", false, "Output the raw JSON response without styling") +} diff --git a/cmd/editor/editor_test.go b/cmd/editor/editor_test.go new file mode 100644 index 0000000..c872632 --- /dev/null +++ b/cmd/editor/editor_test.go @@ -0,0 +1,42 @@ +package editor + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestPersistentPreRunEBlocksSSH(t *testing.T) { + t.Setenv("SSH_CONNECTION", "1.2.3.4 51000 5.6.7.8 22") + + err := EditorCmd.PersistentPreRunE(EditorCmd, nil) + assert.ErrorContains(t, err, "SSH") +} + +func TestParseSelection(t *testing.T) { + t.Run("SinglePositionIsEmptyRange", func(t *testing.T) { + got, err := parseSelection("12:5") + assert.NilError(t, err) + + assert.Equal(t, got.Start.Line, 11) + assert.Equal(t, got.Start.Character, 4) + assert.Equal(t, got.End, got.Start) + }) + + t.Run("Range", func(t *testing.T) { + got, err := parseSelection("1:1-3:8") + assert.NilError(t, err) + + assert.Equal(t, got.Start.Line, 0) + assert.Equal(t, got.Start.Character, 0) + assert.Equal(t, got.End.Line, 2) + assert.Equal(t, got.End.Character, 7) + }) + + t.Run("Invalid", func(t *testing.T) { + for _, bad := range []string{"", "5", "abc:1", "1:0", "0:1", "1:2-bogus"} { + _, err := parseSelection(bad) + assert.ErrorContains(t, err, "invalid selection", "input %q should fail", bad) + } + }) +} diff --git a/cmd/editor/list.go b/cmd/editor/list.go new file mode 100644 index 0000000..6dd43bc --- /dev/null +++ b/cmd/editor/list.go @@ -0,0 +1,64 @@ +package editor + +import ( + "encoding/json" + "fmt" + "io" + + editoripc "github.com/kloudkit/ws-cli/internals/editor" + "github.com/kloudkit/ws-cli/internals/styles" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List the currently open editor tabs", + RunE: func(cmd *cobra.Command, args []string) error { + body, err := editoripc.FetchEditors() + if err != nil { + return err + } + + return emit(cmd, body, renderEditors) + }, +} + +func renderEditors(out io.Writer, body []byte) error { + var tabs []editoripc.Tab + if err := json.Unmarshal(body, &tabs); err != nil { + return fmt.Errorf("error parsing editor response: %w", err) + } + + if len(tabs) == 0 { + styles.PrintWarning(out, "No open editors") + return nil + } + + fmt.Fprintf(out, "%s\n", styles.TitleWithCount("Open Editors", len(tabs))) + + rows := make([][]string, len(tabs)) + for i, tab := range tabs { + language := "-" + if tab.LanguageID != nil { + language = *tab.LanguageID + } + + rows[i] = []string{tab.Path, language, check(tab.Active), check(tab.Dirty)} + } + + fmt.Fprintf(out, "%s\n", styles.Table("Path", "Language", "Active", "Dirty").Rows(rows...).Render()) + + return nil +} + +func check(value bool) string { + if value { + return "✓" + } + + return "" +} + +func init() { + EditorCmd.AddCommand(listCmd) +} diff --git a/cmd/editor/open.go b/cmd/editor/open.go new file mode 100644 index 0000000..2ec2b9b --- /dev/null +++ b/cmd/editor/open.go @@ -0,0 +1,85 @@ +package editor + +import ( + "fmt" + "strconv" + "strings" + + editoripc "github.com/kloudkit/ws-cli/internals/editor" + "github.com/spf13/cobra" +) + +var openCmd = &cobra.Command{ + Use: "open ", + Short: "Open a file in the editor", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + newWindow, _ := cmd.Flags().GetBool("new-window") + preview, _ := cmd.Flags().GetBool("preview") + selection, _ := cmd.Flags().GetString("selection") + + req := editoripc.OpenRequest{ + Path: args[0], + Window: "reuse", + Preview: preview, + } + + if newWindow { + req.Window = "new" + } + + if selection != "" { + parsed, err := parseSelection(selection) + if err != nil { + return err + } + + req.Selection = parsed + } + + return editoripc.Open(req) + }, +} + +func parseSelection(value string) (*editoripc.Range, error) { + start, end, found := strings.Cut(value, "-") + + from, err := parsePosition(start) + if err != nil { + return nil, err + } + + to := from + if found { + if to, err = parsePosition(end); err != nil { + return nil, err + } + } + + return &editoripc.Range{Start: from, End: to}, nil +} + +func parsePosition(value string) (editoripc.Position, error) { + rawLine, rawCol, ok := strings.Cut(value, ":") + line, errLine := strconv.Atoi(rawLine) + col, errCol := strconv.Atoi(rawCol) + + if !ok || errLine != nil || errCol != nil || line < 1 || col < 1 { + return editoripc.Position{}, fmt.Errorf( + "invalid selection %q (want LINE:COL[-LINE:COL], 1-based)", value, + ) + } + + return editoripc.Position{Line: line - 1, Character: col - 1}, nil +} + +func init() { + openCmd.Flags().Bool("reuse-window", false, "Open in the current window as a tab (default)") + openCmd.Flags().Bool("new-window", false, "Open in a new window") + openCmd.Flags().Bool("preview", false, "Open as a preview tab (reuse-window only)") + openCmd.Flags().String("selection", "", "Select a range: LINE:COL[-LINE:COL] (1-based)") + + openCmd.MarkFlagsMutuallyExclusive("reuse-window", "new-window") + + EditorCmd.AddCommand(openCmd) +} diff --git a/cmd/editor/selection.go b/cmd/editor/selection.go new file mode 100644 index 0000000..5151c0d --- /dev/null +++ b/cmd/editor/selection.go @@ -0,0 +1,53 @@ +package editor + +import ( + "encoding/json" + "fmt" + "io" + + editoripc "github.com/kloudkit/ws-cli/internals/editor" + "github.com/kloudkit/ws-cli/internals/styles" + "github.com/spf13/cobra" +) + +var selectionCmd = &cobra.Command{ + Use: "selection", + Short: "Show the active editor's current selection", + RunE: func(cmd *cobra.Command, args []string) error { + body, err := editoripc.FetchSelection() + if err != nil { + return err + } + + return emit(cmd, body, renderSelection) + }, +} + +func renderSelection(out io.Writer, body []byte) error { + if len(body) == 0 { + styles.PrintWarning(out, "No active selection") + return nil + } + + var selection editoripc.Selection + if err := json.Unmarshal(body, &selection); err != nil { + return fmt.Errorf("error parsing selection response: %w", err) + } + + location := fmt.Sprintf( + "%d:%d-%d:%d", + selection.Range.Start.Line+1, selection.Range.Start.Character+1, + selection.Range.End.Line+1, selection.Range.End.Character+1, + ) + + styles.PrintTitle(out, "Selection") + styles.PrintKeyValue(out, "Path", selection.Path) + styles.PrintKeyValue(out, "Range", location) + styles.PrintKeyCode(out, "Text", selection.Text) + + return nil +} + +func init() { + EditorCmd.AddCommand(selectionCmd) +} diff --git a/cmd/info/version.go b/cmd/info/version.go index d9d8ea0..ca2ecd6 100644 --- a/cmd/info/version.go +++ b/cmd/info/version.go @@ -1,3 +1,3 @@ package info -var Version = "0.0.65" +var Version = "0.0.66" diff --git a/cmd/root.go b/cmd/root.go index 3a9ad57..7a6d0a0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "charm.land/fang/v2" "github.com/kloudkit/ws-cli/cmd/clip" + "github.com/kloudkit/ws-cli/cmd/editor" "github.com/kloudkit/ws-cli/cmd/feature" "github.com/kloudkit/ws-cli/cmd/info" "github.com/kloudkit/ws-cli/cmd/log" @@ -26,11 +27,7 @@ var rootCmd = &cobra.Command{ Aliases: []string{"ws"}, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if err := config.RequireWorkspace(); err != nil { - return err - } - _, err := config.LoadEnvReference() - return err + return config.Bootstrap() }, } @@ -51,6 +48,7 @@ func Execute() { func init() { rootCmd.AddCommand( clip.ClipCmd, + editor.EditorCmd, feature.FeatureCmd, serve.ServeCmd, show.ShowCmd, diff --git a/internals/config/workspace.go b/internals/config/workspace.go index ce1ed65..42047bb 100644 --- a/internals/config/workspace.go +++ b/internals/config/workspace.go @@ -19,3 +19,13 @@ func RequireWorkspace() error { } return nil } + +func Bootstrap() error { + if err := RequireWorkspace(); err != nil { + return err + } + + _, err := LoadEnvReference() + + return err +} diff --git a/internals/editor/editor.go b/internals/editor/editor.go new file mode 100644 index 0000000..888a0d8 --- /dev/null +++ b/internals/editor/editor.go @@ -0,0 +1,156 @@ +package editor + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/kloudkit/ws-cli/internals/net" +) + +const base = "http://localhost" + +type Position struct { + Line int `json:"line"` + Character int `json:"character"` +} + +type Range struct { + Start Position `json:"start"` + End Position `json:"end"` +} + +type Severity struct { + Code int `json:"code"` + Label string `json:"label"` +} + +type Diagnostic struct { + Severity Severity `json:"severity"` + Message string `json:"message"` + Source string `json:"source"` + Code string `json:"code"` + Range Range `json:"range"` +} + +type DiagnosticFile struct { + URI string `json:"uri"` + Items []Diagnostic `json:"items"` +} + +type Tab struct { + Path string `json:"path"` + LanguageID *string `json:"languageId"` + Active bool `json:"active"` + Dirty bool `json:"dirty"` +} + +type Selection struct { + Path string `json:"path"` + Text string `json:"text"` + Range Range `json:"range"` +} + +type OpenRequest struct { + Path string `json:"path"` + Window string `json:"window"` + Preview bool `json:"preview,omitempty"` + Selection *Range `json:"selection,omitempty"` +} + +func FetchDiagnostics(uri string) ([]byte, error) { + query := url.Values{} + if uri != "" { + query.Set("uri", uri) + } + + return read("/diagnostics", query) +} + +func FetchEditors() ([]byte, error) { + return read("/editors", nil) +} + +func FetchSelection() ([]byte, error) { + return read("/selection", nil) +} + +func Open(req OpenRequest) error { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("error encoding open request: %w", err) + } + + resp, err := request(http.MethodPost, base+"/open", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkStatus(resp) +} + +func read(path string, query url.Values) ([]byte, error) { + target := base + path + if len(query) > 0 { + target += "?" + query.Encode() + } + + resp, err := request(http.MethodGet, target, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent { + return nil, nil + } + + if err := checkStatus(resp); err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading editor response: %w", err) + } + + return body, nil +} + +func request(method, target string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(method, target, body) + if err != nil { + return nil, err + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := net.GetIPCClient().Do(req) + if err != nil { + return nil, errors.New("cannot reach the workspace editor — is an editor session open?") + } + + return resp, nil +} + +func checkStatus(resp *http.Response) error { + if resp.StatusCode < http.StatusMultipleChoices { + return nil + } + + body, _ := io.ReadAll(resp.Body) + message := strings.TrimSpace(string(body)) + if message == "" { + message = resp.Status + } + + return fmt.Errorf("workspace editor returned %d: %s", resp.StatusCode, message) +} diff --git a/internals/editor/editor_test.go b/internals/editor/editor_test.go new file mode 100644 index 0000000..a1aa50a --- /dev/null +++ b/internals/editor/editor_test.go @@ -0,0 +1,173 @@ +package editor_test + +import ( + "bytes" + "encoding/json" + "net" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/kloudkit/ws-cli/internals/config" + "github.com/kloudkit/ws-cli/internals/editor" + "gotest.tools/v3/assert" +) + +func startStub(t *testing.T, handler http.Handler) { + t.Helper() + + socket := filepath.Join(t.TempDir(), "ws-ipc.sock") + t.Setenv(config.EnvIPCSocket, socket) + + listener, err := net.Listen("unix", socket) + assert.NilError(t, err) + + server := &http.Server{Handler: handler} + go func() { _ = server.Serve(listener) }() + + t.Cleanup(func() { _ = server.Close() }) +} + +func fixture(t *testing.T, name string) []byte { + t.Helper() + + data, err := os.ReadFile(filepath.Join("testdata", name)) + assert.NilError(t, err) + + return data +} + +func TestFetchDiagnosticsForwardsURI(t *testing.T) { + body := fixture(t, "diagnostics.json") + + startStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet) + assert.Equal(t, r.URL.Path, "/diagnostics") + assert.Equal(t, r.URL.Query().Get("uri"), "file:///workspace/main.go") + _, _ = w.Write(body) + })) + + got, err := editor.FetchDiagnostics("file:///workspace/main.go") + assert.NilError(t, err) + assert.DeepEqual(t, got, body) +} + +func TestFetchDiagnosticsWithoutURIOmitsQuery(t *testing.T) { + startStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.URL.RawQuery, "") + _, _ = w.Write([]byte("[]")) + })) + + got, err := editor.FetchDiagnostics("") + assert.NilError(t, err) + assert.Equal(t, string(got), "[]") +} + +func TestFetchEditors(t *testing.T) { + body := fixture(t, "editors.json") + + startStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.URL.Path, "/editors") + _, _ = w.Write(body) + })) + + got, err := editor.FetchEditors() + assert.NilError(t, err) + assert.DeepEqual(t, got, body) +} + +func TestFetchSelection(t *testing.T) { + body := fixture(t, "selection.json") + + startStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(body) + })) + + got, err := editor.FetchSelection() + assert.NilError(t, err) + assert.DeepEqual(t, got, body) +} + +func TestFetchSelectionNoContentIsNil(t *testing.T) { + startStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + got, err := editor.FetchSelection() + assert.NilError(t, err) + assert.Equal(t, len(got), 0) +} + +func TestOpenSendsRequestBody(t *testing.T) { + var got editor.OpenRequest + + startStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodPost) + assert.Equal(t, r.URL.Path, "/open") + assert.NilError(t, json.NewDecoder(r.Body).Decode(&got)) + _, _ = w.Write(fixture(t, "open.json")) + })) + + err := editor.Open(editor.OpenRequest{ + Path: "/workspace/main.go", + Window: "new", + Selection: &editor.Range{End: editor.Position{Line: 2, Character: 5}}, + }) + assert.NilError(t, err) + + assert.Equal(t, got.Path, "/workspace/main.go") + assert.Equal(t, got.Window, "new") + assert.Assert(t, got.Selection != nil) + assert.Equal(t, got.Selection.End.Line, 2) +} + +func TestErrorResponseSurfacesBody(t *testing.T) { + startStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("kernel exploded")) + })) + + _, err := editor.FetchEditors() + assert.ErrorContains(t, err, "500") + assert.ErrorContains(t, err, "kernel exploded") +} + +func TestServerDownIsFriendly(t *testing.T) { + t.Setenv(config.EnvIPCSocket, filepath.Join(t.TempDir(), "absent.sock")) + + _, err := editor.FetchEditors() + assert.ErrorContains(t, err, "workspace editor") +} + +func TestFixturesMatchContract(t *testing.T) { + t.Run("diagnostics", func(t *testing.T) { + var v []editor.DiagnosticFile + decodeStrict(t, "diagnostics.json", &v) + }) + + t.Run("editors", func(t *testing.T) { + var v []editor.Tab + decodeStrict(t, "editors.json", &v) + }) + + t.Run("selection", func(t *testing.T) { + var v editor.Selection + decodeStrict(t, "selection.json", &v) + }) + + t.Run("open", func(t *testing.T) { + var v struct { + Opened bool `json:"opened"` + } + decodeStrict(t, "open.json", &v) + }) +} + +func decodeStrict(t *testing.T, name string, target any) { + t.Helper() + + decoder := json.NewDecoder(bytes.NewReader(fixture(t, name))) + decoder.DisallowUnknownFields() + assert.NilError(t, decoder.Decode(target)) +} diff --git a/internals/editor/testdata/diagnostics.json b/internals/editor/testdata/diagnostics.json new file mode 100644 index 0000000..f90d808 --- /dev/null +++ b/internals/editor/testdata/diagnostics.json @@ -0,0 +1,17 @@ +[ + { + "uri": "file:///workspace/main.go", + "items": [ + { + "severity": { "code": 0, "label": "Error" }, + "message": "undefined: foo", + "source": "compiler", + "code": "UndeclaredName", + "range": { + "start": { "line": 10, "character": 4 }, + "end": { "line": 10, "character": 7 } + } + } + ] + } +] diff --git a/internals/editor/testdata/editors.json b/internals/editor/testdata/editors.json new file mode 100644 index 0000000..6009a66 --- /dev/null +++ b/internals/editor/testdata/editors.json @@ -0,0 +1,4 @@ +[ + { "path": "/workspace/main.go", "languageId": "go", "active": true, "dirty": false }, + { "path": "/workspace/README.md", "languageId": null, "active": false, "dirty": true } +] diff --git a/internals/editor/testdata/open.json b/internals/editor/testdata/open.json new file mode 100644 index 0000000..26ada47 --- /dev/null +++ b/internals/editor/testdata/open.json @@ -0,0 +1 @@ +{ "opened": true } diff --git a/internals/editor/testdata/selection.json b/internals/editor/testdata/selection.json new file mode 100644 index 0000000..9374fb7 --- /dev/null +++ b/internals/editor/testdata/selection.json @@ -0,0 +1,8 @@ +{ + "path": "/workspace/main.go", + "text": "package main", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 12 } + } +} diff --git a/internals/env/env.go b/internals/env/env.go index 8aa3d70..ff939cc 100644 --- a/internals/env/env.go +++ b/internals/env/env.go @@ -54,3 +54,13 @@ func IsValidName(name string) bool { func Home() string { return String("HOME", "/home/kloud") } + +func IsSSHSession() bool { + for _, key := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { + if String(key) != "" { + return true + } + } + + return false +} diff --git a/internals/env/env_test.go b/internals/env/env_test.go index 9bef021..128bacb 100644 --- a/internals/env/env_test.go +++ b/internals/env/env_test.go @@ -42,6 +42,34 @@ func TestMustString(t *testing.T) { }) } +func TestIsSSHSession(t *testing.T) { + clear := func(t *testing.T) { + for _, key := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { + t.Setenv(key, "") + } + } + + t.Run("NotSSH", func(t *testing.T) { + clear(t) + + assert.Assert(t, !IsSSHSession()) + }) + + t.Run("SSHConnection", func(t *testing.T) { + clear(t) + t.Setenv("SSH_CONNECTION", "1.2.3.4 51000 5.6.7.8 22") + + assert.Assert(t, IsSSHSession()) + }) + + t.Run("SSHTTY", func(t *testing.T) { + clear(t) + t.Setenv("SSH_TTY", "/dev/pts/0") + + assert.Assert(t, IsSSHSession()) + }) +} + func TestGetAll(t *testing.T) { t.Run("ReturnsAllEnvVars", func(t *testing.T) { t.Setenv("TEST_KEY1", "value1")