Skip to content

Commit c5d578d

Browse files
authored
Merge pull request #8 from dandehoon/feature/tokens [patch]
feat!: add support for more tokens, refactor tests with actual editor, multiple cursors support
2 parents e6a2414 + c97b46e commit c5d578d

14 files changed

+1401
-773
lines changed

README.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,25 @@ Smartly expand or shrink your code selection, recover from misclicks or accident
2121
- **Expand Selection**: `Ctrl+E` (Windows/Linux) or `Cmd+E` (Mac)
2222
- **Retract Selection**: `Ctrl+Shift+E` (Windows/Linux) or `Cmd+Shift+E` (Mac)
2323

24-
### Expansion Examples
25-
26-
#### Basic Token Expansion
27-
28-
```javascript
29-
const API_BASE_URL = 'https://api.example.com';
30-
```
24+
> [!TIP]
25+
> Both commands work with single and multiple cursor selections.
3126
32-
With cursor on `api``api.example.com``https://api.example.com` → full string
33-
34-
#### Nested Scopes
27+
### Expansion Examples
3528

36-
```javascript
37-
const config = { url: 'https://example.com' };
29+
Text: `const config = { url: 'https://example.com' };`
30+
31+
```txt
32+
With cursor on `xamp`, next expansions will be:
33+
→ example
34+
→ example.com
35+
→ https://example.com
36+
→ 'https://example.com'
37+
→ url: 'https://example.com'
38+
→ { url: 'https://example.com' }
39+
→ const config = { url: 'https://example.com' }
40+
→ const config = { url: 'https://example.com' };
3841
```
3942

40-
With cursor on `example``example.com``https://example.com``[url]``{...}`
41-
4243
## Commands
4344

4445
- **`vscode-generic-expand-selection.expandSelection`**: Expand Selection

package.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"url": "git://github.com/dandehoon/vscode-generic-expand-selection.git"
1010
},
1111
"engines": {
12-
"vscode": "^1.92.0"
12+
"vscode": "^1.101.0"
1313
},
1414
"categories": [
1515
"Other"
@@ -49,22 +49,22 @@
4949
"test": "tsc -p . --outDir out && node esbuild.js && vscode-test",
5050
"build": "pnpm run check && node esbuild.js --production && pnpm dlx vsce package --no-dependencies -o out.vsix",
5151
"publish": "pnpm dlx vsce publish --no-dependencies",
52-
"vsce:install": "pnpm run build && code --install-extension out.vsix"
52+
"local": "pnpm run build && code --install-extension out.vsix"
5353
},
5454
"devDependencies": {
5555
"@eslint/js": "^9.29.0",
5656
"@types/mocha": "^10.0.10",
57-
"@types/node": "22.x",
58-
"@types/vscode": "^1.92.0",
59-
"@typescript-eslint/eslint-plugin": "^8.34.1",
60-
"@typescript-eslint/parser": "^8.34.1",
57+
"@types/node": "~22.15.33",
58+
"@types/vscode": "^1.101.0",
59+
"@typescript-eslint/eslint-plugin": "^8.35.0",
60+
"@typescript-eslint/parser": "^8.35.0",
6161
"@vscode/test-cli": "^0.0.11",
62-
"@vscode/test-electron": "^2.4.0",
63-
"@vscode/vsce": "^3.5.0",
62+
"@vscode/test-electron": "^2.5.2",
63+
"@vscode/vsce": "^3.6.0",
6464
"esbuild": "^0.25.5",
6565
"eslint": "^9.29.0",
66-
"mocha": "^11.7.0",
66+
"mocha": "^11.7.1",
6767
"typescript": "5.5.4"
6868
},
69-
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
69+
"packageManager": "pnpm@10.12.3+sha512.467df2c586056165580ad6dfb54ceaad94c5a30f80893ebdec5a44c5aa73c205ae4a5bb9d5ed6bb84ea7c249ece786642bbb49d06a307df218d03da41c317417"
7070
}

pnpm-lock.yaml

Lines changed: 155 additions & 178 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/expandSelection.ts

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,72 @@ import {
66
findToken,
77
} from './finders';
88

9+
const outputChannel = vscode.window.createOutputChannel(
10+
'Generic Expand Selection',
11+
);
12+
913
export class SelectionProvider {
10-
private selectionHistory: vscode.Selection[] = [];
14+
private selectionHistories: vscode.Selection[][] = [];
1115

1216
expandSelection(editor: vscode.TextEditor) {
1317
const document = editor.document;
14-
const selection = editor.selection;
15-
18+
const selections = editor.selections || [editor.selection];
1619
const text = document.getText();
17-
const startOffset = document.offsetAt(selection.start);
18-
const endOffset = document.offsetAt(selection.end);
19-
20-
// Try scoped expansion
21-
const newRange = this.findNextExpansion(
22-
text,
23-
startOffset,
24-
endOffset,
25-
document,
26-
);
27-
if (newRange) {
28-
// Store current selection for retract functionality before changing
29-
this.selectionHistory.push(selection);
30-
if (this.selectionHistory.length > 100) {
31-
this.selectionHistory.shift();
20+
21+
const newSelections: vscode.Selection[] = [];
22+
23+
for (let i = 0; i < selections.length; i++) {
24+
const selection = selections[i];
25+
const startOffset = document.offsetAt(selection.start);
26+
const endOffset = document.offsetAt(selection.end);
27+
28+
const newRange = this.findNextExpansion(
29+
text,
30+
startOffset,
31+
endOffset,
32+
document,
33+
);
34+
35+
if (newRange) {
36+
if (!this.selectionHistories[i]) {
37+
this.selectionHistories[i] = [];
38+
}
39+
this.selectionHistories[i].push(selection);
40+
if (this.selectionHistories[i].length > 100) {
41+
this.selectionHistories[i].shift();
42+
}
43+
const newStart = document.positionAt(newRange.start);
44+
const newEnd = document.positionAt(newRange.end);
45+
newSelections.push(new vscode.Selection(newStart, newEnd));
46+
} else {
47+
newSelections.push(selection);
3248
}
33-
const newStart = document.positionAt(newRange.start);
34-
const newEnd = document.positionAt(newRange.end);
35-
editor.selection = new vscode.Selection(newStart, newEnd);
49+
}
50+
51+
if (editor.selections) {
52+
editor.selections = newSelections;
53+
} else {
54+
editor.selection = newSelections[0];
3655
}
3756
}
3857

3958
shrinkSelection(editor: vscode.TextEditor) {
40-
if (this.selectionHistory.length > 0) {
41-
// Restore previous selection from history
42-
const previousSelection = this.selectionHistory.pop()!;
43-
editor.selection = previousSelection;
59+
const selections = editor.selections || [editor.selection];
60+
const newSelections: vscode.Selection[] = [];
61+
62+
for (let i = 0; i < selections.length; i++) {
63+
if (this.selectionHistories[i] && this.selectionHistories[i].length > 0) {
64+
const previousSelection = this.selectionHistories[i].pop()!;
65+
newSelections.push(previousSelection);
66+
} else {
67+
newSelections.push(selections[i]);
68+
}
69+
}
70+
71+
if (editor.selections) {
72+
editor.selections = newSelections;
73+
} else {
74+
editor.selection = newSelections[0];
4475
}
4576
}
4677

@@ -59,6 +90,9 @@ export class SelectionProvider {
5990
line: findLineExpansion(text, startIndex, endIndex, document),
6091
};
6192

93+
const selectionValue = text.substring(startIndex, endIndex);
94+
outputChannel.appendLine('[expandSelection] Current: ' + selectionValue);
95+
6296
// Return the smallest valid expansion, with priority logic
6397
let best: { start: number; end: number } | null = null;
6498
let smallest = Infinity;
@@ -76,10 +110,16 @@ export class SelectionProvider {
76110
}
77111
}
78112

79-
for (const [_, candidate] of Object.entries(candidateMap)) {
113+
for (const [key, candidate] of Object.entries(candidateMap)) {
80114
if (!candidate) {
81115
continue;
82116
}
117+
outputChannel.appendLine(
118+
`[expandSelection] Candidate: ${key} - ${text.substring(
119+
candidate.start,
120+
candidate.end,
121+
)}`,
122+
);
83123
const size = candidate.end - candidate.start;
84124
if (size > 0 && (best === null || size < smallest)) {
85125
best = candidate;

src/finders/quote.ts

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
11
import { SelectionCandidate } from '../types';
22
import { getCandidate } from './util';
33

4-
/**
5-
* Finds all quote pairs (", ', `) and returns the nearest containing candidate
6-
*/
74
export function findNearestQuotePair(
85
text: string,
96
startIndex: number,
107
endIndex: number,
8+
): SelectionCandidate | null {
9+
const selectionText = text.substring(startIndex, endIndex);
10+
if (!selectionText.includes('\n')) {
11+
const beforeSelection = text.substring(0, startIndex);
12+
const afterSelection = text.substring(endIndex);
13+
14+
const lineStart = beforeSelection.lastIndexOf('\n') + 1;
15+
const nextNewline = afterSelection.indexOf('\n');
16+
const lineEnd = nextNewline === -1 ? text.length : endIndex + nextNewline;
17+
18+
const lineContent = text.substring(lineStart, lineEnd);
19+
const selectionInLine = startIndex - lineStart;
20+
const selectionEndInLine = endIndex - lineStart;
21+
22+
const singleLineResult = findQuotePairsInText(
23+
lineContent,
24+
selectionInLine,
25+
selectionEndInLine,
26+
lineStart,
27+
);
28+
29+
if (singleLineResult) {
30+
return singleLineResult;
31+
}
32+
}
33+
34+
return findQuotePairsInText(text, startIndex, endIndex, 0);
35+
}
36+
37+
function findQuotePairsInText(
38+
text: string,
39+
startIndex: number,
40+
endIndex: number,
41+
offset: number = 0,
1142
): SelectionCandidate | null {
1243
const quotes = ['"', "'", '`'];
1344
let nearestCandidate: SelectionCandidate | null = null;
@@ -16,15 +47,18 @@ export function findNearestQuotePair(
1647
for (const quote of quotes) {
1748
for (let i = 0; i < text.length; i++) {
1849
if (text[i] === quote) {
19-
// Look for closing quote
2050
let j = i + 1;
2151
let escaped = false;
2252

2353
while (j < text.length) {
2454
if (text[j] === '\\' && !escaped) {
2555
escaped = true;
2656
} else if (text[j] === quote && !escaped) {
27-
// Try content inside quotes first
57+
if (offset === 0 && !isValidQuotePair(text, i, j + 1)) {
58+
i = j;
59+
break;
60+
}
61+
2862
const innerCandidate = getCandidate(
2963
startIndex,
3064
endIndex,
@@ -34,18 +68,21 @@ export function findNearestQuotePair(
3468
);
3569
let candidate = innerCandidate;
3670
if (!candidate) {
37-
// Fallback to full quote including delimiters
3871
candidate = getCandidate(startIndex, endIndex, i, j + 1, text);
3972
}
4073
if (candidate) {
74+
candidate = {
75+
start: candidate.start + offset,
76+
end: candidate.end + offset,
77+
};
4178
const size = candidate.end - candidate.start;
4279
if (size < smallestSize) {
4380
smallestSize = size;
4481
nearestCandidate = candidate;
4582
}
4683
}
4784

48-
i = j; // Skip to after this quote pair
85+
i = j;
4986
break;
5087
} else {
5188
escaped = false;
@@ -58,3 +95,76 @@ export function findNearestQuotePair(
5895

5996
return nearestCandidate;
6097
}
98+
99+
function isValidQuotePair(
100+
text: string,
101+
startPos: number,
102+
endPos: number,
103+
): boolean {
104+
const startQuote = text[startPos];
105+
const endQuote = text[endPos - 1];
106+
107+
if (startQuote !== endQuote) {
108+
return false;
109+
}
110+
111+
const content = text.substring(startPos + 1, endPos - 1);
112+
let unescapedQuoteCount = 0;
113+
let escaped = false;
114+
115+
for (let i = 0; i < content.length; i++) {
116+
if (content[i] === '\\' && !escaped) {
117+
escaped = true;
118+
} else if (content[i] === startQuote && !escaped) {
119+
unescapedQuoteCount++;
120+
} else {
121+
escaped = false;
122+
}
123+
}
124+
125+
if (unescapedQuoteCount > 0) {
126+
return false;
127+
}
128+
129+
const beforeQuote = text.substring(Math.max(0, startPos - 20), startPos);
130+
const afterQuote = text.substring(endPos, Math.min(text.length, endPos + 20));
131+
132+
if (beforeQuote.includes('//') || beforeQuote.includes('/*')) {
133+
const commentStart = Math.max(
134+
beforeQuote.lastIndexOf('//'),
135+
beforeQuote.lastIndexOf('/*'),
136+
);
137+
const lineBreak = beforeQuote.lastIndexOf('\n');
138+
if (commentStart > lineBreak) {
139+
return false;
140+
}
141+
}
142+
143+
if (beforeQuote.includes('```') || afterQuote.includes('```')) {
144+
return false;
145+
}
146+
147+
if (content.length < 50) {
148+
return true;
149+
}
150+
151+
const codePatterns = [/[=:]\s*$/, /\(\s*$/, /,\s*$/, /\[\s*$/, /return\s*$/];
152+
153+
const hasCodeContext = codePatterns.some((pattern) =>
154+
pattern.test(beforeQuote),
155+
);
156+
157+
if (
158+
!hasCodeContext &&
159+
(content.includes('"') || content.includes("'") || content.includes('`'))
160+
) {
161+
const differentQuoteTypes = ['"', "'", '`'].filter(
162+
(q) => q !== startQuote && content.includes(q),
163+
);
164+
if (differentQuoteTypes.length > 1) {
165+
return false;
166+
}
167+
}
168+
169+
return true;
170+
}

src/finders/token.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export function findToken(
2727
/[a-zA-Z0-9_]+/, // With underscores
2828
/[a-zA-Z0-9_-]+/, // With hyphens
2929
/[a-zA-Z0-9_\-.]+/, // With dots
30+
/[a-zA-Z0-9_\-.#$@%]+/, // Extended identifiers with programming symbols
31+
/[^\s[\]{}()"'`]+/, // Fallback: all except whitespace, scopes, and quotes
3032
];
3133

3234
for (const pattern of patterns) {

0 commit comments

Comments
 (0)