From 0c965658efd3344df1e00ea6190f7628778cec23 Mon Sep 17 00:00:00 2001 From: Cameron Dubas Date: Tue, 10 Oct 2023 12:49:54 +0100 Subject: [PATCH 1/3] feat(lsp): add code-folding support --- packages/core/src/language-server/binding.ts | 7 +++ .../language-server/glint-language-server.ts | 58 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/core/src/language-server/binding.ts b/packages/core/src/language-server/binding.ts index c35f8376b..1229a5923 100644 --- a/packages/core/src/language-server/binding.ts +++ b/packages/core/src/language-server/binding.ts @@ -30,6 +30,7 @@ export const capabilities: ServerCapabilities = { codeActionProvider: { codeActionKinds: [CodeActionKind.QuickFix], }, + foldingRangeProvider: true, definitionProvider: true, workspaceSymbolProvider: true, renameProvider: { @@ -217,4 +218,10 @@ export function bindLanguageServerPool({ connection, pool, openDocuments }: Bind scheduleDiagnostics(); }); }); + + connection.onFoldingRanges((params) => { + return pool.withServerForURI(params.textDocument.uri, ({ server }) => + server.getFoldingRanges(params.textDocument.uri) + ); + }); } diff --git a/packages/core/src/language-server/glint-language-server.ts b/packages/core/src/language-server/glint-language-server.ts index e6a91ec2b..35b3ba149 100644 --- a/packages/core/src/language-server/glint-language-server.ts +++ b/packages/core/src/language-server/glint-language-server.ts @@ -22,6 +22,8 @@ import { OptionalVersionedTextDocumentIdentifier, TextEdit, MarkupContent, + FoldingRange, + FoldingRangeKind, } from 'vscode-languageserver'; import DocumentCache from '../common/document-cache.js'; import { Position, positionToOffset } from './util/position.js'; @@ -561,6 +563,62 @@ export default class GlintLanguageServer { return edits; } + public getFoldingRanges(uri: string): FoldingRange[] { + const filePath = uriToFilePath(uri); + const documentContents = this.documents.getDocumentContents(filePath); + const spans = this.service.getOutliningSpans(filePath); + + let foldingRanges = []; + + for (const span of spans) { + const foldingRange = this.asFoldingRange(span, documentContents); + if (foldingRange) { + foldingRanges.push(foldingRange); + } + } + + return foldingRanges; + } + + private asFoldingRange(span: ts.OutliningSpan, fileContents: string): FoldingRange { + const start = offsetToPosition(fileContents, span.textSpan.start); + const end = offsetToPosition(fileContents, span.textSpan.start + span.textSpan.length); + const kind = this.asFoldingRangeKind(span); + + // TODO: Implement this before opening a PR + // // workaround for https://github.com/Microsoft/vscode/issues/49904 + // if (span.kind === 'comment') { + // const line = document.getLine(range.start.line); + // if (line.match(/\/\/\s*#endregion/gi)) { + // return undefined; + // } + // } + + // workaround for https://github.com/Microsoft/vscode/issues/47240 + let lastCharOfSpan = fileContents[span.textSpan.start + span.textSpan.length - 1]; + const endLine = lastCharOfSpan === '}' ? Math.max(end.line - 1, start.line) : end.line; + + return { + startLine: start.line, + endLine, + kind, + }; + } + + private asFoldingRangeKind(span: ts.OutliningSpan): FoldingRangeKind | undefined { + switch (span.kind) { + case 'comment': + return FoldingRangeKind.Comment; + case 'region': + return FoldingRangeKind.Region; + case 'imports': + return FoldingRangeKind.Imports; + case 'code': + default: + return undefined; + } + } + private applyCodeAction( uri: string, range: Range, From 0551a3301b0845a7ba3442e21260e1a62e4b527b Mon Sep 17 00:00:00 2001 From: Cameron Dubas Date: Tue, 10 Oct 2023 15:01:14 +0100 Subject: [PATCH 2/3] fix(lsp): use untransformed ranges for code-folding --- .../language-server/glint-language-server.ts | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/core/src/language-server/glint-language-server.ts b/packages/core/src/language-server/glint-language-server.ts index 35b3ba149..933e5bae6 100644 --- a/packages/core/src/language-server/glint-language-server.ts +++ b/packages/core/src/language-server/glint-language-server.ts @@ -565,48 +565,56 @@ export default class GlintLanguageServer { public getFoldingRanges(uri: string): FoldingRange[] { const filePath = uriToFilePath(uri); - const documentContents = this.documents.getDocumentContents(filePath); - const spans = this.service.getOutliningSpans(filePath); - let foldingRanges = []; + let foldingRanges: FoldingRange[] = []; + this.service.getOutliningSpans(filePath).forEach((outliningSpan, index) => { + const foldingRange = this.outliningSpanToFoldingRange(outliningSpan, filePath); - for (const span of spans) { - const foldingRange = this.asFoldingRange(span, documentContents); - if (foldingRange) { - foldingRanges.push(foldingRange); + if ( + !foldingRange || + (index > 0 && foldingRange.startLine === 0) || + foldingRange.startLine === foldingRange.endLine + ) { + return; } - } + + foldingRanges.push(foldingRange); + }); return foldingRanges; } - private asFoldingRange(span: ts.OutliningSpan, fileContents: string): FoldingRange { - const start = offsetToPosition(fileContents, span.textSpan.start); - const end = offsetToPosition(fileContents, span.textSpan.start + span.textSpan.length); - const kind = this.asFoldingRangeKind(span); + private outliningSpanToFoldingRange( + span: ts.OutliningSpan, + fileName: string + ): FoldingRange | undefined { + // The OutliningSpan.textSpan's length is inclusive. This is a + // workaround for off-by-one & out-of-range errors caused by this. + span.textSpan.length = span.textSpan.length - 1; + const location = this.textSpanToLocation(fileName, span.textSpan); + + if (!location) { + return; + } - // TODO: Implement this before opening a PR - // // workaround for https://github.com/Microsoft/vscode/issues/49904 - // if (span.kind === 'comment') { - // const line = document.getLine(range.start.line); - // if (line.match(/\/\/\s*#endregion/gi)) { - // return undefined; - // } - // } + const { start, end } = location.range; // workaround for https://github.com/Microsoft/vscode/issues/47240 - let lastCharOfSpan = fileContents[span.textSpan.start + span.textSpan.length - 1]; - const endLine = lastCharOfSpan === '}' ? Math.max(end.line - 1, start.line) : end.line; + const originalContents = this.documents.getDocumentContents(fileName); + const originalEnd = positionToOffset(originalContents, end); + const lastCharOfSpan = originalContents[originalEnd]; return { startLine: start.line, - endLine, - kind, + endLine: lastCharOfSpan === '}' ? Math.max(end.line - 1, start.line) : end.line, + kind: this.outliningSpanKindToFoldingRangeKind(span), }; } - private asFoldingRangeKind(span: ts.OutliningSpan): FoldingRangeKind | undefined { - switch (span.kind) { + private outliningSpanKindToFoldingRangeKind( + outliningSpan: ts.OutliningSpan + ): FoldingRangeKind | undefined { + switch (outliningSpan.kind) { case 'comment': return FoldingRangeKind.Comment; case 'region': From 9ef35df7d4356fa210e8fc26604fc5bdac4baeec Mon Sep 17 00:00:00 2001 From: Cameron Dubas Date: Sat, 14 Oct 2023 22:20:06 +0100 Subject: [PATCH 3/3] test(lsp): folding ranges --- .../language-server/folding-ranges.test.ts | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 packages/core/__tests__/language-server/folding-ranges.test.ts diff --git a/packages/core/__tests__/language-server/folding-ranges.test.ts b/packages/core/__tests__/language-server/folding-ranges.test.ts new file mode 100644 index 000000000..9bfb74008 --- /dev/null +++ b/packages/core/__tests__/language-server/folding-ranges.test.ts @@ -0,0 +1,238 @@ +import { Project } from 'glint-monorepo-test-utils'; +import { describe, beforeEach, afterEach, test, expect } from 'vitest'; +import { stripIndent } from 'common-tags'; + +describe('Language Server: Folding Ranges', () => { + let project!: Project; + + beforeEach(async () => { + project = await Project.create(); + }); + + afterEach(async () => { + await project.destroy(); + }); + + test('function', () => { + project.write({ + 'example.ts': stripIndent` + function foo() { + return 'bar'; + } + `, + }); + + let server = project.startLanguageServer(); + let folds = server.getFoldingRanges(project.fileURI('example.ts')); + + expect(folds).toEqual([ + { + startLine: 0, + endLine: 1, + kind: undefined, + }, + ]); + }); + + test('nested function', () => { + project.write({ + 'example.ts': stripIndent` + function topLevel() { + + function nested() { + return 'bar'; + } + + return nested(); + } + `, + }); + + let server = project.startLanguageServer(); + let folds = server.getFoldingRanges(project.fileURI('example.ts')); + + expect(folds).toEqual([ + { + startLine: 0, + endLine: 6, + kind: undefined, + }, + { + startLine: 2, + endLine: 3, + kind: undefined, + }, + ]); + }); + + test('imports', () => { + project.write({ + 'example.ts': stripIndent` + import Component, { hbs } from '@glimmerx/component'; + import foo from 'bar'; + import { baz } from 'qux'; + + export default { foo, baz, Component }; + `, + }); + + let server = project.startLanguageServer(); + let folds = server.getFoldingRanges(project.fileURI('example.ts')); + + expect(folds).toEqual([ + { + startLine: 0, + endLine: 2, + kind: 'imports', + }, + ]); + }); + + test('comments', () => { + project.write({ + 'example.ts': stripIndent` + // This is + // a + // multiline + // comment + + const foo = 'bar'; + `, + }); + + let server = project.startLanguageServer(); + let folds = server.getFoldingRanges(project.fileURI('example.ts')); + + expect(folds).toEqual([ + { + startLine: 0, + endLine: 3, + kind: 'comment', + }, + ]); + }); + + test('region', () => { + project.write({ + 'example.ts': stripIndent` + const foo = 'bar'; + + // #region + + const bar = 'baz'; + + // #endregion + + export default { foo, bar }; + `, + }); + + let server = project.startLanguageServer(); + let folds = server.getFoldingRanges(project.fileURI('example.ts')); + + expect(folds).toEqual([ + { + startLine: 2, + endLine: 6, + kind: 'region', + }, + ]); + }); + + test('simple component', () => { + project.write({ + 'example.ts': stripIndent` + import Component, { hbs } from '@glimmerx/component'; + import { tracked } from '@glimmer/tracking'; + + export interface EmberComponentArgs { + message: string; + } + + export interface EmberComponentSignature { + Element: HTMLDivElement; + Args: EmberComponentArgs; + } + + /** + * A simple component that renders a message. + */ + export default class Greeting extends Component { + @tracked message = this.args.message; + + get capitalizedMessage() { + return this.message.toUpperCase(); + } + } + + declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + EmberComponent: typeof EmberComponent; + 'ember-component': typeof EmberComponent; + } + } + `, + }); + + let server = project.startLanguageServer(); + let folds = server.getFoldingRanges(project.fileURI('example.ts')); + + expect(folds).toEqual([ + // Imports + { + startLine: 0, + endLine: 1, + kind: 'imports', + }, + + // EmberComponentArgs + { + startLine: 3, + endLine: 4, + kind: undefined, + }, + + // EmberComponentSignature + { + startLine: 7, + endLine: 9, + kind: undefined, + }, + + // Code Comment + { + startLine: 12, + endLine: 14, + kind: 'comment', + }, + + // Greeting Component + { + startLine: 15, + endLine: 20, + kind: undefined, + }, + + // capitalizedMessage + { + startLine: 18, + endLine: 19, + kind: undefined, + }, + + // declare module + { + startLine: 23, + endLine: 27, + kind: undefined, + }, + + // interface Registry + { + startLine: 24, + endLine: 26, + kind: undefined, + }, + ]); + }); +});