From d637d4e87ad9fd5b8fbf2ba93e374a064c24e342 Mon Sep 17 00:00:00 2001 From: "Lyu, Wei-Da" <36730922+jasonlyu123@users.noreply.github.com> Date: Tue, 10 Oct 2023 15:43:06 +0800 Subject: [PATCH] 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. --- .../src/lib/documents/Document.ts | 2 - .../src/lib/foldingRange/indentFolding.ts | 188 ++++++++++ .../language-server/src/plugins/PluginHost.ts | 16 + .../src/plugins/css/CSSPlugin.ts | 81 +++- .../src/plugins/html/HTMLPlugin.ts | 84 ++++- .../language-server/src/plugins/interfaces.ts | 8 +- .../plugins/typescript/DocumentSnapshot.ts | 18 +- .../plugins/typescript/TypeScriptPlugin.ts | 41 +- .../features/FoldingRangeProvider.ts | 349 ++++++++++++++++++ .../features/SelectionRangeProvider.ts | 2 +- .../plugins/typescript/svelte-ast-utils.ts | 95 +++++ packages/language-server/src/server.ts | 5 +- packages/language-server/src/utils.ts | 5 +- .../test/lib/indentFolding.test.ts | 55 +++ .../test/plugins/css/CSSPlugin.test.ts | 27 +- .../test/plugins/html/HTMLPlugin.test.ts | 27 +- .../features/folding-range/.gitignore | 1 + .../fixtures/await-catch/expectedv2.json | 4 + .../fixtures/await-catch/input.svelte | 9 + .../await-peding-catch/expectedv2.json | 6 + .../fixtures/await-peding-catch/input.svelte | 12 + .../await-pending-only/expectedv2.json | 1 + .../fixtures/await-pending-only/input.svelte | 3 + .../await-pending-then-catch/expectedv2.json | 8 + .../await-pending-then-catch/input.svelte | 17 + .../await-pending-then/expectedv2.json | 4 + .../fixtures/await-pending-then/input.svelte | 5 + .../expectedv2.json | 4 + .../await-then-catch-shorthand/input.svelte | 5 + .../fixtures/await-then-catch/expectedv2.json | 4 + .../fixtures/await-then-catch/input.svelte | 5 + .../fixtures/await-then/expectedv2.json | 4 + .../fixtures/await-then/input.svelte | 9 + .../fixtures/each-block/expectedv2.json | 4 + .../fixtures/each-block/input.svelte | 8 + .../fixtures/each-else/expectedv2.json | 4 + .../fixtures/each-else/input.svelte | 5 + .../fixtures/each-keyed/expectedv2.json | 1 + .../fixtures/each-keyed/input.svelte | 5 + .../fixtures/if-block/expectedv2.json | 8 + .../fixtures/if-block/input.svelte | 17 + .../fixtures/key-block/expectedv2.json | 4 + .../fixtures/key-block/input.svelte | 9 + .../fixtures/parser-error/expectedv2.json | 5 + .../fixtures/parser-error/input.svelte | 11 + .../fixtures/script/expectedv2.json | 4 + .../fixtures/script/input.svelte | 8 + .../features/folding-range/index.test.ts | 122 ++++++ 48 files changed, 1282 insertions(+), 37 deletions(-) create mode 100644 packages/language-server/src/lib/foldingRange/indentFolding.ts create mode 100644 packages/language-server/src/plugins/typescript/features/FoldingRangeProvider.ts create mode 100644 packages/language-server/test/lib/indentFolding.test.ts create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/.gitignore create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-catch/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-catch/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-peding-catch/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-peding-catch/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-only/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-only/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then-catch/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then-catch/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch-shorthand/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch-shorthand/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-block/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-block/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-else/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-else/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-keyed/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-keyed/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/if-block/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/if-block/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/key-block/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/key-block/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/parser-error/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/parser-error/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/script/expectedv2.json create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/fixtures/script/input.svelte create mode 100644 packages/language-server/test/plugins/typescript/features/folding-range/index.test.ts diff --git a/packages/language-server/src/lib/documents/Document.ts b/packages/language-server/src/lib/documents/Document.ts index bb69158f6..fde04e6e1 100644 --- a/packages/language-server/src/lib/documents/Document.ts +++ b/packages/language-server/src/lib/documents/Document.ts @@ -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)); } diff --git a/packages/language-server/src/lib/foldingRange/indentFolding.ts b/packages/language-server/src/lib/foldingRange/indentFolding.ts new file mode 100644 index 000000000..0eda608e7 --- /dev/null +++ b/packages/language-server/src/lib/foldingRange/indentFolding.ts @@ -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(); + 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(); + + 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; +} diff --git a/packages/language-server/src/plugins/PluginHost.ts b/packages/language-server/src/plugins/PluginHost.ts index c0bededfc..2fa463401 100644 --- a/packages/language-server/src/plugins/PluginHost.ts +++ b/packages/language-server/src/plugins/PluginHost.ts @@ -15,6 +15,7 @@ import { CompletionList, DefinitionLink, Diagnostic, + FoldingRange, FormattingOptions, Hover, LinkedEditingRanges, @@ -595,6 +596,21 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { ); } + async getFoldingRanges(textDocument: TextDocumentIdentifier): Promise { + const document = this.getDocument(textDocument.uri); + + const result = flatten( + await this.execute( + 'getFoldingRanges', + [document], + ExecuteMode.Collect, + 'high' + ) + ); + + return result; + } + onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void { for (const support of this.plugins) { support.onWatchFileChanges?.(onWatchFileChangesParas); diff --git a/packages/language-server/src/plugins/css/CSSPlugin.ts b/packages/language-server/src/plugins/css/CSSPlugin.ts index ddcc1f4bb..1147bef81 100644 --- a/packages/language-server/src/plugins/css/CSSPlugin.ts +++ b/packages/language-server/src/plugins/css/CSSPlugin.ts @@ -26,7 +26,9 @@ import { mapObjWithRangeToOriginal, mapHoverToParent, mapSelectionRangeToParent, - isInTag + isInTag, + mapRangeToOriginal, + TagInformation } from '../../lib/documents'; import { LSConfigManager, LSCSSConfig } from '../../ls-config'; import { @@ -35,6 +37,7 @@ import { DiagnosticsProvider, DocumentColorsProvider, DocumentSymbolsProvider, + FoldingRangeProvider, HoverProvider, SelectionRangeProvider } from '../interfaces'; @@ -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 @@ -54,7 +59,8 @@ export class CSSPlugin DocumentColorsProvider, ColorPresentationsProvider, DocumentSymbolsProvider, - SelectionRangeProvider + SelectionRangeProvider, + FoldingRangeProvider { __name = 'css'; private configManager: LSConfigManager; @@ -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) { @@ -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': diff --git a/packages/language-server/src/plugins/html/HTMLPlugin.ts b/packages/language-server/src/plugins/html/HTMLPlugin.ts index 6dd9e9a55..238e2536c 100644 --- a/packages/language-server/src/plugins/html/HTMLPlugin.ts +++ b/packages/language-server/src/plugins/html/HTMLPlugin.ts @@ -17,7 +17,8 @@ import { Range, WorkspaceEdit, LinkedEditingRanges, - CompletionContext + CompletionContext, + FoldingRange } from 'vscode-languageserver'; import { DocumentManager, @@ -31,21 +32,29 @@ import { HoverProvider, CompletionsProvider, RenameProvider, - LinkedEditingRangesProvider + LinkedEditingRangesProvider, + FoldingRangeProvider } from '../interfaces'; import { isInsideMoustacheTag, toRange } from '../../lib/documents/utils'; import { isNotNullOrUndefined, possiblyComponent } from '../../utils'; import { importPrettier } from '../../importPackage'; import path from 'path'; import { Logger } from '../../logger'; +import { indentBasedFoldingRangeForTag } from '../../lib/foldingRange/indentFolding'; export class HTMLPlugin - implements HoverProvider, CompletionsProvider, RenameProvider, LinkedEditingRangesProvider + implements + HoverProvider, + CompletionsProvider, + RenameProvider, + LinkedEditingRangesProvider, + FoldingRangeProvider { __name = 'html'; private lang = getLanguageService({ customDataProviders: this.getCustomDataProviders(), - useDefaultDataProvider: false + useDefaultDataProvider: false, + clientCapabilities: this.configManager.getClientCapabilities() }); private documents = new WeakMap(); private styleScriptTemplate = new Set(['template', 'style', 'script']); @@ -324,6 +333,73 @@ export class HTMLPlugin return { ranges }; } + getFoldingRanges(document: Document): FoldingRange[] { + const result = this.lang.getFoldingRanges(document); + const templateRange = document.templateInfo + ? indentBasedFoldingRangeForTag(document, document.templateInfo) + : []; + + const ARROW = '=>'; + + if (!document.getText().includes(ARROW)) { + return result.concat(templateRange); + } + + const byEnd = new Map(); + for (const fold of result) { + byEnd.set(fold.endLine, (byEnd.get(fold.endLine) ?? []).concat(fold)); + } + + let startIndex = 0; + while (startIndex < document.getTextLength()) { + const index = document.getText().indexOf(ARROW, startIndex); + startIndex = index + ARROW.length; + + if (index === -1) { + break; + } + const position = document.positionAt(index); + const isInStyleOrScript = + isInTag(position, document.styleInfo) || + isInTag(position, document.scriptInfo) || + isInTag(position, document.moduleScriptInfo); + + if (isInStyleOrScript) { + continue; + } + + const tag = document.html.findNodeAt(index); + + // our version of html document patched it so it's within the start tag + // but not the folding range returned by the language service + // which uses unpatched scanner + if (!tag.startTagEnd || index > tag.startTagEnd) { + continue; + } + + const tagStartPosition = document.positionAt(tag.start); + const range = byEnd + .get(position.line) + ?.find((r) => r.startLine === tagStartPosition.line); + + const newEndLine = document.positionAt(tag.end).line - 1; + if (newEndLine <= tagStartPosition.line) { + continue; + } + + if (range) { + range.endLine = newEndLine; + } else { + result.push({ + startLine: tagStartPosition.line, + endLine: newEndLine + }); + } + } + + return result.concat(templateRange); + } + /** * Returns true if rename happens at the tag name, not anywhere inbetween. */ diff --git a/packages/language-server/src/plugins/interfaces.ts b/packages/language-server/src/plugins/interfaces.ts index b95836c65..cceef9558 100644 --- a/packages/language-server/src/plugins/interfaces.ts +++ b/packages/language-server/src/plugins/interfaces.ts @@ -20,6 +20,7 @@ import { CompletionList, DefinitionLink, Diagnostic, + FoldingRange, FormattingOptions, Hover, InlayHint, @@ -223,6 +224,10 @@ export interface InlayHintProvider { ): Resolvable; } +export interface FoldingRangeProvider { + getFoldingRanges(document: Document): Resolvable; +} + export interface OnWatchFileChanges { onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void; } @@ -251,7 +256,8 @@ type ProviderBase = DiagnosticsProvider & ImplementationProvider & TypeDefinitionProvider & InlayHintProvider & - CallHierarchyProvider; + CallHierarchyProvider & + FoldingRangeProvider; export type LSProvider = ProviderBase & BackwardsCompatibleDefinitionsProvider; diff --git a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts index 764e8cfc7..72895882a 100644 --- a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts +++ b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts @@ -1,6 +1,4 @@ import { EncodedSourceMap, TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; -import path from 'path'; -import { walk } from 'svelte/compiler'; import { TemplateNode } from 'svelte/types/compiler/interfaces'; import { svelte2tsx, IExportedNames, internalHelpers } from 'svelte2tsx'; import ts from 'typescript'; @@ -19,7 +17,7 @@ import { } from '../../lib/documents'; import { pathToUrl, urlToPath } from '../../utils'; import { ConsumerDocumentMapper } from './DocumentMapper'; -import { SvelteNode } from './svelte-ast-utils'; +import { SvelteNode, SvelteNodeWalker, walkSvelteAst } from './svelte-ast-utils'; import { getScriptKindFromAttributes, getScriptKindFromFileName, @@ -335,19 +333,19 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot { : this.parent.offsetAt(positionOrOffset); let foundNode: SvelteNode | null = null; - walk(this.htmlAst as any, { + this.walkSvelteAst({ enter(node) { // In case the offset is at a point where a node ends and a new one begins, // the node where the code ends is used. If this introduces problems, introduce // an affinity parameter to prefer the node where it ends/starts. - if ((node as SvelteNode).start > offset || (node as SvelteNode).end < offset) { + if (node.start > offset || node.end < offset) { this.skip(); return; } const parent = foundNode; // Spread so the "parent" property isn't added to the original ast, // causing an infinite loop - foundNode = { ...node } as SvelteNode; + foundNode = { ...node }; if (parent) { foundNode.parent = parent; } @@ -357,6 +355,14 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot { return foundNode; } + walkSvelteAst(walker: SvelteNodeWalker) { + if (!this.htmlAst) { + return; + } + + walkSvelteAst(this.htmlAst, walker); + } + getOriginalPosition(pos: Position): Position { return this.getMapper().getOriginalPosition(pos); } diff --git a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts index fa7709bea..af6fad0e0 100644 --- a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -11,6 +11,7 @@ import { DefinitionLink, Diagnostic, FileChangeType, + FoldingRange, Hover, InlayHint, Location, @@ -29,19 +30,21 @@ import { } from 'vscode-languageserver'; import { Document, getTextInRange, mapSymbolInformationToOriginal } from '../../lib/documents'; import { LSConfigManager, LSTypescriptConfig } from '../../ls-config'; -import { isNotNullOrUndefined, isZeroLengthRange, pathToUrl } from '../../utils'; +import { isNotNullOrUndefined, isZeroLengthRange } from '../../utils'; import { AppCompletionItem, AppCompletionList, + CallHierarchyProvider, CodeActionsProvider, CompletionsProvider, DefinitionsProvider, DiagnosticsProvider, DocumentSymbolsProvider, - FileRename, - FindReferencesProvider, FileReferencesProvider, + FileRename, FindComponentReferencesProvider, + FindReferencesProvider, + FoldingRangeProvider, HoverProvider, ImplementationProvider, InlayHintProvider, @@ -53,19 +56,21 @@ import { SignatureHelpProvider, TypeDefinitionProvider, UpdateImportsProvider, - UpdateTsOrJsFile, - CallHierarchyProvider + UpdateTsOrJsFile } from '../interfaces'; +import { LSAndTSDocResolver } from './LSAndTSDocResolver'; +import { ignoredBuildDirectories } from './SnapshotManager'; +import { CallHierarchyProviderImpl } from './features/CallHierarchyProvider'; import { CodeActionsProviderImpl } from './features/CodeActionsProvider'; import { CompletionEntryWithIdentifier, CompletionsProviderImpl } from './features/CompletionProvider'; import { DiagnosticsProviderImpl } from './features/DiagnosticsProvider'; -import { FindFileReferencesProviderImpl } from './features/FindFileReferencesProvider'; import { FindComponentReferencesProviderImpl } from './features/FindComponentReferencesProvider'; +import { FindFileReferencesProviderImpl } from './features/FindFileReferencesProvider'; import { FindReferencesProviderImpl } from './features/FindReferencesProvider'; -import { getDirectiveCommentCompletions } from './features/getDirectiveCommentCompletions'; +import { FoldingRangeProviderImpl } from './features/FoldingRangeProvider'; import { HoverProviderImpl } from './features/HoverProvider'; import { ImplementationProviderImpl } from './features/ImplementationProvider'; import { InlayHintProviderImpl } from './features/InlayHintProvider'; @@ -75,13 +80,12 @@ import { SemanticTokensProviderImpl } from './features/SemanticTokensProvider'; import { SignatureHelpProviderImpl } from './features/SignatureHelpProvider'; import { TypeDefinitionProviderImpl } from './features/TypeDefinitionProvider'; import { UpdateImportsProviderImpl } from './features/UpdateImportsProvider'; +import { getDirectiveCommentCompletions } from './features/getDirectiveCommentCompletions'; import { + SnapshotMap, is$storeVariableIn$storeDeclaration, - isTextSpanInGeneratedCode, - SnapshotMap + isTextSpanInGeneratedCode } from './features/utils'; -import { LSAndTSDocResolver } from './LSAndTSDocResolver'; -import { ignoredBuildDirectories } from './SnapshotManager'; import { isAttributeName, isAttributeShorthand, isEventHandler } from './svelte-ast-utils'; import { convertToLocationForReferenceOrDefinition, @@ -90,7 +94,6 @@ import { isInScript, symbolKindFromString } from './utils'; -import { CallHierarchyProviderImpl } from './features/CallHierarchyProvider'; export class TypeScriptPlugin implements @@ -111,6 +114,7 @@ export class TypeScriptPlugin TypeDefinitionProvider, InlayHintProvider, CallHierarchyProvider, + FoldingRangeProvider, OnWatchFileChanges, CompletionsProvider, UpdateTsOrJsFile @@ -134,6 +138,7 @@ export class TypeScriptPlugin private readonly implementationProvider: ImplementationProviderImpl; private readonly typeDefinitionProvider: TypeDefinitionProviderImpl; private readonly inlayHintProvider: InlayHintProviderImpl; + private readonly foldingRangeProvider: FoldingRangeProviderImpl; private readonly callHierarchyProvider: CallHierarchyProviderImpl; constructor( @@ -179,6 +184,10 @@ export class TypeScriptPlugin this.lsAndTsDocResolver, workspaceUris ); + this.foldingRangeProvider = new FoldingRangeProviderImpl( + this.lsAndTsDocResolver, + configManager + ); } async getDiagnostics( @@ -208,7 +217,7 @@ export class TypeScriptPlugin return []; } - const { lang, tsDoc } = await this.getLSAndTSDoc(document); + const { lang, tsDoc } = await this.lsAndTsDocResolver.getLsForSyntheticOperations(document); if (cancellationToken?.isCancellationRequested) { return []; @@ -357,7 +366,7 @@ export class TypeScriptPlugin } async getDefinitions(document: Document, position: Position): Promise { - const { lang, tsDoc } = await this.getLSAndTSDoc(document); + const { lang, tsDoc } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); const defs = lang.getDefinitionAndBoundSpan( tsDoc.filePath, @@ -631,8 +640,8 @@ export class TypeScriptPlugin return this.callHierarchyProvider.getOutgoingCalls(item, cancellationToken); } - private async getLSAndTSDoc(document: Document) { - return this.lsAndTsDocResolver.getLSAndTSDoc(document); + async getFoldingRanges(document: Document): Promise { + return this.foldingRangeProvider.getFoldingRanges(document); } /** diff --git a/packages/language-server/src/plugins/typescript/features/FoldingRangeProvider.ts b/packages/language-server/src/plugins/typescript/features/FoldingRangeProvider.ts new file mode 100644 index 000000000..022d58801 --- /dev/null +++ b/packages/language-server/src/plugins/typescript/features/FoldingRangeProvider.ts @@ -0,0 +1,349 @@ +import ts from 'typescript'; +import { FoldingRangeKind, Range } from 'vscode-languageserver'; +import { FoldingRange } from 'vscode-languageserver-types'; +import { Document, isInTag, mapRangeToOriginal, toRange } from '../../../lib/documents'; +import { isNotNullOrUndefined } from '../../../utils'; +import { FoldingRangeProvider } from '../../interfaces'; +import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; +import { convertRange } from '../utils'; +import { isTextSpanInGeneratedCode } from './utils'; +import { LSConfigManager } from '../../../ls-config'; +import { LineRange, indentBasedFoldingRange } from '../../../lib/foldingRange/indentFolding'; +import { SvelteDocumentSnapshot } from '../DocumentSnapshot'; +import { + SvelteNode, + SvelteNodeWalker, + findElseBlockTagStart, + findIfBlockEndTagStart, + hasElseBlock, + isAwaitBlock, + isEachBlock, + isElseBlockWithElseIf +} from '../svelte-ast-utils'; + +export class FoldingRangeProviderImpl implements FoldingRangeProvider { + constructor( + private readonly lsAndTsDocResolver: LSAndTSDocResolver, + private readonly configManager: LSConfigManager + ) {} + private readonly foldEndPairCharacters = ['}', ']', ')', '`', '>']; + + async getFoldingRanges(document: Document): Promise { + // don't use ls.getProgram unless it's necessary + // this feature is pure syntactic and doesn't need type information + + const { lang, tsDoc } = await this.lsAndTsDocResolver.getLsForSyntheticOperations(document); + + const foldingRanges = + tsDoc.parserError && !document.moduleScriptInfo && !document.scriptInfo + ? [] + : lang.getOutliningSpans(tsDoc.filePath); + + const lineFoldingOnly = + !!this.configManager.getClientCapabilities()?.textDocument?.foldingRange + ?.lineFoldingOnly; + + const result = foldingRanges + .filter((span) => !isTextSpanInGeneratedCode(tsDoc.getFullText(), span.textSpan)) + .map((span) => ({ + originalRange: this.mapToOriginalRange(tsDoc, span.textSpan, document), + span + })) + .map(({ originalRange, span }) => + this.convertOutliningSpan(span, document, originalRange, lineFoldingOnly) + ) + .filter(isNotNullOrUndefined) + .concat(this.collectSvelteBlockFolding(document, tsDoc, lineFoldingOnly)) + .concat(this.getSvelteTagFoldingIfParserError(document, tsDoc)) + .filter((r) => (lineFoldingOnly ? r.startLine < r.endLine : r.startLine <= r.endLine)); + + return result; + } + + private mapToOriginalRange( + tsDoc: SvelteDocumentSnapshot, + textSpan: ts.TextSpan, + document: Document + ) { + const range = mapRangeToOriginal(tsDoc, convertRange(tsDoc, textSpan)); + const startOffset = document.offsetAt(range.start); + + if (range.start.line < 0 || range.end.line < 0 || range.start.line > range.end.line) { + return; + } + + if ( + isInTag(range.start, document.scriptInfo) || + isInTag(range.start, document.moduleScriptInfo) + ) { + return range; + } + + const endOffset = document.offsetAt(range.end); + const originalText = document.getText().slice(startOffset, endOffset); + + if (originalText.length === 0) { + return; + } + + const generatedText = tsDoc.getText(textSpan.start, textSpan.start + textSpan.length); + const oneToOne = originalText.trim() === generatedText.trim(); + + if (oneToOne) { + return range; + } + } + + /** + * Doing this here with the svelte2tsx's svelte ast is slightly + * less prone to error and faster than + * using the svelte ast in the svelte plugins. + */ + private collectSvelteBlockFolding( + document: Document, + tsDoc: SvelteDocumentSnapshot, + lineFoldingOnly: boolean + ) { + if (tsDoc.parserError) { + return []; + } + + const ranges: FoldingRange[] = []; + + const provider = this; + const enter: SvelteNodeWalker['enter'] = function (node, parent, key) { + if (key === 'attributes') { + this.skip(); + } + + // use sub-block for await block + if (!node.type.endsWith('Block') || node.type === 'AwaitBlock') { + return; + } + + if (node.type === 'IfBlock') { + provider.getIfBlockFolding(node, document, ranges); + return; + } + + if (isElseBlockWithElseIf(node)) { + return; + } + + if ((node.type === 'CatchBlock' || node.type === 'ThenBlock') && isAwaitBlock(parent)) { + const expressionEnd = + (node.type === 'CatchBlock' ? parent.error?.end : parent.value?.end) ?? + document.getText().indexOf('}', node.start); + + const beforeBlockStartTagEnd = document.getText().indexOf('}', expressionEnd); + if (beforeBlockStartTagEnd == -1) { + return; + } + ranges.push( + provider.createFoldingRange(document, beforeBlockStartTagEnd + 1, node.end) + ); + + return; + } + + if (isEachBlock(node)) { + const start = document.getText().indexOf('}', (node.key ?? node.expression).end); + const elseStart = node.else + ? findElseBlockTagStart(document.getText(), node.else) + : -1; + + ranges.push( + provider.createFoldingRange( + document, + start, + elseStart === -1 ? node.end : elseStart + ) + ); + + return; + } + + if ('expression' in node && node.expression && typeof node.expression === 'object') { + const start = provider.getStartForNodeWithExpression( + node as SvelteNode & { expression: SvelteNode }, + document + ); + const end = node.end; + + ranges.push(provider.createFoldingRange(document, start, end)); + return; + } + + if (node.start != null && node.end != null) { + const start = node.start; + const end = node.end; + + ranges.push(provider.createFoldingRange(document, start, end)); + } + }; + + tsDoc.walkSvelteAst({ + enter + }); + + if (lineFoldingOnly) { + return ranges.map((r) => ({ + startLine: r.startLine, + endLine: this.previousLineOfEndLine(r.startLine, r.endLine) + })); + } + + return ranges; + } + + private getIfBlockFolding(node: SvelteNode, document: Document, ranges: FoldingRange[]) { + const typed = node as SvelteNode & { + else?: SvelteNode; + expression: SvelteNode; + }; + + const documentText = document.getText(); + const start = this.getStartForNodeWithExpression(typed, document); + const end = hasElseBlock(typed) + ? findElseBlockTagStart(documentText, typed.else) + : findIfBlockEndTagStart(documentText, typed); + + ranges.push(this.createFoldingRange(document, start, end)); + } + + private getStartForNodeWithExpression( + node: SvelteNode & { expression: SvelteNode }, + document: Document + ) { + return document.getText().indexOf('}', node.expression.end) + 1; + } + + private createFoldingRange(document: Document, start: number, end: number) { + const range = toRange(document.getText(), start, end); + return { + startLine: range.start.line, + startCharacter: range.start.character, + endLine: range.end.line, + endCharacter: range.end.character + }; + } + + private convertOutliningSpan( + span: ts.OutliningSpan, + document: Document, + originalRange: Range | undefined, + lineFoldingOnly: boolean + ): FoldingRange | null { + if (!originalRange) { + return null; + } + + const end = lineFoldingOnly + ? this.adjustFoldingEndToNotHideEnd(originalRange, document) + : originalRange.end; + + const result = { + startLine: originalRange.start.line, + endLine: end.line, + kind: this.getFoldingRangeKind(span), + startCharacter: lineFoldingOnly ? undefined : originalRange.start.character, + endCharacter: lineFoldingOnly ? undefined : end.character + }; + + return result; + } + + private getFoldingRangeKind(span: ts.OutliningSpan): FoldingRangeKind | undefined { + switch (span.kind) { + case ts.OutliningSpanKind.Comment: + return FoldingRangeKind.Comment; + case ts.OutliningSpanKind.Region: + return FoldingRangeKind.Region; + case ts.OutliningSpanKind.Imports: + return FoldingRangeKind.Imports; + case ts.OutliningSpanKind.Code: + default: + return undefined; + } + } + + private adjustFoldingEndToNotHideEnd( + range: Range, + document: Document + ): { line: number; character?: number } { + // don't fold end bracket, brace... + if (range.end.character > 0) { + const text = document.getText(); + const offsetBeforeEnd = document.offsetAt({ + line: range.end.line, + character: range.end.character - 1 + }); + const foldEndCharacter = text[offsetBeforeEnd]; + if (this.foldEndPairCharacters.includes(foldEndCharacter)) { + return { line: this.previousLineOfEndLine(range.start.line, range.end.line) }; + } + } + + return range.end; + } + + private getSvelteTagFoldingIfParserError(document: Document, tsDoc: SvelteDocumentSnapshot) { + if (!tsDoc.parserError) { + return []; + } + + const htmlTemplateRanges = this.getHtmlTemplateRangesForChecking(document); + + return indentBasedFoldingRange({ + document, + skipFold: (_, lineContent) => { + return !/{\s*(#|\/|:)/.test(lineContent); + }, + ranges: htmlTemplateRanges + }); + } + + private getHtmlTemplateRangesForChecking(document: Document) { + const ranges: LineRange[] = []; + + const excludeTags = [ + document.templateInfo, + document.moduleScriptInfo, + document.scriptInfo, + document.styleInfo + ] + .filter(isNotNullOrUndefined) + .map((info) => ({ + startLine: document.positionAt(info.container.start).line, + endLine: document.positionAt(info.container.end).line + })) + .sort((a, b) => a.startLine - b.startLine); + + if (excludeTags.length === 0) { + return [{ startLine: 0, endLine: document.lineCount - 1 }]; + } + + if (excludeTags[0].startLine > 0) { + ranges.push({ + startLine: 0, + endLine: excludeTags[0].startLine - 1 + }); + } + + for (let index = 0; index < excludeTags.length; index++) { + const element = excludeTags[index]; + const next = excludeTags[index + 1]; + + ranges.push({ + startLine: element.endLine + 1, + endLine: next ? next.startLine - 1 : document.lineCount - 1 + }); + } + + return ranges; + } + + private previousLineOfEndLine(startLine: number, endLine: number) { + return Math.max(endLine - 1, startLine); + } +} diff --git a/packages/language-server/src/plugins/typescript/features/SelectionRangeProvider.ts b/packages/language-server/src/plugins/typescript/features/SelectionRangeProvider.ts index 1d2ba6c4c..bd7cb1c35 100644 --- a/packages/language-server/src/plugins/typescript/features/SelectionRangeProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/SelectionRangeProvider.ts @@ -13,7 +13,7 @@ export class SelectionRangeProviderImpl implements SelectionRangeProvider { document: Document, position: Position ): Promise { - const { tsDoc, lang } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); + const { tsDoc, lang } = await this.lsAndTsDocResolver.getLsForSyntheticOperations(document); const tsSelectionRange = lang.getSmartSelectionRange( tsDoc.filePath, diff --git a/packages/language-server/src/plugins/typescript/svelte-ast-utils.ts b/packages/language-server/src/plugins/typescript/svelte-ast-utils.ts index 86c7ee579..46d90acc0 100644 --- a/packages/language-server/src/plugins/typescript/svelte-ast-utils.ts +++ b/packages/language-server/src/plugins/typescript/svelte-ast-utils.ts @@ -1,3 +1,7 @@ +import { Node } from 'estree'; +import { walk } from 'svelte/compiler'; +import { TemplateNode } from 'svelte/types/compiler/interfaces'; + export interface SvelteNode { start: number; end: number; @@ -7,6 +11,30 @@ export interface SvelteNode { type HTMLLike = 'Element' | 'InlineComponent' | 'Body' | 'Window'; +export interface AwaitBlock extends SvelteNode { + type: 'AwaitBlock'; + expression: SvelteNode & Node; + value: (SvelteNode & Node) | null; + error: (SvelteNode & Node) | null; + pending: AwaitSubBlock; + then: AwaitSubBlock; + catch: AwaitSubBlock; +} + +export interface AwaitSubBlock extends SvelteNode { + skip: boolean; + children: SvelteNode[]; +} + +export interface EachBlock extends SvelteNode { + type: 'EachBlock'; + expression: SvelteNode & Node; + context: SvelteNode & Node; + key?: SvelteNode & Node; + else?: SvelteNode; + children: SvelteNode[]; +} + function matchesOnly(type: string | undefined, only?: 'Element' | 'InlineComponent'): boolean { return ( !only || @@ -73,3 +101,70 @@ export function isEventHandler( ) { return !!node && node.type === 'EventHandler' && matchesOnly(node.parent?.type, only); } + +export function isElseBlockWithElseIf(node: SvelteNode | null | undefined) { + return ( + !!node && + node.type === 'ElseBlock' && + 'children' in node && + Array.isArray(node.children) && + node.children.length === 1 && + node.children[0].type === 'IfBlock' + ); +} + +export function hasElseBlock(node: SvelteNode): node is SvelteNode & { else: SvelteNode } { + return 'else' in node && !!node.else; +} + +export function findElseBlockTagStart(documentText: string, elseBlock: SvelteNode) { + return documentText.lastIndexOf('{', documentText.lastIndexOf(':else', elseBlock.start)); +} + +export function findIfBlockEndTagStart(documentText: string, ifBlock: SvelteNode) { + return documentText.lastIndexOf('{', documentText.lastIndexOf('/if', ifBlock.end)); +} + +export interface SvelteNodeWalker { + enter?: ( + this: { + skip: () => void; + remove: () => void; + replace: (node: SvelteNode) => void; + }, + node: SvelteNode, + parent: SvelteNode, + key: string, + index: number + ) => void; + leave?: ( + this: { + skip: () => void; + remove: () => void; + replace: (node: SvelteNode) => void; + }, + node: SvelteNode, + parent: SvelteNode, + key: string, + index: number + ) => void; +} + +export function walkSvelteAst(htmlAst: TemplateNode, walker: SvelteNodeWalker) { + walk(htmlAst as any, { + enter(node, parent, key, index) { + walker.enter?.call(this, node as SvelteNode, parent as SvelteNode, key, index); + }, + leave(node, parent, key, index) { + walker.leave?.call(this, node as SvelteNode, parent as SvelteNode, key, index); + } + }); +} + +export function isAwaitBlock(node: SvelteNode): node is AwaitBlock { + return node.type === 'AwaitBlock'; +} + +export function isEachBlock(node: SvelteNode): node is EachBlock { + return node.type === 'EachBlock'; +} diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 3a27f5a35..feb2f4de4 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -292,7 +292,8 @@ export function startServer(options?: LSOptions) { implementationProvider: true, typeDefinitionProvider: true, inlayHintProvider: true, - callHierarchyProvider: true + callHierarchyProvider: true, + foldingRangeProvider: true } }; }); @@ -427,6 +428,8 @@ export function startServer(options?: LSOptions) { pluginHost.getTypeDefinition(evt.textDocument, evt.position) ); + connection.onFoldingRanges((evt) => pluginHost.getFoldingRanges(evt.textDocument)); + const diagnosticsManager = new DiagnosticsManager( connection.sendDiagnostics, docManager, diff --git a/packages/language-server/src/utils.ts b/packages/language-server/src/utils.ts index 008604d42..34d828937 100644 --- a/packages/language-server/src/utils.ts +++ b/packages/language-server/src/utils.ts @@ -1,7 +1,8 @@ -import { isEqual, uniqWith } from 'lodash'; -import { Node } from 'vscode-html-languageservice'; +import { isEqual, sum, uniqWith } from 'lodash'; +import { FoldingRange, Node } from 'vscode-html-languageservice'; import { Position, Range } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; +import { Document, TagInformation } from './lib/documents'; type Predicate = (x: T) => boolean; diff --git a/packages/language-server/test/lib/indentFolding.test.ts b/packages/language-server/test/lib/indentFolding.test.ts new file mode 100644 index 000000000..357e5172d --- /dev/null +++ b/packages/language-server/test/lib/indentFolding.test.ts @@ -0,0 +1,55 @@ +import assert from 'assert'; +import { guessTabSize } from '../../src/lib/foldingRange/indentFolding'; + +describe('indent based folding', () => { + it('can guess tab size', () => { + assert.deepStrictEqual( + guessTabSize([ + { spaceCount: 2, tabCount: 1 }, + { spaceCount: 4, tabCount: 1 }, + { spaceCount: 6, tabCount: 1 } + ]), + 2 + ); + + assert.deepStrictEqual( + guessTabSize([ + { spaceCount: 4, tabCount: 1 }, + { spaceCount: 8, tabCount: 1 }, + { spaceCount: 12, tabCount: 1 } + ]), + 4 + ); + }); + + it('can guess tab size with inconsistent mix of tab and space', () => { + assert.deepStrictEqual( + guessTabSize([ + { spaceCount: 0, tabCount: 1 }, + { spaceCount: 2, tabCount: 1 }, + { spaceCount: 6, tabCount: 0 }, + { spaceCount: 4, tabCount: 1 } + ]), + 2 + ); + + assert.deepStrictEqual( + guessTabSize([ + { spaceCount: 0, tabCount: 1 }, + { spaceCount: 4, tabCount: 0 }, + { spaceCount: 6, tabCount: 0 }, + { spaceCount: 4, tabCount: 1 } + ]), + 2 + ); + + assert.deepStrictEqual( + guessTabSize([ + { spaceCount: 0, tabCount: 2 }, + { spaceCount: 4, tabCount: 0 }, + { spaceCount: 4, tabCount: 1 } + ]), + 2 + ); + }); +}); diff --git a/packages/language-server/test/plugins/css/CSSPlugin.test.ts b/packages/language-server/test/plugins/css/CSSPlugin.test.ts index 97aef32f6..06973b4ca 100644 --- a/packages/language-server/test/plugins/css/CSSPlugin.test.ts +++ b/packages/language-server/test/plugins/css/CSSPlugin.test.ts @@ -8,7 +8,8 @@ import { TextEdit, CompletionContext, SelectionRange, - CompletionTriggerKind + CompletionTriggerKind, + FoldingRangeKind } from 'vscode-languageserver'; import { DocumentManager, Document } from '../../../src/lib/documents'; import { CSSPlugin } from '../../../src/plugins'; @@ -475,4 +476,28 @@ describe('CSS Plugin', () => { assert.equal(selectionRange, null); }); + + describe('folding ranges', () => { + it('provides folding ranges', () => { + const { plugin, document } = setup(''); + + const foldingRanges = plugin.getFoldingRanges(document); + + assert.deepStrictEqual(foldingRanges, [{ startLine: 1, endLine: 2, kind: undefined }]); + }); + + it('provides folding ranges for known indent style', () => { + const { plugin, document } = setup( + '' + ); + + const foldingRanges = plugin.getFoldingRanges(document); + + assert.deepStrictEqual(foldingRanges, [ + { startLine: 1, endLine: 6, kind: FoldingRangeKind.Region }, + { startLine: 2, endLine: 3 }, + { startLine: 4, endLine: 5 } + ]); + }); + }); }); diff --git a/packages/language-server/test/plugins/html/HTMLPlugin.test.ts b/packages/language-server/test/plugins/html/HTMLPlugin.test.ts index e9d4be84a..9d69035eb 100644 --- a/packages/language-server/test/plugins/html/HTMLPlugin.test.ts +++ b/packages/language-server/test/plugins/html/HTMLPlugin.test.ts @@ -7,7 +7,8 @@ import { TextEdit, CompletionItemKind, InsertTextFormat, - CompletionTriggerKind + CompletionTriggerKind, + FoldingRange } from 'vscode-languageserver'; import { HTMLPlugin } from '../../../src/plugins'; import { DocumentManager, Document } from '../../../src/lib/documents'; @@ -258,4 +259,28 @@ describe('HTML Plugin', () => { ] }); }); + + it('provides folding range', () => { + const { plugin, document } = setup('
\n
\n
\n
'); + + const ranges = plugin.getFoldingRanges(document); + assert.deepStrictEqual(ranges, [{ startLine: 0, endLine: 2 }]); + }); + + it('provides folding range for element with arrow function handler', () => { + const { plugin, document } = setup('
{}}\n />'); + + const ranges = plugin.getFoldingRanges(document); + assert.deepStrictEqual(ranges, [{ startLine: 0, endLine: 1 }]); + }); + + it('provides indent based folding range for template tag', () => { + const { plugin, document } = setup(''); + + const ranges = plugin.getFoldingRanges(document); + assert.deepStrictEqual(ranges, [ + { startLine: 0, endLine: 2 }, + { startLine: 1, endLine: 2 } + ]); + }); }); diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/.gitignore b/packages/language-server/test/plugins/typescript/features/folding-range/.gitignore new file mode 100644 index 000000000..c3bc17626 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/.gitignore @@ -0,0 +1 @@ +debug.svelte \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-catch/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-catch/expectedv2.json new file mode 100644 index 000000000..ff07652a2 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-catch/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 6, "endLine": 7 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-catch/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-catch/input.svelte new file mode 100644 index 000000000..e9e6d5619 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-catch/input.svelte @@ -0,0 +1,9 @@ +{#await somePromise catch error} +

Promise Pending

+{/await} + +{#await somePromise + +catch error} +

Promise Pending

+{/await} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-peding-catch/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-peding-catch/expectedv2.json new file mode 100644 index 000000000..6c5814c47 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-peding-catch/expectedv2.json @@ -0,0 +1,6 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 2, "endLine": 3 }, + { "startLine": 6, "endLine": 7 }, + { "startLine": 9, "endLine": 10 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-peding-catch/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-peding-catch/input.svelte new file mode 100644 index 000000000..2345b706b --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-peding-catch/input.svelte @@ -0,0 +1,12 @@ +{#await somePromise} +

Promise Pending

+{:catch error} +

Promise Errored {error}

+{/await} + +{#await somePromise} +

Promise Pending

+{ + :catch error} +

Promise Errored {error}

+{/await} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-only/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-only/expectedv2.json new file mode 100644 index 000000000..ee866a024 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-only/expectedv2.json @@ -0,0 +1 @@ +[{ "startLine": 0, "endLine": 1 }] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-only/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-only/input.svelte new file mode 100644 index 000000000..cc4ccd6f6 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-only/input.svelte @@ -0,0 +1,3 @@ +{#await somePromise} +

Loading

+{/await} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then-catch/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then-catch/expectedv2.json new file mode 100644 index 000000000..a029fbdc6 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then-catch/expectedv2.json @@ -0,0 +1,8 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 2, "endLine": 3 }, + { "startLine": 4, "endLine": 5 }, + { "startLine": 8, "endLine": 9 }, + { "startLine": 11, "endLine": 12 }, + { "startLine": 14, "endLine": 15 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then-catch/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then-catch/input.svelte new file mode 100644 index 000000000..af0e22c8e --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then-catch/input.svelte @@ -0,0 +1,17 @@ +{#await somePromise} +

Promise Pending

+{:then value} +

Promise Resolved {value}

+{:catch error} +

Promise Errored {error}

+{/await} + +{#await somePromise} +

Promise Pending

+{ + :then value} +

Promise Resolved {value}

+{ + :catch error} +

Promise Errored {error}

+{/await} diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then/expectedv2.json new file mode 100644 index 000000000..c75b3a509 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 2, "endLine": 3 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then/input.svelte new file mode 100644 index 000000000..edd46fdb1 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-pending-then/input.svelte @@ -0,0 +1,5 @@ +{#await somePromise} +

Promise Pending

+{:catch error} +

Promise Errored {error}

+{/await} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch-shorthand/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch-shorthand/expectedv2.json new file mode 100644 index 000000000..c75b3a509 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch-shorthand/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 2, "endLine": 3 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch-shorthand/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch-shorthand/input.svelte new file mode 100644 index 000000000..2c787de40 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch-shorthand/input.svelte @@ -0,0 +1,5 @@ +{#await Promise.resolve() then value} + {value} +{:catch} + error +{/await} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch/expectedv2.json new file mode 100644 index 000000000..c75b3a509 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 2, "endLine": 3 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch/input.svelte new file mode 100644 index 000000000..98955f55e --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then-catch/input.svelte @@ -0,0 +1,5 @@ +{#await Promise.resolve() then value} + {value} +{:catch error} + {error} +{/await} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then/expectedv2.json new file mode 100644 index 000000000..ff07652a2 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 6, "endLine": 7 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then/input.svelte new file mode 100644 index 000000000..9708ebccc --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/await-then/input.svelte @@ -0,0 +1,9 @@ +{#await somePromise then value} +

Promise Pending

+{/await} + +{#await somePromise + + then value} +

Promise Pending

+{/await} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-block/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-block/expectedv2.json new file mode 100644 index 000000000..9cfe3e186 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-block/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 5, "endLine": 6 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-block/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-block/input.svelte new file mode 100644 index 000000000..e09edee46 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-block/input.svelte @@ -0,0 +1,8 @@ +{#each items as item} + {item} +{/each} + +{#each items as + item} + {item} +{/each} diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-else/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-else/expectedv2.json new file mode 100644 index 000000000..c75b3a509 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-else/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 2, "endLine": 3 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-else/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-else/input.svelte new file mode 100644 index 000000000..bfbff7239 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-else/input.svelte @@ -0,0 +1,5 @@ +{#each items as item} + {item} +{:else} + no items +{/each} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-keyed/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-keyed/expectedv2.json new file mode 100644 index 000000000..c46adf55a --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-keyed/expectedv2.json @@ -0,0 +1 @@ +[{ "startLine": 2, "endLine": 3 }] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-keyed/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-keyed/input.svelte new file mode 100644 index 000000000..c5fd34b66 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/each-keyed/input.svelte @@ -0,0 +1,5 @@ +{#each items as +{id} +(id)} + {id} +{/each} diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/if-block/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/if-block/expectedv2.json new file mode 100644 index 000000000..1a70a9ed7 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/if-block/expectedv2.json @@ -0,0 +1,8 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 2, "endLine": 3 }, + { "startLine": 4, "endLine": 5 }, + { "startLine": 8, "endLine": 9 }, + { "startLine": 12, "endLine": 13 }, + { "startLine": 14, "endLine": 15 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/if-block/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/if-block/input.svelte new file mode 100644 index 000000000..0444a9773 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/if-block/input.svelte @@ -0,0 +1,17 @@ +{#if name1 == "world"} +

Hello {name2}

+{:else if name3 == "person"} +

hello {name4}

+{:else} +

hey {name5}

+{/if} + +{#if kenobi} +

Hello There

+{/if} + +{#if name1 = 'hi'} +

hi

+{:else} +

hello

+{/if} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/key-block/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/key-block/expectedv2.json new file mode 100644 index 000000000..ff07652a2 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/key-block/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 0, "endLine": 1 }, + { "startLine": 6, "endLine": 7 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/key-block/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/key-block/input.svelte new file mode 100644 index 000000000..1dc659512 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/key-block/input.svelte @@ -0,0 +1,9 @@ +{#key hi} + {hi} +{/key} + +{#key + +hi} + {hi} +{/key} \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/parser-error/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/parser-error/expectedv2.json new file mode 100644 index 000000000..4b0d4a657 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/parser-error/expectedv2.json @@ -0,0 +1,5 @@ +[ + { "startLine": 1, "endLine": 2 }, + { "startLine": 6, "endLine": 7 }, + { "startLine": 8, "endLine": 9 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/parser-error/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/parser-error/input.svelte new file mode 100644 index 000000000..e2c5abde5 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/parser-error/input.svelte @@ -0,0 +1,11 @@ + + +{#if 'hi'} +
+{:else} +
+{/if diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/script/expectedv2.json b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/script/expectedv2.json new file mode 100644 index 000000000..395632733 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/script/expectedv2.json @@ -0,0 +1,4 @@ +[ + { "startLine": 1, "endLine": 2, "kind": "imports" }, + { "startLine": 4, "endLine": 5 } +] diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/script/input.svelte b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/script/input.svelte new file mode 100644 index 000000000..3046b7cd6 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/fixtures/script/input.svelte @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/features/folding-range/index.test.ts b/packages/language-server/test/plugins/typescript/features/folding-range/index.test.ts new file mode 100644 index 000000000..76a342c4b --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/folding-range/index.test.ts @@ -0,0 +1,122 @@ +import * as assert from 'assert'; +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import ts from 'typescript'; +import { Document, DocumentManager } from '../../../../../src/lib/documents'; +import { LSConfigManager } from '../../../../../src/ls-config'; +import { LSAndTSDocResolver } from '../../../../../src/plugins'; +import { FoldingRangeProviderImpl } from '../../../../../src/plugins/typescript/features/FoldingRangeProvider'; +import { pathToUrl } from '../../../../../src/utils'; +import { + createJsonSnapshotFormatter, + createSnapshotTester, + updateSnapshotIfFailedOrEmpty +} from '../../test-utils'; + +function setup(workspaceDir: string, filePath: string) { + const docManager = new DocumentManager( + (textDocument) => new Document(textDocument.uri, textDocument.text) + ); + const configManager = new LSConfigManager(); + configManager.updateClientCapabilities({ + textDocument: { foldingRange: { lineFoldingOnly: true } } + }); + const lsAndTsDocResolver = new LSAndTSDocResolver( + docManager, + [pathToUrl(workspaceDir)], + configManager + ); + const plugin = new FoldingRangeProviderImpl(lsAndTsDocResolver, configManager); + const document = docManager.openClientDocument({ + uri: pathToUrl(filePath), + text: ts.sys.readFile(filePath) || '' + }); + return { plugin, document, docManager, lsAndTsDocResolver }; +} + +async function executeTest( + inputFile: string, + { + workspaceDir, + dir + }: { + workspaceDir: string; + dir: string; + } +) { + const expected = 'expectedv2.json'; + const { plugin, document } = setup(workspaceDir, inputFile); + const folding = await plugin.getFoldingRanges(document); + + const expectedFile = join(dir, expected); + if (process.argv.includes('--debug')) { + writeFileSync(join(dir, 'debug.svelte'), appendFoldingAsComment()); + } + + const snapshotFormatter = await createJsonSnapshotFormatter(dir); + + await updateSnapshotIfFailedOrEmpty({ + assertion() { + assert.deepStrictEqual( + JSON.parse(JSON.stringify(folding)), + JSON.parse(readFileSync(expectedFile, 'utf-8')) + ); + }, + expectedFile, + getFileContent() { + return snapshotFormatter(folding); + }, + rootDir: __dirname + }); + + function appendFoldingAsComment() { + if (!folding) { + return document.getText(); + } + + const offsetMap = new Map(); + const lineLength = document + .getText() + .split('\n') + .map((line) => (line[line.length - 1] === '\r' ? line.length - 1 : line.length)); + + for (const fold of folding) { + const startOffset = document.offsetAt({ + line: fold.startLine, + character: lineLength[fold.startLine] + }); + const endOffset = document.offsetAt({ + line: fold.endLine, + character: lineLength[fold.endLine] + }); + + offsetMap.set(startOffset, (offsetMap.get(startOffset) ?? []).concat(`/*s*/`)); + offsetMap.set(endOffset, (offsetMap.get(endOffset) ?? []).concat(`/*e*/`)); + } + + const offsets = Array.from(offsetMap.keys()).sort((a, b) => a - b); + const parts: string[] = []; + + for (let index = 0; index < offsets.length; index++) { + const offset = offsets[index]; + parts.push( + document.getText().slice(offsets[index - 1], offset), + ...(offsetMap.get(offset) ?? []) + ); + } + + parts.push(document.getText().slice(offsets[offsets.length - 1])); + + return parts.join(''); + } +} + +const executeTests = createSnapshotTester(executeTest); + +describe('FoldingRangeProvider', function () { + executeTests({ + dir: join(__dirname, 'fixtures'), + workspaceDir: join(__dirname, 'fixtures'), + context: this + }); +});