Skip to content

Commit 88fc6a2

Browse files
cache inspections by symbols (#1320)
* cache inspections by symbols, so that inspections of one symbol are still usable even if other symbols are modified. * reuse cached (by symbol) inspections, to improve UX in case of modifying an inspected class, reopen a document, updating inspection display types.... * resolve comments * resolve comments * invalidate inspection cache on document closed.
1 parent 2194de0 commit 88fc6a2

File tree

8 files changed

+319
-116
lines changed

8 files changed

+319
-116
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/* eslint-disable @typescript-eslint/ban-ts-comment */
2+
import { ExtensionContext, TextDocument, WorkspaceConfiguration, workspace } from "vscode";
3+
import { CodeLensRenderer } from "./render/CodeLensRenderer";
4+
import { DiagnosticRenderer } from "./render/DiagnosticRenderer";
5+
import { GutterIconRenderer } from "./render/GutterIconRenderer";
6+
import { RulerHighlightRenderer } from "./render/RulerHighlightRenderer";
7+
import { InspectionRenderer } from "./render/InspectionRenderer";
8+
import { sendInfo } from "vscode-extension-telemetry-wrapper";
9+
import { isCodeLensDisabled, logger } from "../utils";
10+
import { InspectActionCodeLensProvider } from "./InspectActionCodeLensProvider";
11+
import { debounce } from "lodash";
12+
import InspectionCache from "./InspectionCache";
13+
14+
/**
15+
* `DocumentRenderer` is responsible for
16+
* - managing `Rewrite with new syntax` code lenses renderer
17+
* - managing inspection renderers based on settings
18+
* - rendering inspections for a document
19+
*/
20+
export class DocumentRenderer {
21+
private readonly availableRenderers: { [type: string]: InspectionRenderer } = {};
22+
private readonly installedRenderers: InspectionRenderer[] = [];
23+
private readonly inspectActionCodeLensProvider: InspectActionCodeLensProvider;
24+
private readonly rerenderDebouncelyMap: { [key: string]: (document: TextDocument) => void } = {};
25+
26+
public constructor() {
27+
this.inspectActionCodeLensProvider = new InspectActionCodeLensProvider();
28+
this.availableRenderers['diagnostics'] = new DiagnosticRenderer();
29+
this.availableRenderers['guttericons'] = new GutterIconRenderer();
30+
this.availableRenderers['codelenses'] = new CodeLensRenderer();
31+
this.availableRenderers['rulerhighlights'] = new RulerHighlightRenderer();
32+
}
33+
34+
public install(context: ExtensionContext): DocumentRenderer {
35+
if (this.installedRenderers.length > 0) {
36+
logger.warn('DefaultRenderer is already installed');
37+
return this;
38+
}
39+
this.inspectActionCodeLensProvider.install(context);
40+
// watch for inspection renderers configuration changes
41+
workspace.onDidChangeConfiguration(event => {
42+
if (event.affectsConfiguration('java.copilot.inspection.renderer')) {
43+
const settings = this.reloadInspectionRenderers(context);
44+
sendInfo('java.copilot.inspection.renderer.changed', { 'settings': `${settings.join(',')}` });
45+
}
46+
});
47+
this.reloadInspectionRenderers(context);
48+
return this;
49+
}
50+
51+
/**
52+
* rerender all inspections for the given document
53+
* @param document the document to rerender
54+
* @param debounced whether to rerender debouncely
55+
*/
56+
public async rerender(document: TextDocument, debounced: boolean = false): Promise<void> {
57+
if (document.languageId !== 'java') return;
58+
if (!debounced) {
59+
this.inspectActionCodeLensProvider.rerender(document);
60+
this.rerenderInspections(document);
61+
return;
62+
}
63+
// clear all rendered inspections first
64+
this.installedRenderers.forEach(r => r.clear(document));
65+
const key = document.uri.fsPath;
66+
if (!this.rerenderDebouncelyMap[key]) {
67+
this.rerenderDebouncelyMap[key] = debounce((document: TextDocument) => {
68+
this.inspectActionCodeLensProvider.rerender(document);
69+
this.rerenderInspections(document);
70+
});
71+
}
72+
this.rerenderDebouncelyMap[key](document);
73+
}
74+
75+
private async rerenderInspections(document: TextDocument): Promise<void> {
76+
const inspections = await InspectionCache.getCachedInspectionsOfDoc(document);
77+
this.installedRenderers.forEach(r => r.clear(document));
78+
this.installedRenderers.forEach(r => {
79+
r.renderInspections(document, inspections);
80+
});
81+
}
82+
83+
private reloadInspectionRenderers(context: ExtensionContext): string[] {
84+
this.installedRenderers.splice(0, this.installedRenderers.length);
85+
const settings = this.reloadInspectionRendererSettings();
86+
Object.entries(this.availableRenderers).forEach(([type, renderer]) => {
87+
if (settings.includes(type.toLowerCase())) { // if enabled
88+
this.installedRenderers.push(renderer);
89+
renderer.install(context);
90+
} else {
91+
renderer.uninstall();
92+
}
93+
});
94+
return settings;
95+
}
96+
97+
/**
98+
* get the enabled inspection renderer names
99+
*/
100+
private reloadInspectionRendererSettings(): string[] {
101+
const config: WorkspaceConfiguration = workspace.getConfiguration('java.copilot.inspection.renderer');
102+
const types: string[] = Object.keys(this.availableRenderers);
103+
const settings = types.map(type => config.get<boolean>(type) ? type.toLowerCase() : '').filter(t => t);
104+
if (settings.length === 0) {
105+
settings.push('diagnostics');
106+
settings.push('rulerhighlights');
107+
settings.push(isCodeLensDisabled() ? 'guttericons' : 'codelenses');
108+
}
109+
return settings;
110+
}
111+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { SymbolKind, TextDocument } from 'vscode';
2+
import { METHOD_KINDS, getClassesAndMethodsOfDocument, logger } from '../utils';
3+
import { Inspection } from './Inspection';
4+
import * as crypto from "crypto";
5+
import { SymbolNode } from './SymbolNode';
6+
7+
/**
8+
* A map based cache for inspections of a document.
9+
* format: `Map<documentKey, Map<symbolQualifiedName, [symbolVersionId, Inspection[]]`
10+
*/
11+
const DOC_SYMBOL_VERSION_INSPECTIONS: Map<string, Map<string, [string, Inspection[]]>> = new Map();
12+
13+
export default class InspectionCache {
14+
public static hasCache(document: TextDocument, symbol?: SymbolNode): boolean {
15+
const documentKey = document.uri.fsPath;
16+
if (!symbol) {
17+
return DOC_SYMBOL_VERSION_INSPECTIONS.has(documentKey);
18+
}
19+
const symbolInspections = DOC_SYMBOL_VERSION_INSPECTIONS.get(documentKey);
20+
const versionInspections = symbolInspections?.get(symbol.qualifiedName);
21+
const symbolVersionId = InspectionCache.calculateSymbolVersionId(document, symbol);
22+
return versionInspections?.[0] === symbolVersionId;
23+
}
24+
25+
public static async getCachedInspectionsOfDoc(document: TextDocument): Promise<Inspection[]> {
26+
const symbols: SymbolNode[] = await getClassesAndMethodsOfDocument(document);
27+
const inspections: Inspection[] = [];
28+
for (const symbol of symbols) {
29+
const cachedInspections = InspectionCache.getCachedInspectionsOfSymbol(document, symbol);
30+
inspections.push(...cachedInspections);
31+
}
32+
return inspections;
33+
}
34+
35+
/**
36+
* @returns the cached inspections, or undefined if not found
37+
*/
38+
public static getCachedInspectionsOfSymbol(document: TextDocument, symbol: SymbolNode): Inspection[] {
39+
const documentKey = document.uri.fsPath;
40+
const symbolInspections = DOC_SYMBOL_VERSION_INSPECTIONS.get(documentKey);
41+
const versionInspections = symbolInspections?.get(symbol.qualifiedName);
42+
const symbolVersionId = InspectionCache.calculateSymbolVersionId(document, symbol);
43+
if (versionInspections?.[0] === symbolVersionId) {
44+
logger.debug(`cache hit for ${SymbolKind[symbol.kind]} ${symbol.qualifiedName} of ${document.uri.fsPath}`);
45+
const inspections = versionInspections[1];
46+
inspections.forEach(s => {
47+
s.document = document;
48+
s.problem.position.line = s.problem.position.relativeLine + symbol.range.start.line;
49+
});
50+
return inspections;
51+
}
52+
logger.debug(`cache miss for ${SymbolKind[symbol.kind]} ${symbol.qualifiedName} of ${document.uri.fsPath}`);
53+
return [];
54+
}
55+
56+
public static cache(document: TextDocument, symbols: SymbolNode[], inspections: Inspection[]): void {
57+
for (const symbol of symbols) {
58+
const isMethod = METHOD_KINDS.includes(symbol.kind);
59+
const symbolInspections: Inspection[] = inspections.filter(inspection => {
60+
const inspectionLine = inspection.problem.position.line;
61+
return isMethod ?
62+
// NOTE: method inspections are inspections whose `position.line` is within the method's range
63+
inspectionLine >= symbol.range.start.line && inspectionLine <= symbol.range.end.line :
64+
// NOTE: class inspections are inspections whose `position.line` is exactly the first line number of the class
65+
inspectionLine === symbol.range.start.line;
66+
});
67+
// re-calculate `relativeLine` of method inspections, `relativeLine` is the relative line number to the start of the method
68+
symbolInspections.forEach(inspection => inspection.problem.position.relativeLine = inspection.problem.position.line - symbol.range.start.line);
69+
InspectionCache.cacheSymbolInspections(document, symbol, symbolInspections);
70+
}
71+
}
72+
73+
/**
74+
* invalidate the cache of a document, a symbol, or an inspection.
75+
* NOTE: the cached inspections of the symbol and its contained symbols will be removed when invalidating a symbol.
76+
*/
77+
public static invalidateInspectionCache(document?: TextDocument, symbol?: SymbolNode, inspeciton?: Inspection): void {
78+
if (!document) {
79+
DOC_SYMBOL_VERSION_INSPECTIONS.clear();
80+
} else if (!symbol) {
81+
const documentKey = document.uri.fsPath;
82+
DOC_SYMBOL_VERSION_INSPECTIONS.delete(documentKey);
83+
} else if (!inspeciton) {
84+
const documentKey = document.uri.fsPath;
85+
const symbolInspections = DOC_SYMBOL_VERSION_INSPECTIONS.get(documentKey);
86+
// remove the cached inspections of the symbol
87+
symbolInspections?.delete(symbol.qualifiedName);
88+
// remove the cached inspections of contained symbols
89+
symbolInspections?.forEach((_, key) => {
90+
if (key.startsWith(symbol.qualifiedName)) {
91+
symbolInspections.delete(key);
92+
}
93+
});
94+
} else {
95+
const documentKey = document.uri.fsPath;
96+
const symbolInspections = DOC_SYMBOL_VERSION_INSPECTIONS.get(documentKey);
97+
const versionInspections = symbolInspections?.get(symbol.qualifiedName);
98+
const symbolVersionId = InspectionCache.calculateSymbolVersionId(document, symbol);
99+
if (versionInspections?.[0] === symbolVersionId) {
100+
const inspections = versionInspections[1];
101+
// remove the inspection
102+
inspections.splice(inspections.indexOf(inspeciton), 1);
103+
}
104+
}
105+
}
106+
107+
private static cacheSymbolInspections(document: TextDocument, symbol: SymbolNode, inspections: Inspection[]): void {
108+
logger.debug(`cache ${inspections.length} inspections for ${SymbolKind[symbol.kind]} ${symbol.qualifiedName} of ${document.uri.fsPath}`);
109+
const documentKey = document.uri.fsPath;
110+
const symbolVersionId = InspectionCache.calculateSymbolVersionId(document, symbol);
111+
const cachedSymbolInspections = DOC_SYMBOL_VERSION_INSPECTIONS.get(documentKey) ?? new Map();
112+
// use qualified name to prevent conflicts between symbols with the same signature in same document
113+
cachedSymbolInspections.set(symbol.qualifiedName, [symbolVersionId, inspections]);
114+
DOC_SYMBOL_VERSION_INSPECTIONS.set(documentKey, cachedSymbolInspections);
115+
}
116+
117+
/**
118+
* generate a unique id for the symbol based on its content, so that we can detect if the symbol has changed
119+
*/
120+
private static calculateSymbolVersionId(document: TextDocument, symbol: SymbolNode): string {
121+
const body = document.getText(symbol.range);
122+
return crypto.createHash('md5').update(body).digest("hex")
123+
}
124+
}

src/copilot/inspect/InspectionCopilot.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import Copilot from "../Copilot";
33
import { getClassesContainedInRange, getInnermostClassContainsRange, getIntersectionMethodsOfRange, getUnionRange, logger } from "../utils";
44
import { Inspection } from "./Inspection";
55
import path from "path";
6-
import { TextDocument, DocumentSymbol, SymbolKind, ProgressLocation, commands, Position, Range, Selection, window } from "vscode";
6+
import { TextDocument, SymbolKind, ProgressLocation, commands, Position, Range, Selection, window } from "vscode";
77
import { COMMAND_FIX } from "./commands";
8+
import InspectionCache from "./InspectionCache";
9+
import { SymbolNode } from "./SymbolNode";
810

911
export default class InspectionCopilot extends Copilot {
1012

@@ -119,31 +121,31 @@ export default class InspectionCopilot extends Copilot {
119121
return this.inspectRange(document, range);
120122
}
121123

122-
public async inspectClass(document: TextDocument, clazz: DocumentSymbol): Promise<Inspection[]> {
123-
logger.info('inspecting class:', clazz.name);
124+
public async inspectClass(document: TextDocument, clazz: SymbolNode): Promise<Inspection[]> {
125+
logger.info('inspecting class:', clazz.qualifiedName);
124126
return this.inspectRange(document, clazz.range);
125127
}
126128

127-
public async inspectSymbol(document: TextDocument, symbol: DocumentSymbol): Promise<Inspection[]> {
128-
logger.info(`inspecting symbol ${SymbolKind[symbol.kind]} ${symbol.name}`);
129+
public async inspectSymbol(document: TextDocument, symbol: SymbolNode): Promise<Inspection[]> {
130+
logger.info(`inspecting symbol ${SymbolKind[symbol.kind]} ${symbol.qualifiedName}`);
129131
return this.inspectRange(document, symbol.range);
130132
}
131133

132134
public async inspectRange(document: TextDocument, range: Range | Selection): Promise<Inspection[]> {
133135
// ajust the range to the minimal container class or (multiple) method symbols
134-
const methods: DocumentSymbol[] = await getIntersectionMethodsOfRange(range, document);
135-
const classes: DocumentSymbol[] = await getClassesContainedInRange(range, document);
136-
const symbols: DocumentSymbol[] = [...classes, ...methods];
136+
const methods: SymbolNode[] = await getIntersectionMethodsOfRange(range, document);
137+
const classes: SymbolNode[] = await getClassesContainedInRange(range, document);
138+
const symbols: SymbolNode[] = [...classes, ...methods];
137139
if (symbols.length < 1) {
138-
const containingClass: DocumentSymbol = await getInnermostClassContainsRange(range, document);
140+
const containingClass: SymbolNode = await getInnermostClassContainsRange(range, document);
139141
symbols.push(containingClass);
140142
}
141143

142144
// get the union range of the container symbols, which will be insepcted by copilot
143145
const expandedRange: Range = getUnionRange(symbols);
144146

145147
// inspect the expanded union range
146-
const symbolName = symbols[0].name;
148+
const symbolName = symbols[0].symbol.name;
147149
const symbolKind = SymbolKind[symbols[0].kind].toLowerCase();
148150
const inspections = await window.withProgress({
149151
location: ProgressLocation.Notification,
@@ -165,6 +167,7 @@ export default class InspectionCopilot extends Copilot {
165167
selection === "Go to" && void Inspection.revealFirstLineOfInspection(inspections[0]);
166168
});
167169
}
170+
InspectionCache.cache(document, symbols, inspections);
168171
return inspections;
169172
}
170173

src/copilot/inspect/SymbolNode.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { DocumentSymbol, SymbolKind, Range } from "vscode";
2+
3+
/**
4+
* A wrapper class for DocumentSymbol to provide additional functionalities:
5+
* - parent: the parent symbol
6+
* - qualifiedName: the fully qualified name of the symbol
7+
*/
8+
export class SymbolNode {
9+
public constructor(
10+
public readonly symbol: DocumentSymbol,
11+
public readonly parent?: SymbolNode
12+
) {
13+
}
14+
15+
public get range(): Range {
16+
return this.symbol.range;
17+
}
18+
19+
public get kind(): SymbolKind {
20+
return this.symbol.kind;
21+
}
22+
23+
/**
24+
* The fully qualified name of the symbol.
25+
*/
26+
public get qualifiedName(): string {
27+
if (this.parent) {
28+
return this.parent.qualifiedName + "." + this.symbol.name;
29+
} else {
30+
return this.symbol.name;
31+
}
32+
}
33+
34+
public get children(): SymbolNode[] {
35+
return this.symbol.children.map(symbol => new SymbolNode(symbol, this));
36+
}
37+
}

src/copilot/inspect/commands.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
1-
import { DocumentSymbol, TextDocument, Range, Selection, commands } from "vscode";
1+
import { TextDocument, Range, Selection, commands } from "vscode";
22
import { instrumentOperationAsVsCodeCommand, sendInfo } from "vscode-extension-telemetry-wrapper";
33
import InspectionCopilot from "./InspectionCopilot";
44
import { Inspection, InspectionProblem } from "./Inspection";
55
import { uncapitalize } from "../utils";
6-
import { InspectionRenderer } from "./render/InspectionRenderer";
6+
import { SymbolNode } from "./SymbolNode";
7+
import { DocumentRenderer } from "./DocumentRenderer";
78

89
export const COMMAND_INSPECT_CLASS = 'java.copilot.inspect.class';
910
export const COMMAND_INSPECT_RANGE = 'java.copilot.inspect.range';
1011
export const COMMAND_FIX = 'java.copilot.fix.inspection';
1112

12-
export function registerCommands(renderer: InspectionRenderer) {
13-
instrumentOperationAsVsCodeCommand(COMMAND_INSPECT_CLASS, async (document: TextDocument, clazz: DocumentSymbol) => {
13+
export function registerCommands(renderer: DocumentRenderer) {
14+
instrumentOperationAsVsCodeCommand(COMMAND_INSPECT_CLASS, async (document: TextDocument, clazz: SymbolNode) => {
1415
const copilot = new InspectionCopilot();
15-
const inspections = await copilot.inspectClass(document, clazz);
16-
renderer.renderInspections(document, inspections);
16+
await copilot.inspectClass(document, clazz);
17+
renderer.rerender(document);
1718
});
1819

1920
instrumentOperationAsVsCodeCommand(COMMAND_INSPECT_RANGE, async (document: TextDocument, range: Range | Selection) => {
2021
const copilot = new InspectionCopilot();
21-
const inspections = await copilot.inspectRange(document, range);
22-
renderer.renderInspections(document, inspections);
22+
await copilot.inspectRange(document, range);
23+
renderer.rerender(document);
2324
});
2425

2526
instrumentOperationAsVsCodeCommand(COMMAND_FIX, async (problem: InspectionProblem, solution: string, source: string) => {

0 commit comments

Comments
 (0)