Skip to content

Commit 8e9d4fa

Browse files
16bit-ykikoclaude
andcommitted
feat(completion): smart parenthesis detection to avoid duplicate parens
When the next non-whitespace character after the cursor is already '(', skip inserting parentheses and parameter placeholders in the snippet. This prevents duplicate parens when completing a function name that already has arguments written after it. Uses the original file content (not Clang's internal buffer) for lookahead to correctly detect the next token. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 418e190 commit 8e9d4fa

File tree

2 files changed

+96
-16
lines changed

2 files changed

+96
-16
lines changed

src/feature/code_completion.cpp

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,28 @@ auto extract_signature(const clang::CodeCompletionString& ccs) -> std::string {
158158
return signature;
159159
}
160160

161+
/// Find the first non-whitespace character after the given offset in content.
162+
/// Returns '\0' if none found (end of content).
163+
auto next_token_char(llvm::StringRef content, std::uint32_t offset) -> char {
164+
for(auto i = offset; i < content.size(); ++i) {
165+
char c = content[i];
166+
if(c != ' ' && c != '\t' && c != '\n' && c != '\r') {
167+
return c;
168+
}
169+
}
170+
return '\0';
171+
}
172+
161173
/// Build a snippet string from a CodeCompletionString.
162174
/// Produces e.g. "funcName(${1:int x}, ${2:float y})" for functions,
163175
/// or "ClassName<${1:T}>" for class templates.
164-
auto build_snippet(const clang::CodeCompletionString& ccs) -> std::string {
176+
/// If skip_parens is true, omits everything from '(' onward (when the next
177+
/// token after the cursor is already '(').
178+
auto build_snippet(const clang::CodeCompletionString& ccs, bool skip_parens = false)
179+
-> std::string {
165180
std::string snippet;
166181
unsigned placeholder_index = 0;
182+
bool in_parens = false;
167183

168184
for(const auto& chunk: ccs) {
169185
using CK = clang::CodeCompletionString::ChunkKind;
@@ -174,33 +190,47 @@ auto build_snippet(const clang::CodeCompletionString& ccs) -> std::string {
174190
}
175191
break;
176192
case CK::CK_Placeholder:
193+
if(in_parens && skip_parens) {
194+
break;
195+
}
177196
if(chunk.Text) {
178197
snippet += std::format("${{{0}:{1}}}", ++placeholder_index, chunk.Text);
179198
}
180199
break;
181-
case CK::CK_LeftParen: snippet += '('; break;
182-
case CK::CK_RightParen: snippet += ')'; break;
200+
case CK::CK_LeftParen:
201+
in_parens = true;
202+
if(!skip_parens) {
203+
snippet += '(';
204+
}
205+
break;
206+
case CK::CK_RightParen:
207+
in_parens = false;
208+
if(!skip_parens) {
209+
snippet += ')';
210+
}
211+
break;
183212
case CK::CK_LeftAngle: snippet += '<'; break;
184213
case CK::CK_RightAngle: snippet += '>'; break;
185-
case CK::CK_Comma: snippet += ", "; break;
214+
case CK::CK_Comma:
215+
if(!(in_parens && skip_parens)) {
216+
snippet += ", ";
217+
}
218+
break;
186219
case CK::CK_Text:
187-
if(chunk.Text) {
220+
if(!(in_parens && skip_parens) && chunk.Text) {
188221
snippet += chunk.Text;
189222
}
190223
break;
191-
case CK::CK_Optional:
192-
// Optional chunks contain default arguments — skip for snippet.
193-
break;
224+
case CK::CK_Optional: break;
194225
case CK::CK_Informative:
195226
case CK::CK_ResultType:
196-
case CK::CK_CurrentParameter:
197-
// Display-only chunks, not part of insertion.
198-
break;
227+
case CK::CK_CurrentParameter: break;
199228
default: break;
200229
}
201230
}
202231

203-
// If no placeholders were generated, return empty to signal plain text.
232+
// If no placeholders were generated and parens were skipped,
233+
// return empty to signal plain text.
204234
if(placeholder_index == 0) {
205235
return {};
206236
}
@@ -229,9 +259,11 @@ class CodeCompletionCollector final : public clang::CodeCompleteConsumer {
229259
CodeCompletionCollector(std::uint32_t offset,
230260
PositionEncoding encoding,
231261
std::vector<protocol::CompletionItem>& output,
232-
const CodeCompletionOptions& options) :
262+
const CodeCompletionOptions& options,
263+
llvm::StringRef original_content) :
233264
clang::CodeCompleteConsumer({}), offset(offset), encoding(encoding), output(output),
234-
options(options), info(std::make_shared<clang::GlobalCodeCompletionAllocator>()) {}
265+
options(options), original_content(original_content),
266+
info(std::make_shared<clang::GlobalCodeCompletionAllocator>()) {}
235267

236268
clang::CodeCompletionAllocator& getAllocator() final {
237269
return info.getAllocator();
@@ -425,7 +457,8 @@ class CodeCompletionCollector final : public clang::CodeCompleteConsumer {
425457
// Generate snippet for non-bundled callables.
426458
if(is_callable && !options.bundle_overloads &&
427459
options.enable_function_arguments_snippet) {
428-
snippet = build_snippet(*ccs);
460+
bool next_is_paren = next_token_char(original_content, offset) == '(';
461+
snippet = build_snippet(*ccs, /*skip_parens=*/next_is_paren);
429462
}
430463
}
431464

@@ -495,6 +528,7 @@ class CodeCompletionCollector final : public clang::CodeCompleteConsumer {
495528
std::uint32_t offset;
496529
PositionEncoding encoding;
497530
std::vector<protocol::CompletionItem>& output;
531+
llvm::StringRef original_content;
498532
const CodeCompletionOptions& options;
499533
clang::CodeCompletionTUInfo info;
500534
};
@@ -509,7 +543,15 @@ auto code_complete(CompilationParams& params,
509543
auto& [file, offset] = params.completion;
510544
(void)file;
511545

512-
auto* consumer = new CodeCompletionCollector(offset, encoding, items, options);
546+
// Get the original file content for lookahead (smart parens detection).
547+
llvm::StringRef original_content;
548+
auto buf_it = params.buffers.find(file);
549+
if(buf_it != params.buffers.end()) {
550+
original_content = buf_it->second->getBuffer();
551+
}
552+
553+
auto* consumer =
554+
new CodeCompletionCollector(offset, encoding, items, options, original_content);
513555
auto unit = complete(params, consumer);
514556
(void)unit;
515557

tests/unit/feature/code_completion_tests.cpp

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,44 @@ int z = fo$(pos)
335335
*it->insert_text_format == protocol::InsertTextFormat::PlainText);
336336
}
337337

338+
TEST_CASE(SmartParensSkip) {
339+
// When next token after cursor is '(', snippet should not insert parens.
340+
feature::CodeCompletionOptions opts;
341+
opts.bundle_overloads = false;
342+
opts.enable_function_arguments_snippet = true;
343+
code_complete(R"cpp(
344+
int foooo(int x, float y);
345+
int z = fo$(pos)(1, 2.0f);
346+
)cpp",
347+
opts);
348+
349+
auto it = find_item("foooo");
350+
ASSERT_TRUE(it != items.end());
351+
// With parens already present, snippet should degrade to plain text
352+
// (no placeholders → build_snippet returns empty → label used).
353+
auto& edit = std::get<protocol::TextEdit>(*it->text_edit);
354+
ASSERT_TRUE(edit.new_text.find("(") == std::string::npos);
355+
}
356+
357+
TEST_CASE(SmartParensInsert) {
358+
// When next token is NOT '(', snippet should include parens normally.
359+
feature::CodeCompletionOptions opts;
360+
opts.bundle_overloads = false;
361+
opts.enable_function_arguments_snippet = true;
362+
code_complete(R"cpp(
363+
int foooo(int x, float y);
364+
int z = fo$(pos);
365+
)cpp",
366+
opts);
367+
368+
auto it = find_item("foooo");
369+
ASSERT_TRUE(it != items.end());
370+
auto& edit = std::get<protocol::TextEdit>(*it->text_edit);
371+
// Should contain '(' since there's no existing paren.
372+
ASSERT_TRUE(edit.new_text.find("(") != std::string::npos);
373+
ASSERT_TRUE(edit.new_text.find("${1:") != std::string::npos);
374+
}
375+
338376
TEST_CASE(SnippetBundleMode) {
339377
// In bundle mode, snippets should NOT be generated even if enabled.
340378
feature::CodeCompletionOptions opts;

0 commit comments

Comments
 (0)