Skip to content

Commit e21b55f

Browse files
authored
Autocomplete for Refinements (#58)
1 parent 3faf90c commit e21b55f

20 files changed

Lines changed: 513 additions & 9 deletions

client/src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { registerStatusBar, updateStatusBar } from "./services/status-bar";
88
import { registerWebview } from "./services/webview";
99
import { registerHover } from "./services/hover";
1010
import { registerEvents } from "./services/events";
11+
import { registerAutocomplete } from "./services/autocomplete";
1112
import { runLanguageServer, stopLanguageServer } from "./lsp/server";
1213
import { runClient, stopClient } from "./lsp/client";
1314

@@ -21,6 +22,7 @@ export async function activate(context: vscode.ExtensionContext) {
2122
registerCommands(context);
2223
registerWebview(context);
2324
registerEvents(context);
25+
registerAutocomplete(context);
2426
registerHover();
2527

2628
extension.logger.client.info("Activating LiquidJava extension...");

client/src/lsp/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +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';
911

1012
/**
1113
* Starts the client and connects it to the language server
@@ -42,6 +44,10 @@ export async function runClient(context: vscode.ExtensionContext, port: number)
4244
handleLJDiagnostics(diagnostics);
4345
});
4446

47+
extension.client.onNotification("liquidjava/context", (contextHistory: ContextHistory) => {
48+
handleContextHistory(contextHistory);
49+
});
50+
4551
const editor = vscode.window.activeTextEditor;
4652
if (editor && editor.document.languageId === "java") {
4753
await onActiveFileChange(editor);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import * as vscode from "vscode";
2+
import { extension } from "../state";
3+
import type { Variable, ContextHistory, Ghost, Alias } from "../types/context";
4+
import { getSimpleName } from "../utils/utils";
5+
import { getVariablesInScope } from "./context";
6+
import { LIQUIDJAVA_ANNOTATION_START } from "../utils/constants";
7+
8+
/**
9+
* Registers a completion provider for LiquidJava annotations, providing context-aware suggestions based on the current context history
10+
*/
11+
export function registerAutocomplete(context: vscode.ExtensionContext) {
12+
context.subscriptions.push(
13+
vscode.languages.registerCompletionItemProvider("java", {
14+
provideCompletionItems(document, position) {
15+
if (!isInsideLiquidJavaAnnotationString(document, position) || !extension.contextHistory) return null;
16+
const file = document.uri.toString().replace("file://", "");
17+
const nextChar = document.getText(new vscode.Range(position, position.translate(0, 1)));
18+
return getContextCompletionItems(extension.contextHistory, file, nextChar);
19+
},
20+
})
21+
);
22+
}
23+
24+
function getContextCompletionItems(context: ContextHistory, file: string, nextChar: string): vscode.CompletionItem[] {
25+
const variablesInScope = getVariablesInScope(file, extension.selection);
26+
const inScope = variablesInScope !== null;
27+
const triggerParameterHints = nextChar !== "(";
28+
const variableItems = getVariableCompletionItems([...(variablesInScope || []), ...context.globalVars]); // not including instance vars
29+
const ghostItems = getGhostCompletionItems(context.ghosts, triggerParameterHints);
30+
const aliasItems = getAliasCompletionItems(context.aliases, triggerParameterHints);
31+
const keywordItems = getKeywordsCompletionItems(triggerParameterHints, inScope);
32+
const allItems = [...variableItems, ...ghostItems, ...aliasItems, ...keywordItems];
33+
34+
// remove duplicates
35+
const uniqueItems = new Map<string, vscode.CompletionItem>();
36+
allItems.forEach(item => {
37+
const label = typeof item.label === "string" ? item.label : item.label.label;
38+
if (!uniqueItems.has(label)) uniqueItems.set(label, item);
39+
});
40+
return Array.from(uniqueItems.values());
41+
}
42+
43+
function getVariableCompletionItems(variables: Variable[]): vscode.CompletionItem[] {
44+
return variables.map(variable => {
45+
const varSig = `${variable.type} ${variable.name}`;
46+
const codeBlocks: string[] = [];
47+
if (variable.mainRefinement !== "true") codeBlocks.push(`@Refinement("${variable.mainRefinement}")`);
48+
codeBlocks.push(varSig);
49+
return createCompletionItem({
50+
name: variable.name,
51+
kind: vscode.CompletionItemKind.Variable,
52+
description: variable.type,
53+
detail: "variable",
54+
codeBlocks,
55+
});
56+
});
57+
}
58+
59+
function getGhostCompletionItems(ghosts: Ghost[], triggerParameterHints: boolean): vscode.CompletionItem[] {
60+
return ghosts.map(ghost => {
61+
const parameters = ghost.parameterTypes.map(getSimpleName).join(", ");
62+
const ghostSig = `${ghost.returnType} ${ghost.name}(${parameters})`;
63+
const isState = /^state\d+\(_\) == \d+$/.test(ghost.refinement);
64+
const description = isState ? "state" : "ghost";
65+
return createCompletionItem({
66+
name: ghost.name,
67+
kind: vscode.CompletionItemKind.Function,
68+
labelDetail: `(${parameters})`,
69+
description,
70+
detail: description,
71+
codeBlocks: [ghostSig],
72+
insertText: triggerParameterHints ? `${ghost.name}($1)` : ghost.name,
73+
triggerParameterHints,
74+
});
75+
});
76+
}
77+
78+
function getAliasCompletionItems(aliases: Alias[], triggerParameterHints: boolean): vscode.CompletionItem[] {
79+
return aliases.map(alias => {
80+
const parameters = alias.parameters
81+
.map((parameter, index) => {
82+
const type = getSimpleName(alias.types[index]);
83+
return `${type} ${parameter}`;
84+
}).join(", ");
85+
const aliasSig = `${alias.name}(${parameters}) { ${alias.predicate} }`;
86+
const description = "alias";
87+
return createCompletionItem({
88+
name: alias.name,
89+
kind: vscode.CompletionItemKind.Function,
90+
labelDetail: `(${parameters}){ ${alias.predicate} }`,
91+
description,
92+
detail: description,
93+
codeBlocks: [aliasSig],
94+
insertText: triggerParameterHints ? `${alias.name}($1)` : alias.name,
95+
triggerParameterHints,
96+
});
97+
});
98+
}
99+
100+
function getKeywordsCompletionItems(triggerParameterHints: boolean, inScope: boolean): vscode.CompletionItem[] {
101+
const thisItem = createCompletionItem({
102+
name: "this",
103+
kind: vscode.CompletionItemKind.Keyword,
104+
description: "",
105+
detail: "keyword",
106+
documentationBlocks: ["Keyword referring to the **current instance**"],
107+
});
108+
const oldItem = createCompletionItem({
109+
name: "old",
110+
kind: vscode.CompletionItemKind.Keyword,
111+
description: "",
112+
detail: "keyword",
113+
documentationBlocks: ["Keyword referring to the **previous state of the current instance**"],
114+
insertText: triggerParameterHints ? "old($1)" : "old",
115+
triggerParameterHints,
116+
});
117+
const items: vscode.CompletionItem[] = [thisItem, oldItem];
118+
if (!inScope) {
119+
const returnItem = createCompletionItem({
120+
name: "return",
121+
kind: vscode.CompletionItemKind.Keyword,
122+
description: "",
123+
detail: "keyword",
124+
documentationBlocks: ["Keyword referring to the **method return value**"],
125+
});
126+
items.push(returnItem);
127+
}
128+
return items;
129+
}
130+
131+
type CompletionItemOptions = {
132+
name: string;
133+
kind: vscode.CompletionItemKind;
134+
description?: string;
135+
labelDetail?: string;
136+
detail: string;
137+
documentationBlocks?: string[];
138+
codeBlocks?: string[];
139+
insertText?: string;
140+
triggerParameterHints?: boolean;
141+
}
142+
143+
function createCompletionItem({ name, kind, labelDetail, description, detail, documentationBlocks, codeBlocks, insertText, triggerParameterHints }: CompletionItemOptions): vscode.CompletionItem {
144+
const item = new vscode.CompletionItem(name, kind);
145+
item.label = { label: name, detail: labelDetail, description };
146+
item.detail = detail;
147+
if (insertText) item.insertText = new vscode.SnippetString(insertText);
148+
if (triggerParameterHints) item.command = { command: "editor.action.triggerParameterHints", title: "Trigger Parameter Hints" };
149+
150+
const documentation = new vscode.MarkdownString();
151+
if (documentationBlocks) documentationBlocks.forEach(block => documentation.appendMarkdown(block));
152+
if (codeBlocks) codeBlocks.forEach(block => documentation.appendCodeblock(block));
153+
item.documentation = documentation;
154+
155+
return item;
156+
}
157+
158+
function isInsideLiquidJavaAnnotationString(document: vscode.TextDocument, position: vscode.Position): boolean {
159+
const textUntilCursor = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
160+
LIQUIDJAVA_ANNOTATION_START.lastIndex = 0;
161+
let match: RegExpExecArray | null = null;
162+
let lastAnnotationStart = -1;
163+
while ((match = LIQUIDJAVA_ANNOTATION_START.exec(textUntilCursor)) !== null) {
164+
lastAnnotationStart = match.index;
165+
}
166+
if (lastAnnotationStart === -1) return false;
167+
168+
const fromLastAnnotation = textUntilCursor.slice(lastAnnotationStart);
169+
let parenthesisDepth = 0;
170+
let isInsideString = false;
171+
for (let i = 0; i < fromLastAnnotation.length; i++) {
172+
const char = fromLastAnnotation[i];
173+
const previousChar = i > 0 ? fromLastAnnotation[i - 1] : "";
174+
if (char === '"' && previousChar !== "\\") {
175+
isInsideString = !isInsideString;
176+
continue;
177+
}
178+
if (isInsideString) continue;
179+
if (char === "(") parenthesisDepth++;
180+
if (char === ")") parenthesisDepth--;
181+
}
182+
return parenthesisDepth > 0;
183+
}

client/src/services/context.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { extension } from "../state";
2+
import { ContextHistory, Selection, Variable } from "../types/context";
3+
4+
export function handleContextHistory(contextHistory: ContextHistory) {
5+
extension.contextHistory = contextHistory;
6+
}
7+
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+
}
30+
}
31+
}
32+
if (mostSpecificScope === null)
33+
return null;
34+
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#"));
39+
}
40+
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 };
46+
}
47+
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;
54+
}
55+
56+
function getReachableVariables(variables: Variable[], selection: Selection): Variable[] {
57+
return variables.filter((variable) => {
58+
const placement = variable.placementInCode?.position;
59+
if (!placement) return true;
60+
return placement.line < selection.startLine || (placement.line === selection.startLine && placement.column <= selection.startColumn);
61+
});
62+
}

client/src/services/events.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import * as vscode from 'vscode';
22
import { extension } from '../state';
33
import { updateStateMachine } from './state-machine';
4+
import { Selection } from '../types/context';
5+
import { SELECTION_DEBOUNCE_MS } from '../utils/constants';
6+
7+
let selectionTimeout: NodeJS.Timeout | null = null;
8+
let currentSelection: Selection = { startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 };
49

510
/**
611
* Initializes file system event listeners
@@ -12,11 +17,15 @@ export function registerEvents(context: vscode.ExtensionContext) {
1217
vscode.window.onDidChangeActiveTextEditor(async editor => {
1318
if (!editor || editor.document.languageId !== "java") return;
1419
await onActiveFileChange(editor);
15-
1620
}),
1721
vscode.workspace.onDidSaveTextDocument(async document => {
1822
if (document.uri.scheme !== 'file' || document.languageId !== "java") return;
1923
await updateStateMachine(document)
24+
}),
25+
vscode.window.onDidChangeTextEditorSelection(event => {
26+
if (event.textEditor.document.uri.scheme !== 'file' || event.textEditor.document.languageId !== "java") return;
27+
if (event.selections.length === 0) return;
28+
onSelectionChange(event);
2029
})
2130
);
2231
}
@@ -29,4 +38,23 @@ export async function onActiveFileChange(editor: vscode.TextEditor) {
2938
extension.file = editor.document.uri.fsPath;
3039
extension.webview?.sendMessage({ type: "file", file: extension.file });
3140
await updateStateMachine(editor.document);
41+
}
42+
43+
/**
44+
* Handles selection change events
45+
* @param event The selection change event
46+
*/
47+
export async function onSelectionChange(event: vscode.TextEditorSelectionChangeEvent) {
48+
// update current selection
49+
const selectionStart = event.selections[0].start;
50+
const selectionEnd = event.selections[0].end;
51+
currentSelection = {
52+
startLine: selectionStart.line,
53+
startColumn: selectionStart.character,
54+
endLine: selectionEnd.line,
55+
endColumn: selectionEnd.character
56+
};
57+
// debounce selection changes
58+
if (selectionTimeout) clearTimeout(selectionTimeout);
59+
selectionTimeout = setTimeout(() => extension.selection = currentSelection, SELECTION_DEBOUNCE_MS);
3260
}

client/src/state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { LiquidJavaLogger } from "./services/logger";
66
import { LiquidJavaWebviewProvider } from "./webview/provider";
77
import type { LJDiagnostic } from "./types/diagnostics";
88
import type { StateMachine } from "./types/fsm";
9+
import { ContextHistory, Selection } from "./types/context";
910

1011
export class ExtensionState {
1112
// server/client state
@@ -22,6 +23,8 @@ export class ExtensionState {
2223
file?: string;
2324
diagnostics?: LJDiagnostic[];
2425
stateMachine?: StateMachine;
26+
contextHistory?: ContextHistory;
27+
selection?: Selection;
2528
}
2629

2730
export const extension = new ExtensionState();

client/src/types/context.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { PlacementInCode } from "./diagnostics";
2+
3+
// Type definitions used for LiquidJava context information
4+
5+
export type Variable = {
6+
name: string;
7+
type: string;
8+
refinement: string;
9+
mainRefinement: string;
10+
placementInCode: PlacementInCode | null;
11+
}
12+
13+
export type Ghost = {
14+
name: string;
15+
qualifiedName: string;
16+
returnType: string;
17+
parameterTypes: string[];
18+
refinement: string;
19+
}
20+
21+
export type Alias = {
22+
name: string;
23+
parameters: string[];
24+
types: string[];
25+
predicate: string;
26+
}
27+
28+
export type ContextHistory = {
29+
vars: Record<string, Record<string, Variable[]>>; // file -> (scope -> variables in scope)
30+
instanceVars: Variable[];
31+
globalVars: Variable[];
32+
ghosts: Ghost[];
33+
aliases: Alias[];
34+
}
35+
36+
export type Selection = {
37+
startLine: number;
38+
startColumn: number;
39+
endLine: number;
40+
endColumn: number;
41+
}

0 commit comments

Comments
 (0)