From 963e16736e620161b42af2115dd569113251152a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:56:40 +0000 Subject: [PATCH] Implement switch/case completion prioritization Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- src/services/completions.ts | 171 +++++++++++++----- .../fourslash/switchCaseCompletionPriority.ts | 125 +++++++++++++ 2 files changed, 253 insertions(+), 43 deletions(-) create mode 100644 tests/cases/fourslash/switchCaseCompletionPriority.ts diff --git a/src/services/completions.ts b/src/services/completions.ts index dc01ea8ede4..a781bf1168e 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -11,7 +11,8 @@ import { CancellationToken, canHaveDecorators, canUsePropertyAccess, - CaseBlock, + CaseBlock, + CaseClause, cast, CharacterCodes, ClassElement, @@ -44,9 +45,10 @@ import { createSortedArray, createTextSpanFromBounds, createTextSpanFromNode, - createTextSpanFromRange, - Debug, - Declaration, + createTextSpanFromRange, + Debug, + Declaration, + DefaultClause, Decorator, Diagnostics, diagnosticToString, @@ -349,12 +351,13 @@ import { skipAlias, SnippetKind, some, - SortedArray, - SourceFile, - SpreadAssignment, - startsWith, - stringToToken, - stripQuotes, + SortedArray, + SourceFile, + SpreadAssignment, + startsWith, + stringToToken, + stripQuotes, + SwitchStatement, Symbol, SymbolDisplay, SymbolDisplayPart, @@ -1264,11 +1267,88 @@ function keywordFiltersFromSyntaxKind(keywordCompletion: TokenSyntaxKind): Keywo } } -function getOptionalReplacementSpan(location: Node | undefined) { - // StringLiteralLike locations are handled separately in stringCompletions.ts - return location?.kind === SyntaxKind.Identifier ? createTextSpanFromNode(location) : undefined; -} - +function getOptionalReplacementSpan(location: Node | undefined) { + // StringLiteralLike locations are handled separately in stringCompletions.ts + return location?.kind === SyntaxKind.Identifier ? createTextSpanFromNode(location) : undefined; +} + +function shouldPrioritizeCaseAndDefaultKeywords(contextToken: Node | undefined, position: number): boolean { + if (!contextToken) return false; + + // Check if we're in a switch statement context + const switchStatement = findAncestor(contextToken, node => + node.kind === SyntaxKind.SwitchStatement ? true : + isFunctionLikeDeclaration(node) || isClassLike(node) ? "quit" : + false + ) as SwitchStatement | undefined; + + if (!switchStatement) return false; + + const sourceFile = contextToken.getSourceFile(); + const { line: currentLine, character: currentColumn } = getLineAndCharacterOfPosition(sourceFile, position); + + // Case 1: Cursor is directly inside the switch block + // switch (thing) { + // /*cursor*/ + // } + if (contextToken.parent === switchStatement.caseBlock) { + return true; + } + + // Case 2: Cursor is at the same column as a case/default keyword but on a different line, + // with at least one statement in the previous clause that meets certain conditions + const caseBlock = switchStatement.caseBlock; + if (!caseBlock) return false; + + // Find the last case/default clause before the cursor position + let lastClause: CaseClause | DefaultClause | undefined; + for (const clause of caseBlock.clauses) { + if (clause.pos >= position) break; + lastClause = clause; + } + + if (!lastClause) return false; + + // Check if cursor is at the same column as the last clause's keyword + const clauseKeywordPos = lastClause.kind === SyntaxKind.CaseClause ? + lastClause.getFirstToken(sourceFile)!.getStart(sourceFile) : + lastClause.getFirstToken(sourceFile)!.getStart(sourceFile); + const { line: clauseLine, character: clauseColumn } = getLineAndCharacterOfPosition(sourceFile, clauseKeywordPos); + + if (currentLine === clauseLine || currentColumn !== clauseColumn) { + return false; + } + + // Check if there's at least one statement in the clause + if (!lastClause.statements || lastClause.statements.length === 0) { + return false; + } + + const lastStatement = lastClause.statements[lastClause.statements.length - 1]; + + // Get position of the last statement + const { line: stmtLine, character: stmtColumn } = getLineAndCharacterOfPosition(sourceFile, lastStatement.getStart(sourceFile)); + + // Check if it's a jump statement + const isJumpStatement = isBreakOrContinueStatement(lastStatement) || + lastStatement.kind === SyntaxKind.ReturnStatement || + lastStatement.kind === SyntaxKind.ThrowStatement; + + if (isJumpStatement) { + // For jump statements: prioritize if on same line as case, or on different line with different indentation + if (stmtLine === clauseLine || (stmtLine !== clauseLine && stmtColumn !== clauseColumn)) { + return true; + } + } else { + // For non-jump statements: prioritize only if on different line and different column + if (stmtLine !== clauseLine && stmtColumn !== clauseColumn) { + return true; + } + } + + return false; +} + function completionInfoFromData( sourceFile: SourceFile, host: LanguageServiceHost, @@ -1368,17 +1448,22 @@ function completionInfoFromData( includeSymbol, ); - if (keywordFilters !== KeywordCompletionFilters.None) { - for (const keywordEntry of getKeywordCompletions(keywordFilters, !insideJsDocTagTypeExpression && isSourceFileJS(sourceFile))) { - if ( - isTypeOnlyLocation && isTypeKeyword(stringToToken(keywordEntry.name)!) || - !isTypeOnlyLocation && isContextualKeywordInAutoImportableExpressionSpace(keywordEntry.name) || - !uniqueNames.has(keywordEntry.name) - ) { - uniqueNames.add(keywordEntry.name); - insertSorted(entries, keywordEntry, compareCompletionEntries, /*equalityComparer*/ undefined, /*allowDuplicates*/ true); - } - } + if (keywordFilters !== KeywordCompletionFilters.None) { + const shouldPrioritizeCaseDefault = shouldPrioritizeCaseAndDefaultKeywords(contextToken, position); + for (const keywordEntry of getKeywordCompletions(keywordFilters, !insideJsDocTagTypeExpression && isSourceFileJS(sourceFile))) { + if ( + isTypeOnlyLocation && isTypeKeyword(stringToToken(keywordEntry.name)!) || + !isTypeOnlyLocation && isContextualKeywordInAutoImportableExpressionSpace(keywordEntry.name) || + !uniqueNames.has(keywordEntry.name) + ) { + uniqueNames.add(keywordEntry.name); + // Create a modified keyword entry with prioritized sort text for case/default in switch contexts + const modifiedKeywordEntry = shouldPrioritizeCaseDefault && (keywordEntry.name === "case" || keywordEntry.name === "default") + ? { ...keywordEntry, sortText: SortText.LocalDeclarationPriority } + : keywordEntry; + insertSorted(entries, modifiedKeywordEntry, compareCompletionEntries, /*equalityComparer*/ undefined, /*allowDuplicates*/ true); + } + } } for (const keywordEntry of getContextualKeywords(contextToken, position)) { @@ -4812,23 +4897,23 @@ function getCompletionData( return undefined; } - function tryGetFunctionLikeBodyCompletionContainer(contextToken: Node): FunctionLikeDeclaration | undefined { - if (contextToken) { - let prev: Node; - const container = findAncestor(contextToken.parent, (node: Node) => { - if (isClassLike(node)) { - return "quit"; - } - if (isFunctionLikeDeclaration(node) && prev === node.body) { - return true; - } - prev = node; - return false; - }); - return container && container as FunctionLikeDeclaration; - } - } - + function tryGetFunctionLikeBodyCompletionContainer(contextToken: Node): FunctionLikeDeclaration | undefined { + if (contextToken) { + let prev: Node; + const container = findAncestor(contextToken.parent, (node: Node) => { + if (isClassLike(node)) { + return "quit"; + } + if (isFunctionLikeDeclaration(node) && prev === node.body) { + return true; + } + prev = node; + return false; + }); + return container && container as FunctionLikeDeclaration; + } + } + function tryGetContainingJsxElement(contextToken: Node): JsxOpeningLikeElement | undefined { if (contextToken) { const parent = contextToken.parent; diff --git a/tests/cases/fourslash/switchCaseCompletionPriority.ts b/tests/cases/fourslash/switchCaseCompletionPriority.ts new file mode 100644 index 00000000000..6dd28abe47e --- /dev/null +++ b/tests/cases/fourslash/switchCaseCompletionPriority.ts @@ -0,0 +1,125 @@ +/// + +//// declare const thing: string; + +//// // Basic switch block - should prioritize case/default +//// switch (thing) { +//// /*basic*/ +//// } + +//// // Same - show all completions (not at switch body level) +//// switch (thing) { +//// case 42: +//// /*sameAll1*/ +//// } + +//// // Same - show all completions (after break at same column as case) +//// switch (thing) { +//// case 42: +//// break; +//// /*sameAll2*/ +//// } + +//// // Same - show all completions (complex nested structure) +//// switch (thing) { +//// case 42: +//// if (Math.random()) { +//// } +//// else { +//// } +//// /*sameAll3*/ +//// } + +//// // NEW - prioritize case/default (after break on different column) +//// switch (thing) { +//// case 42: +//// break; +//// /*newPrio1*/ +//// } + +//// // NEW - prioritize case/default (break on same line) +//// switch (thing) { +//// case 42: break; +//// /*newPrio2*/ +//// } + +//// // NEW - prioritize case/default (after return) +//// switch (thing) { +//// case 42: +//// return 1; +//// /*newPrio3*/ +//// } + +//// // NEW - prioritize case/default (after throw) +//// switch (thing) { +//// case 42: +//// throw new Error(); +//// /*newPrio4*/ +//// } + +verify.completions( + { + marker: "basic", + isNewIdentifierLocation: false, + includes: [ + { name: "case", sortText: completion.SortText.LocalDeclarationPriority }, + { name: "default", sortText: completion.SortText.LocalDeclarationPriority } + ] + }, + { + marker: "sameAll1", + isNewIdentifierLocation: false, + includes: [ + { name: "case", sortText: completion.SortText.GlobalsOrKeywords }, + { name: "default", sortText: completion.SortText.GlobalsOrKeywords } + ] + }, + { + marker: "sameAll2", + isNewIdentifierLocation: false, + includes: [ + { name: "case", sortText: completion.SortText.GlobalsOrKeywords }, + { name: "default", sortText: completion.SortText.GlobalsOrKeywords } + ] + }, + { + marker: "sameAll3", + isNewIdentifierLocation: false, + includes: [ + { name: "case", sortText: completion.SortText.GlobalsOrKeywords }, + { name: "default", sortText: completion.SortText.GlobalsOrKeywords } + ] + }, + { + marker: "newPrio1", + isNewIdentifierLocation: false, + includes: [ + { name: "case", sortText: completion.SortText.LocalDeclarationPriority }, + { name: "default", sortText: completion.SortText.LocalDeclarationPriority } + ] + }, + { + marker: "newPrio2", + isNewIdentifierLocation: false, + includes: [ + { name: "case", sortText: completion.SortText.LocalDeclarationPriority }, + { name: "default", sortText: completion.SortText.LocalDeclarationPriority } + ] + }, + { + marker: "newPrio3", + isNewIdentifierLocation: false, + includes: [ + { name: "case", sortText: completion.SortText.LocalDeclarationPriority }, + { name: "default", sortText: completion.SortText.LocalDeclarationPriority } + ] + }, + { + marker: "newPrio4", + isNewIdentifierLocation: false, + includes: [ + { name: "case", sortText: completion.SortText.LocalDeclarationPriority }, + { name: "default", sortText: completion.SortText.LocalDeclarationPriority } + ] + } +); \ No newline at end of file