Skip to content

Commit d637d4e

Browse files
authored
feat: folding range support (#2169)
#1704 #1120 This adds the syntactic folding range support instead of the VSCode's default indentation-based and regex-based folding. For embedded languages like Pug and Sass, I added a simplified version of indentation folding. The indentation folding is also a fallback for svelte blocks if there is a parser error.
1 parent 4424524 commit d637d4e

File tree

48 files changed

+1282
-37
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+1282
-37
lines changed

packages/language-server/src/lib/documents/Document.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,6 @@ export class Document extends WritableDocument {
6767
* Get text content
6868
*/
6969
getText(range?: Range): string {
70-
// Currently none of our own methods use the optional range parameter,
71-
// but it's used by the HTML language service during hover
7270
if (range) {
7371
return this.content.substring(this.offsetAt(range.start), this.offsetAt(range.end));
7472
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { sum } from 'lodash';
2+
import { FoldingRange } from 'vscode-languageserver-types';
3+
import { Document, TagInformation } from '../documents';
4+
5+
/**
6+
*
7+
* 1. check tab and space counts for lines
8+
* 2. if there're mixing space and tab guess the tabSize otherwise we only need to compare the numbers of spaces or tabs between lines.
9+
*/
10+
export function indentBasedFoldingRangeForTag(
11+
document: Document,
12+
tag: TagInformation
13+
): FoldingRange[] {
14+
if (tag.startPos.line === tag.endPos.line) {
15+
return [];
16+
}
17+
18+
const startLine = tag.startPos.line + 1;
19+
const endLine = tag.endPos.line - 1;
20+
21+
if (startLine > endLine || startLine === endLine) {
22+
return [];
23+
}
24+
25+
return indentBasedFoldingRange({ document, ranges: [{ startLine, endLine }] });
26+
}
27+
28+
export interface LineRange {
29+
startLine: number;
30+
endLine: number;
31+
}
32+
33+
export function indentBasedFoldingRange({
34+
document,
35+
ranges,
36+
skipFold
37+
}: {
38+
document: Document;
39+
ranges?: LineRange[] | undefined;
40+
skipFold?: (startLine: number, startLineContent: string) => boolean;
41+
}): FoldingRange[] {
42+
const text = document.getText();
43+
const lines = text.split(/\r?\n/);
44+
45+
const indents = lines
46+
.map((line, index) => ({
47+
...collectIndents(line),
48+
index
49+
}))
50+
.filter((line) => !line.empty);
51+
52+
const tabs = sum(indents.map((l) => l.tabCount));
53+
const spaces = sum(indents.map((l) => l.spaceCount));
54+
55+
const tabSize = tabs && spaces ? guessTabSize(indents) : 4;
56+
57+
let currentIndent: number | undefined;
58+
const result: FoldingRange[] = [];
59+
const unfinishedFolds = new Map<number, { startLine: number; endLine: number }>();
60+
ranges ??= [{ startLine: 0, endLine: lines.length - 1 }];
61+
let rangeIndex = 0;
62+
let range = ranges[rangeIndex++];
63+
64+
if (!range) {
65+
return [];
66+
}
67+
68+
for (const indentInfo of indents) {
69+
if (indentInfo.index < range.startLine || indentInfo.empty) {
70+
continue;
71+
}
72+
73+
if (indentInfo.index > range.endLine) {
74+
for (const fold of unfinishedFolds.values()) {
75+
fold.endLine = range.endLine;
76+
}
77+
78+
range = ranges[rangeIndex++];
79+
if (!range) {
80+
break;
81+
}
82+
}
83+
84+
const lineIndent = indentInfo.tabCount * tabSize + indentInfo.spaceCount;
85+
86+
currentIndent ??= lineIndent;
87+
88+
if (lineIndent > currentIndent) {
89+
const startLine = indentInfo.index - 1;
90+
if (!skipFold?.(startLine, lines[startLine])) {
91+
const fold = { startLine, endLine: indentInfo.index };
92+
unfinishedFolds.set(currentIndent, fold);
93+
result.push(fold);
94+
}
95+
96+
currentIndent = lineIndent;
97+
}
98+
99+
if (lineIndent < currentIndent) {
100+
const last = unfinishedFolds.get(lineIndent);
101+
unfinishedFolds.delete(lineIndent);
102+
if (last) {
103+
last.endLine = Math.max(last.endLine, indentInfo.index - 1);
104+
}
105+
106+
currentIndent = lineIndent;
107+
}
108+
}
109+
110+
return result;
111+
}
112+
113+
function collectIndents(line: string) {
114+
let tabCount = 0;
115+
let spaceCount = 0;
116+
let empty = true;
117+
118+
for (let index = 0; index < line.length; index++) {
119+
const char = line[index];
120+
121+
if (char === '\t') {
122+
tabCount++;
123+
} else if (char === ' ') {
124+
spaceCount++;
125+
} else {
126+
empty = false;
127+
break;
128+
}
129+
}
130+
131+
return { tabCount, spaceCount, empty };
132+
}
133+
134+
/**
135+
*
136+
* The indentation guessing is based on the indentation difference between lines.
137+
* And if the count equals, then the one used more often takes priority.
138+
*/
139+
export function guessTabSize(
140+
nonEmptyLines: Array<{ spaceCount: number; tabCount: number }>
141+
): number {
142+
// simplified version of
143+
// https://github.com/microsoft/vscode/blob/559e9beea981b47ffd76d90158ccccafef663324/src/vs/editor/common/model/indentationGuesser.ts#L106
144+
if (nonEmptyLines.length === 1) {
145+
return 4;
146+
}
147+
148+
const guessingTabSize = [2, 4, 6, 8, 3, 5, 7];
149+
const MAX_GUESS = 8;
150+
const matchCounts = new Map<number, number>();
151+
152+
for (let index = 0; index < nonEmptyLines.length; index++) {
153+
const line = nonEmptyLines[index];
154+
const previousLine = nonEmptyLines[index - 1] ?? { spaceCount: 0, tabCount: 0 };
155+
156+
const spaceDiff = Math.abs(line.spaceCount - previousLine.spaceCount);
157+
const tabDiff = Math.abs(line.tabCount - previousLine.tabCount);
158+
const diff =
159+
tabDiff === 0 ? spaceDiff : spaceDiff % tabDiff === 0 ? spaceDiff / tabDiff : 0;
160+
161+
if (diff === 0 || diff > MAX_GUESS) {
162+
continue;
163+
}
164+
165+
for (const guess of guessingTabSize) {
166+
if (diff === guess) {
167+
matchCounts.set(guess, (matchCounts.get(guess) ?? 0) + 1);
168+
}
169+
}
170+
}
171+
172+
let max = 0;
173+
let tabSize: number | undefined;
174+
for (const [size, count] of matchCounts) {
175+
max = Math.max(max, count);
176+
if (max === count) {
177+
tabSize = size;
178+
}
179+
}
180+
181+
const match4 = matchCounts.get(4);
182+
const match2 = matchCounts.get(2);
183+
if (tabSize === 4 && match4 && match4 > 0 && match2 && match2 > 0 && match2 >= match4 / 2) {
184+
tabSize = 2;
185+
}
186+
187+
return tabSize ?? 4;
188+
}

packages/language-server/src/plugins/PluginHost.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
CompletionList,
1616
DefinitionLink,
1717
Diagnostic,
18+
FoldingRange,
1819
FormattingOptions,
1920
Hover,
2021
LinkedEditingRanges,
@@ -595,6 +596,21 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
595596
);
596597
}
597598

599+
async getFoldingRanges(textDocument: TextDocumentIdentifier): Promise<FoldingRange[]> {
600+
const document = this.getDocument(textDocument.uri);
601+
602+
const result = flatten(
603+
await this.execute<FoldingRange[]>(
604+
'getFoldingRanges',
605+
[document],
606+
ExecuteMode.Collect,
607+
'high'
608+
)
609+
);
610+
611+
return result;
612+
}
613+
598614
onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void {
599615
for (const support of this.plugins) {
600616
support.onWatchFileChanges?.(onWatchFileChangesParas);

packages/language-server/src/plugins/css/CSSPlugin.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import {
2626
mapObjWithRangeToOriginal,
2727
mapHoverToParent,
2828
mapSelectionRangeToParent,
29-
isInTag
29+
isInTag,
30+
mapRangeToOriginal,
31+
TagInformation
3032
} from '../../lib/documents';
3133
import { LSConfigManager, LSCSSConfig } from '../../ls-config';
3234
import {
@@ -35,6 +37,7 @@ import {
3537
DiagnosticsProvider,
3638
DocumentColorsProvider,
3739
DocumentSymbolsProvider,
40+
FoldingRangeProvider,
3841
HoverProvider,
3942
SelectionRangeProvider
4043
} from '../interfaces';
@@ -45,6 +48,8 @@ import { getIdClassCompletion } from './features/getIdClassCompletion';
4548
import { AttributeContext, getAttributeContextAtPosition } from '../../lib/documents/parseHtml';
4649
import { StyleAttributeDocument } from './StyleAttributeDocument';
4750
import { getDocumentContext } from '../documentContext';
51+
import { FoldingRange, FoldingRangeKind } from 'vscode-languageserver-types';
52+
import { indentBasedFoldingRangeForTag } from '../../lib/foldingRange/indentFolding';
4853

4954
export class CSSPlugin
5055
implements
@@ -54,7 +59,8 @@ export class CSSPlugin
5459
DocumentColorsProvider,
5560
ColorPresentationsProvider,
5661
DocumentSymbolsProvider,
57-
SelectionRangeProvider
62+
SelectionRangeProvider,
63+
FoldingRangeProvider
5864
{
5965
__name = 'css';
6066
private configManager: LSConfigManager;
@@ -371,6 +377,65 @@ export class CSSPlugin
371377
.map((symbol) => mapSymbolInformationToOriginal(cssDocument, symbol));
372378
}
373379

380+
getFoldingRanges(document: Document): FoldingRange[] {
381+
if (!document.styleInfo) {
382+
return [];
383+
}
384+
385+
const cssDocument = this.getCSSDoc(document);
386+
387+
if (shouldUseIndentBasedFolding(cssDocument.languageId)) {
388+
return this.nonSyntacticFolding(document, document.styleInfo);
389+
}
390+
391+
return this.getLanguageService(extractLanguage(cssDocument))
392+
.getFoldingRanges(cssDocument)
393+
.map((range) => {
394+
const originalRange = mapRangeToOriginal(cssDocument, {
395+
start: { line: range.startLine, character: range.startCharacter ?? 0 },
396+
end: { line: range.endLine, character: range.endCharacter ?? 0 }
397+
});
398+
399+
return {
400+
startLine: originalRange.start.line,
401+
endLine: originalRange.end.line,
402+
kind: range.kind
403+
};
404+
});
405+
}
406+
407+
private nonSyntacticFolding(document: Document, styleInfo: TagInformation): FoldingRange[] {
408+
const ranges = indentBasedFoldingRangeForTag(document, styleInfo);
409+
const startRegion = /^\s*(\/\/|\/\*\*?)\s*#?region\b/;
410+
const endRegion = /^\s*(\/\/|\/\*\*?)\s*#?endregion\b/;
411+
412+
const lines = document
413+
.getText()
414+
.split(/\r?\n/)
415+
.slice(styleInfo.startPos.line, styleInfo.endPos.line);
416+
417+
let start = -1;
418+
419+
for (let index = 0; index < lines.length; index++) {
420+
const line = lines[index];
421+
422+
if (startRegion.test(line)) {
423+
start = index;
424+
} else if (endRegion.test(line)) {
425+
if (start >= 0) {
426+
ranges.push({
427+
startLine: start + styleInfo.startPos.line,
428+
endLine: index + styleInfo.startPos.line,
429+
kind: FoldingRangeKind.Region
430+
});
431+
}
432+
start = -1;
433+
}
434+
}
435+
436+
return ranges.sort((a, b) => a.startLine - b.startLine);
437+
}
438+
374439
private getCSSDoc(document: Document) {
375440
let cssDoc = this.cssDocuments.get(document);
376441
if (!cssDoc || cssDoc.version < document.version) {
@@ -453,6 +518,18 @@ function shouldExcludeColor(document: CSSDocument) {
453518
}
454519
}
455520

521+
function shouldUseIndentBasedFolding(kind?: string) {
522+
switch (kind) {
523+
case 'postcss':
524+
case 'sass':
525+
case 'stylus':
526+
case 'styl':
527+
return true;
528+
default:
529+
return false;
530+
}
531+
}
532+
456533
function isSASS(document: CSSDocumentBase) {
457534
switch (extractLanguage(document)) {
458535
case 'sass':

0 commit comments

Comments
 (0)