summaryrefslogtreecommitdiff
path: root/internal/lsp
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-10-03 23:50:49 +0300
committerPaul Buetow <paul@buetow.org>2025-10-03 23:50:49 +0300
commit420b5aebf888c638ac096e1476c06eac979ac257 (patch)
tree59434cbc37837399d7b5bc7920ffd7be62f1fc7d /internal/lsp
parente36a5446bc62842ae3b3e165f66fecb7285a8c6a (diff)
Switch inline prompt markers to >! prefixv0.15.1
Diffstat (limited to 'internal/lsp')
-rw-r--r--internal/lsp/chat_commands_test.go2
-rw-r--r--internal/lsp/codeaction_custom_test.go2
-rw-r--r--internal/lsp/codeaction_test.go2
-rw-r--r--internal/lsp/completion_prefix_strip_test.go20
-rw-r--r--internal/lsp/coverage_add_test.go6
-rw-r--r--internal/lsp/document_test.go4
-rw-r--r--internal/lsp/handlers.go23
-rw-r--r--internal/lsp/handlers_completion.go14
-rw-r--r--internal/lsp/handlers_document.go8
-rw-r--r--internal/lsp/handlers_helpers_test.go20
-rw-r--r--internal/lsp/handlers_test.go28
-rw-r--r--internal/lsp/handlers_utils.go208
-rw-r--r--internal/lsp/helpers_inline_prompt_test.go8
-rw-r--r--internal/lsp/helpers_more_test.go14
-rw-r--r--internal/lsp/init_and_trigger_test.go4
-rw-r--r--internal/lsp/inline_prompt_completion_test.go2
-rw-r--r--internal/lsp/instruction_table_test.go2
-rw-r--r--internal/lsp/postprocess_indent_test.go2
-rw-r--r--internal/lsp/provider_native_success_test.go2
-rw-r--r--internal/lsp/server.go2
-rw-r--r--internal/lsp/triggers_config_test.go4
21 files changed, 245 insertions, 132 deletions
diff --git a/internal/lsp/chat_commands_test.go b/internal/lsp/chat_commands_test.go
index 0e31c7b..ffe31dd 100644
--- a/internal/lsp/chat_commands_test.go
+++ b/internal/lsp/chat_commands_test.go
@@ -50,6 +50,7 @@ func TestHandleReloadCommandReloadsStore(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", tmp)
t.Setenv("HEXAI_MAX_TOKENS", "321")
+ t.Setenv("HEXAI_PROVIDER", "")
var logBuf bytes.Buffer
logger := log.New(&logBuf, "", 0)
@@ -96,6 +97,7 @@ func TestDetectAndHandleChatExecutesSlashCommand(t *testing.T) {
}
t.Setenv("XDG_CONFIG_HOME", tmp)
t.Setenv("HEXAI_MAX_TOKENS", "")
+ t.Setenv("HEXAI_PROVIDER", "")
var logBuf bytes.Buffer
logger := log.New(&logBuf, "", 0)
diff --git a/internal/lsp/codeaction_custom_test.go b/internal/lsp/codeaction_custom_test.go
index ea8ae82..36f99d4 100644
--- a/internal/lsp/codeaction_custom_test.go
+++ b/internal/lsp/codeaction_custom_test.go
@@ -30,7 +30,7 @@ func capResp(t *testing.T, buf *bytes.Buffer) Response {
func TestHandleCodeAction_ListsCustomActions(t *testing.T) {
var out bytes.Buffer
cfg := appconfig.App{
- InlineOpen: ">",
+ InlineOpen: ">!",
InlineClose: ">",
ChatSuffix: ">",
ChatPrefixes: []string{"?", "!", ":", ";"},
diff --git a/internal/lsp/codeaction_test.go b/internal/lsp/codeaction_test.go
index 29cb416..af08fe1 100644
--- a/internal/lsp/codeaction_test.go
+++ b/internal/lsp/codeaction_test.go
@@ -23,7 +23,7 @@ func TestBuildRewriteCodeAction_LazyAndResolves(t *testing.T) {
s := newTestServer()
s.llmClient = fakeLLM{resp: "REWRITTEN"}
p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 4}}}
- sel := ">rewrite>\nold code"
+ sel := ">!rewrite>\nold code"
ca := s.buildRewriteCodeAction(p, sel)
if ca == nil {
t.Fatalf("expected code action")
diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go
index e0c655c..c8e2bd7 100644
--- a/internal/lsp/completion_prefix_strip_test.go
+++ b/internal/lsp/completion_prefix_strip_test.go
@@ -69,12 +69,12 @@ func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) {
cfg.TriggerCharacters = []string{".", ":", "/", "_"}
s.cfg = cfg
s.llmClient = fakeLLM{resp: "replacement"}
- line := "prefix >do something> suffix"
+ line := "prefix >!do something> suffix"
// No trigger char immediately before cursor; place cursor at end
p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://inline.go"}}
items, ok, _ := s.tryLLMCompletion(p, "", line, "", "", "", false, "")
if !ok || len(items) == 0 {
- t.Fatalf("expected completion to trigger on inline >text> prompt")
+ t.Fatalf("expected completion to trigger on inline >!text> prompt")
}
}
@@ -87,7 +87,7 @@ func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) {
s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
- line := ">> " // empty content after double-open should not force-trigger
+ line := ">>! " // empty content after double-open should not force-trigger
p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://empty-inline.go"}}
items, ok, _ := s.tryLLMCompletion(p, "", line, "", "", "", false, "")
if !ok {
@@ -102,16 +102,16 @@ func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) {
}
func TestHasDoubleSemicolonTrigger_Variants(t *testing.T) {
- if hasDoubleOpenTrigger(">>", '>', '>') {
+ if hasDoubleOpenTrigger(">>!", ">!", '>', '>') {
t.Fatalf("bare double-open should not trigger")
}
- if hasDoubleOpenTrigger(">> ", '>', '>') {
+ if hasDoubleOpenTrigger(">>! ", ">!", '>', '>') {
t.Fatalf("double-open followed by space should not trigger")
}
- if hasDoubleOpenTrigger(">>>", '>', '>') {
+ if hasDoubleOpenTrigger(">>!>", ">!", '>', '>') {
t.Fatalf("';;;' should not trigger (no content)")
}
- if !hasDoubleOpenTrigger(">>x>", '>', '>') {
+ if !hasDoubleOpenTrigger(">>!x>", ">!", '>', '>') {
t.Fatalf("expected trigger for ';;x;' pattern")
}
}
@@ -126,7 +126,7 @@ func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) {
fake := &countingLLM{}
s.llmClient = fake
// Place a '.' earlier but also include bare double-open at end; should not auto-trigger
- line := "obj. call >>"
+ line := "obj. call >>!"
p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds.go"}}
items, ok, _ := s.tryLLMCompletion(p, "", line, "", "", "", false, "")
if !ok {
@@ -150,7 +150,7 @@ func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) {
fake := &countingLLM{}
s.llmClient = fake
current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")"
- below := ">>"
+ below := ">>!"
p := CompletionParams{Position: Position{Line: 0, Character: len(current)}, TextDocument: TextDocumentIdentifier{URI: "file://nextline.go"}}
items, ok, _ := s.tryLLMCompletion(p, "", current, below, "", "", false, "")
if !ok {
@@ -173,7 +173,7 @@ func TestBareDoubleOpenPreventsManualInvoke(t *testing.T) {
s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
- line := ">>"
+ line := ">>!"
p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds-manual.go"}}
// Simulate manual invoke
p.Context = json.RawMessage([]byte(`{"triggerKind":1}`))
diff --git a/internal/lsp/coverage_add_test.go b/internal/lsp/coverage_add_test.go
index b3b7322..2967fb5 100644
--- a/internal/lsp/coverage_add_test.go
+++ b/internal/lsp/coverage_add_test.go
@@ -56,7 +56,7 @@ func TestFindGoFunctionAtLine_NoBody(t *testing.T) {
}
func TestLineHasInlinePrompt(t *testing.T) {
- if !lineHasInlinePrompt(">do>", '>', '>') {
+ if !lineHasInlinePrompt(">!do>", ">!", '>', '>') {
t.Fatalf("expected inline prompt")
}
}
@@ -89,12 +89,12 @@ func TestIndentHelpersAndPromptRemoval(t *testing.T) {
t.Fatalf("applyIndent: %q", out)
}
// double-open trigger removes whole line
- edits := promptRemovalEditsForLine(">>ask>", 3, '>', '>')
+ edits := promptRemovalEditsForLine(">>!ask>", 3, ">!", '>', '>')
if len(edits) != 1 || edits[0].Range.Start.Line != 3 {
t.Fatalf("unexpected edits: %#v", edits)
}
// semicolon tags collect correctly when provided explicitly
- edits2 := collectSemicolonMarkers("pre;do;post", 1, ';', ';')
+ edits2 := collectSemicolonMarkers("pre;do;post", 1, ";", ';', ';')
if len(edits2) != 1 {
t.Fatalf("expected one semicolon edit, got %#v", edits2)
}
diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go
index fd13e5d..95f0157 100644
--- a/internal/lsp/document_test.go
+++ b/internal/lsp/document_test.go
@@ -13,7 +13,7 @@ import (
func newTestServer() *Server {
cfg := appconfig.App{
- InlineOpen: ">",
+ InlineOpen: ">!",
InlineClose: ">",
ChatSuffix: ">",
ChatPrefixes: []string{"?", "!", ":", ";"},
@@ -47,7 +47,7 @@ func newTestServer() *Server {
func initServerDefaults(s *Server) {
cfg := s.cfg
if strings.TrimSpace(cfg.InlineOpen) == "" {
- cfg.InlineOpen = ">"
+ cfg.InlineOpen = ">!"
}
if strings.TrimSpace(cfg.InlineClose) == "" {
cfg.InlineClose = ">"
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index 94b6348..7b61970 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -51,8 +51,8 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned
text string
}
cands := []cand{}
- _, _, openChar, closeChar := s.inlineMarkers()
- if t, l, r, ok := findStrictInlineTag(line, openChar, closeChar); ok {
+ openStr, _, openChar, closeChar := s.inlineMarkers()
+ if t, l, r, ok := findStrictInlineTag(line, openStr, openChar, closeChar); ok {
cands = append(cands, cand{start: l, end: r, text: t})
}
if i := strings.Index(line, "/*"); i >= 0 {
@@ -288,6 +288,7 @@ func (s *Server) compCacheTouchLocked(key string) {
// immediately to the left of the cursor.
func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
open, _, openChar, closeChar := s.inlineMarkers()
+ doubleSeqs := doubleOpenSequences(open, openChar, closeChar)
triggerChars := s.triggerCharacters()
// 1) Inspect LSP completion context if present
if p.Context != nil {
@@ -301,9 +302,9 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
b, _ := json.Marshal(p.Context)
_ = json.Unmarshal(b, &ctx)
}
- // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'),
+ // If configured and the line contains a bare double-open marker (e.g., '>>!' with no '>>!text>'),
// do not treat as a trigger source.
- if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) {
+ if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) {
return false
}
// TriggerKind 1 = Invoked (manual). Always allow manual invoke.
@@ -331,7 +332,7 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
return false
}
// Bare double-open should not trigger via fallback char either (only when configured)
- if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) {
+ if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) {
return false
}
ch := string(current[idx-1])
@@ -366,6 +367,18 @@ func (s *Server) makeCompletionItems(cleaned string, inParams bool, current stri
}}
}
+func containsAny(haystack string, seqs []string) bool {
+ for _, seq := range seqs {
+ if seq == "" {
+ continue
+ }
+ if strings.Contains(haystack, seq) {
+ return true
+ }
+ }
+ return false
+}
+
// small helpers to keep tryLLMCompletion short
// LLM stats helpers moved to handlers_utils.go
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go
index db6866b..2fac1f3 100644
--- a/internal/lsp/handlers_completion.go
+++ b/internal/lsp/handlers_completion.go
@@ -199,8 +199,8 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below
hasExtra: hasExtra,
extraText: extraText,
}
- _, _, openChar, closeChar := s.inlineMarkers()
- plan.inlinePrompt = lineHasInlinePrompt(current, openChar, closeChar)
+ openStr, _, openChar, closeChar := s.inlineMarkers()
+ plan.inlinePrompt = lineHasInlinePrompt(current, openStr, openChar, closeChar)
if !plan.inlinePrompt && !s.isTriggerEvent(p, current) {
logging.Logf("lsp ", "%scompletion skip=no-trigger line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase)
return plan, []CompletionItem{}, true
@@ -214,7 +214,7 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below
if pending := s.takePendingCompletion(plan.cacheKey); len(pending) > 0 {
return plan, pending, true
}
- if isBareDoubleOpen(current, openChar, closeChar) || isBareDoubleOpen(below, openChar, closeChar) {
+ if isBareDoubleOpen(current, openStr, openChar, closeChar) || isBareDoubleOpen(below, openStr, openChar, closeChar) {
logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase)
return plan, []CompletionItem{}, true
}
@@ -368,7 +368,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio
before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position)
path := strings.TrimPrefix(p.TextDocument.URI, "file://")
cfg := s.currentConfig()
- _, _, openChar, closeChar := s.inlineMarkers()
+ openStr, _, openChar, closeChar := s.inlineMarkers()
prompt := renderTemplate(cfg.PromptNativeCompletion, map[string]string{
"path": path,
"before": before,
@@ -409,7 +409,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio
if cleaned == "" {
return nil, false
}
- if strings.TrimSpace(cleaned) != "" && hasDoubleOpenTrigger(current, openChar, closeChar) {
+ if strings.TrimSpace(cleaned) != "" && hasDoubleOpenTrigger(current, openStr, openChar, closeChar) {
indent := leadingIndent(current)
if indent != "" {
cleaned = applyIndent(indent, cleaned)
@@ -537,8 +537,8 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current
if cleaned != "" {
cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned)
}
- _, _, openChar, closeChar := s.inlineMarkers()
- if cleaned != "" && hasDoubleOpenTrigger(currentLine, openChar, closeChar) {
+ openStr, _, openChar, closeChar := s.inlineMarkers()
+ if cleaned != "" && hasDoubleOpenTrigger(currentLine, openStr, openChar, closeChar) {
if indent := leadingIndent(currentLine); indent != "" {
cleaned = applyIndent(indent, cleaned)
}
diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go
index da7db51..a047324 100644
--- a/internal/lsp/handlers_document.go
+++ b/internal/lsp/handlers_document.go
@@ -91,9 +91,9 @@ func (s *Server) detectAndHandleChat(uri string) {
return
}
suffix, prefixes, _ := s.chatConfig()
- _, _, openChar, closeChar := s.inlineMarkers()
+ openStr, _, openChar, closeChar := s.inlineMarkers()
for i, raw := range d.lines {
- if lineHasInlinePrompt(raw, openChar, closeChar) {
+ if lineHasInlinePrompt(raw, openStr, openChar, closeChar) {
if s.currentLLMClient() != nil {
pos := Position{Line: i, Character: len(raw)}
go s.runInlinePrompt(uri, pos)
@@ -221,8 +221,8 @@ func (s *Server) runInlinePrompt(uri string, pos Position) {
return
}
line := d.lines[pos.Line]
- _, _, openChar, closeChar := s.inlineMarkers()
- if !lineHasInlinePrompt(line, openChar, closeChar) {
+ openStr, _, openChar, closeChar := s.inlineMarkers()
+ if !lineHasInlinePrompt(line, openStr, openChar, closeChar) {
return
}
p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: pos.Line, Character: len(line)}}
diff --git a/internal/lsp/handlers_helpers_test.go b/internal/lsp/handlers_helpers_test.go
index 2bd677e..8a0231a 100644
--- a/internal/lsp/handlers_helpers_test.go
+++ b/internal/lsp/handlers_helpers_test.go
@@ -10,14 +10,14 @@ func TestHasDoubleSemicolonTrigger(t *testing.T) {
line string
want bool
}{
- {">>todo> remove this", true},
- {"prefix >>x> suffix", true},
- {">> spaced >", false},
+ {">>!todo> remove this", true},
+ {"prefix >>!x> suffix", true},
+ {">>! spaced >", false},
{"no markers", false},
- {">>x > space before close", false},
+ {">>!x > space before close", false},
}
for _, tc := range cases {
- got := hasDoubleOpenTrigger(tc.line, '>', '>')
+ got := hasDoubleOpenTrigger(tc.line, ">!", '>', '>')
if got != tc.want {
t.Fatalf("hasDoubleOpenTrigger(%q)=%v want %v", tc.line, got, tc.want)
}
@@ -25,13 +25,13 @@ func TestHasDoubleSemicolonTrigger(t *testing.T) {
}
func TestCollectSemicolonMarkers(t *testing.T) {
- line := "keep >ok> this and >another> that"
- edits := collectSemicolonMarkers(line, 7, '>', '>')
+ line := "keep >!ok> this and >!another> that"
+ edits := collectSemicolonMarkers(line, 7, ">!", '>', '>')
if len(edits) != 2 {
t.Fatalf("expected 2 edits, got %d", len(edits))
}
// Validate the first edit aligns with ;ok;
- start := strings.Index(line, ">ok>")
+ start := strings.Index(line, ">!ok>")
if start < 0 {
t.Fatalf("test setup: missing ;ok;")
}
@@ -41,8 +41,8 @@ func TestCollectSemicolonMarkers(t *testing.T) {
}
func TestPromptRemovalEditsForLine_WholeLine(t *testing.T) {
- line := ">>todo> remove this whole line"
- edits := promptRemovalEditsForLine(line, 3, '>', '>')
+ line := ">>!todo> remove this whole line"
+ edits := promptRemovalEditsForLine(line, 3, ">!", '>', '>')
if len(edits) != 1 {
t.Fatalf("expected 1 whole-line edit, got %d", len(edits))
}
diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go
index 6803d1e..b2b47c0 100644
--- a/internal/lsp/handlers_test.go
+++ b/internal/lsp/handlers_test.go
@@ -16,7 +16,7 @@ func TestFindFirstInstructionInLine_NoMarker(t *testing.T) {
}
func TestFindFirstInstructionInLine_StrictInline_Basic(t *testing.T) {
- line := "prefix >rename var> suffix"
+ line := "prefix >!rename var> suffix"
s := newTestServer()
instr, cleaned, ok := s.findFirstInstructionInLine(line)
if !ok {
@@ -32,7 +32,7 @@ func TestFindFirstInstructionInLine_StrictInline_Basic(t *testing.T) {
}
func TestFindFirstInstructionInLine_StrictInline_TrailingSpacesTrimmed(t *testing.T) {
- line := "code>fix> \t\t"
+ line := "code>!fix> \t\t"
s := newTestServer()
instr, cleaned, ok := s.findFirstInstructionInLine(line)
if !ok {
@@ -48,9 +48,9 @@ func TestFindFirstInstructionInLine_StrictInline_TrailingSpacesTrimmed(t *testin
func TestFindFirstInstructionInLine_Inline_InvalidPatterns(t *testing.T) {
cases := []string{
- "prefix > bad> suffix", // space after first '>' ⇒ invalid
- "prefix >bad > suffix", // space before closing '>' ⇒ invalid
- "prefix > > suffix", // empty inner ⇒ invalid
+ "prefix >! bad> suffix", // space after '!'
+ "prefix >!bad > suffix", // space before closing '>' ⇒ invalid
+ "prefix >! > suffix", // empty inner ⇒ invalid
}
for _, line := range cases {
s := newTestServer()
@@ -136,14 +136,14 @@ func TestFindFirstInstructionInLine_DoubleDash(t *testing.T) {
}
func TestFindFirstInstructionInLine_EarliestWins_CommentOverInline(t *testing.T) {
- line := "aa // comment >not this> trailing"
+ line := "aa // comment >!not this> trailing"
s := newTestServer()
instr, cleaned, ok := s.findFirstInstructionInLine(line)
if !ok {
t.Fatalf("expected ok=true")
}
- if instr != "comment >not this> trailing" {
- t.Fatalf("instr got %q want %q", instr, "comment >not this> trailing")
+ if instr != "comment >!not this> trailing" {
+ t.Fatalf("instr got %q want %q", instr, "comment >!not this> trailing")
}
if cleaned != "aa" {
t.Fatalf("cleaned got %q want %q", cleaned, "aa")
@@ -151,7 +151,7 @@ func TestFindFirstInstructionInLine_EarliestWins_CommentOverInline(t *testing.T)
}
func TestFindFirstInstructionInLine_EarliestWins_InlineOverComment(t *testing.T) {
- line := "aa >short> // comment"
+ line := "aa >!short> // comment"
s := newTestServer()
instr, cleaned, ok := s.findFirstInstructionInLine(line)
if !ok {
@@ -168,19 +168,19 @@ func TestFindFirstInstructionInLine_EarliestWins_InlineOverComment(t *testing.T)
func TestFindStrictInlineTag_Various(t *testing.T) {
// basic
- if text, l, r, ok := findStrictInlineTag("pre>do it>post", '>', '>'); !ok || text != "do it" || l != 3 || r != 10 {
+ if text, l, r, ok := findStrictInlineTag("pre>!do it>post", ">!", '>', '>'); !ok || text != "do it" || l != 3 || r != 11 {
t.Fatalf("unexpected: ok=%v text=%q l=%d r=%d", ok, text, l, r)
}
// at start
- if text, l, r, ok := findStrictInlineTag(">x>", '>', '>'); !ok || text != "x" || l != 0 || r != 3 {
+ if text, l, r, ok := findStrictInlineTag(">!x>", ">!", '>', '>'); !ok || text != "x" || l != 0 || r != 4 {
t.Fatalf("unexpected at start: ok=%v text=%q l=%d r=%d", ok, text, l, r)
}
- // double opening '>>' should still allow a tag starting at the second '>'
- if text, _, _, ok := findStrictInlineTag("prefix >>bad> suffix", '>', '>'); !ok || text != "bad" {
+ // double opening '>>!' should still allow a tag starting after the double marker when configured for '>!'
+ if text, _, _, ok := findStrictInlineTag("prefix >>!bad> suffix", ">!", '>', '>'); !ok || text != "bad" {
t.Fatalf("unexpected double-open handling: ok=%v text=%q", ok, text)
}
// inner spaces directly after first '>' or before last '>' invalidate the tag
- if _, _, _, ok := findStrictInlineTag("a> inner >b", '>', '>'); ok {
+ if _, _, _, ok := findStrictInlineTag("a>! inner >b", ">!", '>', '>'); ok {
t.Fatalf("expected invalid strict tag due to spaces at boundaries")
}
}
diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go
index 2748a60..b3056b9 100644
--- a/internal/lsp/handlers_utils.go
+++ b/internal/lsp/handlers_utils.go
@@ -312,11 +312,36 @@ func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec re
// Inline prompt utilities
-func lineHasInlinePrompt(line string, open, close byte) bool {
- if _, _, _, ok := findStrictInlineTag(line, open, close); ok {
+func lineHasInlinePrompt(line string, openStr string, open, close byte) bool {
+ if openStr == "" {
+ openStr = string(open)
+ }
+ if _, _, _, ok := findStrictInlineTag(line, openStr, open, close); ok {
return true
}
- return hasDoubleOpenTrigger(line, open, close)
+ return hasDoubleOpenTrigger(line, openStr, open, close)
+}
+
+func doubleOpenSequences(openStr string, open, close byte) []string {
+ seen := make(map[string]struct{}, 2)
+ var seqs []string
+ if openStr != "" && close != 0 {
+ seq := openStr + string(close)
+ if _, ok := seen[seq]; !ok {
+ seen[seq] = struct{}{}
+ seqs = append(seqs, seq)
+ }
+ }
+ if openStr != "" && open != 0 {
+ seq := string(open) + openStr
+ if len(seq) > len(openStr) {
+ if _, ok := seen[seq]; !ok {
+ seen[seq] = struct{}{}
+ seqs = append(seqs, seq)
+ }
+ }
+ }
+ return seqs
}
func leadingIndent(line string) string {
@@ -353,34 +378,66 @@ func applyIndent(indent, suggestion string) string {
// --- Inline marker parsing and general string utilities ---
-// findStrictInlineTag finds >text> (configurable), with no space after the first
+// findStrictInlineTag finds >!text> (configurable), with no space after the first
// opening marker and no space immediately before the closing marker. Returns the
// text between markers, the start index, the end index just after closing, and ok.
-func findStrictInlineTag(line string, open, close byte) (string, int, int, bool) {
+func findStrictInlineTag(line string, openStr string, open, close byte) (string, int, int, bool) {
+ if openStr == "" {
+ openStr = string(open)
+ }
+ if openStr == "" {
+ return "", 0, 0, false
+ }
+ openChar := open
+ if openChar == 0 {
+ openChar = openStr[0]
+ }
+ doubleSeqs := doubleOpenSequences(openStr, openChar, close)
pos := 0
for pos < len(line) {
- // find opening marker
- j := strings.IndexByte(line[pos:], open)
+ j := strings.IndexByte(line[pos:], openChar)
if j < 0 {
return "", 0, 0, false
}
j += pos
- // ensure single open (not double) and non-space after
- if j+1 >= len(line) || line[j+1] == open || line[j+1] == ' ' {
+ if !strings.HasPrefix(line[j:], openStr) {
pos = j + 1
continue
}
- // find closing marker
- k := strings.IndexByte(line[j+1:], close)
+ contentStart := j + len(openStr)
+ if contentStart >= len(line) {
+ return "", 0, 0, false
+ }
+ doubleHit := false
+ for _, seq := range doubleSeqs {
+ if strings.HasPrefix(line[j:], seq) {
+ doubleHit = true
+ contentStart += len(seq) - len(openStr)
+ if contentStart >= len(line) {
+ return "", 0, 0, false
+ }
+ break
+ }
+ }
+ next := line[contentStart]
+ if next == ' ' {
+ pos = contentStart + 1
+ continue
+ }
+ if !doubleHit && next == close {
+ pos = contentStart + 1
+ continue
+ }
+ k := strings.IndexByte(line[contentStart:], close)
if k < 0 {
return "", 0, 0, false
}
- closeIdx := j + 1 + k
- if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
+ closeIdx := contentStart + k
+ if closeIdx-1 >= contentStart && line[closeIdx-1] == ' ' {
pos = closeIdx + 1
continue
}
- inner := strings.TrimSpace(line[j+1 : closeIdx])
+ inner := strings.TrimSpace(line[contentStart:closeIdx])
if inner == "" {
pos = closeIdx + 1
continue
@@ -394,20 +451,20 @@ func findStrictInlineTag(line string, open, close byte) (string, int, int, bool)
// isBareDoubleSemicolon reports whether the line contains a standalone
// double-semicolon marker with no inline content (";;" possibly with only
// whitespace after it). It explicitly excludes the valid form ";;text;".
-func isBareDoubleOpen(line string, open, close byte) bool {
+func isBareDoubleOpen(line string, openStr string, open, close byte) bool {
t := strings.TrimSpace(line)
- // check for double-open pattern
- dbl := string([]byte{open, open})
- if !strings.Contains(t, dbl) {
- return false
+ if openStr == "" {
+ openStr = string(open)
}
- if hasDoubleOpenTrigger(t, open, close) {
+ if openStr == "" {
return false
}
- if strings.HasPrefix(t, dbl) {
- rest := strings.TrimSpace(t[len(dbl):])
- if rest == "" || rest == ";" {
- return true
+ for _, seq := range doubleOpenSequences(openStr, open, close) {
+ if strings.HasPrefix(t, seq) {
+ rest := strings.TrimSpace(t[len(seq):])
+ if rest == "" || rest == string(close) {
+ return true
+ }
}
}
return false
@@ -562,40 +619,62 @@ func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit {
return nil
}
var edits []TextEdit
- _, _, openChar, closeChar := s.inlineMarkers()
+ openStr, _, openChar, closeChar := s.inlineMarkers()
for i, line := range d.lines {
- edits = append(edits, promptRemovalEditsForLine(line, i, openChar, closeChar)...)
+ edits = append(edits, promptRemovalEditsForLine(line, i, openStr, openChar, closeChar)...)
}
return edits
}
-func promptRemovalEditsForLine(line string, lineNum int, open, close byte) []TextEdit {
- if hasDoubleOpenTrigger(line, open, close) {
+func promptRemovalEditsForLine(line string, lineNum int, openStr string, open, close byte) []TextEdit {
+ if hasDoubleOpenTrigger(line, openStr, open, close) {
return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}}
}
- return collectSemicolonMarkers(line, lineNum, open, close)
+ return collectSemicolonMarkers(line, lineNum, openStr, open, close)
}
-func hasDoubleOpenTrigger(line string, open, close byte) bool {
+func hasDoubleOpenTrigger(line string, openStr string, open, close byte) bool {
+ if openStr == "" {
+ openStr = string(open)
+ }
+ if openStr == "" {
+ return false
+ }
+ seqs := doubleOpenSequences(openStr, open, close)
+ if len(seqs) == 0 {
+ return false
+ }
pos := 0
for pos < len(line) {
- // look for double-open sequence
- dbl := string([]byte{open, open})
- j := strings.Index(line[pos:], dbl)
- if j < 0 {
+ found := -1
+ var seq string
+ for _, cand := range seqs {
+ if cand == "" {
+ continue
+ }
+ if idx := strings.Index(line[pos:], cand); idx >= 0 {
+ abs := pos + idx
+ if found < 0 || abs < found {
+ found = abs
+ seq = cand
+ }
+ }
+ }
+ if found < 0 {
return false
}
- j += pos
- contentStart := j + len(dbl)
+ contentStart := found + len(seq)
if contentStart >= len(line) {
return false
}
first := line[contentStart]
- if first == ' ' || first == open {
+ if first == ' ' || first == close || first == open {
pos = contentStart + 1
continue
}
- // find closing
+ if contentStart+1 >= len(line) {
+ return false
+ }
k := strings.IndexByte(line[contentStart+1:], close)
if k < 0 {
return false
@@ -610,34 +689,53 @@ func hasDoubleOpenTrigger(line string, open, close byte) bool {
return false
}
-func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextEdit {
+func collectSemicolonMarkers(line string, lineNum int, openStr string, open, close byte) []TextEdit {
+ if openStr == "" {
+ openStr = string(open)
+ }
+ if openStr == "" {
+ return nil
+ }
var edits []TextEdit
- startSemi := 0
- for startSemi < len(line) {
- j := strings.IndexByte(line[startSemi:], open)
+ start := 0
+ doubleSeqs := doubleOpenSequences(openStr, open, close)
+ for start < len(line) {
+ j := strings.Index(line[start:], openStr)
if j < 0 {
break
}
- j += startSemi
- k := strings.IndexByte(line[j+1:], close)
- if k < 0 {
+ j += start
+ contentStart := j + len(openStr)
+ if contentStart >= len(line) {
break
}
- if j+1 >= len(line) || line[j+1] == ' ' {
- startSemi = j + 1
+ next := line[contentStart]
+ if next == ' ' {
+ start = j + 1
continue
}
- if line[j+1] == open { // skip double-open start
- startSemi = j + 2
+ skipDouble := false
+ for _, seq := range doubleSeqs {
+ if strings.HasPrefix(line[j:], seq) {
+ skipDouble = true
+ break
+ }
+ }
+ if skipDouble {
+ start = j + 1
continue
}
- closeIdx := j + 1 + k
- if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
- startSemi = closeIdx + 1
+ k := strings.IndexByte(line[contentStart:], close)
+ if k < 0 {
+ break
+ }
+ closeIdx := contentStart + k
+ if closeIdx-1 < contentStart || line[closeIdx-1] == ' ' {
+ start = closeIdx + 1
continue
}
- if closeIdx-(j+1) < 1 {
- startSemi = closeIdx + 1
+ if closeIdx == contentStart {
+ start = closeIdx + 1
continue
}
endChar := closeIdx + 1
@@ -645,7 +743,7 @@ func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextE
endChar++
}
edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""})
- startSemi = endChar
+ start = endChar
}
return edits
}
diff --git a/internal/lsp/helpers_inline_prompt_test.go b/internal/lsp/helpers_inline_prompt_test.go
index 5554d89..5dc698a 100644
--- a/internal/lsp/helpers_inline_prompt_test.go
+++ b/internal/lsp/helpers_inline_prompt_test.go
@@ -7,12 +7,12 @@ import (
func TestLineHasInlinePrompt_BasicAndDoubleOpen(t *testing.T) {
// Basic inline
- if !lineHasInlinePrompt("do >task> now", '>', '>') {
- t.Fatalf("expected inline prompt detection for >text>")
+ if !lineHasInlinePrompt("do >!task> now", ">!", '>', '>') {
+ t.Fatalf("expected inline prompt detection for >!text>")
}
// Double-open variant should be recognized as inline prompt too
- if !lineHasInlinePrompt(">>replace>", '>', '>') {
- t.Fatalf("expected inline prompt detection for >>text>")
+ if !lineHasInlinePrompt(">>!replace>", ">!", '>', '>') {
+ t.Fatalf("expected inline prompt detection for >>!text>")
}
}
diff --git a/internal/lsp/helpers_more_test.go b/internal/lsp/helpers_more_test.go
index 287aa9d..683a69c 100644
--- a/internal/lsp/helpers_more_test.go
+++ b/internal/lsp/helpers_more_test.go
@@ -25,10 +25,10 @@ func TestLeadingAndApplyIndent(t *testing.T) {
}
func TestFindStrictInlineTag(t *testing.T) {
- if _, _, _, ok := findStrictInlineTag(">do this> next", '>', '>'); !ok {
+ if _, _, _, ok := findStrictInlineTag(">!do this> next", ">!", '>', '>'); !ok {
t.Fatalf("expected strict tag")
}
- if _, _, _, ok := findStrictInlineTag("> spaced >", '>', '>'); ok {
+ if _, _, _, ok := findStrictInlineTag(">! spaced >", ">!", '>', '>'); ok {
t.Fatalf("should ignore spaced tag")
}
}
@@ -81,11 +81,11 @@ func TestRangesOverlapAndOrder(t *testing.T) {
}
func TestPromptRemovalEditsForLine(t *testing.T) {
- edits := promptRemovalEditsForLine(">>do thing>", 3, '>', '>')
+ edits := promptRemovalEditsForLine(">>!do thing>", 3, ">!", '>', '>')
if len(edits) != 1 || edits[0].Range.Start.Line != 3 {
t.Fatalf("expected full-line removal for double-semicolon")
}
- edits2 := promptRemovalEditsForLine(">act> and >b>", 1, '>', '>')
+ edits2 := promptRemovalEditsForLine(">!act> and >!b>", 1, ">!", '>', '>')
if len(edits2) == 0 {
t.Fatalf("expected edits to remove strict markers")
}
@@ -94,7 +94,7 @@ func TestPromptRemovalEditsForLine(t *testing.T) {
func TestCollectPromptRemovalEdits_MultiLine(t *testing.T) {
s := newTestServer()
uri := "file:///t.go"
- s.setDocument(uri, "a\n>do> x\n>>wipe>\nend")
+ s.setDocument(uri, "a\n>!do> x\n>>!wipe>\nend")
edits := s.collectPromptRemovalEdits(uri)
if len(edits) < 2 {
t.Fatalf("expected >=2 edits, got %d", len(edits))
@@ -143,10 +143,10 @@ func TestComputeTextEditAndFilter(t *testing.T) {
}
func TestIsBareDoubleOpen(t *testing.T) {
- if !isBareDoubleOpen(">> ", '>', '>') {
+ if !isBareDoubleOpen(">>! ", ">!", '>', '>') {
t.Fatalf("expected true")
}
- if isBareDoubleOpen(">>x>", '>', '>') {
+ if isBareDoubleOpen(">>!x>", ">!", '>', '>') {
t.Fatalf("expected false for content form")
}
}
diff --git a/internal/lsp/init_and_trigger_test.go b/internal/lsp/init_and_trigger_test.go
index 2c5cd62..832d451 100644
--- a/internal/lsp/init_and_trigger_test.go
+++ b/internal/lsp/init_and_trigger_test.go
@@ -72,8 +72,8 @@ func TestIsTriggerEvent_Variants(t *testing.T) {
t.Fatalf("fallback char should trigger")
}
// 4) Bare double-open disables trigger
- p4 := CompletionParams{Position: Position{Line: 0, Character: 2}}
- if s.isTriggerEvent(p4, ">>") {
+ p4 := CompletionParams{Position: Position{Line: 0, Character: 3}}
+ if s.isTriggerEvent(p4, ">>!") {
t.Fatalf("bare double-open should not trigger")
}
}
diff --git a/internal/lsp/inline_prompt_completion_test.go b/internal/lsp/inline_prompt_completion_test.go
index 0b71d13..9599a70 100644
--- a/internal/lsp/inline_prompt_completion_test.go
+++ b/internal/lsp/inline_prompt_completion_test.go
@@ -27,7 +27,7 @@ func TestHandleCompletionInlinePromptDoubleArrow(t *testing.T) {
initServerDefaults(s)
s.llmClient = fakeLLMInline{}
uri := "file:///inline.go"
- line := "hello world >>translate this into bulgarian>"
+ line := "hello world >>!translate this into bulgarian>"
s.setDocument(uri, line)
p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: 0, Character: len(line)}}
ctx := struct {
diff --git a/internal/lsp/instruction_table_test.go b/internal/lsp/instruction_table_test.go
index a6042b1..542bc68 100644
--- a/internal/lsp/instruction_table_test.go
+++ b/internal/lsp/instruction_table_test.go
@@ -8,7 +8,7 @@ func TestFindFirstInstructionInLine_Table(t *testing.T) {
line string
instr string
}{
- {"strict_inline_marker", ">do> trailing", "do"},
+ {"strict_inline_marker", ">!do> trailing", "do"},
{"c_block", "x /* add docs */ y", "add docs"},
{"html_comment", "<!-- fix --> code", "fix"},
{"slash_slash", "code // please refactor", "please refactor"},
diff --git a/internal/lsp/postprocess_indent_test.go b/internal/lsp/postprocess_indent_test.go
index 28f73a5..f00fb74 100644
--- a/internal/lsp/postprocess_indent_test.go
+++ b/internal/lsp/postprocess_indent_test.go
@@ -4,7 +4,7 @@ import "testing"
func TestPostProcessCompletion_IndentWithDoubleOpen(t *testing.T) {
s := newTestServer()
- cleaned := s.postProcessCompletion("a\nb", "", " >>gen>")
+ cleaned := s.postProcessCompletion("a\nb", "", " >>!gen>")
// Expect each non-empty line to be indented by two spaces
want := " a\n b"
if cleaned != want {
diff --git a/internal/lsp/provider_native_success_test.go b/internal/lsp/provider_native_success_test.go
index e5ab81e..5bfe434 100644
--- a/internal/lsp/provider_native_success_test.go
+++ b/internal/lsp/provider_native_success_test.go
@@ -50,7 +50,7 @@ func TestProviderNativeCompletion_IndentWithDoubleOpen(t *testing.T) {
s := newTestServer()
s.llmClient = fakeCompleterIndent{}
spec := s.buildRequestSpec(surfaceCompletion)
- current := " >>do>" // leading indent + double-open marker
+ current := " >>!do>" // leading indent + double-open marker
p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 0, Character: len(current)}}
plan := completionPlan{current: current, params: p, funcCtx: "func f(){}", docStr: "doc", cacheKey: "k"}
items, ok := s.tryProviderNativeCompletion(context.Background(), plan, spec, s.llmClient, "0000")
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index d55a967..e3a21f3 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -448,7 +448,7 @@ func (s *Server) inlineMarkers() (open string, close string, openChar byte, clos
cfg := s.currentConfig()
open = strings.TrimSpace(cfg.InlineOpen)
if open == "" {
- open = ">"
+ open = ">!"
}
close = strings.TrimSpace(cfg.InlineClose)
if close == "" {
diff --git a/internal/lsp/triggers_config_test.go b/internal/lsp/triggers_config_test.go
index 96ac4ba..9ab2752 100644
--- a/internal/lsp/triggers_config_test.go
+++ b/internal/lsp/triggers_config_test.go
@@ -31,7 +31,7 @@ func TestNewServer_AssignsTriggerGlobals_AndParsingUsesThem(t *testing.T) {
s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{
InlineOpen: "<", InlineClose: ">", ChatSuffix: ")", ChatPrefixes: []string{":"},
})
- _, _, openChar, closeChar := s.inlineMarkers()
+ openStr, _, openChar, closeChar := s.inlineMarkers()
if openChar != '<' || closeChar != '>' {
t.Fatalf("inline markers not applied: %q %q", string(openChar), string(closeChar))
}
@@ -39,7 +39,7 @@ func TestNewServer_AssignsTriggerGlobals_AndParsingUsesThem(t *testing.T) {
if suffixChar != ')' || len(prefixes) == 0 || prefixes[0] != ":" {
t.Fatalf("chat markers not applied: suffix=%q prefixes=%v", string(suffixChar), prefixes)
}
- if txt, l, r, ok := findStrictInlineTag("x<do>y", openChar, closeChar); !ok || txt != "do" || l != 1 || r != 5 {
+ if txt, l, r, ok := findStrictInlineTag("x<do>y", openStr, openChar, closeChar); !ok || txt != "do" || l != 1 || r != 5 {
t.Fatalf("findStrictInlineTag failed: ok=%v txt=%q l=%d r=%d", ok, txt, l, r)
}
if got := s.stripTrailingTrigger("note:)"); got != "note:" {