Skip to content

Commit 33446a0

Browse files
committed
Use hierarchical markdown document symbols
Fixes microsoft#52546
1 parent a5d4d74 commit 33446a0

3 files changed

Lines changed: 144 additions & 6 deletions

File tree

extensions/markdown-language-features/src/features/documentSymbolProvider.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,71 @@
55

66
import * as vscode from 'vscode';
77
import { MarkdownEngine } from '../markdownEngine';
8-
import { TableOfContentsProvider, SkinnyTextDocument } from '../tableOfContentsProvider';
8+
import { TableOfContentsProvider, SkinnyTextDocument, TocEntry } from '../tableOfContentsProvider';
99

10+
interface MarkdownSymbol {
11+
readonly level: number;
12+
readonly parent: MarkdownSymbol | undefined;
13+
readonly children: vscode.DocumentSymbol[];
14+
}
1015

1116
export default class MDDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
1217

1318
constructor(
1419
private readonly engine: MarkdownEngine
1520
) { }
1621

17-
public async provideDocumentSymbols(document: SkinnyTextDocument): Promise<vscode.SymbolInformation[]> {
22+
public async provideDocumentSymbolInformation(document: SkinnyTextDocument): Promise<vscode.SymbolInformation[]> {
1823
const toc = await new TableOfContentsProvider(this.engine, document).getToc();
19-
return toc.map(entry => {
20-
return new vscode.SymbolInformation('#'.repeat(entry.level) + ' ' + entry.text, vscode.SymbolKind.String, '', entry.location);
21-
});
24+
return toc.map(entry => this.toSymbolInformation(entry));
25+
}
26+
27+
public async provideDocumentSymbols(document: SkinnyTextDocument): Promise<vscode.DocumentSymbol[]> {
28+
const toc = await new TableOfContentsProvider(this.engine, document).getToc();
29+
const root: MarkdownSymbol = {
30+
level: -Infinity,
31+
children: [],
32+
parent: undefined
33+
};
34+
this.buildTree(root, toc);
35+
return root.children;
36+
}
37+
38+
private buildTree(parent: MarkdownSymbol, entries: TocEntry[]) {
39+
if (!entries.length) {
40+
return;
41+
}
42+
43+
const entry = entries[0];
44+
const symbol = this.toDocumentSymbol(entry);
45+
symbol.children = [];
46+
47+
while (parent && entry.level <= parent.level) {
48+
parent = parent.parent!;
49+
}
50+
parent.children.push(symbol);
51+
this.buildTree({ level: entry.level, children: symbol.children, parent }, entries.slice(1));
52+
}
53+
54+
55+
private toSymbolInformation(entry: TocEntry): vscode.SymbolInformation {
56+
return new vscode.SymbolInformation(
57+
this.getSymbolName(entry),
58+
vscode.SymbolKind.String,
59+
'',
60+
entry.location);
61+
}
62+
63+
private toDocumentSymbol(entry: TocEntry) {
64+
return new vscode.DocumentSymbol(
65+
this.getSymbolName(entry),
66+
'',
67+
vscode.SymbolKind.String,
68+
entry.location.range,
69+
entry.location.range);
70+
}
71+
72+
private getSymbolName(entry: TocEntry): string {
73+
return '#'.repeat(entry.level) + ' ' + entry.text;
2274
}
2375
}

extensions/markdown-language-features/src/features/workspaceSymbolProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export default class MarkdownWorkspaceSymbolProvider implements vscode.Workspace
136136

137137
private getSymbols(document: SkinnyTextDocument): Lazy<Thenable<vscode.SymbolInformation[]>> {
138138
return lazy(async () => {
139-
return this._symbolProvider.provideDocumentSymbols(document);
139+
return this._symbolProvider.provideDocumentSymbolInformation(document);
140140
});
141141
}
142142

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as assert from 'assert';
7+
import 'mocha';
8+
import * as vscode from 'vscode';
9+
import SymbolProvider from '../features/documentSymbolProvider';
10+
import { InMemoryDocument } from './inMemoryDocument';
11+
import { createNewMarkdownEngine } from './engine';
12+
13+
14+
const testFileName = vscode.Uri.parse('test.md');
15+
16+
17+
function getSymbolsForFile(fileContents: string) {
18+
const doc = new InMemoryDocument(testFileName, fileContents);
19+
const provider = new SymbolProvider(createNewMarkdownEngine());
20+
return provider.provideDocumentSymbols(doc);
21+
}
22+
23+
24+
suite('markdown.DocumentSymbolProvider', () => {
25+
test('Should not return anything for empty document', async () => {
26+
const symbols = await getSymbolsForFile('');
27+
assert.strictEqual(symbols.length, 0);
28+
});
29+
30+
test('Should not return anything for document with no headers', async () => {
31+
const symbols = await getSymbolsForFile('a\na');
32+
assert.strictEqual(symbols.length, 0);
33+
});
34+
35+
test('Should not return anything for document with # but no real headers', async () => {
36+
const symbols = await getSymbolsForFile('a#a\na#');
37+
assert.strictEqual(symbols.length, 0);
38+
});
39+
40+
test('Should return single symbol for single header', async () => {
41+
const symbols = await getSymbolsForFile('# h');
42+
assert.strictEqual(symbols.length, 1);
43+
assert.strictEqual(symbols[0].name, '# h');
44+
});
45+
46+
test('Should not care about symbol level for single header', async () => {
47+
const symbols = await getSymbolsForFile('### h');
48+
assert.strictEqual(symbols.length, 1);
49+
assert.strictEqual(symbols[0].name, '### h');
50+
});
51+
52+
test('Should put symbols of same level in flat list', async () => {
53+
const symbols = await getSymbolsForFile('## h\n## h2');
54+
assert.strictEqual(symbols.length, 2);
55+
assert.strictEqual(symbols[0].name, '## h');
56+
assert.strictEqual(symbols[1].name, '## h2');
57+
});
58+
59+
test('Should nest symbol of level - 1 under parent', async () => {
60+
61+
const symbols = await getSymbolsForFile('# h\n## h2\n## h3');
62+
assert.strictEqual(symbols.length, 1);
63+
assert.strictEqual(symbols[0].name, '# h');
64+
assert.strictEqual(symbols[0].children.length, 2);
65+
assert.strictEqual(symbols[0].children[0].name, '## h2');
66+
assert.strictEqual(symbols[0].children[1].name, '## h3');
67+
});
68+
69+
test('Should nest symbol of level - n under parent', async () => {
70+
const symbols = await getSymbolsForFile('# h\n#### h2');
71+
assert.strictEqual(symbols.length, 1);
72+
assert.strictEqual(symbols[0].name, '# h');
73+
assert.strictEqual(symbols[0].children.length, 1);
74+
assert.strictEqual(symbols[0].children[0].name, '#### h2');
75+
});
76+
77+
test('Should flatten children where lower level occurs first', async () => {
78+
const symbols = await getSymbolsForFile('# h\n### h2\n## h3');
79+
assert.strictEqual(symbols.length, 1);
80+
assert.strictEqual(symbols[0].name, '# h');
81+
assert.strictEqual(symbols[0].children.length, 2);
82+
assert.strictEqual(symbols[0].children[0].name, '### h2');
83+
assert.strictEqual(symbols[0].children[1].name, '## h3');
84+
});
85+
});
86+

0 commit comments

Comments
 (0)