Skip to content

Commit

Permalink
feat: folding range support (#2169)
Browse files Browse the repository at this point in the history
#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.
  • Loading branch information
jasonlyu123 authored Oct 10, 2023
1 parent 4424524 commit d637d4e
Show file tree
Hide file tree
Showing 48 changed files with 1,282 additions and 37 deletions.
2 changes: 0 additions & 2 deletions packages/language-server/src/lib/documents/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,6 @@ export class Document extends WritableDocument {
* Get text content
*/
getText(range?: Range): string {
// Currently none of our own methods use the optional range parameter,
// but it's used by the HTML language service during hover
if (range) {
return this.content.substring(this.offsetAt(range.start), this.offsetAt(range.end));
}
Expand Down
188 changes: 188 additions & 0 deletions packages/language-server/src/lib/foldingRange/indentFolding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { sum } from 'lodash';
import { FoldingRange } from 'vscode-languageserver-types';
import { Document, TagInformation } from '../documents';

/**
*
* 1. check tab and space counts for lines
* 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.
*/
export function indentBasedFoldingRangeForTag(
document: Document,
tag: TagInformation
): FoldingRange[] {
if (tag.startPos.line === tag.endPos.line) {
return [];
}

const startLine = tag.startPos.line + 1;
const endLine = tag.endPos.line - 1;

if (startLine > endLine || startLine === endLine) {
return [];
}

return indentBasedFoldingRange({ document, ranges: [{ startLine, endLine }] });
}

export interface LineRange {
startLine: number;
endLine: number;
}

export function indentBasedFoldingRange({
document,
ranges,
skipFold
}: {
document: Document;
ranges?: LineRange[] | undefined;
skipFold?: (startLine: number, startLineContent: string) => boolean;
}): FoldingRange[] {
const text = document.getText();
const lines = text.split(/\r?\n/);

const indents = lines
.map((line, index) => ({
...collectIndents(line),
index
}))
.filter((line) => !line.empty);

const tabs = sum(indents.map((l) => l.tabCount));
const spaces = sum(indents.map((l) => l.spaceCount));

const tabSize = tabs && spaces ? guessTabSize(indents) : 4;

let currentIndent: number | undefined;
const result: FoldingRange[] = [];
const unfinishedFolds = new Map<number, { startLine: number; endLine: number }>();
ranges ??= [{ startLine: 0, endLine: lines.length - 1 }];
let rangeIndex = 0;
let range = ranges[rangeIndex++];

if (!range) {
return [];
}

for (const indentInfo of indents) {
if (indentInfo.index < range.startLine || indentInfo.empty) {
continue;
}

if (indentInfo.index > range.endLine) {
for (const fold of unfinishedFolds.values()) {
fold.endLine = range.endLine;
}

range = ranges[rangeIndex++];
if (!range) {
break;
}
}

const lineIndent = indentInfo.tabCount * tabSize + indentInfo.spaceCount;

currentIndent ??= lineIndent;

if (lineIndent > currentIndent) {
const startLine = indentInfo.index - 1;
if (!skipFold?.(startLine, lines[startLine])) {
const fold = { startLine, endLine: indentInfo.index };
unfinishedFolds.set(currentIndent, fold);
result.push(fold);
}

currentIndent = lineIndent;
}

if (lineIndent < currentIndent) {
const last = unfinishedFolds.get(lineIndent);
unfinishedFolds.delete(lineIndent);
if (last) {
last.endLine = Math.max(last.endLine, indentInfo.index - 1);
}

currentIndent = lineIndent;
}
}

return result;
}

function collectIndents(line: string) {
let tabCount = 0;
let spaceCount = 0;
let empty = true;

for (let index = 0; index < line.length; index++) {
const char = line[index];

if (char === '\t') {
tabCount++;
} else if (char === ' ') {
spaceCount++;
} else {
empty = false;
break;
}
}

return { tabCount, spaceCount, empty };
}

/**
*
* The indentation guessing is based on the indentation difference between lines.
* And if the count equals, then the one used more often takes priority.
*/
export function guessTabSize(
nonEmptyLines: Array<{ spaceCount: number; tabCount: number }>
): number {
// simplified version of
// https://github.com/microsoft/vscode/blob/559e9beea981b47ffd76d90158ccccafef663324/src/vs/editor/common/model/indentationGuesser.ts#L106
if (nonEmptyLines.length === 1) {
return 4;
}

const guessingTabSize = [2, 4, 6, 8, 3, 5, 7];
const MAX_GUESS = 8;
const matchCounts = new Map<number, number>();

for (let index = 0; index < nonEmptyLines.length; index++) {
const line = nonEmptyLines[index];
const previousLine = nonEmptyLines[index - 1] ?? { spaceCount: 0, tabCount: 0 };

const spaceDiff = Math.abs(line.spaceCount - previousLine.spaceCount);
const tabDiff = Math.abs(line.tabCount - previousLine.tabCount);
const diff =
tabDiff === 0 ? spaceDiff : spaceDiff % tabDiff === 0 ? spaceDiff / tabDiff : 0;

if (diff === 0 || diff > MAX_GUESS) {
continue;
}

for (const guess of guessingTabSize) {
if (diff === guess) {
matchCounts.set(guess, (matchCounts.get(guess) ?? 0) + 1);
}
}
}

let max = 0;
let tabSize: number | undefined;
for (const [size, count] of matchCounts) {
max = Math.max(max, count);
if (max === count) {
tabSize = size;
}
}

const match4 = matchCounts.get(4);
const match2 = matchCounts.get(2);
if (tabSize === 4 && match4 && match4 > 0 && match2 && match2 > 0 && match2 >= match4 / 2) {
tabSize = 2;
}

return tabSize ?? 4;
}
16 changes: 16 additions & 0 deletions packages/language-server/src/plugins/PluginHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CompletionList,
DefinitionLink,
Diagnostic,
FoldingRange,
FormattingOptions,
Hover,
LinkedEditingRanges,
Expand Down Expand Up @@ -595,6 +596,21 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
);
}

async getFoldingRanges(textDocument: TextDocumentIdentifier): Promise<FoldingRange[]> {
const document = this.getDocument(textDocument.uri);

const result = flatten(
await this.execute<FoldingRange[]>(
'getFoldingRanges',
[document],
ExecuteMode.Collect,
'high'
)
);

return result;
}

onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void {
for (const support of this.plugins) {
support.onWatchFileChanges?.(onWatchFileChangesParas);
Expand Down
81 changes: 79 additions & 2 deletions packages/language-server/src/plugins/css/CSSPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import {
mapObjWithRangeToOriginal,
mapHoverToParent,
mapSelectionRangeToParent,
isInTag
isInTag,
mapRangeToOriginal,
TagInformation
} from '../../lib/documents';
import { LSConfigManager, LSCSSConfig } from '../../ls-config';
import {
Expand All @@ -35,6 +37,7 @@ import {
DiagnosticsProvider,
DocumentColorsProvider,
DocumentSymbolsProvider,
FoldingRangeProvider,
HoverProvider,
SelectionRangeProvider
} from '../interfaces';
Expand All @@ -45,6 +48,8 @@ import { getIdClassCompletion } from './features/getIdClassCompletion';
import { AttributeContext, getAttributeContextAtPosition } from '../../lib/documents/parseHtml';
import { StyleAttributeDocument } from './StyleAttributeDocument';
import { getDocumentContext } from '../documentContext';
import { FoldingRange, FoldingRangeKind } from 'vscode-languageserver-types';
import { indentBasedFoldingRangeForTag } from '../../lib/foldingRange/indentFolding';

export class CSSPlugin
implements
Expand All @@ -54,7 +59,8 @@ export class CSSPlugin
DocumentColorsProvider,
ColorPresentationsProvider,
DocumentSymbolsProvider,
SelectionRangeProvider
SelectionRangeProvider,
FoldingRangeProvider
{
__name = 'css';
private configManager: LSConfigManager;
Expand Down Expand Up @@ -371,6 +377,65 @@ export class CSSPlugin
.map((symbol) => mapSymbolInformationToOriginal(cssDocument, symbol));
}

getFoldingRanges(document: Document): FoldingRange[] {
if (!document.styleInfo) {
return [];
}

const cssDocument = this.getCSSDoc(document);

if (shouldUseIndentBasedFolding(cssDocument.languageId)) {
return this.nonSyntacticFolding(document, document.styleInfo);
}

return this.getLanguageService(extractLanguage(cssDocument))
.getFoldingRanges(cssDocument)
.map((range) => {
const originalRange = mapRangeToOriginal(cssDocument, {
start: { line: range.startLine, character: range.startCharacter ?? 0 },
end: { line: range.endLine, character: range.endCharacter ?? 0 }
});

return {
startLine: originalRange.start.line,
endLine: originalRange.end.line,
kind: range.kind
};
});
}

private nonSyntacticFolding(document: Document, styleInfo: TagInformation): FoldingRange[] {
const ranges = indentBasedFoldingRangeForTag(document, styleInfo);
const startRegion = /^\s*(\/\/|\/\*\*?)\s*#?region\b/;
const endRegion = /^\s*(\/\/|\/\*\*?)\s*#?endregion\b/;

const lines = document
.getText()
.split(/\r?\n/)
.slice(styleInfo.startPos.line, styleInfo.endPos.line);

let start = -1;

for (let index = 0; index < lines.length; index++) {
const line = lines[index];

if (startRegion.test(line)) {
start = index;
} else if (endRegion.test(line)) {
if (start >= 0) {
ranges.push({
startLine: start + styleInfo.startPos.line,
endLine: index + styleInfo.startPos.line,
kind: FoldingRangeKind.Region
});
}
start = -1;
}
}

return ranges.sort((a, b) => a.startLine - b.startLine);
}

private getCSSDoc(document: Document) {
let cssDoc = this.cssDocuments.get(document);
if (!cssDoc || cssDoc.version < document.version) {
Expand Down Expand Up @@ -453,6 +518,18 @@ function shouldExcludeColor(document: CSSDocument) {
}
}

function shouldUseIndentBasedFolding(kind?: string) {
switch (kind) {
case 'postcss':
case 'sass':
case 'stylus':
case 'styl':
return true;
default:
return false;
}
}

function isSASS(document: CSSDocumentBase) {
switch (extractLanguage(document)) {
case 'sass':
Expand Down
Loading

0 comments on commit d637d4e

Please sign in to comment.