summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-14 00:16:49 +0300
committerPaul Buetow <paul@buetow.org>2025-08-14 00:16:49 +0300
commit0098488f4c869c257ae30fe7dea9a5d8fce9894b (patch)
tree1d50c91f331676b98a7a0b67b83c3b8ab91f1a42
parent5e02ad3bcfb643c44866f65d763d266b1d257e20 (diff)
feat(lsp): scaffold barebones LSP server with contextual completion; add Taskfile and AGENTS.md; enable -log context logging
-rw-r--r--.gitignore17
-rw-r--r--AGENTS.md36
-rw-r--r--Taskfile.yaml44
-rw-r--r--go.mod4
-rw-r--r--internal/lsp/server.go361
-rw-r--r--internal/version.go4
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
+
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f42d416
--- /dev/null
+++ b/go.mod
@@ -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"
+