extension: support follow cursor and sorting in package outline

Package Outline now supports follow cursor, sort by name, and sort by
position, matching the controls available in Outline.

This change switches the Package Outline view to a TreeView so the active
symbol can be revealed as the cursor moves. It also adds view actions for
follow cursor and both sort modes, and keeps symbol ordering stable by
name or source position.

The cursor matching logic also handles receiver methods returned by
gopls.package_symbols, where methods may be grouped under a type without
being nested inside that type's source range.

Fixes golang/vscode-go#3998

Change-Id: I0ed9cc1526dd84bac44a29cec918b53465b81274
GitHub-Last-Rev: 6f9c67f1bb2c1d5bdae45307a8670b075d990e37
GitHub-Pull-Request: golang/vscode-go#4008
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/761181
Reviewed-by: Madeline Kalil <mkalil@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Hongxiang Jiang <hxjiang@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/docs/commands.md b/docs/commands.md
index 271faa2..7780fb9 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -39,6 +39,14 @@
 
 List all the Go tools being used by this extension along with their locations.
 
+### `Package Outline: Sort By Name`
+
+Sort Package Outline symbols alphabetically.
+
+### `Package Outline: Sort By Position`
+
+Sort Package Outline symbols by source position.
+
 ### `Go: Test Function At Cursor`
 
 Runs a unit test at the cursor.
diff --git a/extension/package.json b/extension/package.json
index 868bdbf..be82dc2 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -224,6 +224,16 @@
         "description": "List all the Go tools being used by this extension along with their locations."
       },
       {
+        "command": "go.packageOutline.sortByName",
+        "title": "Package Outline: Sort By Name",
+        "description": "Sort Package Outline symbols alphabetically."
+      },
+      {
+        "command": "go.packageOutline.sortByPosition",
+        "title": "Package Outline: Sort By Position",
+        "description": "Sort Package Outline symbols by source position."
+      },
+      {
         "command": "go.test.cursor",
         "title": "Go: Test Function At Cursor",
         "description": "Runs a unit test at the cursor."
@@ -3846,6 +3856,16 @@
           "command": "go.explorer.refresh",
           "when": "view == go.explorer",
           "group": "navigation"
+        },
+        {
+          "command": "go.packageOutline.sortByName",
+          "when": "view == go.package.outline && go.packageOutline.sortOrder != 'name'",
+          "group": "2_packageOutline"
+        },
+        {
+          "command": "go.packageOutline.sortByPosition",
+          "when": "view == go.package.outline && go.packageOutline.sortOrder != 'position'",
+          "group": "2_packageOutline"
         }
       ],
       "view/item/context": [
diff --git a/extension/src/goPackageOutline.ts b/extension/src/goPackageOutline.ts
index ce57fdf..9b24b17 100644
--- a/extension/src/goPackageOutline.ts
+++ b/extension/src/goPackageOutline.ts
@@ -12,6 +12,11 @@
 	Symbols: PackageSymbolData[];
 }
 
+enum PackageOutlineSortOrder {
+	Position = 'position',
+	Name = 'name'
+}
+
 export class GoPackageOutlineProvider implements vscode.TreeDataProvider<PackageSymbol> {
 	private _onDidChangeTreeData: vscode.EventEmitter<PackageSymbol | undefined> = new vscode.EventEmitter<
 		PackageSymbol | undefined
@@ -21,13 +26,30 @@
 
 	public result?: PackageSymbolsCommandResult;
 	public activeDocument?: vscode.TextDocument;
+	public view?: vscode.TreeView<PackageSymbol>;
+
+	private readonly collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
+	private packageSymbols: PackageSymbol[] = [];
+	private packageItem = this.createPackageItem();
+	private sortOrder = PackageOutlineSortOrder.Position;
+	private lastRevealedSymbol?: PackageSymbol;
 
 	static setup(ctx: vscode.ExtensionContext) {
 		const provider = new this(ctx);
-		const {
-			window: { registerTreeDataProvider }
-		} = vscode;
-		ctx.subscriptions.push(registerTreeDataProvider('go.package.outline', provider));
+		provider.view = vscode.window.createTreeView('go.package.outline', {
+			treeDataProvider: provider,
+			showCollapseAll: true
+		});
+		ctx.subscriptions.push(provider.view);
+		ctx.subscriptions.push(
+			vscode.commands.registerCommand('go.packageOutline.sortByName', () =>
+				provider.setSortOrder(PackageOutlineSortOrder.Name)
+			),
+			vscode.commands.registerCommand('go.packageOutline.sortByPosition', () =>
+				provider.setSortOrder(PackageOutlineSortOrder.Position)
+			)
+		);
+		provider.updateContextKeys();
 		return provider;
 	}
 
@@ -51,46 +73,24 @@
 				this.reload(e?.document);
 			})
 		);
+		ctx.subscriptions.push(
+			vscode.window.onDidChangeTextEditorSelection((e) => {
+				void this.revealActiveSymbol(e.textEditor);
+			})
+		);
 	}
 
 	getTreeItem(element: PackageSymbol) {
 		return element;
 	}
 
+	// TreeView.reveal uses getParent to expand the path to nested symbols.
+	getParent(element: PackageSymbol): PackageSymbol | undefined {
+		return element.parent;
+	}
+
 	rootItems(): Promise<PackageSymbol[]> {
-		const list = Array<PackageSymbol>();
-		// Add a tree item to display the current package name. Its "command" value will be undefined and thus
-		// will not link anywhere when clicked
-		list.push(
-			new PackageSymbol(
-				{
-					name: this.result?.PackageName ? 'Current Package: ' + this.result.PackageName : '',
-					detail: '',
-					kind: 0,
-					range: new vscode.Range(0, 0, 0, 0),
-					selectionRange: new vscode.Range(0, 0, 0, 0),
-					children: [],
-					file: 0
-				},
-				[],
-				vscode.TreeItemCollapsibleState.None
-			)
-		);
-		const res = this.result;
-		if (res) {
-			res.Symbols?.forEach((d) =>
-				list.push(
-					new PackageSymbol(
-						d,
-						res.Files ?? [],
-						d.children?.length > 0
-							? vscode.TreeItemCollapsibleState.Collapsed
-							: vscode.TreeItemCollapsibleState.None
-					)
-				)
-			);
-		}
-		return new Promise((resolve) => resolve(list));
+		return Promise.resolve([this.packageItem, ...this.sortSymbols(this.packageSymbols)]);
 	}
 
 	getChildren(element?: PackageSymbol): Thenable<PackageSymbol[] | undefined> {
@@ -98,13 +98,18 @@
 		if (!element) {
 			return this.rootItems();
 		}
-		return Promise.resolve(element.children);
+		return Promise.resolve(this.sortSymbols(element.children));
 	}
 
 	async reload(e?: vscode.TextDocument) {
 		if (e?.languageId !== 'go' || e?.uri?.scheme !== 'file') {
 			this.result = undefined;
 			this.activeDocument = undefined;
+			this.packageSymbols = [];
+			this.packageItem = this.createPackageItem();
+			this.lastRevealedSymbol = undefined;
+			vscode.commands.executeCommand('setContext', 'go.showPackageOutline', false);
+			this._onDidChangeTreeData.fire(undefined);
 			return;
 		}
 		this.activeDocument = e;
@@ -113,15 +118,135 @@
 				URI: e.uri.toString()
 			})) as PackageSymbolsCommandResult;
 			this.result = res;
+			this.packageSymbols = this.createPackageSymbols(res);
+			this.packageItem = this.createPackageItem(res.PackageName);
+			this.lastRevealedSymbol = undefined;
 			// Show the Package Outline explorer if the request returned symbols for the current package
 			vscode.commands.executeCommand('setContext', 'go.showPackageOutline', res?.Symbols?.length > 0);
 			this._onDidChangeTreeData.fire(undefined);
+			await this.revealActiveSymbol(vscode.window.activeTextEditor);
 		} catch (e) {
+			this.result = undefined;
+			this.packageSymbols = [];
+			this.packageItem = this.createPackageItem();
+			this.lastRevealedSymbol = undefined;
 			// Hide the Package Outline explorer
 			vscode.commands.executeCommand('setContext', 'go.showPackageOutline', false);
+			this._onDidChangeTreeData.fire(undefined);
 			console.log('ERROR', e);
 		}
 	}
+
+	private createPackageSymbols(res: PackageSymbolsCommandResult): PackageSymbol[] {
+		return (res.Symbols ?? []).map(
+			(symbol) =>
+				new PackageSymbol(
+					symbol,
+					res.Files ?? [],
+					symbol.children?.length > 0
+						? vscode.TreeItemCollapsibleState.Collapsed
+						: vscode.TreeItemCollapsibleState.None
+				)
+		);
+	}
+
+	private createPackageItem(packageName?: string): PackageSymbol {
+		return new PackageSymbol(
+			{
+				name: packageName ? 'Current Package: ' + packageName : '',
+				detail: '',
+				kind: 0,
+				range: new vscode.Range(0, 0, 0, 0),
+				selectionRange: new vscode.Range(0, 0, 0, 0),
+				children: [],
+				file: 0
+			},
+			[],
+			vscode.TreeItemCollapsibleState.None
+		);
+	}
+
+	private sortSymbols(symbols: readonly PackageSymbol[]): PackageSymbol[] {
+		return [...symbols].sort((a, b) => this.compareSymbols(a, b));
+	}
+
+	// Sort alphabetically when requested, otherwise preserve source order.
+	private compareSymbols(a: PackageSymbol, b: PackageSymbol): number {
+		if (this.sortOrder === PackageOutlineSortOrder.Name) {
+			const byName = this.collator.compare(a.symbolName, b.symbolName);
+			if (byName !== 0) {
+				return byName;
+			}
+			return this.compareByPosition(a, b);
+		}
+		const byPosition = this.compareByPosition(a, b);
+		if (byPosition !== 0) {
+			return byPosition;
+		}
+		return this.collator.compare(a.symbolName, b.symbolName);
+	}
+
+	private compareByPosition(a: PackageSymbol, b: PackageSymbol): number {
+		if (a.fileIndex !== b.fileIndex) {
+			return a.fileIndex - b.fileIndex;
+		}
+		if (a.range.start.line !== b.range.start.line) {
+			return a.range.start.line - b.range.start.line;
+		}
+		return a.range.start.character - b.range.start.character;
+	}
+
+	private setSortOrder(sortOrder: PackageOutlineSortOrder) {
+		if (this.sortOrder === sortOrder) {
+			return;
+		}
+		this.sortOrder = sortOrder;
+		vscode.commands.executeCommand('setContext', 'go.packageOutline.sortOrder', sortOrder);
+		this.lastRevealedSymbol = undefined;
+		this._onDidChangeTreeData.fire(undefined);
+		void this.revealActiveSymbol(vscode.window.activeTextEditor);
+	}
+
+	private updateContextKeys() {
+		vscode.commands.executeCommand('setContext', 'go.packageOutline.sortOrder', this.sortOrder);
+	}
+
+	private async revealActiveSymbol(editor?: vscode.TextEditor) {
+		if (!this.view || !editor || editor.document !== this.activeDocument) {
+			return;
+		}
+		const symbol = this.findSymbolAtPosition(this.packageSymbols, editor.document.uri, editor.selection.active);
+		if (!symbol) {
+			this.lastRevealedSymbol = undefined;
+			return;
+		}
+		if (symbol === this.lastRevealedSymbol) {
+			return;
+		}
+		this.lastRevealedSymbol = symbol;
+		try {
+			await this.view.reveal(symbol, { expand: true, select: true });
+		} catch (e) {
+			console.log('ERROR', e);
+		}
+	}
+
+	private findSymbolAtPosition(
+		symbols: readonly PackageSymbol[],
+		uri: vscode.Uri,
+		position: vscode.Position
+	): PackageSymbol | undefined {
+		for (const symbol of symbols) {
+			const childMatch = this.findSymbolAtPosition(symbol.children, uri, position);
+			if (childMatch) {
+				return childMatch;
+			}
+			if (symbol.contains(uri, position)) {
+				return symbol;
+			}
+		}
+		return undefined;
+	}
 }
 
 interface PackageSymbolData {
@@ -168,12 +293,26 @@
 }
 
 export class PackageSymbol extends vscode.TreeItem {
+	public readonly children: PackageSymbol[];
+
 	constructor(
 		private readonly data: PackageSymbolData,
 		private readonly files: string[],
-		public readonly collapsibleState: vscode.TreeItemCollapsibleState
+		public readonly collapsibleState: vscode.TreeItemCollapsibleState,
+		public readonly parent?: PackageSymbol
 	) {
 		super(data.name, collapsibleState);
+		this.children = (data.children ?? []).map(
+			(child) =>
+				new PackageSymbol(
+					child,
+					files,
+					child.children?.length > 0
+						? vscode.TreeItemCollapsibleState.Collapsed
+						: vscode.TreeItemCollapsibleState.None,
+					this
+				)
+		);
 		const file = files[data.file ?? 0];
 		this.resourceUri = files && files.length > 0 ? vscode.Uri.parse(file) : undefined;
 		const [icon, kind] = this.getSymbolInfo();
@@ -195,17 +334,28 @@
 			: undefined;
 	}
 
-	get children(): PackageSymbol[] | undefined {
-		return this.data.children?.map(
-			(c) =>
-				new PackageSymbol(
-					c,
-					this.files,
-					c.children?.length > 0
-						? vscode.TreeItemCollapsibleState.Collapsed
-						: vscode.TreeItemCollapsibleState.None
-				)
-		);
+	get range(): vscode.Range {
+		return this.data.range;
+	}
+
+	get fileIndex(): number {
+		return this.data.file ?? 0;
+	}
+
+	get symbolName(): string {
+		return this.data.name;
+	}
+
+	contains(uri: vscode.Uri, position: vscode.Position): boolean {
+		if (this.resourceUri?.toString() !== uri.toString()) {
+			return false;
+		}
+		const { start, end } = this.range;
+		const afterStart =
+			start.line < position.line || (start.line === position.line && start.character <= position.character);
+		const beforeEnd =
+			end.line > position.line || (end.line === position.line && end.character >= position.character);
+		return afterStart && beforeEnd;
 	}
 
 	private getSymbolInfo(): [vscode.ThemeIcon | undefined, string] {
diff --git a/extension/test/integration/goPackageOutline.test.ts b/extension/test/integration/goPackageOutline.test.ts
index e81dba7..967f0f2 100644
--- a/extension/test/integration/goPackageOutline.test.ts
+++ b/extension/test/integration/goPackageOutline.test.ts
@@ -28,6 +28,10 @@
 		provider = GoPackageOutlineProvider.setup(ctx);
 	});
 
+	setup(async () => {
+		await vscode.commands.executeCommand('go.packageOutline.sortByPosition');
+	});
+
 	suiteTeardown(() => {
 		ctx.teardown();
 	});
@@ -37,7 +41,7 @@
 			vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go'))
 		);
 		await window.showTextDocument(document);
-		await sleep(500); // wait for gopls response
+		await waitForOutlineResult(provider, 'package_outline_test');
 		const res = provider.result;
 		assert.strictEqual(res?.PackageName, 'package_outline_test');
 		assert.strictEqual(res?.Files.length, 2);
@@ -51,7 +55,7 @@
 			vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go'))
 		);
 		await window.showTextDocument(document);
-		await sleep(500); // wait for gopls response
+		await waitForOutlineResult(provider, 'package_outline_test');
 		await vscode.commands.executeCommand('setContext', 'go.showPackageOutline');
 		const children = await provider.getChildren();
 		const receiver = children?.find((symbol) => symbol.label === 'TestReceiver');
@@ -70,7 +74,7 @@
 			vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go'))
 		);
 		await window.showTextDocument(document);
-		await sleep(500); // wait for gopls response
+		await waitForOutlineResult(provider, 'package_outline_test');
 		await vscode.commands.executeCommand('setContext', 'go.showPackageOutline');
 		const children = await provider.getChildren();
 		const receiver = children?.find((symbol) => symbol.label === 'TestReceiver');
@@ -87,6 +91,66 @@
 		assert.strictEqual(window.activeTextEditor?.selection.active.character, 0);
 	});
 
+	test('sort by name orders symbols alphabetically', async () => {
+		const document = await vscode.workspace.openTextDocument(
+			vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go'))
+		);
+		await window.showTextDocument(document);
+		await waitForOutlineResult(provider, 'package_outline_test');
+		await vscode.commands.executeCommand('go.packageOutline.sortByName');
+		const children = await provider.getChildren();
+		assert.deepStrictEqual(
+			(children ?? []).slice(1).map((symbol) => symbol.label),
+			['main', 'print', 'TestReceiver']
+		);
+		const receiver = children?.find((symbol) => symbol.label === 'TestReceiver');
+		assert.ok(receiver, 'receiver symbol not found');
+		const receiverChildren = await provider.getChildren(receiver);
+		assert.deepStrictEqual(
+			(receiverChildren ?? []).map((symbol) => symbol.label),
+			['field1', 'field2', 'field3', 'method1', 'method2', 'method3']
+		);
+	});
+
+	test('sort by position orders symbols by source location', async () => {
+		const document = await vscode.workspace.openTextDocument(
+			vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go'))
+		);
+		await window.showTextDocument(document);
+		await waitForOutlineResult(provider, 'package_outline_test');
+		const children = await provider.getChildren();
+		assert.deepStrictEqual(
+			(children ?? []).slice(1).map((symbol) => symbol.label),
+			['print', 'main', 'TestReceiver']
+		);
+	});
+
+	test('cursor changes reveal the active symbol', async () => {
+		const document1 = await vscode.workspace.openTextDocument(
+			vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go'))
+		);
+		await window.showTextDocument(document1);
+		await waitForOutlineResult(provider, 'package_outline_test');
+		await moveCursor(document1, 19);
+		await sleep(500); // wait for tree view reveal
+		assert.strictEqual(
+			((provider as unknown) as { lastRevealedSymbol?: PackageSymbol }).lastRevealedSymbol?.label,
+			'method1'
+		);
+
+		const document2 = await vscode.workspace.openTextDocument(
+			vscode.Uri.file(path.join(fixtureDir, 'symbols_2.go'))
+		);
+		await window.showTextDocument(document2);
+		await waitForOutlineResult(provider, 'package_outline_test');
+		await moveCursor(document2, 2);
+		await sleep(500); // wait for tree view reveal
+		assert.strictEqual(
+			((provider as unknown) as { lastRevealedSymbol?: PackageSymbol }).lastRevealedSymbol?.label,
+			'method2'
+		);
+	});
+
 	test('non-go file does not trigger outline', async () => {
 		const document = await vscode.workspace.openTextDocument(
 			vscode.Uri.file(path.join(fixtureDir, 'symbols_3.ts'))
@@ -101,6 +165,23 @@
 	return new Promise((resolve) => setTimeout(resolve, ms));
 }
 
+async function waitForOutlineResult(provider: GoPackageOutlineProvider, packageName: string) {
+	const deadline = Date.now() + 5000;
+	while (Date.now() < deadline) {
+		if (provider.result?.PackageName === packageName) {
+			return;
+		}
+		await sleep(100);
+	}
+	assert.fail(`timed out waiting for outline result for ${packageName}`);
+}
+
+async function moveCursor(document: vscode.TextDocument, line: number, character = 0) {
+	const editor = await window.showTextDocument(document);
+	const position = new vscode.Position(line, character);
+	editor.selection = new vscode.Selection(position, position);
+}
+
 function clickSymbol(symbol: PackageSymbol) {
 	if (symbol.command) {
 		vscode.commands.executeCommand(symbol.command.command, ...(symbol.command.arguments || []));