Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions client/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { updateStatusBar } from '../services/status-bar';
import { handleLJDiagnostics } from '../services/diagnostics';
import { onActiveFileChange } from '../services/events';
import type { LJDiagnostic } from "../types/diagnostics";
import { ContextHistory } from '../types/context';
import { handleContextHistory } from '../services/context';
import { LJContext } from '../types/context';
import { handleContext } from '../services/context';

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

extension.client.onNotification("liquidjava/context", (contextHistory: ContextHistory) => {
handleContextHistory(contextHistory);
extension.client.onNotification("liquidjava/context", (context: LJContext) => {
handleContext(context);
});

const editor = vscode.window.activeTextEditor;
Expand Down
29 changes: 14 additions & 15 deletions client/src/services/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as vscode from "vscode";
import { extension } from "../state";
import type { Variable, ContextHistory, Ghost, Alias } from "../types/context";
import type { LJVariable, LJContext, LJGhost, LJAlias } from "../types/context";
import { getSimpleName } from "../utils/utils";
import { getVariablesInScope } from "./context";
import { LIQUIDJAVA_ANNOTATION_START, LJAnnotation } from "../utils/constants";
import { filterDuplicateVariables, filterInstanceVariables } from "./context";

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

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

const isDotTrigger = completionContext.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && completionContext.triggerCharacter === ".";
const receiver = isDotTrigger ? getReceiverBeforeDot(document, position) : null;
const file = document.uri.toString().replace("file://", "");
const nextChar = document.getText(new vscode.Range(position, position.translate(0, 1)));
const items = getContextCompletionItems(extension.contextHistory, file, annotation, nextChar, isDotTrigger, receiver);
const items = getContextCompletionItems(extension.context, file, annotation, nextChar, isDotTrigger, receiver);
const uniqueItems = new Map<string, vscode.CompletionItem>();
items.forEach(item => {
const label = typeof item.label === "string" ? item.label : item.label.label;
Expand All @@ -44,18 +44,18 @@ export function registerAutocomplete(context: vscode.ExtensionContext) {
);
}

function getContextCompletionItems(context: ContextHistory, file: string, annotation: LJAnnotation, nextChar: string, isDotTrigger: boolean, receiver: string | null): vscode.CompletionItem[] {
function getContextCompletionItems(context: LJContext, file: string, annotation: LJAnnotation, nextChar: string, isDotTrigger: boolean, receiver: string | null): vscode.CompletionItem[] {
const triggerParameterHints = nextChar !== "(";
if (isDotTrigger) {
if (receiver === "this" || receiver === "old(this)") {
return getGhostCompletionItems(context.ghosts[file] || [], triggerParameterHints);
}
return [];
}
const variablesInScope = getVariablesInScope(file, extension.selection);
const inScope = variablesInScope !== null;
}
const inScope = extension.context.visibleVars !== null;
const varsInScope = filterDuplicateVariables(filterInstanceVariables(context.visibleVars || []));
const itemsHandlers: Record<CompletionItemKind, () => vscode.CompletionItem[]> = {
vars: () => getVariableCompletionItems(variablesInScope || []),
vars: () => getVariableCompletionItems(varsInScope),
ghosts: () => getGhostCompletionItems(context.ghosts[file] || [], triggerParameterHints),
aliases: () => getAliasCompletionItems(context.aliases, triggerParameterHints),
keywords: () => getKeywordsCompletionItems(triggerParameterHints, inScope),
Expand All @@ -75,7 +75,7 @@ function getContextCompletionItems(context: ContextHistory, file: string, annota
return itemsMap[annotation].map(key => itemsHandlers[key]()).flat();
}

function getVariableCompletionItems(variables: Variable[]): vscode.CompletionItem[] {
function getVariableCompletionItems(variables: LJVariable[]): vscode.CompletionItem[] {
return variables.map(variable => {
const varSig = `${variable.type} ${variable.name}`;
const codeBlocks: string[] = [];
Expand All @@ -91,12 +91,11 @@ function getVariableCompletionItems(variables: Variable[]): vscode.CompletionIte
});
}

function getGhostCompletionItems(ghosts: Ghost[], triggerParameterHints: boolean): vscode.CompletionItem[] {
function getGhostCompletionItems(ghosts: LJGhost[], triggerParameterHints: boolean): vscode.CompletionItem[] {
return ghosts.map(ghost => {
const parameters = ghost.parameterTypes.map(getSimpleName).join(", ");
const ghostSig = `${ghost.returnType} ${ghost.name}(${parameters})`;
const isState = /^state\d+\(_\) == \d+$/.test(ghost.refinement);
const description = isState ? "state" : "ghost";
const description = ghost.isState ? "state" : "ghost";
return createCompletionItem({
name: ghost.name,
kind: vscode.CompletionItemKind.Function,
Expand All @@ -110,7 +109,7 @@ function getGhostCompletionItems(ghosts: Ghost[], triggerParameterHints: boolean
});
}

function getAliasCompletionItems(aliases: Alias[], triggerParameterHints: boolean): vscode.CompletionItem[] {
function getAliasCompletionItems(aliases: LJAlias[], triggerParameterHints: boolean): vscode.CompletionItem[] {
return aliases.map(alias => {
const parameters = alias.parameters
.map((parameter, index) => {
Expand Down
144 changes: 114 additions & 30 deletions client/src/services/context.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
import { extension } from "../state";
import { ContextHistory, Selection, Variable } from "../types/context";
import { LJContext, Range, LJVariable } from "../types/context";
import { SourcePosition } from "../types/diagnostics";
import { getOriginalVariableName } from "../utils/utils";

export function handleContextHistory(contextHistory: ContextHistory) {
extension.contextHistory = contextHistory;
export function handleContext(context: LJContext) {
extension.context = context;
updateContext(extension.currentSelection);
extension.webview.sendMessage({ type: "context", context: extension.context });
}

export function updateContext(range: Range) {
if (!range) return;
const variablesInScope = getVariablesInScope(extension.file, range) || [];
const visibleVars = getVisibleVariables(variablesInScope, extension.file, range);
const globalVariables = extension.context.globalVars || [];
const allVars = sortVariables(normalizeRefinements([...globalVariables, ...visibleVars]));
extension.context.visibleVars = visibleVars;
extension.context.allVars = allVars;
}

// Gets the variables in scope for a given file and position
// Returns null if position not in any scope
export function getVariablesInScope(file: string, selection: Selection): Variable[] | null {
if (!extension.contextHistory || !selection || !file) return null;

export function getVariablesInScope(file: string, range: Range): LJVariable[] | null {
// get variables in file
const fileVars = extension.contextHistory.vars[file];
const fileVars = extension.context.vars[file];
if (!fileVars) return null;

// get variables in the current scope based on the selection
// get variables in the current scope based on the range
let mostSpecificScope: string | null = null;
let minScopeSize = Infinity;

// find the most specific scope that contains the selection
// find the most specific scope that contains the range
for (const scope of Object.keys(fileVars)) {
const scopeSelection = parseScopeString(scope);
if (isSelectionWithinScope(selection, scopeSelection)) {
const scopeSize = (scopeSelection.endLine - scopeSelection.startLine) * 10000 + (scopeSelection.endColumn - scopeSelection.startColumn);
const scopeRange: Range = parseScopeString(scope);
if (isRangeWithin(range, scopeRange)) {
const scopeSize = (scopeRange.lineEnd - scopeRange.lineStart) * 10000 + (scopeRange.colEnd - scopeRange.colStart);
if (scopeSize < minScopeSize) {
mostSpecificScope = scope;
minScopeSize = scopeSize;
Expand All @@ -33,33 +45,105 @@ export function getVariablesInScope(file: string, selection: Selection): Variabl
return null;

// filter variables to only include those that are reachable based on their position
const variablesInScope = fileVars[mostSpecificScope];
const reachableVariables = getReachableVariables(variablesInScope, selection);
return reachableVariables.filter(v => !v.name.startsWith("this#"));
const variablesInScope = [...fileVars[mostSpecificScope], ...extension.context.instanceVars];
return getVisibleVariables(variablesInScope, file, range);
}

function parseScopeString(scope: string): Selection {
function getVisibleVariables(variables: LJVariable[], file: string, range: Range, useAnnotationPositions: boolean = false): LJVariable[] {
const isCollapsedRange = range.lineStart === range.lineEnd && range.colStart === range.colEnd;
return variables.filter((variable) => {
if (!variable.position) return false; // variable has no position
if (variable.position?.file !== file) return false; // variable is not in the current file

// single point cursor
if (isCollapsedRange) {
const position: SourcePosition = useAnnotationPositions ? variable.annPosition || variable.position : variable.position;
if (!position || variable.isParameter) return true; // if is parameter we need to access it even if it's declared after the range (for method and parameter refinements)

// variable was declared before the cursor line or its in the same line but before the cursor column
return (
position.lineStart < range.lineStart ||
(position.lineStart === range.lineStart && position.colStart + 1 <= range.colStart)
);
}
// normal range, filter variables that are only within the range
return isRangeWithin(variable.position, range);
});
}

// Normalizes the range to ensure start is before end
export function normalizeRange(range: Range): Range {
const isStartBeforeEnd =
range.lineStart < range.lineEnd ||
(range.lineStart === range.lineEnd && range.colStart <= range.colEnd);

if (isStartBeforeEnd) return range;
return {
lineStart: range.lineEnd,
colStart: range.colEnd,
lineEnd: range.lineStart,
colEnd: range.colStart,
};
}

function parseScopeString(scope: string): Range {
const [start, end] = scope.split("-");
const [startLine, startColumn] = start.split(":").map(Number);
const [endLine, endColumn] = end.split(":").map(Number);
return { startLine, startColumn, endLine, endColumn };
return { lineStart: startLine, colStart: startColumn, lineEnd: endLine, colEnd: endColumn };
}

function isSelectionWithinScope(selection: Selection, scope: Selection): boolean {
const startsWithin = selection.startLine > scope.startLine ||
(selection.startLine === scope.startLine && selection.startColumn >= scope.startColumn);
const endsWithin = selection.endLine < scope.endLine ||
(selection.endLine === scope.endLine && selection.endColumn <= scope.endColumn);
function isRangeWithin(range: Range, another: Range): boolean {
const startsWithin = range.lineStart > another.lineStart ||
(range.lineStart === another.lineStart && range.colStart >= another.colStart);
const endsWithin = range.lineEnd < another.lineEnd ||
(range.lineEnd === another.lineEnd && range.colEnd <= another.colEnd);
return startsWithin && endsWithin;
}

function getReachableVariables(variables: Variable[], selection: Selection): Variable[] {
return variables.filter((variable) => {
const placement = variable.placementInCode?.position;
const startPosition = variable.annPosition || placement;
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)

// variable was declared before the cursor line or its in the same line but before the cursor column
return startPosition.line < selection.startLine || startPosition.line === selection.startLine && startPosition.column <= selection.startColumn;
export function filterDuplicateVariables(variables: LJVariable[]): LJVariable[] {
const uniqueVariables: Map<string, LJVariable> = new Map();
for (const variable of variables) {
if (!uniqueVariables.has(variable.name)) {
uniqueVariables.set(variable.name, variable);
}
}
return Array.from(uniqueVariables.values());
}

function sortVariables(variables: LJVariable[]): LJVariable[] {
// sort by position or name
return variables.sort((left, right) => {
const leftPosition = left.position
const rightPosition = right.position

if (!leftPosition && !rightPosition) return compareVariableNames(left, right);
if (!leftPosition) return 1;
if (!rightPosition) return -1;
if (getOriginalVariableName(left.name) === "ret") return 1;
if (getOriginalVariableName(right.name) === "ret") return -1;
if (leftPosition.lineStart !== rightPosition.lineStart) return leftPosition.lineStart - rightPosition.lineStart;
if (leftPosition.colStart !== rightPosition.colStart) return leftPosition.colStart - rightPosition.colStart;

return compareVariableNames(left, right);
});
}

function compareVariableNames(a: LJVariable, b: LJVariable): number {
return getOriginalVariableName(a.name).localeCompare(getOriginalVariableName(b.name));
}

export function filterInstanceVariables(variables: LJVariable[]): LJVariable[] {
return variables.filter(v => !v.name.includes("#"));
}

function normalizeRefinements(variables: LJVariable[]): LJVariable[] {
return Array.from(new Map(variables.map(v => [v.refinement, v])).values())
.flatMap(v => {
if (v.refinement.includes("==")) {
const [left, right] = v.refinement.split("==").map(s => s.trim());
return left === right ? [] : [{ ...v, refinement: right }];
}
return v;
});
}
2 changes: 1 addition & 1 deletion client/src/services/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export function handleLJDiagnostics(diagnostics: LJDiagnostic[]) {
const containsError = diagnostics.some(d => d.category === "error");
const statusBarState: StatusBarState = containsError ? "failed" : "passed";
updateStatusBar(statusBarState);
extension.webview?.sendMessage({ type: "diagnostics", diagnostics });
extension.diagnostics = diagnostics;
extension.webview?.sendMessage({ type: "diagnostics", diagnostics });
}

/**
Expand Down
38 changes: 25 additions & 13 deletions client/src/services/events.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as vscode from 'vscode';
import { extension } from '../state';
import { updateStateMachine } from './state-machine';
import { Selection } from '../types/context';
import { SELECTION_DEBOUNCE_MS } from '../utils/constants';
import { normalizeRange, updateContext } from './context';
import { Range } from '../types/context';

let selectionTimeout: NodeJS.Timeout | null = null;
let currentSelection: Selection = { startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 };

/**
* Initializes file system event listeners
Expand Down Expand Up @@ -38,23 +38,35 @@ export async function onActiveFileChange(editor: vscode.TextEditor) {
extension.file = editor.document.uri.fsPath;
extension.webview?.sendMessage({ type: "file", file: extension.file });
await updateStateMachine(editor.document);
handleContextUpdate(editor.selection);
}

/**
* Handles selection change events
* @param event The selection change event
*/
export async function onSelectionChange(event: vscode.TextEditorSelectionChangeEvent) {
// update current selection
const selectionStart = event.selections[0].start;
const selectionEnd = event.selections[0].end;
currentSelection = {
startLine: selectionStart.line,
startColumn: selectionStart.character,
endLine: selectionEnd.line,
endColumn: selectionEnd.character
};
export async function onSelectionChange(event: vscode.TextEditorSelectionChangeEvent) {
// debounce selection changes
if (selectionTimeout) clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => extension.selection = currentSelection, SELECTION_DEBOUNCE_MS);
selectionTimeout = setTimeout(() => {
handleContextUpdate(event.selections[0]);
}, SELECTION_DEBOUNCE_MS);
}

/**
* Updates the current selection and context
* @param selection The new selection
*/
function handleContextUpdate(selection: vscode.Selection) {
if (!extension.file || !extension.context) return;
const range: Range = {
lineStart: selection.start.line,
colStart: selection.start.character,
lineEnd: selection.end.line,
colEnd: selection.end.character
};
const normalizedRange = normalizeRange(range);
extension.currentSelection = normalizedRange;
updateContext(normalizedRange);
extension.webview?.sendMessage({ type: "context", context: extension.context });
}
19 changes: 19 additions & 0 deletions client/src/services/highlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as vscode from 'vscode'
import { Range } from '../types/context';

const highlight = vscode.window.createTextEditorDecorationType({
backgroundColor: 'rgba(255, 255, 0, 0.3)'
})

export function highlightRange(editor: vscode.TextEditor, range: Range) {
if (!range) {
editor.setDecorations(highlight, []);
return;
}
const nativeRange = new vscode.Range(
new vscode.Position(range.lineStart, range.colStart),
new vscode.Position(range.lineEnd, range.colEnd)
)
editor.setDecorations(highlight, [{ range: nativeRange }])
editor.revealRange(nativeRange, vscode.TextEditorRevealType.InCenter)
}
Loading