Path: blob/main/src/vs/editor/contrib/documentSymbols/browser/outlineModel.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { binarySearch, coalesceInPlace, equals } from '../../../../base/common/arrays.js';6import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';7import { onUnexpectedExternalError } from '../../../../base/common/errors.js';8import { Iterable } from '../../../../base/common/iterator.js';9import { LRUCache } from '../../../../base/common/map.js';10import { commonPrefixLength } from '../../../../base/common/strings.js';11import { URI } from '../../../../base/common/uri.js';12import { IPosition, Position } from '../../../common/core/position.js';13import { IRange, Range } from '../../../common/core/range.js';14import { ITextModel } from '../../../common/model.js';15import { DocumentSymbol, DocumentSymbolProvider } from '../../../common/languages.js';16import { MarkerSeverity } from '../../../../platform/markers/common/markers.js';17import { IFeatureDebounceInformation, ILanguageFeatureDebounceService } from '../../../common/services/languageFeatureDebounce.js';18import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';19import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';20import { IModelService } from '../../../common/services/model.js';21import { DisposableStore } from '../../../../base/common/lifecycle.js';22import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';23import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';2425export abstract class TreeElement {2627abstract id: string;28abstract children: Map<string, TreeElement>;29abstract parent: TreeElement | undefined;3031remove(): void {32this.parent?.children.delete(this.id);33}3435static findId(candidate: DocumentSymbol | string, container: TreeElement): string {36// complex id-computation which contains the origin/extension,37// the parent path, and some dedupe logic when names collide38let candidateId: string;39if (typeof candidate === 'string') {40candidateId = `${container.id}/${candidate}`;41} else {42candidateId = `${container.id}/${candidate.name}`;43if (container.children.get(candidateId) !== undefined) {44candidateId = `${container.id}/${candidate.name}_${candidate.range.startLineNumber}_${candidate.range.startColumn}`;45}46}4748let id = candidateId;49for (let i = 0; container.children.get(id) !== undefined; i++) {50id = `${candidateId}_${i}`;51}5253return id;54}5556static getElementById(id: string, element: TreeElement): TreeElement | undefined {57if (!id) {58return undefined;59}60const len = commonPrefixLength(id, element.id);61if (len === id.length) {62return element;63}64if (len < element.id.length) {65return undefined;66}67for (const [, child] of element.children) {68const candidate = TreeElement.getElementById(id, child);69if (candidate) {70return candidate;71}72}73return undefined;74}7576static size(element: TreeElement): number {77let res = 1;78for (const [, child] of element.children) {79res += TreeElement.size(child);80}81return res;82}8384static empty(element: TreeElement): boolean {85return element.children.size === 0;86}87}8889export interface IOutlineMarker {90startLineNumber: number;91startColumn: number;92endLineNumber: number;93endColumn: number;94severity: MarkerSeverity;95}9697export class OutlineElement extends TreeElement {9899children = new Map<string, OutlineElement>();100marker: { count: number; topSev: MarkerSeverity } | undefined;101102constructor(103readonly id: string,104public parent: TreeElement | undefined,105readonly symbol: DocumentSymbol106) {107super();108}109}110111export class OutlineGroup extends TreeElement {112113children = new Map<string, OutlineElement>();114115constructor(116readonly id: string,117public parent: TreeElement | undefined,118readonly label: string,119readonly order: number,120) {121super();122}123124getItemEnclosingPosition(position: IPosition): OutlineElement | undefined {125return position ? this._getItemEnclosingPosition(position, this.children) : undefined;126}127128private _getItemEnclosingPosition(position: IPosition, children: Map<string, OutlineElement>): OutlineElement | undefined {129for (const [, item] of children) {130if (!item.symbol.range || !Range.containsPosition(item.symbol.range, position)) {131continue;132}133return this._getItemEnclosingPosition(position, item.children) || item;134}135return undefined;136}137138updateMarker(marker: IOutlineMarker[]): void {139for (const [, child] of this.children) {140this._updateMarker(marker, child);141}142}143144private _updateMarker(markers: IOutlineMarker[], item: OutlineElement): void {145item.marker = undefined;146147// find the proper start index to check for item/marker overlap.148const idx = binarySearch<IRange>(markers, item.symbol.range, Range.compareRangesUsingStarts);149let start: number;150if (idx < 0) {151start = ~idx;152if (start > 0 && Range.areIntersecting(markers[start - 1], item.symbol.range)) {153start -= 1;154}155} else {156start = idx;157}158159const myMarkers: IOutlineMarker[] = [];160let myTopSev: MarkerSeverity | undefined;161162for (; start < markers.length && Range.areIntersecting(item.symbol.range, markers[start]); start++) {163// remove markers intersecting with this outline element164// and store them in a 'private' array.165const marker = markers[start];166myMarkers.push(marker);167(markers as Array<IOutlineMarker | undefined>)[start] = undefined;168if (!myTopSev || marker.severity > myTopSev) {169myTopSev = marker.severity;170}171}172173// Recurse into children and let them match markers that have matched174// this outline element. This might remove markers from this element and175// therefore we remember that we have had markers. That allows us to render176// the dot, saying 'this element has children with markers'177for (const [, child] of item.children) {178this._updateMarker(myMarkers, child);179}180181if (myTopSev) {182item.marker = {183count: myMarkers.length,184topSev: myTopSev185};186}187188coalesceInPlace(markers);189}190}191192export class OutlineModel extends TreeElement {193194static create(registry: LanguageFeatureRegistry<DocumentSymbolProvider>, textModel: ITextModel, token: CancellationToken): Promise<OutlineModel> {195196const cts = new CancellationTokenSource(token);197const result = new OutlineModel(textModel.uri);198const provider = registry.ordered(textModel);199const promises = provider.map((provider, index) => {200201const id = TreeElement.findId(`provider_${index}`, result);202const group = new OutlineGroup(id, result, provider.displayName ?? 'Unknown Outline Provider', index);203204205return Promise.resolve(provider.provideDocumentSymbols(textModel, cts.token)).then(result => {206for (const info of result || []) {207OutlineModel._makeOutlineElement(info, group);208}209return group;210}, err => {211onUnexpectedExternalError(err);212return group;213}).then(group => {214if (!TreeElement.empty(group)) {215result._groups.set(id, group);216} else {217group.remove();218}219});220});221222const listener = registry.onDidChange(() => {223const newProvider = registry.ordered(textModel);224if (!equals(newProvider, provider)) {225cts.cancel();226}227});228229return Promise.all(promises).then(() => {230if (cts.token.isCancellationRequested && !token.isCancellationRequested) {231return OutlineModel.create(registry, textModel, token);232} else {233return result._compact();234}235}).finally(() => {236cts.dispose();237listener.dispose();238cts.dispose();239});240}241242private static _makeOutlineElement(info: DocumentSymbol, container: OutlineGroup | OutlineElement): void {243const id = TreeElement.findId(info, container);244const res = new OutlineElement(id, container, info);245if (info.children) {246for (const childInfo of info.children) {247OutlineModel._makeOutlineElement(childInfo, res);248}249}250container.children.set(res.id, res);251}252253static get(element: TreeElement | undefined): OutlineModel | undefined {254while (element) {255if (element instanceof OutlineModel) {256return element;257}258element = element.parent;259}260return undefined;261}262263readonly id = 'root';264readonly parent = undefined;265266protected _groups = new Map<string, OutlineGroup>();267children = new Map<string, OutlineGroup | OutlineElement>();268269protected constructor(readonly uri: URI) {270super();271272this.id = 'root';273this.parent = undefined;274}275276private _compact(): this {277let count = 0;278for (const [key, group] of this._groups) {279if (group.children.size === 0) { // empty280this._groups.delete(key);281} else {282count += 1;283}284}285if (count !== 1) {286//287this.children = this._groups;288} else {289// adopt all elements of the first group290const group = Iterable.first(this._groups.values())!;291for (const [, child] of group.children) {292child.parent = this;293this.children.set(child.id, child);294}295}296return this;297}298299merge(other: OutlineModel): boolean {300if (this.uri.toString() !== other.uri.toString()) {301return false;302}303if (this._groups.size !== other._groups.size) {304return false;305}306this._groups = other._groups;307this.children = other.children;308return true;309}310311getItemEnclosingPosition(position: IPosition, context?: OutlineElement): OutlineElement | undefined {312313let preferredGroup: OutlineGroup | undefined;314if (context) {315let candidate = context.parent;316while (candidate && !preferredGroup) {317if (candidate instanceof OutlineGroup) {318preferredGroup = candidate;319}320candidate = candidate.parent;321}322}323324let result: OutlineElement | undefined = undefined;325for (const [, group] of this._groups) {326result = group.getItemEnclosingPosition(position);327if (result && (!preferredGroup || preferredGroup === group)) {328break;329}330}331return result;332}333334getItemById(id: string): TreeElement | undefined {335return TreeElement.getElementById(id, this);336}337338updateMarker(marker: IOutlineMarker[]): void {339// sort markers by start range so that we can use340// outline element starts for quicker look up341marker.sort(Range.compareRangesUsingStarts);342343for (const [, group] of this._groups) {344group.updateMarker(marker.slice(0));345}346}347348getTopLevelSymbols(): DocumentSymbol[] {349const roots: DocumentSymbol[] = [];350for (const child of this.children.values()) {351if (child instanceof OutlineElement) {352roots.push(child.symbol);353} else {354roots.push(...Iterable.map(child.children.values(), child => child.symbol));355}356}357return roots.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));358}359360asListOfDocumentSymbols(): DocumentSymbol[] {361const roots = this.getTopLevelSymbols();362const bucket: DocumentSymbol[] = [];363OutlineModel._flattenDocumentSymbols(bucket, roots, '');364return bucket.sort((a, b) =>365Position.compare(Range.getStartPosition(a.range), Range.getStartPosition(b.range)) || Position.compare(Range.getEndPosition(b.range), Range.getEndPosition(a.range))366);367}368369private static _flattenDocumentSymbols(bucket: DocumentSymbol[], entries: DocumentSymbol[], overrideContainerLabel: string): void {370for (const entry of entries) {371bucket.push({372kind: entry.kind,373tags: entry.tags,374name: entry.name,375detail: entry.detail,376containerName: entry.containerName || overrideContainerLabel,377range: entry.range,378selectionRange: entry.selectionRange,379children: undefined, // we flatten it...380});381382// Recurse over children383if (entry.children) {384OutlineModel._flattenDocumentSymbols(bucket, entry.children, entry.name);385}386}387}388}389390391export const IOutlineModelService = createDecorator<IOutlineModelService>('IOutlineModelService');392393export interface IOutlineModelService {394_serviceBrand: undefined;395getOrCreate(model: ITextModel, token: CancellationToken): Promise<OutlineModel>;396getDebounceValue(textModel: ITextModel): number;397getCachedModels(): Iterable<OutlineModel>;398}399400interface CacheEntry {401versionId: number;402provider: DocumentSymbolProvider[];403404promiseCnt: number;405source: CancellationTokenSource;406promise: Promise<OutlineModel>;407model: OutlineModel | undefined;408}409410export class OutlineModelService implements IOutlineModelService {411412declare _serviceBrand: undefined;413414private readonly _disposables = new DisposableStore();415private readonly _debounceInformation: IFeatureDebounceInformation;416private readonly _cache = new LRUCache<string, CacheEntry>(15, 0.7);417418constructor(419@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,420@ILanguageFeatureDebounceService debounces: ILanguageFeatureDebounceService,421@IModelService modelService: IModelService422) {423this._debounceInformation = debounces.for(_languageFeaturesService.documentSymbolProvider, 'DocumentSymbols', { min: 350 });424425// don't cache outline models longer than their text model426this._disposables.add(modelService.onModelRemoved(textModel => {427this._cache.delete(textModel.id);428}));429}430431dispose(): void {432this._disposables.dispose();433}434435async getOrCreate(textModel: ITextModel, token: CancellationToken): Promise<OutlineModel> {436437const registry = this._languageFeaturesService.documentSymbolProvider;438const provider = registry.ordered(textModel);439440let data = this._cache.get(textModel.id);441if (!data || data.versionId !== textModel.getVersionId() || !equals(data.provider, provider)) {442const source = new CancellationTokenSource();443data = {444versionId: textModel.getVersionId(),445provider,446promiseCnt: 0,447source,448promise: OutlineModel.create(registry, textModel, source.token),449model: undefined,450};451this._cache.set(textModel.id, data);452453const now = Date.now();454data.promise.then(outlineModel => {455data!.model = outlineModel;456this._debounceInformation.update(textModel, Date.now() - now);457}).catch(_err => {458this._cache.delete(textModel.id);459});460}461462if (data.model) {463// resolved -> return data464return data.model;465}466467// increase usage counter468data.promiseCnt += 1;469470const listener = token.onCancellationRequested(() => {471// last -> cancel provider request, remove cached promise472if (--data.promiseCnt === 0) {473data.source.cancel();474this._cache.delete(textModel.id);475}476});477478try {479return await data.promise;480} finally {481listener.dispose();482}483}484485getDebounceValue(textModel: ITextModel): number {486return this._debounceInformation.get(textModel);487}488489getCachedModels(): Iterable<OutlineModel> {490return Iterable.filter<OutlineModel | undefined, OutlineModel>(Iterable.map(this._cache.values(), entry => entry.model), model => model !== undefined);491}492}493494registerSingleton(IOutlineModelService, OutlineModelService, InstantiationType.Delayed);495496497