Skip to content

Commit 97ded3d

Browse files
authored
Add Context Debugger (#64)
1 parent 81fcfaf commit 97ded3d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+779
-321
lines changed

client/.vscode/launch.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
],
1313
"stopOnEntry": false,
1414
"sourceMaps": true,
15-
"outFiles": [
16-
"${workspaceRoot}/out/src/**/*.js"
17-
],
1815
"preLaunchTask": "npm: watch"
1916
},
2017
{

client/src/lsp/client.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { updateStatusBar } from '../services/status-bar';
66
import { handleLJDiagnostics } from '../services/diagnostics';
77
import { onActiveFileChange } from '../services/events';
88
import type { LJDiagnostic } from "../types/diagnostics";
9-
import { ContextHistory } from '../types/context';
10-
import { handleContextHistory } from '../services/context';
9+
import { LJContext } from '../types/context';
10+
import { handleContext } from '../services/context';
1111

1212
/**
1313
* Starts the client and connects it to the language server
@@ -44,8 +44,8 @@ export async function runClient(context: vscode.ExtensionContext, port: number)
4444
handleLJDiagnostics(diagnostics);
4545
});
4646

47-
extension.client.onNotification("liquidjava/context", (contextHistory: ContextHistory) => {
48-
handleContextHistory(contextHistory);
47+
extension.client.onNotification("liquidjava/context", (context: LJContext) => {
48+
handleContext(context);
4949
});
5050

5151
const editor = vscode.window.activeTextEditor;

client/src/lsp/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as vscode from 'vscode';
22
import * as child_process from 'child_process';
33
import * as path from 'path';
4-
import { getAvailablePort, killProcess } from '../utils/utils';
4+
import { getAvailablePort, killProcess, normalizeFilePath } from '../utils/utils';
55
import { extension } from '../state';
66
import { DEBUG_MODE, DEBUG_PORT, SERVER_JAR } from '../utils/constants';
77

@@ -22,7 +22,7 @@ export async function runLanguageServer(context: vscode.ExtensionContext, javaEx
2222
const jarPath = path.resolve(context.extensionPath, "dist", "server", SERVER_JAR);
2323
const args = ["-jar", jarPath, port.toString()];
2424
const options = {
25-
cwd: vscode.workspace.workspaceFolders[0].uri.fsPath, // root path
25+
cwd: normalizeFilePath(vscode.workspace.workspaceFolders[0].uri.fsPath), // root path
2626
};
2727
extension.logger.client.info("Creating language server process...");
2828
extension.serverProcess = child_process.spawn(javaExecutablePath, args, options);

client/src/services/autocomplete.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import * as vscode from "vscode";
22
import { extension } from "../state";
3-
import type { Variable, ContextHistory, Ghost, Alias } from "../types/context";
3+
import type { LJVariable, LJContext, LJGhost, LJAlias } from "../types/context";
44
import { getSimpleName } from "../utils/utils";
5-
import { getVariablesInScope } from "./context";
65
import { LIQUIDJAVA_ANNOTATION_START, LJAnnotation } from "../utils/constants";
6+
import { filterDuplicateVariables, filterInstanceVariables } from "./context";
77

88
type CompletionItemOptions = {
99
name: string;
@@ -19,20 +19,20 @@ type CompletionItemOptions = {
1919
type CompletionItemKind = "vars" | "ghosts" | "aliases" | "keywords" | "types" | "decls" | "packages";
2020

2121
/**
22-
* Registers a completion provider for LiquidJava annotations, providing context-aware suggestions based on the current context history
22+
* Registers a completion provider for LiquidJava annotations, providing context-aware suggestions based on the current context
2323
*/
2424
export function registerAutocomplete(context: vscode.ExtensionContext) {
2525
context.subscriptions.push(
2626
vscode.languages.registerCompletionItemProvider("java", {
2727
provideCompletionItems(document, position, _token, completionContext) {
2828
const annotation = getActiveLiquidJavaAnnotation(document, position);
29-
if (!annotation || !extension.contextHistory) return null;
29+
if (!annotation || !extension.context) return null;
3030

3131
const isDotTrigger = completionContext.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && completionContext.triggerCharacter === ".";
3232
const receiver = isDotTrigger ? getReceiverBeforeDot(document, position) : null;
3333
const file = document.uri.toString().replace("file://", "");
3434
const nextChar = document.getText(new vscode.Range(position, position.translate(0, 1)));
35-
const items = getContextCompletionItems(extension.contextHistory, file, annotation, nextChar, isDotTrigger, receiver);
35+
const items = getContextCompletionItems(extension.context, file, annotation, nextChar, isDotTrigger, receiver);
3636
const uniqueItems = new Map<string, vscode.CompletionItem>();
3737
items.forEach(item => {
3838
const label = typeof item.label === "string" ? item.label : item.label.label;
@@ -44,19 +44,20 @@ export function registerAutocomplete(context: vscode.ExtensionContext) {
4444
);
4545
}
4646

47-
function getContextCompletionItems(context: ContextHistory, file: string, annotation: LJAnnotation, nextChar: string, isDotTrigger: boolean, receiver: string | null): vscode.CompletionItem[] {
47+
function getContextCompletionItems(context: LJContext, file: string, annotation: LJAnnotation, nextChar: string, isDotTrigger: boolean, receiver: string | null): vscode.CompletionItem[] {
4848
const triggerParameterHints = nextChar !== "(";
49+
const ghosts = context.ghosts.filter(ghost => ghost.file === file);
4950
if (isDotTrigger) {
5051
if (receiver === "this" || receiver === "old(this)") {
51-
return getGhostCompletionItems(context.ghosts[file] || [], triggerParameterHints);
52+
return getGhostCompletionItems(ghosts, triggerParameterHints);
5253
}
5354
return [];
54-
}
55-
const variablesInScope = getVariablesInScope(file, extension.selection);
56-
const inScope = variablesInScope !== null;
55+
}
56+
const inScope = extension.context.visibleVars !== null;
57+
const varsInScope = filterDuplicateVariables(filterInstanceVariables([...context.visibleVars || []]));
5758
const itemsHandlers: Record<CompletionItemKind, () => vscode.CompletionItem[]> = {
58-
vars: () => getVariableCompletionItems(variablesInScope || []),
59-
ghosts: () => getGhostCompletionItems(context.ghosts[file] || [], triggerParameterHints),
59+
vars: () => getVariableCompletionItems(varsInScope),
60+
ghosts: () => getGhostCompletionItems(ghosts, triggerParameterHints),
6061
aliases: () => getAliasCompletionItems(context.aliases, triggerParameterHints),
6162
keywords: () => getKeywordsCompletionItems(triggerParameterHints, inScope),
6263
types: () => getTypesCompletionItems(),
@@ -75,7 +76,7 @@ function getContextCompletionItems(context: ContextHistory, file: string, annota
7576
return itemsMap[annotation].map(key => itemsHandlers[key]()).flat();
7677
}
7778

78-
function getVariableCompletionItems(variables: Variable[]): vscode.CompletionItem[] {
79+
function getVariableCompletionItems(variables: LJVariable[]): vscode.CompletionItem[] {
7980
return variables.map(variable => {
8081
const varSig = `${variable.type} ${variable.name}`;
8182
const codeBlocks: string[] = [];
@@ -91,12 +92,11 @@ function getVariableCompletionItems(variables: Variable[]): vscode.CompletionIte
9192
});
9293
}
9394

94-
function getGhostCompletionItems(ghosts: Ghost[], triggerParameterHints: boolean): vscode.CompletionItem[] {
95+
function getGhostCompletionItems(ghosts: LJGhost[], triggerParameterHints: boolean): vscode.CompletionItem[] {
9596
return ghosts.map(ghost => {
9697
const parameters = ghost.parameterTypes.map(getSimpleName).join(", ");
9798
const ghostSig = `${ghost.returnType} ${ghost.name}(${parameters})`;
98-
const isState = /^state\d+\(_\) == \d+$/.test(ghost.refinement);
99-
const description = isState ? "state" : "ghost";
99+
const description = ghost.isState ? "state" : "ghost";
100100
return createCompletionItem({
101101
name: ghost.name,
102102
kind: vscode.CompletionItemKind.Function,
@@ -110,7 +110,7 @@ function getGhostCompletionItems(ghosts: Ghost[], triggerParameterHints: boolean
110110
});
111111
}
112112

113-
function getAliasCompletionItems(aliases: Alias[], triggerParameterHints: boolean): vscode.CompletionItem[] {
113+
function getAliasCompletionItems(aliases: LJAlias[], triggerParameterHints: boolean): vscode.CompletionItem[] {
114114
return aliases.map(alias => {
115115
const parameters = alias.parameters
116116
.map((parameter, index) => {

client/src/services/context.ts

Lines changed: 117 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,131 @@
11
import { extension } from "../state";
2-
import { ContextHistory, Selection, Variable } from "../types/context";
2+
import { LJContext, Range, LJVariable } from "../types/context";
3+
import { SourcePosition } from "../types/diagnostics";
4+
import { getOriginalVariableName } from "../utils/utils";
35

4-
export function handleContextHistory(contextHistory: ContextHistory) {
5-
extension.contextHistory = contextHistory;
6+
export function handleContext(context: LJContext) {
7+
extension.context = context;
8+
updateContextForSelection(extension.currentSelection);
9+
extension.webview.sendMessage({ type: "context", context: extension.context, errorAtCursor: extension.errorAtCursor });
610
}
711

8-
// Gets the variables in scope for a given file and position
9-
// Returns null if position not in any scope
10-
export function getVariablesInScope(file: string, selection: Selection): Variable[] | null {
11-
if (!extension.contextHistory || !selection || !file) return null;
12-
13-
// get variables in file
14-
const fileVars = extension.contextHistory.vars[file];
15-
if (!fileVars) return null;
16-
17-
// get variables in the current scope based on the selection
18-
let mostSpecificScope: string | null = null;
19-
let minScopeSize = Infinity;
20-
21-
// find the most specific scope that contains the selection
22-
for (const scope of Object.keys(fileVars)) {
23-
const scopeSelection = parseScopeString(scope);
24-
if (isSelectionWithinScope(selection, scopeSelection)) {
25-
const scopeSize = (scopeSelection.endLine - scopeSelection.startLine) * 10000 + (scopeSelection.endColumn - scopeSelection.startColumn);
26-
if (scopeSize < minScopeSize) {
27-
mostSpecificScope = scope;
28-
minScopeSize = scopeSize;
29-
}
12+
export function updateContextForSelection(selection: Range) {
13+
if (!selection) return;
14+
15+
const globalVars = extension.context.globalVars || [];
16+
const localVars = extension.context.localVars || [];
17+
const variablesInScope = getVariablesInScope(localVars, extension.file, selection);
18+
const visibleVarsByPosition = getVisibleVariables(variablesInScope, extension.file, selection, false);
19+
const visibleVarsByAnnotationPosition = getVisibleVariables(variablesInScope, extension.file, selection, true);
20+
const allVars = sortVariables(normalizeVariableRefinements([...globalVars, ...visibleVarsByPosition]));
21+
extension.context.visibleVars = visibleVarsByAnnotationPosition;
22+
extension.context.allVars = allVars;
23+
}
24+
25+
function getVariablesInScope(variables: LJVariable[], file: string, selection: Range): LJVariable[] {
26+
const scopes = extension.context.fileScopes[file] || [];
27+
const enclosingScopes = scopes.filter(scope => isRangeWithin(selection, scope));
28+
return variables.filter(v =>
29+
v.position?.file === file &&
30+
enclosingScopes.some(scope => isRangeWithin(v.position, scope))
31+
);
32+
}
33+
34+
function getVisibleVariables(variables: LJVariable[], file: string, selection: Range, useAnnotationPosition: boolean): LJVariable[] {
35+
const isCollapsedRange = selection.lineStart === selection.lineEnd && selection.colStart === selection.colEnd;
36+
const fileScopes = isCollapsedRange ? (extension.context.fileScopes[file] || []) : [];
37+
return variables.filter((variable) => {
38+
// variable must be declared in the same file
39+
if (!variable.position || variable.position?.file !== file) return false;
40+
41+
// single point cursor
42+
if (isCollapsedRange) {
43+
const position: SourcePosition = (useAnnotationPosition && variable.annotationPosition) || variable.position;
44+
45+
// variable was declared before the cursor line or its in the same line but before the cursor column
46+
const beforeCursor = isPositionBefore(position, selection);
47+
if (!beforeCursor) return false;
48+
49+
// exclude variables that in unreachable scopes
50+
const isInUnreachableScope = fileScopes.some(scope =>
51+
isRangeWithin(variable.position!, scope) && !isRangeWithin(selection, scope)
52+
);
53+
return !isInUnreachableScope;
3054
}
31-
}
32-
if (mostSpecificScope === null)
33-
return null;
55+
// normal range, filter variables that intersect the selection
56+
return rangesIntersect(variable.position, selection);
57+
});
58+
}
3459

35-
// filter variables to only include those that are reachable based on their position
36-
const variablesInScope = fileVars[mostSpecificScope];
37-
const reachableVariables = getReachableVariables(variablesInScope, selection);
38-
return reachableVariables.filter(v => !v.name.startsWith("this#"));
60+
// Normalizes the range to ensure start is before end
61+
export function normalizeRange(range: Range): Range {
62+
if (isBefore(range.lineStart, range.colStart, range.lineEnd, range.colEnd)) return range;
63+
return { lineStart: range.lineEnd, colStart: range.colEnd, lineEnd: range.lineStart, colEnd: range.colStart };
3964
}
4065

41-
function parseScopeString(scope: string): Selection {
42-
const [start, end] = scope.split("-");
43-
const [startLine, startColumn] = start.split(":").map(Number);
44-
const [endLine, endColumn] = end.split(":").map(Number);
45-
return { startLine, startColumn, endLine, endColumn };
66+
export function rangesIntersect(a: Range, b: Range): boolean {
67+
return isBeforeOrEqual(a.lineStart, a.colStart, b.lineEnd, b.colEnd) &&
68+
isBeforeOrEqual(b.lineStart, b.colStart, a.lineEnd, a.colEnd);
4669
}
4770

48-
function isSelectionWithinScope(selection: Selection, scope: Selection): boolean {
49-
const startsWithin = selection.startLine > scope.startLine ||
50-
(selection.startLine === scope.startLine && selection.startColumn >= scope.startColumn);
51-
const endsWithin = selection.endLine < scope.endLine ||
52-
(selection.endLine === scope.endLine && selection.endColumn <= scope.endColumn);
53-
return startsWithin && endsWithin;
71+
export function isRangeWithin(range: Range, another: Range): boolean {
72+
return isBeforeOrEqual(another.lineStart, another.colStart, range.lineStart, range.colStart) &&
73+
isBeforeOrEqual(range.lineEnd, range.colEnd, another.lineEnd, another.colEnd);
5474
}
5575

56-
function getReachableVariables(variables: Variable[], selection: Selection): Variable[] {
57-
return variables.filter((variable) => {
58-
const placement = variable.placementInCode?.position;
59-
const startPosition = variable.annPosition || placement;
60-
if (!startPosition || variable.isParameter) return true; // if is parameter we need to access it even if it's declared after the selection (for method and parameter refinements)
61-
62-
// variable was declared before the cursor line or its in the same line but before the cursor column
63-
return startPosition.line < selection.startLine || startPosition.line === selection.startLine && startPosition.column <= selection.startColumn;
76+
export function isPositionBefore(range: Range, another: Range): boolean {
77+
return isBefore(range.lineStart, range.colStart, another.lineStart, another.colStart);
78+
}
79+
80+
function isBefore(line1: number, col1: number, line2: number, col2: number): boolean {
81+
return line1 < line2 || (line1 === line2 && col1 < col2);
82+
}
83+
84+
function isBeforeOrEqual(line1: number, col1: number, line2: number, col2: number): boolean {
85+
return line1 < line2 || (line1 === line2 && col1 <= col2);
86+
}
87+
88+
export function filterInstanceVariables(variables: LJVariable[]): LJVariable[] {
89+
return variables.filter(v => !v.name.includes("#"));
90+
}
91+
92+
export function filterDuplicateVariables(variables: LJVariable[]): LJVariable[] {
93+
const uniqueVariables: Map<string, LJVariable> = new Map();
94+
for (const variable of variables) {
95+
if (!uniqueVariables.has(variable.name)) {
96+
uniqueVariables.set(variable.name, variable);
97+
}
98+
}
99+
return Array.from(uniqueVariables.values());
100+
}
101+
102+
// Sorts variables by their position or name
103+
function sortVariables(variables: LJVariable[]): LJVariable[] {
104+
return variables.sort((left, right) => {
105+
if (!left.position && !right.position) return compareVariableNames(left, right);
106+
if (!left.position) return 1;
107+
if (!right.position) return -1;
108+
if (left.position.lineStart !== right.position.lineStart) return left.position.lineStart - right.position.lineStart;
109+
if (left.position.colStart !== right.position.colStart) return right.position.colStart - left.position.colStart;
110+
return compareVariableNames(left, right);
111+
});
112+
}
113+
114+
function compareVariableNames(a: LJVariable, b: LJVariable): number {
115+
if (a.name.startsWith("#") && b.name.startsWith("#")) return getOriginalVariableName(a.name).localeCompare(getOriginalVariableName(b.name));
116+
if (a.name.startsWith("#")) return 1;
117+
if (b.name.startsWith("#")) return -1;
118+
return a.name.localeCompare(b.name);
119+
}
120+
121+
function normalizeVariableRefinements(variables: LJVariable[]): LJVariable[] {
122+
return Array.from(new Map(variables.map(v => [v.refinement, v])).values()).flatMap(v => {
123+
if (!v.refinement || v.refinement === "true") return []; // filter out trivial refinements
124+
if (v.refinement.includes("==")) {
125+
const [left, right] = v.refinement.split("==").map(s => s.trim());
126+
return left !== right ? [v] : []; // filter tautologies like x == x
127+
}
128+
if (v.refinement.includes("!=") || v.refinement.includes(">") || v.refinement.includes("<")) return [v];
129+
return [{ ...v, refinement: `${v.name} == ${v.refinement}` }];
64130
});
65131
}

client/src/services/diagnostics.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as vscode from "vscode";
22
import { extension } from "../state";
3-
import { LJDiagnostic } from "../types/diagnostics";
3+
import { LJDiagnostic, RefinementMismatchError } from "../types/diagnostics";
44
import { StatusBarState, updateStatusBar } from "./status-bar";
5+
import { isPositionBefore, isRangeWithin } from "./context";
56

67
/**
78
* Handles LiquidJava diagnostics received from the language server
@@ -11,8 +12,8 @@ export function handleLJDiagnostics(diagnostics: LJDiagnostic[]) {
1112
const containsError = diagnostics.some(d => d.category === "error");
1213
const statusBarState: StatusBarState = containsError ? "failed" : "passed";
1314
updateStatusBar(statusBarState);
14-
extension.webview?.sendMessage({ type: "diagnostics", diagnostics });
1515
extension.diagnostics = diagnostics;
16+
extension.webview?.sendMessage({ type: "diagnostics", diagnostics });
1617
}
1718

1819
/**
@@ -35,4 +36,20 @@ export async function verify() {
3536
updateStatusBar("loading");
3637

3738
extension.client.sendNotification("liquidjava/verify", { uri });
39+
}
40+
41+
export function updateErrorAtCursor() {
42+
if (!extension.file || !extension.currentSelection) return;
43+
const errors: RefinementMismatchError[] = extension.diagnostics?.filter(d => d.type === 'refinement-error' || d.type === 'state-refinement-error') as RefinementMismatchError[] || [];
44+
const scopes = extension.context?.fileScopes[extension.file] || [];
45+
const errorAtCursor = errors.find(error => {
46+
if (!error.position) return false;
47+
const sameFile = error.position.file === extension.file;
48+
const beforeCursor = isPositionBefore(error.position, extension.currentSelection);
49+
if (!sameFile || !beforeCursor) return false;
50+
// check if error is within a scope that contains the cursor
51+
const errorScope = scopes.find(scope => isRangeWithin(error.position, scope));
52+
return errorScope && isRangeWithin(extension.currentSelection, errorScope);
53+
});
54+
extension.errorAtCursor = errorAtCursor;
3855
}

0 commit comments

Comments
 (0)