This commit is contained in:
2026-02-15 15:55:49 +08:00
commit 23decb8687
32 changed files with 3822 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(npm create:*)",
"Bash(yes:*)",
"Bash(npx --yes create-vite@latest:*)",
"Bash(npm install)",
"Bash(npx vue-tsc:*)",
"Bash(curl:*)",
"Bash(pkill:*)",
"Bash(npx vite build)"
]
}
}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

51
AGENTS.md Normal file
View File

@@ -0,0 +1,51 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is a Vite + Vue 3 + TypeScript frontend app.
- `src/`: application source code
- `src/components/`: Vue UI components (for example, `MonacoEditor.vue`, `ThemeToggle.vue`)
- `src/api/`: HTTP client logic (`completion.ts`)
- `src/types/`: shared TypeScript interfaces for API payloads
- `public/`: static assets served as-is
- `dist/`: production build output (generated)
- `backend/`: placeholder folder; no active backend code is currently committed
Use the `@` alias for imports from `src` (configured in `vite.config.ts` and `tsconfig.app.json`).
## Build, Test, and Development Commands
- `npm install`: install dependencies.
- `npm run dev`: start local development server with HMR.
- `npm run build`: run type checks (`vue-tsc -b`) and build production assets.
- `npm run preview`: serve the built app locally for verification.
Current package scripts do not include linting or unit test commands.
## Coding Style & Naming Conventions
- Use Vue 3 Composition API with `<script setup lang="ts">`.
- Use TypeScript strict mode patterns; avoid `any` unless justified.
- Use 2-space indentation and single quotes, matching existing files.
- Name components in PascalCase (e.g., `MonacoEditor.vue`).
- Name composables/helpers and API modules in camelCase (e.g., `fetchCompletions`).
- Keep shared interfaces in `src/types/*` and colocate feature-specific logic where used.
## Testing Guidelines
No automated test framework is configured yet in `package.json`.
When adding tests, prefer Vitest + Vue Test Utils and place files as:
- `src/**/__tests__/*.spec.ts`
Minimum verification for every change:
1. `npm run build` succeeds.
2. `npm run dev` runs without console/runtime errors for the changed flow.
## Commit & Pull Request Guidelines
Git history is not available in this workspace snapshot, so no existing commit pattern can be inferred. Use Conventional Commits going forward:
- `feat: add completion request timeout`
- `fix: handle empty completion response`
PRs should include:
1. Clear summary of user-visible and technical changes.
2. Linked issue/task ID (if applicable).
3. Validation steps and commands run.
4. Screenshots or short recordings for UI changes.

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(gopls version:*)",
"Bash(go version:*)",
"Bash(go env:*)",
"Bash(gopls help:*)",
"WebSearch",
"WebFetch(domain:microsoft.github.io)",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)"
]
}
}

88
backend/README.md Normal file
View File

@@ -0,0 +1,88 @@
# Monaco Go Completion Backend
Gin + gopls(JSON-RPC/LSP over stdio) 实现的 Go 代码补全后端。
## 运行
```bash
go mod tidy
go run ./cmd/server
```
默认监听 `http://localhost:8080`
## 环境变量
- `PORT`HTTP 端口,默认 `8080`
- `GOPLS_PATH``gopls` 可执行文件路径,默认 `gopls`
- `WORKSPACE_DIR`gopls 工作目录,默认当前目录
- `CORS_ALLOW_ORIGIN`CORS 允许来源,默认 `*`
## API
### 健康检查
- `GET /health`
### Go 补全
- `POST /api/v1/completions/go`
- `Content-Type: application/json`
### Go 补全WebSocket
- `GET /ws/completions/go`
- 客户端发送:
```json
{
"id": "1",
"uri": "file:///main.go",
"text": "package main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Pri\n}",
"line": 5,
"character": 11
}
```
- 服务端返回:
```json
{
"id": "1",
"items": [{ "label": "Println" }],
"isIncomplete": false
}
```
请求体:
```json
{
"uri": "file:///C:/Users/meowr/Desktop/bishe/monica_editor_with_code_completion/backend/playground.go",
"text": "package main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Pri\n}",
"line": 5,
"character": 11
}
```
说明:
- `uri` 建议使用绝对 `file://` URI`WORKSPACE_DIR` 在同一工作区最稳定)。
- 也支持 `file:///main.go` 这类相对根路径 URI后端会自动映射到 `WORKSPACE_DIR` 下。
- `line` / `character` 为 0-based。
- 每次请求都要传前端当前全文 `text`
响应体:
```json
{
"items": [
{
"label": "Println",
"kind": 3,
"detail": "func(a ...any) (n int, err error)",
"documentation": "Println formats using the default formats..."
}
],
"isIncomplete": false
}
```

105
backend/cmd/server/main.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"monica-go-completion-backend/internal/api"
"monica-go-completion-backend/internal/completion"
"monica-go-completion-backend/internal/lsp"
)
type config struct {
Port string
GoplsPath string
WorkspaceDir string
AllowOrigin string
}
func main() {
cfg := loadConfig()
lspClient, err := lsp.NewClient(context.Background(), cfg.GoplsPath, cfg.WorkspaceDir)
if err != nil {
log.Fatalf("create gopls client failed: %v", err)
}
defer func() {
_ = lspClient.Close()
}()
completionService := completion.NewService(lspClient)
router := gin.New()
router.Use(gin.Logger())
router.Use(gin.Recovery())
router.Use(corsMiddleware(cfg.AllowOrigin))
api.RegisterRoutes(router, completionService)
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("completion backend listening on :%s", cfg.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server failed: %v", err)
}
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown failed: %v", err)
}
}
func loadConfig() config {
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
return config{
Port: getenv("PORT", "8080"),
GoplsPath: getenv("GOPLS_PATH", "gopls"),
WorkspaceDir: getenv("WORKSPACE_DIR", cwd),
AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"),
}
}
func getenv(key, fallback string) string {
v := os.Getenv(key)
if v == "" {
return fallback
}
return v
}
func corsMiddleware(allowOrigin string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", allowOrigin)
c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

42
backend/go.mod Normal file
View File

@@ -0,0 +1,42 @@
module monica-go-completion-backend
go 1.25
require (
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

90
backend/go.sum Normal file
View File

@@ -0,0 +1,90 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,43 @@
package api
import (
"context"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"monica-go-completion-backend/internal/completion"
)
type CompletionService interface {
Complete(ctx context.Context, req completion.Request) (completion.Response, error)
}
func RegisterRoutes(router *gin.Engine, service CompletionService) {
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
registerWSRoutes(router, service)
router.POST("/api/v1/completions/go", func(c *gin.Context) {
var req completion.Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"})
return
}
resp, err := service.Complete(c.Request.Context(), req)
if err != nil {
if errors.Is(err, completion.ErrInvalidRequest) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "completion failed"})
return
}
c.JSON(http.StatusOK, resp)
})
}

View File

@@ -0,0 +1,171 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"monica-go-completion-backend/internal/completion"
)
type fakeCompletionService struct {
resp completion.Response
err error
}
func (f *fakeCompletionService) Complete(_ context.Context, _ completion.Request) (completion.Response, error) {
if f.err != nil {
return completion.Response{}, f.err
}
return f.resp, nil
}
func TestRegisterRoutesCompletionSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r, &fakeCompletionService{
resp: completion.Response{Items: []completion.Item{{Label: "Println"}}, IsIncomplete: true},
})
body, _ := json.Marshal(map[string]any{
"uri": "file:///main.go",
"text": "package main",
"line": 0,
"character": 0,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var got struct {
Items []completion.Item `json:"items"`
IsIncomplete bool `json:"isIncomplete"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(got.Items) != 1 || got.Items[0].Label != "Println" || !got.IsIncomplete {
t.Fatalf("unexpected response body: %s", w.Body.String())
}
}
func TestRegisterRoutesCompletionBadJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r, &fakeCompletionService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader([]byte("not-json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestRegisterRoutesCompletionValidationError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
rv := &fakeCompletionService{err: completion.ErrInvalidRequest}
RegisterRoutes(r, rv)
body, _ := json.Marshal(map[string]any{
"uri": "",
"text": "",
"line": -1,
"character": 0,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestRegisterRoutesCompletionServerError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r, &fakeCompletionService{err: errors.New("boom")})
body, _ := json.Marshal(map[string]any{
"uri": "file:///main.go",
"text": "package main",
"line": 0,
"character": 0,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
}
func TestRegisterRoutesCompletionWebSocketSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r, &fakeCompletionService{
resp: completion.Response{Items: []completion.Item{{Label: "Println"}}, IsIncomplete: false},
})
srv := httptest.NewServer(r)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/completions/go"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("websocket dial failed: %v", err)
}
defer conn.Close()
req := map[string]any{
"id": "1",
"uri": "file:///main.go",
"text": "package main",
"line": 0,
"character": 0,
}
if err := conn.WriteJSON(req); err != nil {
t.Fatalf("WriteJSON() failed: %v", err)
}
var resp struct {
ID string `json:"id"`
Items []completion.Item `json:"items"`
IsIncomplete bool `json:"isIncomplete"`
Error string `json:"error"`
}
if err := conn.ReadJSON(&resp); err != nil {
t.Fatalf("ReadJSON() failed: %v", err)
}
if resp.Error != "" {
t.Fatalf("unexpected websocket error: %s", resp.Error)
}
if resp.ID != "1" {
t.Fatalf("unexpected id: %s", resp.ID)
}
if len(resp.Items) != 1 || resp.Items[0].Label != "Println" {
t.Fatalf("unexpected items: %+v", resp.Items)
}
}

View File

@@ -0,0 +1,101 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"monica-go-completion-backend/internal/completion"
)
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(_ *http.Request) bool {
return true
},
}
type wsCompletionRequest struct {
ID string `json:"id"`
URI string `json:"uri"`
Text string `json:"text"`
Line int `json:"line"`
Character int `json:"character"`
}
type wsCompletionResponse struct {
ID string `json:"id"`
Items []completion.Item `json:"items,omitempty"`
IsIncomplete bool `json:"isIncomplete,omitempty"`
Error string `json:"error,omitempty"`
}
func registerWSRoutes(router *gin.Engine, service CompletionService) {
router.GET("/ws/completions/go", func(c *gin.Context) {
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
var writeMu sync.Mutex
for {
_, payload, err := conn.ReadMessage()
if err != nil {
break
}
var req wsCompletionRequest
if err := json.Unmarshal(payload, &req); err != nil {
sendWSResponse(conn, &writeMu, wsCompletionResponse{
ID: "",
Error: "invalid JSON payload",
})
continue
}
go func(r wsCompletionRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := service.Complete(ctx, completion.Request{
URI: r.URI,
Text: r.Text,
Line: r.Line,
Character: r.Character,
})
if err != nil {
msg := "completion failed"
if errors.Is(err, completion.ErrInvalidRequest) {
msg = err.Error()
}
sendWSResponse(conn, &writeMu, wsCompletionResponse{
ID: r.ID,
Error: msg,
})
return
}
sendWSResponse(conn, &writeMu, wsCompletionResponse{
ID: r.ID,
Items: resp.Items,
IsIncomplete: resp.IsIncomplete,
})
}(req)
}
})
}
func sendWSResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsCompletionResponse) {
writeMu.Lock()
defer writeMu.Unlock()
_ = conn.WriteJSON(resp)
}

View File

@@ -0,0 +1,95 @@
package completion
import (
"context"
"errors"
"fmt"
"sync"
)
var ErrInvalidRequest = errors.New("invalid completion request")
type Request struct {
URI string `json:"uri"`
Text string `json:"text"`
Line int `json:"line"`
Character int `json:"character"`
}
type Item struct {
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Detail string `json:"detail,omitempty"`
Documentation string `json:"documentation,omitempty"`
InsertText string `json:"insertText,omitempty"`
SortText string `json:"sortText,omitempty"`
FilterText string `json:"filterText,omitempty"`
}
type Response struct {
Items []Item `json:"items"`
IsIncomplete bool `json:"isIncomplete"`
}
type Client interface {
DidOpen(ctx context.Context, uri, text string, version int) error
DidChange(ctx context.Context, uri, text string, version int) error
Completion(ctx context.Context, uri string, line, character int) (Response, error)
}
type documentState struct {
version int
}
type Service struct {
client Client
mu sync.Mutex
documents map[string]*documentState
}
func NewService(client Client) *Service {
return &Service{
client: client,
documents: make(map[string]*documentState),
}
}
func (s *Service) Complete(ctx context.Context, req Request) (Response, error) {
if err := validateRequest(req); err != nil {
return Response{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
state, opened := s.documents[req.URI]
if !opened {
if err := s.client.DidOpen(ctx, req.URI, req.Text, 1); err != nil {
return Response{}, fmt.Errorf("didOpen failed: %w", err)
}
s.documents[req.URI] = &documentState{version: 1}
} else {
nextVersion := state.version + 1
if err := s.client.DidChange(ctx, req.URI, req.Text, nextVersion); err != nil {
return Response{}, fmt.Errorf("didChange failed: %w", err)
}
state.version = nextVersion
}
resp, err := s.client.Completion(ctx, req.URI, req.Line, req.Character)
if err != nil {
return Response{}, fmt.Errorf("completion failed: %w", err)
}
return resp, nil
}
func validateRequest(req Request) error {
if req.URI == "" {
return ErrInvalidRequest
}
if req.Line < 0 || req.Character < 0 {
return ErrInvalidRequest
}
return nil
}

View File

@@ -0,0 +1,144 @@
package completion
import (
"context"
"errors"
"testing"
)
type fakeLSPClient struct {
openCalls []openCall
changeCalls []changeCall
completionCalls []completionCall
completionResp Response
openErr error
changeErr error
completionErr error
}
type openCall struct {
uri string
text string
version int
}
type changeCall struct {
uri string
text string
version int
}
type completionCall struct {
uri string
line int
character int
}
func (f *fakeLSPClient) DidOpen(_ context.Context, uri, text string, version int) error {
f.openCalls = append(f.openCalls, openCall{uri: uri, text: text, version: version})
return f.openErr
}
func (f *fakeLSPClient) DidChange(_ context.Context, uri, text string, version int) error {
f.changeCalls = append(f.changeCalls, changeCall{uri: uri, text: text, version: version})
return f.changeErr
}
func (f *fakeLSPClient) Completion(_ context.Context, uri string, line, character int) (Response, error) {
f.completionCalls = append(f.completionCalls, completionCall{uri: uri, line: line, character: character})
if f.completionErr != nil {
return Response{}, f.completionErr
}
return f.completionResp, nil
}
func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) {
fake := &fakeLSPClient{
completionResp: Response{Items: []Item{{Label: "Println"}}, IsIncomplete: true},
}
svc := NewService(fake)
resp, err := svc.Complete(context.Background(), Request{
URI: "file:///main.go",
Text: "package main\nfunc main() { fmt.Pr }",
Line: 1,
Character: 23,
})
if err != nil {
t.Fatalf("Complete() error = %v", err)
}
if len(fake.openCalls) != 1 {
t.Fatalf("expected 1 didOpen call, got %d", len(fake.openCalls))
}
if fake.openCalls[0].version != 1 {
t.Fatalf("expected didOpen version=1, got %d", fake.openCalls[0].version)
}
if len(fake.changeCalls) != 0 {
t.Fatalf("expected 0 didChange calls, got %d", len(fake.changeCalls))
}
if len(fake.completionCalls) != 1 {
t.Fatalf("expected 1 completion call, got %d", len(fake.completionCalls))
}
if !resp.IsIncomplete || len(resp.Items) != 1 || resp.Items[0].Label != "Println" {
t.Fatalf("unexpected response: %+v", resp)
}
}
func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) {
fake := &fakeLSPClient{}
svc := NewService(fake)
_, err := svc.Complete(context.Background(), Request{
URI: "file:///main.go",
Text: "package main\nfunc main() {}",
Line: 1,
Character: 12,
})
if err != nil {
t.Fatalf("first Complete() error = %v", err)
}
_, err = svc.Complete(context.Background(), Request{
URI: "file:///main.go",
Text: "package main\nfunc main() { fmt.Pr }",
Line: 1,
Character: 23,
})
if err != nil {
t.Fatalf("second Complete() error = %v", err)
}
if len(fake.openCalls) != 1 {
t.Fatalf("expected 1 didOpen call, got %d", len(fake.openCalls))
}
if len(fake.changeCalls) != 1 {
t.Fatalf("expected 1 didChange call, got %d", len(fake.changeCalls))
}
if fake.changeCalls[0].version != 2 {
t.Fatalf("expected didChange version=2, got %d", fake.changeCalls[0].version)
}
}
func TestServiceCompleteValidatesRequest(t *testing.T) {
svc := NewService(&fakeLSPClient{})
_, err := svc.Complete(context.Background(), Request{URI: "", Text: "", Line: -1, Character: 0})
if !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("expected ErrInvalidRequest, got %v", err)
}
}
func TestServiceCompleteReturnsClientError(t *testing.T) {
fake := &fakeLSPClient{openErr: errors.New("open failed")}
svc := NewService(fake)
_, err := svc.Complete(context.Background(), Request{
URI: "file:///main.go",
Text: "package main",
Line: 0,
Character: 0,
})
if err == nil {
t.Fatal("expected error, got nil")
}
}

View File

@@ -0,0 +1,552 @@
package lsp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"monica-go-completion-backend/internal/completion"
)
var errClientClosed = errors.New("lsp client closed")
type Client struct {
cmd *exec.Cmd
stdin io.WriteCloser
writeMu sync.Mutex
nextID atomic.Int64
workspaceDir string
pendingMu sync.Mutex
pending map[string]chan rpcResponse
exitCh chan error
closeOnce sync.Once
closed atomic.Bool
}
type rpcResponse struct {
result json.RawMessage
err error
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
type incomingEnvelope struct {
ID *json.RawMessage `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type lspCompletionItem struct {
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Detail string `json:"detail,omitempty"`
InsertText string `json:"insertText,omitempty"`
SortText string `json:"sortText,omitempty"`
FilterText string `json:"filterText,omitempty"`
Documentation json.RawMessage `json:"documentation,omitempty"`
}
type lspCompletionList struct {
IsIncomplete bool `json:"isIncomplete"`
Items []lspCompletionItem `json:"items"`
}
func NewClient(parent context.Context, goplsPath, rootPath string) (*Client, error) {
if goplsPath == "" {
goplsPath = "gopls"
}
if rootPath == "" {
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("get working directory: %w", err)
}
rootPath = cwd
}
cmd := exec.Command(goplsPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("create stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("create stdout pipe: %w", err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start gopls: %w", err)
}
client := &Client{
cmd: cmd,
stdin: stdin,
workspaceDir: rootPath,
pending: make(map[string]chan rpcResponse),
exitCh: make(chan error, 1),
}
go func() {
client.exitCh <- cmd.Wait()
}()
go client.readLoop(stdout)
initCtx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
if err := client.initialize(initCtx, rootPath); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
func (c *Client) initialize(ctx context.Context, rootPath string) error {
rootURI, err := pathToURI(rootPath)
if err != nil {
return fmt.Errorf("build root uri: %w", err)
}
params := map[string]any{
"processId": os.Getpid(),
"rootUri": rootURI,
"capabilities": map[string]any{
"textDocument": map[string]any{
"completion": map[string]any{
"completionItem": map[string]any{
"snippetSupport": true,
},
},
},
},
"workspaceFolders": []map[string]string{
{
"uri": rootURI,
"name": filepath.Base(rootPath),
},
},
"clientInfo": map[string]string{
"name": "monica-go-completion-backend",
"version": "0.1.0",
},
}
var initResult json.RawMessage
if err := c.request(ctx, "initialize", params, &initResult); err != nil {
return fmt.Errorf("initialize request failed: %w", err)
}
if err := c.notify("initialized", map[string]any{}); err != nil {
return fmt.Errorf("initialized notification failed: %w", err)
}
return nil
}
func (c *Client) DidOpen(ctx context.Context, uri, text string, version int) error {
normalizedURI, err := c.normalizeURI(uri)
if err != nil {
return err
}
params := map[string]any{
"textDocument": map[string]any{
"uri": normalizedURI,
"languageId": "go",
"version": version,
"text": text,
},
}
return c.notifyWithContext(ctx, "textDocument/didOpen", params)
}
func (c *Client) DidChange(ctx context.Context, uri, text string, version int) error {
normalizedURI, err := c.normalizeURI(uri)
if err != nil {
return err
}
params := map[string]any{
"textDocument": map[string]any{
"uri": normalizedURI,
"version": version,
},
"contentChanges": []map[string]string{
{
"text": text,
},
},
}
return c.notifyWithContext(ctx, "textDocument/didChange", params)
}
func (c *Client) Completion(ctx context.Context, uri string, line, character int) (completion.Response, error) {
normalizedURI, err := c.normalizeURI(uri)
if err != nil {
return completion.Response{}, err
}
params := map[string]any{
"textDocument": map[string]string{
"uri": normalizedURI,
},
"position": map[string]int{
"line": line,
"character": character,
},
}
var raw json.RawMessage
if err := c.request(ctx, "textDocument/completion", params, &raw); err != nil {
return completion.Response{}, err
}
body := bytes.TrimSpace(raw)
if len(body) == 0 || bytes.Equal(body, []byte("null")) {
return completion.Response{}, nil
}
if body[0] == '[' {
var items []lspCompletionItem
if err := json.Unmarshal(body, &items); err != nil {
return completion.Response{}, fmt.Errorf("decode completion items: %w", err)
}
return completion.Response{Items: mapCompletionItems(items)}, nil
}
var list lspCompletionList
if err := json.Unmarshal(body, &list); err != nil {
return completion.Response{}, fmt.Errorf("decode completion list: %w", err)
}
return completion.Response{
Items: mapCompletionItems(list.Items),
IsIncomplete: list.IsIncomplete,
}, nil
}
var windowsDrivePattern = regexp.MustCompile(`^[A-Za-z]:`)
func (c *Client) normalizeURI(rawURI string) (string, error) {
if rawURI == "" {
return "", errors.New("empty uri")
}
u, err := url.Parse(rawURI)
if err != nil {
return "", fmt.Errorf("invalid uri: %w", err)
}
if u.Scheme != "file" {
return rawURI, nil
}
path := u.Path
if path == "" {
return rawURI, nil
}
var localPath string
trimmed := strings.TrimPrefix(path, "/")
if windowsDrivePattern.MatchString(trimmed) {
localPath = filepath.FromSlash(trimmed)
} else {
rel := strings.TrimLeft(trimmed, "/\\")
localPath = filepath.Join(c.workspaceDir, filepath.FromSlash(rel))
}
return pathToURI(localPath)
}
func (c *Client) Close() error {
c.closeOnce.Do(func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = c.request(shutdownCtx, "shutdown", map[string]any{}, nil)
_ = c.notify("exit", map[string]any{})
_ = c.stdin.Close()
select {
case <-c.exitCh:
case <-time.After(2 * time.Second):
if c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
}
<-c.exitCh
}
c.closed.Store(true)
c.failPending(errClientClosed)
})
return nil
}
func (c *Client) request(ctx context.Context, method string, params any, out any) error {
if c.closed.Load() {
return errClientClosed
}
id := c.nextID.Add(1)
key := strconv.FormatInt(id, 10)
wait := make(chan rpcResponse, 1)
c.pendingMu.Lock()
c.pending[key] = wait
c.pendingMu.Unlock()
msg := map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}
if err := c.writeMessage(msg); err != nil {
c.removePending(key)
return err
}
select {
case <-ctx.Done():
c.removePending(key)
return ctx.Err()
case resp := <-wait:
if resp.err != nil {
return resp.err
}
if out != nil && len(resp.result) > 0 && !bytes.Equal(bytes.TrimSpace(resp.result), []byte("null")) {
if err := json.Unmarshal(resp.result, out); err != nil {
return fmt.Errorf("decode response for %s: %w", method, err)
}
}
return nil
}
}
func (c *Client) notify(method string, params any) error {
return c.notifyWithContext(context.Background(), method, params)
}
func (c *Client) notifyWithContext(ctx context.Context, method string, params any) error {
if c.closed.Load() {
return errClientClosed
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
msg := map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
}
return c.writeMessage(msg)
}
func (c *Client) writeMessage(msg any) error {
payload, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal json-rpc message: %w", err)
}
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(payload))
c.writeMu.Lock()
defer c.writeMu.Unlock()
if _, err := io.WriteString(c.stdin, header); err != nil {
return fmt.Errorf("write lsp header: %w", err)
}
if _, err := c.stdin.Write(payload); err != nil {
return fmt.Errorf("write lsp payload: %w", err)
}
return nil
}
func (c *Client) readLoop(stdout io.Reader) {
reader := bufio.NewReader(stdout)
for {
body, err := readMessage(reader)
if err != nil {
c.failPending(fmt.Errorf("read lsp message: %w", err))
return
}
c.handleIncoming(body)
}
}
func readMessage(reader *bufio.Reader) ([]byte, error) {
contentLength := 0
for {
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
if strings.EqualFold(strings.TrimSpace(parts[0]), "Content-Length") {
n, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return nil, fmt.Errorf("invalid content-length %q: %w", parts[1], err)
}
contentLength = n
}
}
if contentLength <= 0 {
return nil, errors.New("missing content-length")
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(reader, body); err != nil {
return nil, err
}
return body, nil
}
func (c *Client) handleIncoming(body []byte) {
var envelope incomingEnvelope
if err := json.Unmarshal(body, &envelope); err != nil {
return
}
if envelope.ID == nil {
return
}
key := normalizeID(*envelope.ID)
if key == "" {
return
}
c.pendingMu.Lock()
wait := c.pending[key]
delete(c.pending, key)
c.pendingMu.Unlock()
if wait == nil {
return
}
if envelope.Error != nil {
wait <- rpcResponse{
err: fmt.Errorf("lsp error code=%d message=%s", envelope.Error.Code, envelope.Error.Message),
}
return
}
wait <- rpcResponse{result: envelope.Result}
}
func normalizeID(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return ""
}
if raw[0] == '"' {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
}
return string(raw)
}
func (c *Client) removePending(key string) {
c.pendingMu.Lock()
delete(c.pending, key)
c.pendingMu.Unlock()
}
func (c *Client) failPending(err error) {
c.pendingMu.Lock()
defer c.pendingMu.Unlock()
for key, wait := range c.pending {
delete(c.pending, key)
wait <- rpcResponse{err: err}
}
}
func mapCompletionItems(items []lspCompletionItem) []completion.Item {
out := make([]completion.Item, 0, len(items))
for _, it := range items {
out = append(out, completion.Item{
Label: it.Label,
Kind: it.Kind,
Detail: it.Detail,
Documentation: decodeDocumentation(it.Documentation),
InsertText: it.InsertText,
SortText: it.SortText,
FilterText: it.FilterText,
})
}
return out
}
func decodeDocumentation(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return ""
}
if raw[0] == '"' {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return ""
}
var markup struct {
Value string `json:"value"`
}
if err := json.Unmarshal(raw, &markup); err == nil && markup.Value != "" {
return markup.Value
}
return ""
}
func pathToURI(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
slashed := filepath.ToSlash(abs)
if !strings.HasPrefix(slashed, "/") {
slashed = "/" + slashed
}
u := url.URL{Scheme: "file", Path: slashed}
return u.String(), nil
}

View File

@@ -0,0 +1,49 @@
package lsp
import (
"path/filepath"
"testing"
)
func TestNormalizeURIRebasesRelativeFileURI(t *testing.T) {
workspace, err := filepath.Abs(filepath.Join("testdata", "ws"))
if err != nil {
t.Fatalf("filepath.Abs() error = %v", err)
}
client := &Client{workspaceDir: workspace}
got, err := client.normalizeURI("file:///main.go")
if err != nil {
t.Fatalf("normalizeURI() error = %v", err)
}
want, err := pathToURI(filepath.Join(workspace, "main.go"))
if err != nil {
t.Fatalf("pathToURI() error = %v", err)
}
if got != want {
t.Fatalf("normalizeURI() = %q, want %q", got, want)
}
}
func TestNormalizeURIKeepsAbsoluteFileURI(t *testing.T) {
workspace, err := filepath.Abs(filepath.Join("testdata", "ws"))
if err != nil {
t.Fatalf("filepath.Abs() error = %v", err)
}
client := &Client{workspaceDir: workspace}
absPath := filepath.Join(workspace, "demo.go")
uri, err := pathToURI(absPath)
if err != nil {
t.Fatalf("pathToURI() error = %v", err)
}
got, err := client.normalizeURI(uri)
if err != nil {
t.Fatalf("normalizeURI() error = %v", err)
}
if got != uri {
t.Fatalf("normalizeURI() = %q, want %q", got, uri)
}
}

7
env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Monica Editor</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1511
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "monica-editor",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"monaco-editor": "^0.52.2",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}

157
src/App.vue Normal file
View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import MonacoEditor from './components/MonacoEditor.vue'
import ThemeToggle from './components/ThemeToggle.vue'
interface LanguageOption {
label: string
value: string
}
const isDark = ref(true)
const theme = computed(() => (isDark.value ? 'vs-dark' : 'vs'))
const languageOptions: LanguageOption[] = [
{ label: 'Go', value: 'go' },
{ label: 'JavaScript', value: 'javascript' },
{ label: 'TypeScript', value: 'typescript' },
{ label: 'Python', value: 'python' },
{ label: 'Java', value: 'java' },
{ label: 'JSON', value: 'json' },
{ label: 'YAML', value: 'yaml' },
{ label: 'Markdown', value: 'markdown' },
{ label: 'HTML', value: 'html' },
{ label: 'CSS', value: 'css' },
{ label: 'SQL', value: 'sql' },
{ label: 'Shell', value: 'shell' },
]
const initialCodeByLanguage: Record<string, string> = {
go: ['package main', '', 'import "fmt"', '', 'func main() {', '\tfmt.Println("Hello, World!")', '}'].join('\n'),
javascript: ['function hello() {', ' console.log("Hello, World!")', '}', '', 'hello();'].join('\n'),
typescript: [
'function greet(name: string): void {',
' console.log(`Hello, ${name}`)',
'}',
'',
'greet("World")',
].join('\n'),
python: ['def greet(name: str) -> None:', ' print(f"Hello, {name}")', '', 'greet("World")'].join('\n'),
java: [
'public class Main {',
' public static void main(String[] args) {',
' System.out.println("Hello, World!");',
' }',
'}',
].join('\n'),
json: ['{', ' "name": "monica-editor",', ' "version": "1.0.0"', '}'].join('\n'),
yaml: ['name: monica-editor', 'version: 1.0.0', 'enabled: true'].join('\n'),
markdown: ['# Monica Editor', '', '- Multi-language highlighting', '- Go completion backend'].join('\n'),
html: ['<!doctype html>', '<html>', ' <body>', ' <h1>Hello, World!</h1>', ' </body>', '</html>'].join('\n'),
css: ['body {', ' margin: 0;', ' font-family: sans-serif;', '}'].join('\n'),
sql: ['SELECT id, name', 'FROM users', 'WHERE active = true', 'ORDER BY id DESC;'].join('\n'),
shell: ['#!/usr/bin/env bash', 'echo "Hello, World!"'].join('\n'),
}
const selectedLanguage = ref<string>('go')
const codeByLanguage = ref<Record<string, string>>({ ...initialCodeByLanguage })
const currentCode = computed<string>({
get() {
return codeByLanguage.value[selectedLanguage.value] ?? ''
},
set(value: string) {
codeByLanguage.value[selectedLanguage.value] = value
},
})
function toggleTheme() {
isDark.value = !isDark.value
}
</script>
<template>
<div class="app" :class="{ dark: isDark, light: !isDark }">
<header class="toolbar">
<span class="title">Monica Editor</span>
<div class="toolbar-controls">
<label class="language-label" for="language-select">Language</label>
<select id="language-select" v-model="selectedLanguage" class="language-select">
<option v-for="option in languageOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<ThemeToggle :isDark="isDark" @toggle="toggleTheme" />
</div>
</header>
<main class="editor-area">
<MonacoEditor v-model="currentCode" :language="selectedLanguage" :theme="theme" />
</main>
</div>
</template>
<style scoped>
.app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.app.dark {
background: #1e1e1e;
color: #cccccc;
}
.app.light {
background: #ffffff;
color: #333333;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
height: 48px;
flex-shrink: 0;
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
}
.toolbar-controls {
display: flex;
align-items: center;
gap: 10px;
}
.language-label {
font-size: 13px;
opacity: 0.8;
}
.language-select {
min-width: 148px;
height: 30px;
border-radius: 6px;
border: 1px solid rgba(128, 128, 128, 0.35);
padding: 0 10px;
background: transparent;
color: inherit;
outline: none;
}
.language-select:focus {
border-color: #2f81f7;
}
.title {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.5px;
}
.editor-area {
flex: 1;
overflow: hidden;
}
</style>

161
src/api/completion.ts Normal file
View File

@@ -0,0 +1,161 @@
import type { CompletionRequest, CompletionResponse } from '../types/completion'
const COMPLETION_API_URL =
import.meta.env.VITE_COMPLETION_API_URL ??
'http://127.0.0.1:8080/api/v1/completions/go'
const COMPLETION_WS_URL =
import.meta.env.VITE_COMPLETION_WS_URL ??
'ws://127.0.0.1:8080/ws/completions/go'
const WS_TIMEOUT_MS = 1800
interface WSCompletionResponse extends CompletionResponse {
id: string
error?: string
}
interface PendingRequest {
resolve: (value: CompletionResponse) => void
timer: number
}
let ws: WebSocket | null = null
let wsConnecting: Promise<WebSocket> | null = null
let nextRequestID = 0
const pending = new Map<string, PendingRequest>()
export async function fetchCompletions(
request: CompletionRequest,
): Promise<CompletionResponse> {
try {
const socket = await getWebSocket()
if (socket.readyState === WebSocket.OPEN) {
return await fetchCompletionsByWS(socket, request)
}
} catch {
// Fallback to HTTP below.
}
return await fetchCompletionsByHTTP(request)
}
async function fetchCompletionsByHTTP(
request: CompletionRequest,
): Promise<CompletionResponse> {
try {
const response = await fetch(COMPLETION_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
if (!response.ok) {
console.error('Completion API error:', response.status)
return { items: [], isIncomplete: false }
}
return (await response.json()) as CompletionResponse
} catch (error) {
console.error('Completion API request failed:', error)
return { items: [], isIncomplete: false }
}
}
async function fetchCompletionsByWS(
socket: WebSocket,
request: CompletionRequest,
): Promise<CompletionResponse> {
const id = String(++nextRequestID)
return await new Promise<CompletionResponse>((resolve) => {
const timer = window.setTimeout(async () => {
pending.delete(id)
resolve(await fetchCompletionsByHTTP(request))
}, WS_TIMEOUT_MS)
pending.set(id, { resolve, timer })
try {
socket.send(JSON.stringify({ id, ...request }))
} catch (error) {
window.clearTimeout(timer)
pending.delete(id)
console.error('Completion WS send failed:', error)
void fetchCompletionsByHTTP(request).then(resolve)
}
})
}
async function getWebSocket(): Promise<WebSocket> {
if (ws && ws.readyState === WebSocket.OPEN) {
return ws
}
if (wsConnecting) {
return await wsConnecting
}
wsConnecting = new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(COMPLETION_WS_URL)
let settled = false
socket.onopen = () => {
settled = true
ws = socket
wsConnecting = null
console.info('[completion] websocket connected:', COMPLETION_WS_URL)
resolve(socket)
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
let payload: WSCompletionResponse
try {
payload = JSON.parse(event.data) as WSCompletionResponse
} catch {
return
}
const entry = pending.get(payload.id)
if (!entry) return
window.clearTimeout(entry.timer)
pending.delete(payload.id)
if (payload.error) {
entry.resolve({ items: [], isIncomplete: false })
return
}
entry.resolve({
items: payload.items ?? [],
isIncomplete: payload.isIncomplete ?? false,
})
}
socket.onerror = () => {
if (settled) return
settled = true
wsConnecting = null
console.error('[completion] websocket connect failed:', COMPLETION_WS_URL)
reject(new Error('completion websocket connect failed'))
}
socket.onclose = () => {
console.warn('[completion] websocket closed')
ws = null
wsConnecting = null
flushPending()
}
})
return await wsConnecting
}
function flushPending() {
for (const [id, entry] of pending) {
window.clearTimeout(entry.timer)
pending.delete(id)
entry.resolve({ items: [], isIncomplete: false })
}
}

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from 'vue'
import * as monaco from 'monaco-editor'
import { fetchCompletions } from '../api/completion'
import type { CompletionItem } from '../types/completion'
const props = withDefaults(
defineProps<{
modelValue?: string
language?: string
theme?: string
}>(),
{
modelValue: '',
language: 'go',
theme: 'vs-dark',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editorContainer = ref<HTMLDivElement>()
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor>()
let completionDisposable: monaco.IDisposable | null = null
/** Map LSP CompletionItemKind to Monaco CompletionItemKind */
function mapKind(kind?: number): monaco.languages.CompletionItemKind {
switch (kind) {
case 2:
return monaco.languages.CompletionItemKind.Method
case 3:
return monaco.languages.CompletionItemKind.Function
case 4:
return monaco.languages.CompletionItemKind.Constructor
case 5:
return monaco.languages.CompletionItemKind.Field
case 6:
return monaco.languages.CompletionItemKind.Variable
case 7:
return monaco.languages.CompletionItemKind.Class
case 8:
return monaco.languages.CompletionItemKind.Interface
case 9:
return monaco.languages.CompletionItemKind.Module
case 10:
return monaco.languages.CompletionItemKind.Property
case 11:
return monaco.languages.CompletionItemKind.Unit
case 12:
return monaco.languages.CompletionItemKind.Value
case 13:
return monaco.languages.CompletionItemKind.Enum
case 14:
return monaco.languages.CompletionItemKind.Keyword
case 15:
return monaco.languages.CompletionItemKind.Snippet
case 16:
return monaco.languages.CompletionItemKind.Color
case 17:
return monaco.languages.CompletionItemKind.File
case 18:
return monaco.languages.CompletionItemKind.Reference
case 19:
return monaco.languages.CompletionItemKind.Folder
case 20:
return monaco.languages.CompletionItemKind.EnumMember
case 21:
return monaco.languages.CompletionItemKind.Constant
case 22:
return monaco.languages.CompletionItemKind.Struct
case 23:
return monaco.languages.CompletionItemKind.Event
case 24:
return monaco.languages.CompletionItemKind.Operator
case 25:
return monaco.languages.CompletionItemKind.TypeParameter
default:
return monaco.languages.CompletionItemKind.Text
}
}
function getDocumentURI(language: string): string {
const name = `main.${language}`
return `file:///${name}`
}
/** Register the code completion provider for the given language */
function registerCompletionProvider(language: string) {
completionDisposable?.dispose()
completionDisposable = null
// Currently only Go completion is wired to backend gopls.
if (language !== 'go') {
return
}
completionDisposable = monaco.languages.registerCompletionItemProvider(language, {
triggerCharacters: ['.', ':', '('],
async provideCompletionItems(model, position) {
if (model.getLanguageId() !== 'go') {
return { suggestions: [] }
}
const code = model.getValue()
const word = model.getWordUntilPosition(position)
const response = await fetchCompletions({
uri: getDocumentURI(language),
text: code,
line: Math.max(position.lineNumber - 1, 0),
character: Math.max(position.column - 1, 0),
})
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
)
const suggestions: monaco.languages.CompletionItem[] = response.items.map(
(item: CompletionItem) => ({
label: item.label,
kind: mapKind(item.kind),
insertText: item.insertText || item.label,
detail: item.detail,
documentation: item.documentation,
range,
}),
)
return { suggestions }
},
})
}
onMounted(() => {
if (!editorContainer.value) return
editor.value = monaco.editor.create(editorContainer.value, {
value: props.modelValue,
language: props.language,
theme: props.theme,
automaticLayout: true,
fontSize: 14,
lineNumbers: 'on',
minimap: { enabled: true },
scrollBeyondLastLine: false,
wordWrap: 'off',
tabSize: 4,
insertSpaces: false, // Go uses tabs
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
suggestOnTriggerCharacters: true,
quickSuggestions: true,
parameterHints: { enabled: true },
})
// Sync content changes back to parent
editor.value.onDidChangeModelContent(() => {
const value = editor.value!.getValue()
emit('update:modelValue', value)
})
// Register completion provider
registerCompletionProvider(props.language)
})
// Watch for theme changes
watch(
() => props.theme,
(newTheme) => {
monaco.editor.setTheme(newTheme)
},
)
// Watch for language changes
watch(
() => props.language,
(newLang) => {
const model = editor.value?.getModel()
if (model) {
monaco.editor.setModelLanguage(model, newLang)
}
registerCompletionProvider(newLang)
},
)
// Watch for external modelValue changes
watch(
() => props.modelValue,
(newValue) => {
if (editor.value && editor.value.getValue() !== newValue) {
editor.value.setValue(newValue)
}
},
)
onBeforeUnmount(() => {
completionDisposable?.dispose()
editor.value?.dispose()
})
</script>
<template>
<div ref="editorContainer" class="editor-container" />
</template>
<style scoped>
.editor-container {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
defineProps<{
isDark: boolean
}>()
const emit = defineEmits<{
toggle: []
}>()
</script>
<template>
<button class="theme-toggle" @click="emit('toggle')" :title="isDark ? 'Switch to light theme' : 'Switch to dark theme'">
<span v-if="isDark" class="icon">&#9728;</span>
<span v-else class="icon">&#9790;</span>
</button>
</template>
<style scoped>
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: transparent;
cursor: pointer;
transition: background 0.2s;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.icon {
font-size: 18px;
line-height: 1;
}
</style>

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
// Monaco Editor worker setup — must run before any monaco import
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
self.MonacoEnvironment = {
getWorker() {
return new editorWorker()
},
}
createApp(App).mount('#app')

19
src/style.css Normal file
View File

@@ -0,0 +1,19 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

30
src/types/completion.ts Normal file
View File

@@ -0,0 +1,30 @@
/** Types for the code completion API */
export interface CompletionRequest {
/** Document URI (file://...) */
uri: string
/** The full text content of the editor */
text: string
/** Cursor line number (0-based) */
line: number
/** Cursor character number (0-based) */
character: number
}
export interface CompletionItem {
/** The text shown in the completion list */
label: string
/** The text to insert when the completion is accepted */
insertText?: string
/** Optional detail text (e.g., type info) */
detail?: string
/** Optional documentation */
documentation?: string
/** LSP CompletionItemKind (number) */
kind?: number
}
export interface CompletionResponse {
items: CompletionItem[]
isIncomplete?: boolean
}

25
tsconfig.app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
}

1
tsconfig.app.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/main.ts","./src/api/completion.ts","./src/types/completion.ts","./src/app.vue","./src/components/monacoeditor.vue","./src/components/themetoggle.vue","./env.d.ts"],"version":"5.7.3"}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

19
tsconfig.node.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.7.3"}

19
vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/ws': {
target: 'ws://127.0.0.1:8080',
ws: true,
changeOrigin: true,
},
},
},
})