summaryrefslogtreecommitdiff
path: root/internal/lsp
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-16 23:16:54 +0300
committerPaul Buetow <paul@buetow.org>2025-08-16 23:16:54 +0300
commit765eda955eb811d08d867ff4d3914fc6d60c22dd (patch)
treefdc87da6af9d86dbda2ea9ab08244e93fd167188 /internal/lsp
parent1b01e35c34b953cbf51298f4650dc3215c382a4f (diff)
refactor(config): drop env-based config (except OPENAI_API_KEY)
- Switch to config-file-only; only OPENAI_API_KEY read from env.\n- llm: replace env autodetect with Config + NewFromConfig; add newOpenAI/newOllama.\n- lsp: NewServer now accepts injected llm.Client.\n- cli: remove env overrides; extend appConfig with provider-specific fields; build client from config + OPENAI_API_KEY.\n- docs: update README (config-only, defaults to OpenAI, minimal example); simplify flags table.\n- add config.json.example.\n- prompts: enforce ;text; (no spaces) and add ;;text; to remove entire line; tests added.
Diffstat (limited to 'internal/lsp')
-rw-r--r--internal/lsp/handlers.go106
-rw-r--r--internal/lsp/handlers_test.go57
-rw-r--r--internal/lsp/server.go9
3 files changed, 137 insertions, 35 deletions
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index d8c13a1..8edfbb6 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -237,36 +237,86 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun
// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
// Supported form (inclusive):
-// - ";...;" (optional single space after trailing ';')
+// - ";...;" where there is no space immediately after the first ';'
+// and no space immediately before the last ';'. An optional single space
+// after the trailing ';' is also removed for cleanliness.
// Multiple markers per line are supported.
func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit {
- d := s.getDocument(uri)
- if d == nil || len(d.lines) == 0 {
- return nil
- }
- var edits []TextEdit
- for i, line := range d.lines {
- // Scan for ;...; markers
- startSemi := 0
- for startSemi < len(line) {
- j := strings.Index(line[startSemi:], ";")
- if j < 0 {
- break
- }
- j += startSemi
- k := strings.Index(line[j+1:], ";")
- if k < 0 {
- break
- }
- endChar := j + 1 + k + 1 // include trailing ';'
- if endChar < len(line) && line[endChar] == ' ' {
- endChar++
- }
- edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: j}, End: Position{Line: i, Character: endChar}}, NewText: ""})
- startSemi = endChar
- }
- }
- return edits
+ d := s.getDocument(uri)
+ if d == nil || len(d.lines) == 0 {
+ return nil
+ }
+ var edits []TextEdit
+ for i, line := range d.lines {
+ // If the line contains a double-semicolon trigger of the form
+ // ";;text;" (no space after the ";;" and no space before the closing ';'),
+ // remove the entire line.
+ removeWholeLine := false
+ {
+ pos := 0
+ for pos < len(line) {
+ j := strings.Index(line[pos:], ";;")
+ if j < 0 { break }
+ j += pos
+ // ensure there's a non-space after the two semicolons
+ if j+2 >= len(line) || line[j+2] == ' ' { pos = j + 2; continue }
+ // find closing ';' after the content
+ k := strings.Index(line[j+2:], ";")
+ if k < 0 { break }
+ closeIdx := j + 2 + k
+ // ensure char before closing ';' is not a space
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { pos = closeIdx + 1; continue }
+ removeWholeLine = true
+ break
+ }
+ }
+ if removeWholeLine {
+ edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: 0}, End: Position{Line: i, Character: len(line)}}, NewText: ""})
+ continue
+ }
+ // Scan for ;...; markers that have no spaces directly inside the semicolons
+ startSemi := 0
+ for startSemi < len(line) {
+ j := strings.Index(line[startSemi:], ";")
+ if j < 0 {
+ break
+ }
+ j += startSemi
+ k := strings.Index(line[j+1:], ";")
+ if k < 0 {
+ break
+ }
+ // Require no space immediately after the first ';'
+ if j+1 >= len(line) || line[j+1] == ' ' {
+ startSemi = j + 1
+ continue
+ }
+ // Ignore patterns that start with double semicolon here; handled above
+ if line[j+1] == ';' {
+ startSemi = j + 2
+ continue
+ }
+ // Index of the closing ';'
+ closeIdx := j + 1 + k
+ // Require no space immediately before the closing ';'
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
+ startSemi = closeIdx + 1
+ continue
+ }
+ // Require at least one character between the semicolons
+ if closeIdx-(j+1) < 1 {
+ startSemi = closeIdx + 1
+ continue
+ }
+ endChar := closeIdx + 1 // include trailing ';'
+ if endChar < len(line) && line[endChar] == ' ' {
+ endChar++
+ }
+ edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: j}, End: Position{Line: i, Character: endChar}}, NewText: ""})
+ startSemi = endChar
+ }
+ }
+ return edits
}
func inParamList(current string, cursor int) bool {
diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go
index 6ce1e5d..3ebddfb 100644
--- a/internal/lsp/handlers_test.go
+++ b/internal/lsp/handlers_test.go
@@ -147,3 +147,60 @@ no markers here`
t.Fatalf("e0 start not at ;")
}
}
+
+func TestCollectPromptRemovalEdits_SkipSpacedMarkers(t *testing.T) {
+ s := newTestServer()
+ uri := "file:///y.go"
+ // Only ;ok; should be removed; "; spaced ;" must be ignored
+ src := `prefix ;ok; middle ; spaced ; suffix`
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 1 {
+ t.Fatalf("expected 1 edit (only ;ok;), got %d", len(edits))
+ }
+ // Ensure the removed region starts at the first ';' of ;ok;
+ line := s.getDocument(uri).lines[0]
+ wantStart := strings.Index(line, ";ok;")
+ if wantStart < 0 {
+ t.Fatalf("test setup: could not find ;ok; in %q", line)
+ }
+ if edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != wantStart {
+ t.Fatalf("unexpected first edit start: got line=%d char=%d want line=0 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, wantStart)
+ }
+}
+
+func TestCollectPromptRemovalEdits_DoubleSemicolonRemovesWholeLine(t *testing.T) {
+ s := newTestServer()
+ uri := "file:///z.go"
+ line0 := "keep"
+ line1 := ";;todo; remove this whole line"
+ line2 := "keep ;ok; end"
+ src := strings.Join([]string{line0, line1, line2}, "\n")
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 2 {
+ t.Fatalf("expected 2 edits (whole line + ;ok;), got %d", len(edits))
+ }
+ // Find the whole-line removal for line1
+ found := false
+ for _, e := range edits {
+ if e.Range.Start.Line == 1 && e.Range.Start.Character == 0 && e.Range.End.Line == 1 && e.Range.End.Character == len(line1) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("did not find whole-line removal edit for line 1")
+ }
+}
+
+func TestCollectPromptRemovalEdits_SkipSpacedDouble(t *testing.T) {
+ s := newTestServer()
+ uri := "file:///w.go"
+ src := "prefix ;; spaced ; suffix"
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 0 {
+ t.Fatalf("expected 0 edits for spaced double-semicolon trigger, got %d", len(edits))
+ }
+}
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index ef51636..bfdbca2 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -35,7 +35,7 @@ type Server struct {
startTime time.Time
}
-func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, maxTokens int, contextMode string, windowLines int, maxContextTokens int, noDiskIO bool) *Server {
+func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, maxTokens int, contextMode string, windowLines int, maxContextTokens int, noDiskIO bool, client llm.Client) *Server {
s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext}
if maxTokens <= 0 {
maxTokens = 500
@@ -55,12 +55,7 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, ma
s.maxContextTokens = maxContextTokens
s.noDiskIO = noDiskIO
s.startTime = time.Now()
- if c, err := llm.NewDefault(); err != nil {
- logging.Logf("lsp ", "llm disabled: %v", err)
- } else {
- s.llmClient = c
- logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel())
- }
+ s.llmClient = client
return s
}