From 33446a0a9e66efbfc106ffa08b948b63d77afdc2 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Tue, 26 Jun 2018 17:31:27 -0700 Subject: [PATCH] Use hierarchical markdown document symbols Fixes #52546 --- .../src/features/documentSymbolProvider.ts | 62 +++++++++++-- .../src/features/workspaceSymbolProvider.ts | 2 +- .../src/test/documentSymbolProvider.test.ts | 86 +++++++++++++++++++ 3 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 extensions/markdown-language-features/src/test/documentSymbolProvider.test.ts diff --git a/extensions/markdown-language-features/src/features/documentSymbolProvider.ts b/extensions/markdown-language-features/src/features/documentSymbolProvider.ts index 38b33197200..bf0d5edb852 100644 --- a/extensions/markdown-language-features/src/features/documentSymbolProvider.ts +++ b/extensions/markdown-language-features/src/features/documentSymbolProvider.ts @@ -5,8 +5,13 @@ import * as vscode from 'vscode'; import { MarkdownEngine } from '../markdownEngine'; -import { TableOfContentsProvider, SkinnyTextDocument } from '../tableOfContentsProvider'; +import { TableOfContentsProvider, SkinnyTextDocument, TocEntry } from '../tableOfContentsProvider'; +interface MarkdownSymbol { + readonly level: number; + readonly parent: MarkdownSymbol | undefined; + readonly children: vscode.DocumentSymbol[]; +} export default class MDDocumentSymbolProvider implements vscode.DocumentSymbolProvider { @@ -14,10 +19,57 @@ export default class MDDocumentSymbolProvider implements vscode.DocumentSymbolPr private readonly engine: MarkdownEngine ) { } - public async provideDocumentSymbols(document: SkinnyTextDocument): Promise { + public async provideDocumentSymbolInformation(document: SkinnyTextDocument): Promise { const toc = await new TableOfContentsProvider(this.engine, document).getToc(); - return toc.map(entry => { - return new vscode.SymbolInformation('#'.repeat(entry.level) + ' ' + entry.text, vscode.SymbolKind.String, '', entry.location); - }); + return toc.map(entry => this.toSymbolInformation(entry)); + } + + public async provideDocumentSymbols(document: SkinnyTextDocument): Promise { + const toc = await new TableOfContentsProvider(this.engine, document).getToc(); + const root: MarkdownSymbol = { + level: -Infinity, + children: [], + parent: undefined + }; + this.buildTree(root, toc); + return root.children; + } + + private buildTree(parent: MarkdownSymbol, entries: TocEntry[]) { + if (!entries.length) { + return; + } + + const entry = entries[0]; + const symbol = this.toDocumentSymbol(entry); + symbol.children = []; + + while (parent && entry.level <= parent.level) { + parent = parent.parent!; + } + parent.children.push(symbol); + this.buildTree({ level: entry.level, children: symbol.children, parent }, entries.slice(1)); + } + + + private toSymbolInformation(entry: TocEntry): vscode.SymbolInformation { + return new vscode.SymbolInformation( + this.getSymbolName(entry), + vscode.SymbolKind.String, + '', + entry.location); + } + + private toDocumentSymbol(entry: TocEntry) { + return new vscode.DocumentSymbol( + this.getSymbolName(entry), + '', + vscode.SymbolKind.String, + entry.location.range, + entry.location.range); + } + + private getSymbolName(entry: TocEntry): string { + return '#'.repeat(entry.level) + ' ' + entry.text; } } \ No newline at end of file diff --git a/extensions/markdown-language-features/src/features/workspaceSymbolProvider.ts b/extensions/markdown-language-features/src/features/workspaceSymbolProvider.ts index 9cbce783e12..12e51c75d76 100644 --- a/extensions/markdown-language-features/src/features/workspaceSymbolProvider.ts +++ b/extensions/markdown-language-features/src/features/workspaceSymbolProvider.ts @@ -136,7 +136,7 @@ export default class MarkdownWorkspaceSymbolProvider implements vscode.Workspace private getSymbols(document: SkinnyTextDocument): Lazy> { return lazy(async () => { - return this._symbolProvider.provideDocumentSymbols(document); + return this._symbolProvider.provideDocumentSymbolInformation(document); }); } diff --git a/extensions/markdown-language-features/src/test/documentSymbolProvider.test.ts b/extensions/markdown-language-features/src/test/documentSymbolProvider.test.ts new file mode 100644 index 00000000000..b87b5cf2ea4 --- /dev/null +++ b/extensions/markdown-language-features/src/test/documentSymbolProvider.test.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import 'mocha'; +import * as vscode from 'vscode'; +import SymbolProvider from '../features/documentSymbolProvider'; +import { InMemoryDocument } from './inMemoryDocument'; +import { createNewMarkdownEngine } from './engine'; + + +const testFileName = vscode.Uri.parse('test.md'); + + +function getSymbolsForFile(fileContents: string) { + const doc = new InMemoryDocument(testFileName, fileContents); + const provider = new SymbolProvider(createNewMarkdownEngine()); + return provider.provideDocumentSymbols(doc); +} + + +suite('markdown.DocumentSymbolProvider', () => { + test('Should not return anything for empty document', async () => { + const symbols = await getSymbolsForFile(''); + assert.strictEqual(symbols.length, 0); + }); + + test('Should not return anything for document with no headers', async () => { + const symbols = await getSymbolsForFile('a\na'); + assert.strictEqual(symbols.length, 0); + }); + + test('Should not return anything for document with # but no real headers', async () => { + const symbols = await getSymbolsForFile('a#a\na#'); + assert.strictEqual(symbols.length, 0); + }); + + test('Should return single symbol for single header', async () => { + const symbols = await getSymbolsForFile('# h'); + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].name, '# h'); + }); + + test('Should not care about symbol level for single header', async () => { + const symbols = await getSymbolsForFile('### h'); + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].name, '### h'); + }); + + test('Should put symbols of same level in flat list', async () => { + const symbols = await getSymbolsForFile('## h\n## h2'); + assert.strictEqual(symbols.length, 2); + assert.strictEqual(symbols[0].name, '## h'); + assert.strictEqual(symbols[1].name, '## h2'); + }); + + test('Should nest symbol of level - 1 under parent', async () => { + + const symbols = await getSymbolsForFile('# h\n## h2\n## h3'); + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].name, '# h'); + assert.strictEqual(symbols[0].children.length, 2); + assert.strictEqual(symbols[0].children[0].name, '## h2'); + assert.strictEqual(symbols[0].children[1].name, '## h3'); + }); + + test('Should nest symbol of level - n under parent', async () => { + const symbols = await getSymbolsForFile('# h\n#### h2'); + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].name, '# h'); + assert.strictEqual(symbols[0].children.length, 1); + assert.strictEqual(symbols[0].children[0].name, '#### h2'); + }); + + test('Should flatten children where lower level occurs first', async () => { + const symbols = await getSymbolsForFile('# h\n### h2\n## h3'); + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].name, '# h'); + assert.strictEqual(symbols[0].children.length, 2); + assert.strictEqual(symbols[0].children[0].name, '### h2'); + assert.strictEqual(symbols[0].children[1].name, '## h3'); + }); +}); +