summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md44
-rw-r--r--IDEAS.md6
-rw-r--r--README.md12
-rw-r--r--Taskfile.yaml64
-rw-r--r--internal/llm/openai.go162
-rw-r--r--internal/llm/provider.go49
-rw-r--r--internal/lsp/server.go201
-rw-r--r--internal/test.go18
-rw-r--r--internal/version.go1
9 files changed, 460 insertions, 97 deletions
diff --git a/AGENTS.md b/AGENTS.md
index b93bef8..e7c6d2b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,36 +1,30 @@
# 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.
+- `assets/`: Optimized images and brand assets (place new images here). Existing
+ legacy files: `hexai.png`, `hexai-small.png`.
+- `src/`: Future implementation code.
+- `tests/`: Future test suites mirroring `src/` paths.
+- `scripts/`: Helper tools and maintenance scripts.
## Build, Test, and Development Commands
+
+- Preview Markdown: `glow README.md` (or your editor’s preview).
+- Lint Markdown: `markdownlint **/*.md` — checks heading/style rules.
+- Spellcheck: `codespell` — catches common typos.
+- Optimize images: `pngquant --quality=70-85 input.png -o assets/input.png`.
- 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.
+- There should be no source code file larger than 1000 lines. If so, split it up into multiple.
+- There should be no function larger then 50 lines. If so, refactor or split up into multiple smaller functions.
+- Markdown: ATX `#` headings, sentence‑case titles, wrap lines ~100 chars,
+ use fenced code blocks and descriptive link text.
+- Filenames: docs use `lowercase-with-dashes.md`; images use kebab‑case with
+ size/purpose suffix (e.g., `hexai-small.png`).
+- Code (when added): follow language idioms; use consistent 2 or 4‑space
+ indentation; avoid one‑letter identifiers; keep functions short and focused.
diff --git a/IDEAS.md b/IDEAS.md
index 299ccb3..8bf8819 100644
--- a/IDEAS.md
+++ b/IDEAS.md
@@ -1,5 +1,11 @@
# Ideas
+## Refactor
+
+* Refactor existing code in a more modular way, especially ./internal/lsp/server.go
+
+## Features
+
* LSP server to be used with the Helix text editor
* Code completion using LLMs
* Code generation using LLMs text
diff --git a/README.md b/README.md
index 3c1d45f..8fd42bd 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,16 @@
Hexai, the AI LSP for the Helix editor.
-At the moment this project is only in the bainstorming phase.
+At the moment this project is only in the brainstorming phase.
+## LLM provider
+
+Hexai exposes a simple LLM provider interface and uses OpenAI by default for
+code completion when `OPENAI_API_KEY` is present in the environment.
+
+- Required: set `OPENAI_API_KEY` to your OpenAI API key.
+- Optional: set `OPENAI_MODEL` (default: `gpt-4o-mini`).
+- Optional: set `OPENAI_BASE_URL` to point at a compatible endpoint.
+
+If no key is configured, Hexai will fall back to a basic, local completion.
diff --git a/Taskfile.yaml b/Taskfile.yaml
index 66e6796..30904e4 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -1,44 +1,36 @@
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
-
+ default:
+ deps: ["build"]
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]
+ 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)"
-
+ - cp -v ./hexai ~/go/bin/
run:
- desc: Build and run the server on stdio
- deps: [build]
+ deps: ["dev"]
cmds:
- - ./{{.BIN_PATH}} -stdio
-
- clean:
- desc: Remove build artifacts and local Go caches
+ - go run cmd/hexai/main.go
+ build:
+ deps: ["buildhexai"]
+ buildhexai:
cmds:
- - rm -rf {{.BIN_DIR}} .gocache .gomodcache
-
+ - go build -o hexai cmd/hexai/main.go
+ dev:
+ deps: ["test", "vet", "lint"]
+ cmds:
+ - go build -race -o hexai cmd/hexai/main.go
+ test:
+ cmds:
+ - go clean -testcache
+ - go test -v ./...
+ vet:
+ cmds:
+ - go vet ./...
+ lint:
+ cmds:
+ - golangci-lint run
+ dev-install:
+ cmds:
+ - go install golang.org/x/tools/gopls@latest
+ - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
diff --git a/internal/llm/openai.go b/internal/llm/openai.go
new file mode 100644
index 0000000..860c80e
--- /dev/null
+++ b/internal/llm/openai.go
@@ -0,0 +1,162 @@
+package llm
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "time"
+)
+
+// openAIClient implements Client against OpenAI's Chat Completions API.
+type openAIClient struct {
+ httpClient *http.Client
+ apiKey string
+ baseURL string
+ defaultModel string
+ logger *log.Logger
+}
+
+func newOpenAIFromEnv(apiKey string, logger *log.Logger) Client {
+ base := os.Getenv("OPENAI_BASE_URL")
+ if base == "" {
+ base = "https://api.openai.com/v1"
+ }
+ model := os.Getenv("OPENAI_MODEL")
+ if model == "" {
+ model = "gpt-4o-mini"
+ }
+ return &openAIClient{
+ httpClient: &http.Client{Timeout: 30 * time.Second},
+ apiKey: apiKey,
+ baseURL: base,
+ defaultModel: model,
+ logger: logger,
+ }
+}
+
+type oaChatRequest struct {
+ Model string `json:"model"`
+ Messages []oaMessage `json:"messages"`
+ Temperature *float64 `json:"temperature,omitempty"`
+ MaxTokens *int `json:"max_tokens,omitempty"`
+ Stop []string `json:"stop,omitempty"`
+}
+
+type oaMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type oaChatResponse struct {
+ Choices []struct {
+ Index int `json:"index"`
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+ Error *struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Param any `json:"param"`
+ Code any `json:"code"`
+ } `json:"error,omitempty"`
+}
+
+func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
+ if c.apiKey == "" {
+ return nilStringErr("missing OpenAI API key")
+ }
+ o := Options{Model: c.defaultModel}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.Model == "" {
+ o.Model = c.defaultModel
+ }
+ start := time.Now()
+ c.logf("chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
+ for i, m := range messages {
+ c.logf("msg[%d] role=%s size=%d preview=%q", i, m.Role, len(m.Content), trimPreview(m.Content, 200))
+ }
+ req := oaChatRequest{Model: o.Model}
+ req.Messages = make([]oaMessage, len(messages))
+ for i, m := range messages {
+ req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content}
+ }
+ if o.Temperature != 0 {
+ req.Temperature = &o.Temperature
+ }
+ if o.MaxTokens > 0 {
+ req.MaxTokens = &o.MaxTokens
+ }
+ if len(o.Stop) > 0 {
+ req.Stop = o.Stop
+ }
+
+ body, err := json.Marshal(req)
+ if err != nil {
+ c.logf("marshal error: %v", err)
+ return "", err
+ }
+ endpoint := c.baseURL + "/chat/completions"
+ c.logf("POST %s", endpoint)
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ c.logf("new request error: %v", err)
+ return "", err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ c.logf("http error after %s: %v", time.Since(start), err)
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ var apiErr oaChatResponse
+ _ = json.NewDecoder(resp.Body).Decode(&apiErr)
+ if apiErr.Error != nil && apiErr.Error.Message != "" {
+ c.logf("api error status=%d type=%s msg=%s duration=%s", resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start))
+ return "", fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
+ }
+ c.logf("http non-2xx status=%d duration=%s", resp.StatusCode, time.Since(start))
+ return "", fmt.Errorf("openai http error: status %d", resp.StatusCode)
+ }
+ var out oaChatResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ c.logf("decode error after %s: %v", time.Since(start), err)
+ return "", err
+ }
+ if len(out.Choices) == 0 {
+ c.logf("no choices returned duration=%s", time.Since(start))
+ return "", errors.New("openai: no choices returned")
+ }
+ content := out.Choices[0].Message.Content
+ c.logf("success choice=0 finish=%s size=%d preview=%q duration=%s", out.Choices[0].FinishReason, len(content), trimPreview(content, 200), time.Since(start))
+ return content, nil
+}
+
+// small helper to keep return type consistent
+func nilStringErr(msg string) (string, error) { return "", errors.New(msg) }
+
+func (c *openAIClient) logf(format string, args ...any) {
+ if c.logger != nil {
+ c.logger.Printf("llm/openai "+format, args...)
+ }
+}
+
+func trimPreview(s string, n int) string {
+ if n <= 0 || len(s) <= n {
+ return s
+ }
+ return s[:n] + "…"
+}
diff --git a/internal/llm/provider.go b/internal/llm/provider.go
new file mode 100644
index 0000000..fd9d4d3
--- /dev/null
+++ b/internal/llm/provider.go
@@ -0,0 +1,49 @@
+package llm
+
+import (
+ "context"
+ "errors"
+ "log"
+ "os"
+)
+
+// Message represents a chat-style prompt message.
+type Message struct {
+ Role string
+ Content string
+}
+
+// Client is a minimal LLM provider interface.
+// Future providers (Ollama, etc.) should implement this.
+type Client interface {
+ // Chat sends chat messages and returns the assistant text.
+ Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error)
+}
+
+// Options for a request. Providers may ignore unsupported fields.
+type Options struct {
+ Model string
+ Temperature float64
+ MaxTokens int
+ Stop []string
+}
+
+// RequestOption mutates Options.
+type RequestOption func(*Options)
+
+func WithModel(model string) RequestOption { return func(o *Options) { o.Model = model } }
+func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } }
+func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } }
+func WithStop(stop ...string) RequestOption {
+ return func(o *Options) { o.Stop = append([]string{}, stop...) }
+}
+
+// NewDefault returns the default provider using environment configuration.
+// Currently this is the OpenAI provider using OPENAI_API_KEY.
+func NewDefault(logger *log.Logger) (Client, error) {
+ apiKey := os.Getenv("OPENAI_API_KEY")
+ if apiKey == "" {
+ return nil, errors.New("OPENAI_API_KEY is not set")
+ }
+ return newOpenAIFromEnv(apiKey, logger), nil
+}
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index 3949680..ec1a113 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -2,9 +2,11 @@ package lsp
import (
"bufio"
+ "context"
"encoding/json"
"fmt"
"hexai/internal"
+ "hexai/internal/llm"
"io"
"log"
"net/textproto"
@@ -12,6 +14,7 @@ import (
"strconv"
"strings"
"sync"
+ "time"
)
// JSON-RPC 2.0 structures (minimal)
@@ -61,27 +64,39 @@ type CompletionList struct {
}
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"`
+ Label string `json:"label"`
+ Kind int `json:"kind,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ InsertText string `json:"insertText,omitempty"`
+ InsertTextFormat int `json:"insertTextFormat,omitempty"`
+ FilterText string `json:"filterText,omitempty"`
+ TextEdit *TextEdit `json:"textEdit,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
+ in *bufio.Reader
+ out io.Writer
+ logger *log.Logger
+ exited bool
+ mu sync.RWMutex
+ docs map[string]*document
+ logContext bool
+ llmClient llm.Client
+ lastInput time.Time
}
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}
+ s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext}
+ if c, err := llm.NewDefault(logger); err != nil {
+ // Keep running without LLM; completions will be basic.
+ s.logger.Printf("llm disabled: %v", err)
+ } else {
+ s.llmClient = c
+ }
+ return s
}
func (s *Server) Run() error {
@@ -137,6 +152,7 @@ func (s *Server) handle(req Request) {
var p DidOpenTextDocumentParams
if err := json.Unmarshal(req.Params, &p); err == nil {
s.setDocument(p.TextDocument.URI, p.TextDocument.Text)
+ s.markActivity()
}
case "textDocument/didChange":
var p DidChangeTextDocumentParams
@@ -144,32 +160,123 @@ func (s *Server) handle(req Request) {
if len(p.ContentChanges) > 0 {
s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text)
}
+ s.markActivity()
}
case "textDocument/didClose":
var p DidCloseTextDocumentParams
if err := json.Unmarshal(req.Params, &p); err == nil {
s.deleteDocument(p.TextDocument.URI)
+ s.markActivity()
}
- 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)
+ 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))
+ }
+ // Previously: gated LLM calls until 1s idle. Removed to complete as you type.
+ // Try LLM-backed suggestion if available (always, no idle gating)
+ if s.llmClient != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
+ defer cancel()
+ // Tailor prompt if inside a Go function parameter list
+ inParams := false
+ if strings.Contains(current, "func ") {
+ open := strings.Index(current, "(")
+ close := strings.Index(current, ")")
+ if open >= 0 && p.Position.Character > open && (close == -1 || p.Position.Character <= close) {
+ inParams = true
+ }
+ }
+ sysPrompt := "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks."
+ userPrompt := fmt.Sprintf("Provide the next likely code to insert at the cursor.\nFile: %s\nFunction/context: %s\nAbove line: %s\nCurrent line (cursor at character %d): %s\nBelow line: %s\nOnly return the completion snippet.", p.TextDocument.URI, funcCtx, above, p.Position.Character, current, below)
+ if inParams {
+ sysPrompt = "You are a terse Go code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types."
+ userPrompt = fmt.Sprintf("Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: %s\nCurrent line (cursor at %d): %s", funcCtx, p.Position.Character, current)
+ }
+ messages := []llm.Message{
+ {Role: "system", Content: sysPrompt},
+ {Role: "user", Content: userPrompt},
+ }
+ // keep completions small by default
+ text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(96), llm.WithTemperature(0.2))
+ if err == nil && strings.TrimSpace(text) != "" {
+ cleaned := strings.TrimSpace(text)
+ var te *TextEdit
+ var filter string
+ if inParams {
+ // Replace inside the parentheses
+ open := strings.Index(current, "(")
+ close := strings.Index(current, ")")
+ if open >= 0 {
+ left := open + 1
+ right := len(current)
+ if close >= 0 && close >= left {
+ right = close
+ }
+ if p.Position.Character < right {
+ right = p.Position.Character
+ }
+ te = &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: left}, End: Position{Line: p.Position.Line, Character: right}}, NewText: cleaned}
+ if left >= 0 && right >= left && right <= len(current) {
+ filter = strings.TrimLeft(current[left:right], " \t")
+ }
+ }
+ }
+ if te == nil {
+ // compute word start for replacement
+ startChar := p.Position.Character
+ if startChar > len(current) {
+ startChar = len(current)
+ }
+ for startChar > 0 {
+ ch := current[startChar-1]
+ if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' {
+ startChar--
+ continue
+ }
+ break
+ }
+ te = &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: startChar}, End: Position{Line: p.Position.Line, Character: p.Position.Character}}, NewText: cleaned}
+ filter = strings.TrimLeft(current[startChar:p.Position.Character], " \t")
+ }
+ // Choose a label that starts with the current prefix when possible so the client doesn't filter it out.
+ label := trimLen(firstLine(cleaned))
+ if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) {
+ label = filter
+ }
+ items := []CompletionItem{{
+ Label: label,
+ Kind: 1,
+ Detail: "OpenAI completion",
+ InsertTextFormat: 1,
+ FilterText: strings.TrimLeft(filter, " \t"),
+ TextEdit: te,
+ SortText: "0000",
+ Documentation: docStr,
+ }}
+ s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)
+ return
+ }
+ if err != nil {
+ s.logger.Printf("llm completion error: %v", err)
+ }
+ }
+ }
+ // Fallback basic/dummy completion
+ items := []CompletionItem{{
+ Label: "hexai-complete",
+ Kind: 1,
+ Detail: "dummy completion",
+ InsertText: "hexai",
+ SortText: "9999",
+ 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 {
@@ -256,6 +363,12 @@ func (s *Server) deleteDocument(uri string) {
delete(s.docs, uri)
}
+func (s *Server) markActivity() {
+ s.mu.Lock()
+ s.lastInput = time.Now()
+ s.mu.Unlock()
+}
+
func (s *Server) getDocument(uri string) *document {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -314,6 +427,18 @@ type CompletionParams struct {
Context any `json:"context,omitempty"`
}
+// Range defines a text range in a document.
+type Range struct {
+ Start Position `json:"start"`
+ End Position `json:"end"`
+}
+
+// TextEdit represents a textual edit applicable to a document.
+type TextEdit struct {
+ Range Range `json:"range"`
+ NewText string `json:"newText"`
+}
+
func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) {
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 {
@@ -359,3 +484,11 @@ func trimLen(s string) string {
}
return s
}
+
+func firstLine(s string) string {
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ if idx := strings.IndexByte(s, '\n'); idx >= 0 {
+ return s[:idx]
+ }
+ return s
+}
diff --git a/internal/test.go b/internal/test.go
new file mode 100644
index 0000000..586a1bc
--- /dev/null
+++ b/internal/test.go
@@ -0,0 +1,18 @@
+package internal
+
+import "os"
+
+func fib(i int) int {
+ if i <= 1 {
+ return i
+ }
+ return fib(i-1) + fib(i-2)
+}
+
+func countFilesInDir(dirPath string) int {
+ files, err := os.ReadDir(dirPath)
+ if err != nil {
+ return 0
+ }
+ return len(files)
+}
diff --git a/internal/version.go b/internal/version.go
index 525ff73..673db85 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,4 +1,3 @@
package internal
const Version = "0.0.1"
-