diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-16 14:58:03 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-16 14:58:03 +0300 |
| commit | 1e1df8c204f6771719f85d8402128d72138bb863 (patch) | |
| tree | 20508d35f86625ff5b74b509176111ffde163605 | |
| parent | a6a8b84690c50767f714b413496b5aeb45b31c21 (diff) | |
llm: add pluggable provider with OpenAI default; extensive logging; LSP completion integration with TextEdit, param-aware prompts; remove idle gating; label/filter improvements; docs update
| -rw-r--r-- | AGENTS.md | 44 | ||||
| -rw-r--r-- | IDEAS.md | 6 | ||||
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | Taskfile.yaml | 64 | ||||
| -rw-r--r-- | internal/llm/openai.go | 162 | ||||
| -rw-r--r-- | internal/llm/provider.go | 49 | ||||
| -rw-r--r-- | internal/lsp/server.go | 201 | ||||
| -rw-r--r-- | internal/test.go | 18 | ||||
| -rw-r--r-- | internal/version.go | 1 |
9 files changed, 460 insertions, 97 deletions
@@ -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. @@ -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 @@ -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" - |
