Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b9fcfea
Add Context Debugger for Variables
rcosta358 Mar 3, 2026
a35920f
Add Ghosts & Aliases to Context Debugger
rcosta358 Mar 3, 2026
aa6a36e
Minor Changes
rcosta358 Mar 3, 2026
424e48a
Filter Instance Variables By Name
rcosta358 Mar 3, 2026
d973d94
Fix Location Line Offset
rcosta358 Mar 3, 2026
c18a40b
Change Variable Column Offset
rcosta358 Mar 3, 2026
0700ae1
Code Refactoring
rcosta358 Mar 3, 2026
6ea0524
Add Variable Highlighting
rcosta358 Mar 3, 2026
31d960b
Handle Edge Cases
rcosta358 Mar 4, 2026
22a14f2
Show Failed Refinements
rcosta358 Mar 5, 2026
dfe76ab
Update Styling
rcosta358 Mar 5, 2026
e6cf402
Rename Selection to Range
rcosta358 Mar 5, 2026
0d5053d
Fix Positions
rcosta358 Mar 5, 2026
04766b6
Fix Context Update
rcosta358 Mar 6, 2026
8e4bd40
Fix Selection
rcosta358 Mar 7, 2026
11dc8ac
Clear Highlight After Switching Tab
rcosta358 Mar 7, 2026
0a0e147
Highlight Variables in TranslationTable
rcosta358 Mar 7, 2026
59b880f
Fix Context Variables
rcosta358 Mar 7, 2026
4cab61d
Improve `normalizeRefinements`
rcosta358 Mar 7, 2026
3f4d6ec
Get Failing Refinements By Diagnostic Position
rcosta358 Mar 7, 2026
e1fc9a4
Multiple Fixes
rcosta358 Mar 8, 2026
288352d
Cleanup
rcosta358 Mar 8, 2026
f4f32ab
Use Ghosts as List
rcosta358 Mar 9, 2026
e6dcec7
Add Hover for Failing Refinements
rcosta358 Mar 9, 2026
febce35
Minor Changes
rcosta358 Mar 9, 2026
385d326
Remove Unused Imports
rcosta358 Mar 9, 2026
e583308
Filter `true` Refinements
rcosta358 Mar 9, 2026
d3d11ff
Use Scopes By File
rcosta358 Mar 9, 2026
2aafc22
Add Null Check
rcosta358 Mar 9, 2026
dc7fc8d
Fix Highlighting Different File
rcosta358 Mar 9, 2026
856625b
Minor Fix
rcosta358 Mar 9, 2026
e4d07fe
Update Messages
rcosta358 Mar 9, 2026
3762757
Reverse Column Sort Order for Variables
rcosta358 Mar 9, 2026
8cbee5a
Normalize Windows File Paths
rcosta358 Mar 10, 2026
072d2e5
Improve UI
rcosta358 Mar 10, 2026
6c26379
Fix View Updates
rcosta358 Mar 10, 2026
10bfebb
Fix Variable Refinements
rcosta358 Mar 10, 2026
8648b19
Fix Filter Instance Variables
rcosta358 Mar 10, 2026
d3ca419
Handle Variables with No Position
rcosta358 Mar 11, 2026
091402c
Fix UI
rcosta358 Mar 11, 2026
00fe892
Show All Refinements
rcosta358 Mar 11, 2026
9f28278
Improve Refinement Normalization
rcosta358 Mar 11, 2026
7466d15
Fix Variable Sorting
rcosta358 Mar 11, 2026
f7f4a73
Filter Variables in Unreachable Scopes
rcosta358 Mar 11, 2026
757b7ef
Filter Duplicate Variable Refinements
rcosta358 Mar 11, 2026
a191992
Refactor Variable Positions
rcosta358 Mar 12, 2026
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
3 changes: 0 additions & 3 deletions client/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@
],
"stopOnEntry": false,
"sourceMaps": true,
"outFiles": [
"${workspaceRoot}/out/src/**/*.js"
],
"preLaunchTask": "npm: watch"
},
{
Expand Down
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
4 changes: 2 additions & 2 deletions client/src/lsp/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import * as child_process from 'child_process';
import * as path from 'path';
import { getAvailablePort, killProcess } from '../utils/utils';
import { getAvailablePort, killProcess, normalizeFilePath } from '../utils/utils';
import { extension } from '../state';
import { DEBUG_MODE, DEBUG_PORT, SERVER_JAR } from '../utils/constants';

Expand All @@ -22,7 +22,7 @@ export async function runLanguageServer(context: vscode.ExtensionContext, javaEx
const jarPath = path.resolve(context.extensionPath, "dist", "server", SERVER_JAR);
const args = ["-jar", jarPath, port.toString()];
const options = {
cwd: vscode.workspace.workspaceFolders[0].uri.fsPath, // root path
cwd: normalizeFilePath(vscode.workspace.workspaceFolders[0].uri.fsPath), // root path
};
extension.logger.client.info("Creating language server process...");
extension.serverProcess = child_process.spawn(javaExecutablePath, args, options);
Expand Down
34 changes: 17 additions & 17 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,19 +44,20 @@ 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 !== "(";
const ghosts = context.ghosts.filter(ghost => ghost.file === file);
if (isDotTrigger) {
if (receiver === "this" || receiver === "old(this)") {
return getGhostCompletionItems(context.ghosts[file] || [], triggerParameterHints);
return getGhostCompletionItems(ghosts, 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 || []),
ghosts: () => getGhostCompletionItems(context.ghosts[file] || [], triggerParameterHints),
vars: () => getVariableCompletionItems(varsInScope),
ghosts: () => getGhostCompletionItems(ghosts, triggerParameterHints),
aliases: () => getAliasCompletionItems(context.aliases, triggerParameterHints),
keywords: () => getKeywordsCompletionItems(triggerParameterHints, inScope),
types: () => getTypesCompletionItems(),
Expand All @@ -75,7 +76,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 +92,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 +110,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
168 changes: 126 additions & 42 deletions client/src/services/context.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,149 @@
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;
updateContextForSelection(extension.currentSelection);
extension.webview.sendMessage({ type: "context", context: extension.context });
}

// 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 updateContextForSelection(selection: Range) {
if (!selection) return;

// get variables in file
const fileVars = extension.contextHistory.vars[file];
if (!fileVars) return null;
const globalVars = extension.context.globalVars || [];
const localVars = extension.context.localVars || [];
const scope = getMostSpecificScope(extension.file, selection);

// get variables in the current scope based on the selection
let mostSpecificScope: string | null = null;
let minScopeSize = Infinity;
let visibleVars: LJVariable[] = [];
if (scope) {
const variablesInScope = getVariablesInScope(localVars, extension.file, scope);
visibleVars = getVisibleVariables(variablesInScope, extension.file, selection);
}

const allVars = sortVariables(normalizeVariableRefinements([...globalVars, ...visibleVars]));
extension.context.visibleVars = visibleVars;
extension.context.allVars = allVars;
}

// find the most specific scope that contains the selection
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);
function getMostSpecificScope(file: string, range: Range): Range | null {
// get scopes for the current file
const scopes = extension.context.fileScopes[file];
if (!scopes) return null;

// find the most specific scope that contains the range
let mostSpecificScope: Range | null = null;
let minScopeSize = Infinity;
for (const scope of scopes) {
if (isRangeWithin(range, scope)) {
const scopeSize = (scope.lineEnd - scope.lineStart) * 10000 + (scope.colEnd - scope.colStart);
if (scopeSize < minScopeSize) {
mostSpecificScope = scope;
minScopeSize = scopeSize;
}
}
}
if (mostSpecificScope === null)
return null;
return mostSpecificScope;
}

function getVariablesInScope(variables: LJVariable[], file: string, scope: Range): LJVariable[] {
return variables.filter(v => v.position?.file === file && isRangeWithin(v.position, scope));
}

function getVisibleVariables(variables: LJVariable[], file: string, selection: Range): LJVariable[] {
const isCollapsedRange = selection.lineStart === selection.lineEnd && selection.colStart === selection.colEnd;
const fileScopes = isCollapsedRange ? (extension.context.fileScopes[file] || []) : [];
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 = variable.annotationPosition || variable.position!;
if (!position) return false;

// variable was declared before the cursor line or its in the same line but before the cursor column
const isDeclaredBeforeCursor =
position.lineStart < selection.lineStart ||
(position.lineStart === selection.lineStart && position.colStart + 1 <= selection.colStart);
if (!isDeclaredBeforeCursor) return false;

// 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#"));
// exclude variables that in unreachable scopes
const isInUnreachableScope = fileScopes.some(scope =>
isRangeWithin(variable.position!, scope) && !isRangeWithin(selection, scope)
);
return !isInUnreachableScope;
}
// normal range, filter variables that are only within the range
return isRangeWithin(variable.position, selection);
});
}

function parseScopeString(scope: string): Selection {
const [start, end] = scope.split("-");
const [startLine, startColumn] = start.split(":").map(Number);
const [endLine, endColumn] = end.split(":").map(Number);
return { startLine, startColumn, endLine, endColumn };
// 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 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) => {
if (!left.position && !right.position) return compareVariableNames(left, right);
if (!left.position) return 1;
if (!right.position) return -1;
if (left.position.lineStart !== right.position.lineStart) return left.position.lineStart - right.position.lineStart;
if (left.position.colStart !== right.position.colStart) return right.position.colStart - left.position.colStart;
return compareVariableNames(left, right);
});
}

function compareVariableNames(a: LJVariable, b: LJVariable): number {
if (a.name.startsWith("#") && b.name.startsWith("#")) return getOriginalVariableName(a.name).localeCompare(getOriginalVariableName(b.name));
if (a.name.startsWith("#")) return 1;
if (b.name.startsWith("#")) return -1;
return a.name.localeCompare(b.name);
}

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

function normalizeVariableRefinements(variables: LJVariable[]): LJVariable[] {
return Array.from(new Map(variables.map(v => [v.refinement, v])).values()).flatMap(v => {
if (!v.refinement || v.refinement === "true") return []; // filter out trivial refinements
if (v.refinement.includes("==")) {
const [left, right] = v.refinement.split("==").map(s => s.trim());
return left !== right ? [v] : []; // filter tautologies like x == x
}
if (v.refinement.includes("!=")) return [v];
return [{ ...v, refinement: `${v.name} == ${v.refinement}` }];
});
}
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
30 changes: 30 additions & 0 deletions client/src/services/editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as vscode from 'vscode'
import { Range } from '../types/context';

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

export async function openFile(filePath: string, line: number, character: number, rangeToHighlight?: Range) {
const uri = vscode.Uri.file(filePath);
const doc = await vscode.workspace.openTextDocument(uri);
const editor = await vscode.window.showTextDocument(doc);
const position = new vscode.Position(line, character);
const range = new vscode.Range(position, position);
editor.selection = new vscode.Selection(position, position);
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
if (rangeToHighlight) highlightRange(editor, rangeToHighlight);
}

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