Path: blob/main/src/vs/editor/contrib/documentSymbols/browser/outlineModel.ts
5263 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) {68// eslint-disable-next-line no-restricted-syntax69const candidate = TreeElement.getElementById(id, child);70if (candidate) {71return candidate;72}73}74return undefined;75}7677static size(element: TreeElement): number {78let res = 1;79for (const [, child] of element.children) {80res += TreeElement.size(child);81}82return res;83}8485static empty(element: TreeElement): boolean {86return element.children.size === 0;87}88}8990export interface IOutlineMarker {91startLineNumber: number;92startColumn: number;93endLineNumber: number;94endColumn: number;95severity: MarkerSeverity;96}9798export class OutlineElement extends TreeElement {99100children = new Map<string, OutlineElement>();101marker: { count: number; topSev: MarkerSeverity } | undefined;102103constructor(104readonly id: string,105public parent: TreeElement | undefined,106readonly symbol: DocumentSymbol107) {108super();109}110}111112export class OutlineGroup extends TreeElement {113114children = new Map<string, OutlineElement>();115116constructor(117readonly id: string,118public parent: TreeElement | undefined,119readonly label: string,120readonly order: number,121) {122super();123}124125getItemEnclosingPosition(position: IPosition): OutlineElement | undefined {126return position ? this._getItemEnclosingPosition(position, this.children) : undefined;127}128129private _getItemEnclosingPosition(position: IPosition, children: Map<string, OutlineElement>): OutlineElement | undefined {130for (const [, item] of children) {131if (!item.symbol.range || !Range.containsPosition(item.symbol.range, position)) {132continue;133}134return this._getItemEnclosingPosition(position, item.children) || item;135}136return undefined;137}138139updateMarker(marker: IOutlineMarker[]): void {140for (const [, child] of this.children) {141this._updateMarker(marker, child);142}143}144145private _updateMarker(markers: IOutlineMarker[], item: OutlineElement): void {146item.marker = undefined;147148// find the proper start index to check for item/marker overlap.149const idx = binarySearch<IRange>(markers, item.symbol.range, Range.compareRangesUsingStarts);150let start: number;151if (idx < 0) {152start = ~idx;153if (start > 0 && Range.areIntersecting(markers[start - 1], item.symbol.range)) {154start -= 1;155}156} else {157start = idx;158}159160const myMarkers: IOutlineMarker[] = [];161let myTopSev: MarkerSeverity | undefined;162163for (; start < markers.length && Range.areIntersecting(item.symbol.range, markers[start]); start++) {164// remove markers intersecting with this outline element165// and store them in a 'private' array.166const marker = markers[start];167myMarkers.push(marker);168(markers as Array<IOutlineMarker | undefined>)[start] = undefined;169if (!myTopSev || marker.severity > myTopSev) {170myTopSev = marker.severity;171}172}173174// Recurse into children and let them match markers that have matched175// this outline element. This might remove markers from this element and176// therefore we remember that we have had markers. That allows us to render177// the dot, saying 'this element has children with markers'178for (const [, child] of item.children) {179this._updateMarker(myMarkers, child);180}181182if (myTopSev) {183item.marker = {184count: myMarkers.length,185topSev: myTopSev186};187}188189coalesceInPlace(markers);190}191}192193export class OutlineModel extends TreeElement {194195static create(registry: LanguageFeatureRegistry<DocumentSymbolProvider>, textModel: ITextModel, token: CancellationToken): Promise<OutlineModel> {196197const cts = new CancellationTokenSource(token);198const result = new OutlineModel(textModel.uri);199const provider = registry.ordered(textModel);200const promises = provider.map((provider, index) => {201202const id = TreeElement.findId(`provider_${index}`, result);203const group = new OutlineGroup(id, result, provider.displayName ?? 'Unknown Outline Provider', index);204205206return Promise.resolve(provider.provideDocumentSymbols(textModel, cts.token)).then(result => {207for (const info of result || []) {208OutlineModel._makeOutlineElement(info, group);209}210return group;211}, err => {212onUnexpectedExternalError(err);213return group;214}).then(group => {215if (!TreeElement.empty(group)) {216result._groups.set(id, group);217} else {218group.remove();219}220});221});222223const listener = registry.onDidChange(() => {224const newProvider = registry.ordered(textModel);225if (!equals(newProvider, provider)) {226cts.cancel();227}228});229230return Promise.all(promises).then(() => {231if (cts.token.isCancellationRequested && !token.isCancellationRequested) {232return OutlineModel.create(registry, textModel, token);233} else {234return result._compact();235}236}).finally(() => {237cts.dispose();238listener.dispose();239cts.dispose();240});241}242243private static _makeOutlineElement(info: DocumentSymbol, container: OutlineGroup | OutlineElement): void {244const id = TreeElement.findId(info, container);245const res = new OutlineElement(id, container, info);246if (info.children) {247for (const childInfo of info.children) {248OutlineModel._makeOutlineElement(childInfo, res);249}250}251container.children.set(res.id, res);252}253254static get(element: TreeElement | undefined): OutlineModel | undefined {255while (element) {256if (element instanceof OutlineModel) {257return element;258}259element = element.parent;260}261return undefined;262}263264readonly id = 'root';265readonly parent = undefined;266267protected _groups = new Map<string, OutlineGroup>();268children = new Map<string, OutlineGroup | OutlineElement>();269270protected constructor(readonly uri: URI) {271super();272273this.id = 'root';274this.parent = undefined;275}276277private _compact(): this {278let count = 0;279for (const [key, group] of this._groups) {280if (group.children.size === 0) { // empty281this._groups.delete(key);282} else {283count += 1;284}285}286if (count !== 1) {287//288this.children = this._groups;289} else {290// adopt all elements of the first group291const group = Iterable.first(this._groups.values())!;292for (const [, child] of group.children) {293child.parent = this;294this.children.set(child.id, child);295}296}297return this;298}299300merge(other: OutlineModel): boolean {301if (this.uri.toString() !== other.uri.toString()) {302return false;303}304if (this._groups.size !== other._groups.size) {305return false;306}307this._groups = other._groups;308this.children = other.children;309return true;310}311312getItemEnclosingPosition(position: IPosition, context?: OutlineElement): OutlineElement | undefined {313314let preferredGroup: OutlineGroup | undefined;315if (context) {316let candidate = context.parent;317while (candidate && !preferredGroup) {318if (candidate instanceof OutlineGroup) {319preferredGroup = candidate;320}321candidate = candidate.parent;322}323}324325let result: OutlineElement | undefined = undefined;326for (const [, group] of this._groups) {327result = group.getItemEnclosingPosition(position);328if (result && (!preferredGroup || preferredGroup === group)) {329break;330}331}332return result;333}334335getItemById(id: string): TreeElement | undefined {336// eslint-disable-next-line no-restricted-syntax337return TreeElement.getElementById(id, this);338}339340updateMarker(marker: IOutlineMarker[]): void {341// sort markers by start range so that we can use342// outline element starts for quicker look up343marker.sort(Range.compareRangesUsingStarts);344345for (const [, group] of this._groups) {346group.updateMarker(marker.slice(0));347}348}349350getTopLevelSymbols(): DocumentSymbol[] {351const roots: DocumentSymbol[] = [];352for (const child of this.children.values()) {353if (child instanceof OutlineElement) {354roots.push(child.symbol);355} else {356roots.push(...Iterable.map(child.children.values(), child => child.symbol));357}358}359return roots.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));360}361362asListOfDocumentSymbols(): DocumentSymbol[] {363const roots = this.getTopLevelSymbols();364const bucket: DocumentSymbol[] = [];365OutlineModel._flattenDocumentSymbols(bucket, roots, '');366return bucket.sort((a, b) =>367Position.compare(Range.getStartPosition(a.range), Range.getStartPosition(b.range)) || Position.compare(Range.getEndPosition(b.range), Range.getEndPosition(a.range))368);369}370371private static _flattenDocumentSymbols(bucket: DocumentSymbol[], entries: DocumentSymbol[], overrideContainerLabel: string): void {372for (const entry of entries) {373bucket.push({374kind: entry.kind,375tags: entry.tags,376name: entry.name,377detail: entry.detail,378containerName: entry.containerName || overrideContainerLabel,379range: entry.range,380selectionRange: entry.selectionRange,381children: undefined, // we flatten it...382});383384// Recurse over children385if (entry.children) {386OutlineModel._flattenDocumentSymbols(bucket, entry.children, entry.name);387}388}389}390}391392393export const IOutlineModelService = createDecorator<IOutlineModelService>('IOutlineModelService');394395export interface IOutlineModelService {396_serviceBrand: undefined;397getOrCreate(model: ITextModel, token: CancellationToken): Promise<OutlineModel>;398getDebounceValue(textModel: ITextModel): number;399getCachedModels(): Iterable<OutlineModel>;400}401402interface CacheEntry {403versionId: number;404provider: DocumentSymbolProvider[];405406promiseCnt: number;407source: CancellationTokenSource;408promise: Promise<OutlineModel>;409model: OutlineModel | undefined;410}411412export class OutlineModelService implements IOutlineModelService {413414declare _serviceBrand: undefined;415416private readonly _disposables = new DisposableStore();417private readonly _debounceInformation: IFeatureDebounceInformation;418private readonly _cache = new LRUCache<string, CacheEntry>(15, 0.7);419420constructor(421@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,422@ILanguageFeatureDebounceService debounces: ILanguageFeatureDebounceService,423@IModelService modelService: IModelService424) {425this._debounceInformation = debounces.for(_languageFeaturesService.documentSymbolProvider, 'DocumentSymbols', { min: 350 });426427// don't cache outline models longer than their text model428this._disposables.add(modelService.onModelRemoved(textModel => {429this._cache.delete(textModel.id);430}));431}432433dispose(): void {434this._disposables.dispose();435}436437async getOrCreate(textModel: ITextModel, token: CancellationToken): Promise<OutlineModel> {438439const registry = this._languageFeaturesService.documentSymbolProvider;440const provider = registry.ordered(textModel);441442let data = this._cache.get(textModel.id);443if (!data || data.versionId !== textModel.getVersionId() || !equals(data.provider, provider)) {444const source = new CancellationTokenSource();445data = {446versionId: textModel.getVersionId(),447provider,448promiseCnt: 0,449source,450promise: OutlineModel.create(registry, textModel, source.token),451model: undefined,452};453this._cache.set(textModel.id, data);454455const now = Date.now();456data.promise.then(outlineModel => {457data!.model = outlineModel;458this._debounceInformation.update(textModel, Date.now() - now);459}).catch(_err => {460this._cache.delete(textModel.id);461});462}463464if (data.model) {465// resolved -> return data466return data.model;467}468469// increase usage counter470data.promiseCnt += 1;471472const listener = token.onCancellationRequested(() => {473// last -> cancel provider request, remove cached promise474if (--data.promiseCnt === 0) {475data.source.cancel();476this._cache.delete(textModel.id);477}478});479480try {481return await data.promise;482} finally {483listener.dispose();484}485}486487getDebounceValue(textModel: ITextModel): number {488return this._debounceInformation.get(textModel);489}490491getCachedModels(): Iterable<OutlineModel> {492return Iterable.filter<OutlineModel | undefined, OutlineModel>(Iterable.map(this._cache.values(), entry => entry.model), model => model !== undefined);493}494}495496registerSingleton(IOutlineModelService, OutlineModelService, InstantiationType.Delayed);497498499