Skip to content

Commit 0e17579

Browse files
Improve some special cases for selection ranges (#939)
* Fix some typings * Improve a few special cases * Add tests --------- Co-authored-by: Muthurajan Sivasubramanian <[email protected]>
1 parent e5dcce0 commit 0e17579

File tree

4 files changed

+155
-46
lines changed

4 files changed

+155
-46
lines changed

src/languageservice/services/yamlSelectionRanges.ts

+75-40
Original file line numberDiff line numberDiff line change
@@ -3,81 +3,72 @@ import { yamlDocumentsCache } from '../parser/yaml-documents';
33
import { TextDocument } from 'vscode-languageserver-textdocument';
44
import { ASTNode } from 'vscode-json-languageservice';
55

6-
export function getSelectionRanges(document: TextDocument, positions: Position[]): SelectionRange[] | undefined {
7-
if (!document) {
8-
return;
9-
}
6+
export function getSelectionRanges(document: TextDocument, positions: Position[]): SelectionRange[] {
107
const doc = yamlDocumentsCache.getYamlDocument(document);
118
return positions.map((position) => {
129
const ranges = getRanges(position);
13-
let current: SelectionRange;
10+
let current: SelectionRange | undefined;
1411
for (const range of ranges) {
1512
current = SelectionRange.create(range, current);
1613
}
17-
if (!current) {
18-
current = SelectionRange.create({
19-
start: position,
20-
end: position,
21-
});
22-
}
23-
return current;
14+
return current ?? SelectionRange.create({ start: position, end: position });
2415
});
2516

2617
function getRanges(position: Position): Range[] {
2718
const offset = document.offsetAt(position);
2819
const result: Range[] = [];
2920
for (const ymlDoc of doc.documents) {
30-
let currentNode: ASTNode;
31-
let firstNodeOffset: number;
32-
let isFirstNode = true;
21+
let currentNode: ASTNode | undefined;
22+
let overrideStartOffset: number | undefined;
3323
ymlDoc.visit((node) => {
3424
const endOffset = node.offset + node.length;
3525
// Skip if end offset doesn't even reach cursor position
3626
if (endOffset < offset) {
3727
return true;
3828
}
39-
let startOffset = node.offset;
40-
// Recheck start offset with the trimmed one in case of this
41-
// key:
42-
// - value
43-
// ↑
44-
if (startOffset > offset) {
45-
const nodePosition = document.positionAt(startOffset);
46-
if (nodePosition.line !== position.line) {
47-
return true;
48-
}
49-
const lineBeginning = { line: nodePosition.line, character: 0 };
50-
const text = document.getText({
51-
start: lineBeginning,
52-
end: nodePosition,
53-
});
54-
if (text.trim().length !== 0) {
29+
// Skip if we're ending at new line
30+
// times:
31+
// - second: 1
32+
// millisecond: 10
33+
// | - second: 2
34+
// ↑ millisecond: 0
35+
// (| is actually part of { second: 1, millisecond: 10 })
36+
// \r\n doesn't matter here
37+
if (getTextFromOffsets(endOffset - 1, endOffset) === '\n') {
38+
if (endOffset - 1 < offset) {
5539
return true;
5640
}
57-
startOffset = document.offsetAt(lineBeginning);
58-
if (startOffset > offset) {
41+
}
42+
43+
let startOffset = node.offset;
44+
if (startOffset > offset) {
45+
// Recheck start offset for some special cases
46+
const newOffset = getStartOffsetForSpecialCases(node, position);
47+
if (!newOffset || newOffset > offset) {
5948
return true;
6049
}
50+
startOffset = newOffset;
6151
}
52+
6253
// Allow equal for children to override
6354
if (!currentNode || startOffset >= currentNode.offset) {
6455
currentNode = node;
65-
firstNodeOffset = startOffset;
56+
overrideStartOffset = startOffset;
6657
}
6758
return true;
6859
});
6960
while (currentNode) {
70-
const startOffset = isFirstNode ? firstNodeOffset : currentNode.offset;
61+
const startOffset = overrideStartOffset ?? currentNode.offset;
7162
const endOffset = currentNode.offset + currentNode.length;
7263
const range = {
7364
start: document.positionAt(startOffset),
7465
end: document.positionAt(endOffset),
7566
};
7667
const text = document.getText(range);
77-
const trimmedText = text.trimEnd();
78-
const trimmedLength = text.length - trimmedText.length;
79-
if (trimmedLength > 0) {
80-
range.end = document.positionAt(endOffset - trimmedLength);
68+
const trimmedText = trimEndNewLine(text);
69+
const trimmedEndOffset = startOffset + trimmedText.length;
70+
if (trimmedEndOffset >= offset) {
71+
range.end = document.positionAt(trimmedEndOffset);
8172
}
8273
// Add a jump between '' "" {} []
8374
const isSurroundedBy = (startCharacter: string, endCharacter?: string): boolean => {
@@ -95,7 +86,7 @@ export function getSelectionRanges(document: TextDocument, positions: Position[]
9586
}
9687
result.push(range);
9788
currentNode = currentNode.parent;
98-
isFirstNode = false;
89+
overrideStartOffset = undefined;
9990
}
10091
// A position can't be in multiple documents
10192
if (result.length > 0) {
@@ -104,4 +95,48 @@ export function getSelectionRanges(document: TextDocument, positions: Position[]
10495
}
10596
return result.reverse();
10697
}
98+
99+
function getStartOffsetForSpecialCases(node: ASTNode, position: Position): number | undefined {
100+
const nodeStartPosition = document.positionAt(node.offset);
101+
if (nodeStartPosition.line !== position.line) {
102+
return;
103+
}
104+
105+
if (node.parent?.type === 'array') {
106+
// array:
107+
// - value
108+
// ↑
109+
if (getTextFromOffsets(node.offset - 2, node.offset) === '- ') {
110+
return node.offset - 2;
111+
}
112+
}
113+
114+
if (node.type === 'array' || node.type === 'object') {
115+
// array:
116+
// - value
117+
// ↑
118+
const lineBeginning = { line: nodeStartPosition.line, character: 0 };
119+
const text = document.getText({ start: lineBeginning, end: nodeStartPosition });
120+
if (text.trim().length === 0) {
121+
return document.offsetAt(lineBeginning);
122+
}
123+
}
124+
}
125+
126+
function getTextFromOffsets(startOffset: number, endOffset: number): string {
127+
return document.getText({
128+
start: document.positionAt(startOffset),
129+
end: document.positionAt(endOffset),
130+
});
131+
}
132+
}
133+
134+
function trimEndNewLine(str: string): string {
135+
if (str.endsWith('\r\n')) {
136+
return str.substring(0, str.length - 2);
137+
}
138+
if (str.endsWith('\n')) {
139+
return str.substring(0, str.length - 1);
140+
}
141+
return str;
107142
}

src/languageservice/yamlLanguageService.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export interface LanguageService {
175175
deleteSchemaContent: (schemaDeletions: SchemaDeletions) => void;
176176
deleteSchemasWhole: (schemaDeletions: SchemaDeletionsAll) => void;
177177
getFoldingRanges: (document: TextDocument, context: FoldingRangesContext) => FoldingRange[] | null;
178-
getSelectionRanges: (document: TextDocument, positions: Position[]) => SelectionRange[] | undefined;
178+
getSelectionRanges: (document: TextDocument, positions: Position[]) => SelectionRange[];
179179
getCodeAction: (document: TextDocument, params: CodeActionParams) => CodeAction[] | undefined;
180180
getCodeLens: (document: TextDocument) => PromiseLike<CodeLens[] | undefined> | CodeLens[] | undefined;
181181
resolveCodeLens: (param: CodeLens) => PromiseLike<CodeLens> | CodeLens;

test/yamlSelectionRanges.test.ts

+78-4
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@ function isRangesEqual(range1: Range, range2: Range): boolean {
1212
);
1313
}
1414

15-
function expectSelections(selectionRange: SelectionRange, ranges: Range[]): void {
15+
function expectSelections(selectionRange: SelectionRange | undefined, ranges: Range[]): void {
1616
for (const range of ranges) {
17-
expect(selectionRange.range).eql(range);
17+
expect(selectionRange?.range).eql(range);
18+
1819
// Deduplicate ranges
19-
while (selectionRange.parent && isRangesEqual(selectionRange.range, selectionRange.parent.range)) {
20+
while (selectionRange?.parent && isRangesEqual(selectionRange.range, selectionRange.parent.range)) {
2021
selectionRange = selectionRange.parent;
2122
}
22-
selectionRange = selectionRange.parent;
23+
24+
selectionRange = selectionRange?.parent;
2325
}
2426
}
2527

@@ -62,6 +64,20 @@ key:
6264
{ start: { line: 1, character: 0 }, end: { line: 3, character: 8 } },
6365
]);
6466

67+
positions = [
68+
{
69+
line: 3,
70+
character: 3,
71+
},
72+
];
73+
ranges = getSelectionRanges(document, positions);
74+
expect(ranges.length).equal(positions.length);
75+
expectSelections(ranges[0], [
76+
{ start: { line: 3, character: 2 }, end: { line: 3, character: 8 } },
77+
{ start: { line: 2, character: 2 }, end: { line: 3, character: 8 } },
78+
{ start: { line: 1, character: 0 }, end: { line: 3, character: 8 } },
79+
]);
80+
6581
positions = [
6682
{
6783
line: 2,
@@ -76,6 +92,64 @@ key:
7692
]);
7793
});
7894

95+
it('selection ranges for array of objects', () => {
96+
const yaml = `
97+
times:
98+
- second: 1
99+
millisecond: 10
100+
- second: 2
101+
millisecond: 0
102+
`;
103+
let positions: Position[] = [
104+
{
105+
line: 4,
106+
character: 0,
107+
},
108+
];
109+
const document = setupTextDocument(yaml);
110+
let ranges = getSelectionRanges(document, positions);
111+
expect(ranges.length).equal(positions.length);
112+
expectSelections(ranges[0], [
113+
{ start: { line: 2, character: 2 }, end: { line: 5, character: 18 } },
114+
{ start: { line: 1, character: 0 }, end: { line: 5, character: 18 } },
115+
]);
116+
117+
positions = [
118+
{
119+
line: 5,
120+
character: 2,
121+
},
122+
];
123+
ranges = getSelectionRanges(document, positions);
124+
expect(ranges.length).equal(positions.length);
125+
expectSelections(ranges[0], [
126+
{ start: { line: 4, character: 4 }, end: { line: 5, character: 18 } },
127+
{ start: { line: 2, character: 2 }, end: { line: 5, character: 18 } },
128+
{ start: { line: 1, character: 0 }, end: { line: 5, character: 18 } },
129+
]);
130+
});
131+
132+
it('selection ranges for trailing spaces', () => {
133+
const yaml = `
134+
key:
135+
- 1
136+
- 2 \t
137+
`;
138+
const positions: Position[] = [
139+
{
140+
line: 2,
141+
character: 9,
142+
},
143+
];
144+
const document = setupTextDocument(yaml);
145+
const ranges = getSelectionRanges(document, positions);
146+
expect(ranges.length).equal(positions.length);
147+
expectSelections(ranges[0], [
148+
{ start: { line: 2, character: 2 }, end: { line: 3, character: 9 } },
149+
{ start: { line: 1, character: 0 }, end: { line: 3, character: 9 } },
150+
]);
151+
});
152+
79153
it('selection ranges jump for "" \'\'', () => {
80154
const yaml = `
81155
- "word"

tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"outDir": "./out/server",
1010
"sourceMap": true,
1111
"target": "es2020",
12-
"allowSyntheticDefaultImports": true,
12+
"allowSyntheticDefaultImports": true,
1313
"skipLibCheck": true
1414
},
1515
"include": [ "src", "test" ],

0 commit comments

Comments
 (0)