diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-14 00:16:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-14 00:16:49 +0300 |
| commit | 0098488f4c869c257ae30fe7dea9a5d8fce9894b (patch) | |
| tree | 1d50c91f331676b98a7a0b67b83c3b8ab91f1a42 | |
| parent | 5e02ad3bcfb643c44866f65d763d266b1d257e20 (diff) | |
feat(lsp): scaffold barebones LSP server with contextual completion; add Taskfile and AGENTS.md; enable -log context logging
| -rw-r--r-- | .gitignore | 17 | ||||
| -rw-r--r-- | AGENTS.md | 36 | ||||
| -rw-r--r-- | Taskfile.yaml | 44 | ||||
| -rw-r--r-- | go.mod | 4 | ||||
| -rw-r--r-- | internal/lsp/server.go | 361 | ||||
| -rw-r--r-- | internal/version.go | 4 |
6 files changed, 466 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..237ce54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Build artifacts and caches +bin/ +.gocache/ +.gomodcache/ + +# Local binaries and logs +hexai +*.log + +# OS/editor files +.DS_Store +Thumbs.db +*.swp +*.swo +.idea/ +.vscode/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b93bef8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Repository Guidelines + +This repository currently holds documentation and brand assets for HexAI. It is intentionally lightweight; additional modules and code may be added over time. The guidance below keeps contributions consistent and easy to review. + +## Project Structure & Module Organization +- `README.md`: Project overview and quick context. +- `IDEAS.md`: Working notes, concepts, and rough drafts. +- Images: `hexai.png`, `hexai-small.png` (place new images under `assets/` going forward, referenced with relative paths). +- Future code (if added): `src/` for implementation, `tests/` for test suites, `scripts/` for helper tools. + +## Build, Test, and Development Commands +- No build step required for docs-only changes. +- Preview Markdown: use your editor’s preview or `glow README.md`. +- Optional checks (if installed locally): + - `markdownlint **/*.md`: Lint Markdown formatting. + - `codespell`: Catch common typos. +- Optimize images before committing, e.g.: `pngquant --quality=70-85 input.png -o assets/input.png`. + +## Coding Style & Naming Conventions +- Markdown: ATX `#` headings, sentence-case titles, wrap lines near ~100 chars, use fenced code blocks and descriptive link text. +- Filenames: lowercase-with-dashes for docs (e.g., `design-notes.md`); images: kebab-case with size or purpose suffix (e.g., `hexai-small.png`). +- If/when code is added: follow language idioms, 2 spaces or 4 spaces consistently per language, avoid one-letter identifiers, and keep functions short and focused. + +## Testing Guidelines +- For now: validate links render and assets load; run a Markdown linter locally if available. +- When tests exist: place unit tests in `tests/` mirroring module paths; name tests `test_<module_or_feature>.ext`; target high-value paths first. + +## Commit & Pull Request Guidelines +- History is currently informal; adopt Conventional Commits (e.g., `feat:`, `fix:`, `docs:`) going forward. +- Commits: small, scoped, and imperative subject line (≤72 chars). +- PRs: clear description, link related issues, include before/after screenshots for visual or asset changes, and note any follow-ups. + +## Security & Asset Tips +- Do not commit secrets or credentials. +- Keep binary assets lean (<5 MB preferred); compress images and remove unused files. + diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..66e6796 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,44 @@ +version: '3' + +vars: + BIN_NAME: hexai + BIN_DIR: bin + BIN_PATH: "{{.BIN_DIR}}/{{.BIN_NAME}}" + +tasks: + build: + desc: Build the hexai LSP binary to ./bin + cmds: + - mkdir -p {{.BIN_DIR}} .gocache .gomodcache + - CGO_ENABLED=0 GOCACHE=$(pwd)/.gocache GOMODCACHE=$(pwd)/.gomodcache go build -o {{.BIN_PATH}} ./cmd/hexai + + install: + desc: Install the hexai LSP binary into your Go bin directory + cmds: + - mkdir -p .gocache .gomodcache + - CGO_ENABLED=0 GOCACHE=$(pwd)/.gocache GOMODCACHE=$(pwd)/.gomodcache go install ./cmd/hexai + - | + DEST="${GOBIN:-$(go env GOBIN)}" + if [ -z "$DEST" ]; then DEST="$(go env GOPATH)/bin"; fi + if [ -z "$DEST" ]; then DEST="$HOME/.local/bin"; fi + echo "Installed to: $DEST (ensure it is on your PATH)" + + install-local: + desc: Copy the built binary to ~/.local/bin (no go install) + deps: [build] + cmds: + - mkdir -p "$HOME/.local/bin" + - cp -f {{.BIN_PATH}} "$HOME/.local/bin/{{.BIN_NAME}}" + - echo "Installed to: $HOME/.local/bin (ensure it is on your PATH)" + + run: + desc: Build and run the server on stdio + deps: [build] + cmds: + - ./{{.BIN_PATH}} -stdio + + clean: + desc: Remove build artifacts and local Go caches + cmds: + - rm -rf {{.BIN_DIR}} .gocache .gomodcache + @@ -0,0 +1,4 @@ +module hexai + +go 1.21 + diff --git a/internal/lsp/server.go b/internal/lsp/server.go new file mode 100644 index 0000000..3949680 --- /dev/null +++ b/internal/lsp/server.go @@ -0,0 +1,361 @@ +package lsp + +import ( + "bufio" + "encoding/json" + "fmt" + "hexai/internal" + "io" + "log" + "net/textproto" + "os" + "strconv" + "strings" + "sync" +) + +// JSON-RPC 2.0 structures (minimal) +type Request struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type Response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *RespError `json:"error,omitempty"` +} + +type RespError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// LSP responses (subset) +type InitializeResult struct { + Capabilities ServerCapabilities `json:"capabilities"` + ServerInfo *ServerInfo `json:"serverInfo,omitempty"` +} + +type ServerInfo struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +type ServerCapabilities struct { + TextDocumentSync any `json:"textDocumentSync,omitempty"` + CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"` +} + +type CompletionOptions struct { + ResolveProvider bool `json:"resolveProvider,omitempty"` + TriggerCharacters []string `json:"triggerCharacters,omitempty"` +} + +type CompletionList struct { + IsIncomplete bool `json:"isIncomplete"` + Items []CompletionItem `json:"items"` +} + +type CompletionItem 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"` + Documentation string `json:"documentation,omitempty"` +} + +// Server implements a minimal LSP over stdio. +type Server struct { + in *bufio.Reader + out io.Writer + logger *log.Logger + exited bool + mu sync.RWMutex + docs map[string]*document + logContext bool +} + +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool) *Server { + return &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext} +} + +func (s *Server) Run() error { + for { + body, err := s.readMessage() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + var req Request + if err := json.Unmarshal(body, &req); err != nil { + s.logger.Printf("invalid JSON: %v", err) + continue + } + if req.Method == "" { + // A response from client; ignore + continue + } + go s.handle(req) + if s.exited { + return nil + } + } +} + +func (s *Server) handle(req Request) { + switch req.Method { + case "initialize": + res := InitializeResult{ + Capabilities: ServerCapabilities{ + // 1 = TextDocumentSyncKindFull + TextDocumentSync: 1, + CompletionProvider: &CompletionOptions{ + ResolveProvider: false, + TriggerCharacters: []string{".", ":", "/", "_"}, + }, + }, + ServerInfo: &ServerInfo{Name: "hexai", Version: internal.Version}, + } + s.reply(req.ID, res, nil) + case "initialized": + // Notification; no response + s.logger.Println("client initialized") + case "shutdown": + s.reply(req.ID, nil, nil) + case "exit": + s.exited = true + // No response per spec. + os.Exit(0) + case "textDocument/didOpen": + var p DidOpenTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + s.setDocument(p.TextDocument.URI, p.TextDocument.Text) + } + case "textDocument/didChange": + var p DidChangeTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + if len(p.ContentChanges) > 0 { + s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text) + } + } + case "textDocument/didClose": + var p DidCloseTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + s.deleteDocument(p.TextDocument.URI) + } + case "textDocument/completion": + var p CompletionParams + var docStr string + if err := json.Unmarshal(req.Params, &p); err == nil { + above, current, below, funcCtx := s.lineContext(p.TextDocument.URI, p.Position) + docStr = fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s", p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) + if s.logContext { + s.logger.Printf("completion ctx uri=%s line=%d char=%d above=%q current=%q below=%q function=%q", + p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) + } + } + items := []CompletionItem{{ + Label: "hexai-complete", + Kind: 14, + Detail: "dummy completion", + InsertText: "hexai", + SortText: "0000", + Documentation: docStr, + }} + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) + default: + // Unknown method; reply with Method Not Found for requests that have an ID. + if len(req.ID) != 0 { + s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}) + } + } +} + +func (s *Server) reply(id json.RawMessage, result any, err *RespError) { + resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} + s.writeMessage(resp) +} + +func (s *Server) readMessage() ([]byte, error) { + tp := textproto.NewReader(s.in) + var contentLength int + for { + line, err := tp.ReadLine() + if err != nil { + return nil, err + } + if line == "" { // end of headers + break + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(strings.ToLower(parts[0])) + val := strings.TrimSpace(parts[1]) + switch key { + case "content-length": + n, err := strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("invalid Content-Length: %v", err) + } + contentLength = n + } + } + if contentLength <= 0 { + return nil, fmt.Errorf("missing or invalid Content-Length") + } + buf := make([]byte, contentLength) + if _, err := io.ReadFull(s.in, buf); err != nil { + return nil, err + } + return buf, nil +} + +func (s *Server) writeMessage(v any) { + data, err := json.Marshal(v) + if err != nil { + s.logger.Printf("marshal error: %v", err) + return + } + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + if _, err := io.WriteString(s.out, header); err != nil { + s.logger.Printf("write header error: %v", err) + return + } + if _, err := s.out.Write(data); err != nil { + s.logger.Printf("write body error: %v", err) + return + } +} + +// --- Document store and helpers --- + +type document struct { + uri string + text string + lines []string +} + +func (s *Server) setDocument(uri, text string) { + s.mu.Lock() + defer s.mu.Unlock() + s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} +} + +func (s *Server) deleteDocument(uri string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.docs, uri) +} + +func (s *Server) getDocument(uri string) *document { + s.mu.RLock() + defer s.mu.RUnlock() + return s.docs[uri] +} + +func splitLines(sx string) []string { + sx = strings.ReplaceAll(sx, "\r\n", "\n") + return strings.Split(sx, "\n") +} + +// LSP param types (subset) +type TextDocumentItem struct { + URI string `json:"uri"` + LanguageID string `json:"languageId,omitempty"` + Version int `json:"version,omitempty"` + Text string `json:"text"` +} + +type VersionedTextDocumentIdentifier struct { + URI string `json:"uri"` + Version int `json:"version,omitempty"` +} + +type TextDocumentIdentifier struct { + URI string `json:"uri"` +} + +type DidOpenTextDocumentParams struct { + TextDocument TextDocumentItem `json:"textDocument"` +} + +type TextDocumentContentChangeEvent struct { + Range any `json:"range,omitempty"` + RangeLength int `json:"rangeLength,omitempty"` + Text string `json:"text"` +} + +type DidChangeTextDocumentParams struct { + TextDocument VersionedTextDocumentIdentifier `json:"textDocument"` + ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"` +} + +type DidCloseTextDocumentParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` +} + +type Position struct { + Line int `json:"line"` + Character int `json:"character"` +} + +type CompletionParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` + Context any `json:"context,omitempty"` +} + +func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) { + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 { + return "", "", "", "" + } + idx := pos.Line + if idx < 0 { + idx = 0 + } + if idx >= len(d.lines) { + idx = len(d.lines) - 1 + } + current = d.lines[idx] + if idx-1 >= 0 { + above = d.lines[idx-1] + } + if idx+1 < len(d.lines) { + below = d.lines[idx+1] + } + for i := idx; i >= 0; i-- { + line := strings.TrimSpace(d.lines[i]) + if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) { + funcCtx = line + break + } + } + return +} + +func hasAny(s string, needles []string) bool { + for _, n := range needles { + if strings.Contains(s, n) { + return true + } + } + return false +} + +func trimLen(s string) string { + s = strings.TrimSpace(s) + if len(s) > 200 { + return s[:200] + "…" + } + return s +} diff --git a/internal/version.go b/internal/version.go new file mode 100644 index 0000000..525ff73 --- /dev/null +++ b/internal/version.go @@ -0,0 +1,4 @@ +package internal + +const Version = "0.0.1" + |
