diff --git a/client/server/language-server-liquidjava.jar b/client/server/language-server-liquidjava.jar index 41c4f59..6df485d 100644 Binary files a/client/server/language-server-liquidjava.jar and b/client/server/language-server-liquidjava.jar differ diff --git a/client/src/extension.ts b/client/src/extension.ts index 37ddd39..cf230fa 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -8,6 +8,7 @@ import { registerStatusBar, updateStatusBar } from "./services/status-bar"; import { registerWebview } from "./services/webview"; import { registerHover } from "./services/hover"; import { registerEvents } from "./services/events"; +import { registerAutocomplete } from "./services/autocomplete"; import { runLanguageServer, stopLanguageServer } from "./lsp/server"; import { runClient, stopClient } from "./lsp/client"; @@ -21,6 +22,7 @@ export async function activate(context: vscode.ExtensionContext) { registerCommands(context); registerWebview(context); registerEvents(context); + registerAutocomplete(context); registerHover(); extension.logger.client.info("Activating LiquidJava extension..."); diff --git a/client/src/lsp/client.ts b/client/src/lsp/client.ts index 8153a08..7ffbf69 100644 --- a/client/src/lsp/client.ts +++ b/client/src/lsp/client.ts @@ -6,6 +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'; /** * Starts the client and connects it to the language server @@ -42,6 +44,10 @@ export async function runClient(context: vscode.ExtensionContext, port: number) handleLJDiagnostics(diagnostics); }); + extension.client.onNotification("liquidjava/context", (contextHistory: ContextHistory) => { + handleContextHistory(contextHistory); + }); + const editor = vscode.window.activeTextEditor; if (editor && editor.document.languageId === "java") { await onActiveFileChange(editor); diff --git a/client/src/services/autocomplete.ts b/client/src/services/autocomplete.ts new file mode 100644 index 0000000..15c5e56 --- /dev/null +++ b/client/src/services/autocomplete.ts @@ -0,0 +1,183 @@ +import * as vscode from "vscode"; +import { extension } from "../state"; +import type { Variable, ContextHistory, Ghost, Alias } from "../types/context"; +import { getSimpleName } from "../utils/utils"; +import { getVariablesInScope } from "./context"; +import { LIQUIDJAVA_ANNOTATION_START } from "../utils/constants"; + +/** + * Registers a completion provider for LiquidJava annotations, providing context-aware suggestions based on the current context history + */ +export function registerAutocomplete(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider("java", { + provideCompletionItems(document, position) { + if (!isInsideLiquidJavaAnnotationString(document, position) || !extension.contextHistory) return null; + const file = document.uri.toString().replace("file://", ""); + const nextChar = document.getText(new vscode.Range(position, position.translate(0, 1))); + return getContextCompletionItems(extension.contextHistory, file, nextChar); + }, + }) + ); +} + +function getContextCompletionItems(context: ContextHistory, file: string, nextChar: string): vscode.CompletionItem[] { + const variablesInScope = getVariablesInScope(file, extension.selection); + const inScope = variablesInScope !== null; + const triggerParameterHints = nextChar !== "("; + const variableItems = getVariableCompletionItems([...(variablesInScope || []), ...context.globalVars]); // not including instance vars + const ghostItems = getGhostCompletionItems(context.ghosts, triggerParameterHints); + const aliasItems = getAliasCompletionItems(context.aliases, triggerParameterHints); + const keywordItems = getKeywordsCompletionItems(triggerParameterHints, inScope); + const allItems = [...variableItems, ...ghostItems, ...aliasItems, ...keywordItems]; + + // remove duplicates + const uniqueItems = new Map(); + allItems.forEach(item => { + const label = typeof item.label === "string" ? item.label : item.label.label; + if (!uniqueItems.has(label)) uniqueItems.set(label, item); + }); + return Array.from(uniqueItems.values()); +} + +function getVariableCompletionItems(variables: Variable[]): vscode.CompletionItem[] { + return variables.map(variable => { + const varSig = `${variable.type} ${variable.name}`; + const codeBlocks: string[] = []; + if (variable.mainRefinement !== "true") codeBlocks.push(`@Refinement("${variable.mainRefinement}")`); + codeBlocks.push(varSig); + return createCompletionItem({ + name: variable.name, + kind: vscode.CompletionItemKind.Variable, + description: variable.type, + detail: "variable", + codeBlocks, + }); + }); +} + +function getGhostCompletionItems(ghosts: Ghost[], 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"; + return createCompletionItem({ + name: ghost.name, + kind: vscode.CompletionItemKind.Function, + labelDetail: `(${parameters})`, + description, + detail: description, + codeBlocks: [ghostSig], + insertText: triggerParameterHints ? `${ghost.name}($1)` : ghost.name, + triggerParameterHints, + }); + }); +} + +function getAliasCompletionItems(aliases: Alias[], triggerParameterHints: boolean): vscode.CompletionItem[] { + return aliases.map(alias => { + const parameters = alias.parameters + .map((parameter, index) => { + const type = getSimpleName(alias.types[index]); + return `${type} ${parameter}`; + }).join(", "); + const aliasSig = `${alias.name}(${parameters}) { ${alias.predicate} }`; + const description = "alias"; + return createCompletionItem({ + name: alias.name, + kind: vscode.CompletionItemKind.Function, + labelDetail: `(${parameters}){ ${alias.predicate} }`, + description, + detail: description, + codeBlocks: [aliasSig], + insertText: triggerParameterHints ? `${alias.name}($1)` : alias.name, + triggerParameterHints, + }); + }); +} + +function getKeywordsCompletionItems(triggerParameterHints: boolean, inScope: boolean): vscode.CompletionItem[] { + const thisItem = createCompletionItem({ + name: "this", + kind: vscode.CompletionItemKind.Keyword, + description: "", + detail: "keyword", + documentationBlocks: ["Keyword referring to the **current instance**"], + }); + const oldItem = createCompletionItem({ + name: "old", + kind: vscode.CompletionItemKind.Keyword, + description: "", + detail: "keyword", + documentationBlocks: ["Keyword referring to the **previous state of the current instance**"], + insertText: triggerParameterHints ? "old($1)" : "old", + triggerParameterHints, + }); + const items: vscode.CompletionItem[] = [thisItem, oldItem]; + if (!inScope) { + const returnItem = createCompletionItem({ + name: "return", + kind: vscode.CompletionItemKind.Keyword, + description: "", + detail: "keyword", + documentationBlocks: ["Keyword referring to the **method return value**"], + }); + items.push(returnItem); + } + return items; +} + +type CompletionItemOptions = { + name: string; + kind: vscode.CompletionItemKind; + description?: string; + labelDetail?: string; + detail: string; + documentationBlocks?: string[]; + codeBlocks?: string[]; + insertText?: string; + triggerParameterHints?: boolean; +} + +function createCompletionItem({ name, kind, labelDetail, description, detail, documentationBlocks, codeBlocks, insertText, triggerParameterHints }: CompletionItemOptions): vscode.CompletionItem { + const item = new vscode.CompletionItem(name, kind); + item.label = { label: name, detail: labelDetail, description }; + item.detail = detail; + if (insertText) item.insertText = new vscode.SnippetString(insertText); + if (triggerParameterHints) item.command = { command: "editor.action.triggerParameterHints", title: "Trigger Parameter Hints" }; + + const documentation = new vscode.MarkdownString(); + if (documentationBlocks) documentationBlocks.forEach(block => documentation.appendMarkdown(block)); + if (codeBlocks) codeBlocks.forEach(block => documentation.appendCodeblock(block)); + item.documentation = documentation; + + return item; +} + +function isInsideLiquidJavaAnnotationString(document: vscode.TextDocument, position: vscode.Position): boolean { + const textUntilCursor = document.getText(new vscode.Range(new vscode.Position(0, 0), position)); + LIQUIDJAVA_ANNOTATION_START.lastIndex = 0; + let match: RegExpExecArray | null = null; + let lastAnnotationStart = -1; + while ((match = LIQUIDJAVA_ANNOTATION_START.exec(textUntilCursor)) !== null) { + lastAnnotationStart = match.index; + } + if (lastAnnotationStart === -1) return false; + + const fromLastAnnotation = textUntilCursor.slice(lastAnnotationStart); + let parenthesisDepth = 0; + let isInsideString = false; + for (let i = 0; i < fromLastAnnotation.length; i++) { + const char = fromLastAnnotation[i]; + const previousChar = i > 0 ? fromLastAnnotation[i - 1] : ""; + if (char === '"' && previousChar !== "\\") { + isInsideString = !isInsideString; + continue; + } + if (isInsideString) continue; + if (char === "(") parenthesisDepth++; + if (char === ")") parenthesisDepth--; + } + return parenthesisDepth > 0; +} \ No newline at end of file diff --git a/client/src/services/context.ts b/client/src/services/context.ts new file mode 100644 index 0000000..dcd53c7 --- /dev/null +++ b/client/src/services/context.ts @@ -0,0 +1,62 @@ +import { extension } from "../state"; +import { ContextHistory, Selection, Variable } from "../types/context"; + +export function handleContextHistory(contextHistory: ContextHistory) { + extension.contextHistory = contextHistory; +} + +// 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; + + // get variables in file + const fileVars = extension.contextHistory.vars[file]; + if (!fileVars) return null; + + // get variables in the current scope based on the selection + let mostSpecificScope: string | null = null; + let minScopeSize = Infinity; + + // 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); + if (scopeSize < minScopeSize) { + mostSpecificScope = scope; + minScopeSize = scopeSize; + } + } + } + if (mostSpecificScope === null) + 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#")); +} + +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 }; +} + +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); + return startsWithin && endsWithin; +} + +function getReachableVariables(variables: Variable[], selection: Selection): Variable[] { + return variables.filter((variable) => { + const placement = variable.placementInCode?.position; + if (!placement) return true; + return placement.line < selection.startLine || (placement.line === selection.startLine && placement.column <= selection.startColumn); + }); +} \ No newline at end of file diff --git a/client/src/services/events.ts b/client/src/services/events.ts index ec1dc71..bc8501b 100644 --- a/client/src/services/events.ts +++ b/client/src/services/events.ts @@ -1,6 +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'; + +let selectionTimeout: NodeJS.Timeout | null = null; +let currentSelection: Selection = { startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 }; /** * Initializes file system event listeners @@ -12,11 +17,15 @@ export function registerEvents(context: vscode.ExtensionContext) { vscode.window.onDidChangeActiveTextEditor(async editor => { if (!editor || editor.document.languageId !== "java") return; await onActiveFileChange(editor); - }), vscode.workspace.onDidSaveTextDocument(async document => { if (document.uri.scheme !== 'file' || document.languageId !== "java") return; await updateStateMachine(document) + }), + vscode.window.onDidChangeTextEditorSelection(event => { + if (event.textEditor.document.uri.scheme !== 'file' || event.textEditor.document.languageId !== "java") return; + if (event.selections.length === 0) return; + onSelectionChange(event); }) ); } @@ -29,4 +38,23 @@ 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); +} + +/** + * 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 + }; + // debounce selection changes + if (selectionTimeout) clearTimeout(selectionTimeout); + selectionTimeout = setTimeout(() => extension.selection = currentSelection, SELECTION_DEBOUNCE_MS); } \ No newline at end of file diff --git a/client/src/state.ts b/client/src/state.ts index aafc630..6b473c8 100644 --- a/client/src/state.ts +++ b/client/src/state.ts @@ -6,6 +6,7 @@ import { LiquidJavaLogger } from "./services/logger"; import { LiquidJavaWebviewProvider } from "./webview/provider"; import type { LJDiagnostic } from "./types/diagnostics"; import type { StateMachine } from "./types/fsm"; +import { ContextHistory, Selection } from "./types/context"; export class ExtensionState { // server/client state @@ -22,6 +23,8 @@ export class ExtensionState { file?: string; diagnostics?: LJDiagnostic[]; stateMachine?: StateMachine; + contextHistory?: ContextHistory; + selection?: Selection; } export const extension = new ExtensionState(); \ No newline at end of file diff --git a/client/src/types/context.ts b/client/src/types/context.ts new file mode 100644 index 0000000..8efebe8 --- /dev/null +++ b/client/src/types/context.ts @@ -0,0 +1,41 @@ +import { PlacementInCode } from "./diagnostics"; + +// Type definitions used for LiquidJava context information + +export type Variable = { + name: string; + type: string; + refinement: string; + mainRefinement: string; + placementInCode: PlacementInCode | null; +} + +export type Ghost = { + name: string; + qualifiedName: string; + returnType: string; + parameterTypes: string[]; + refinement: string; +} + +export type Alias = { + name: string; + parameters: string[]; + types: string[]; + predicate: string; +} + +export type ContextHistory = { + vars: Record>; // file -> (scope -> variables in scope) + instanceVars: Variable[]; + globalVars: Variable[]; + ghosts: Ghost[]; + aliases: Alias[]; +} + +export type Selection = { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} \ No newline at end of file diff --git a/client/src/utils/constants.ts b/client/src/utils/constants.ts index ed8b7d9..7123aef 100644 --- a/client/src/utils/constants.ts +++ b/client/src/utils/constants.ts @@ -2,6 +2,7 @@ export const SERVER_JAR = "language-server-liquidjava.jar"; export const JAVA_BINARY = "java"; export const DEBUG_MODE = false; export const DEBUG_PORT = 50000; +export const SELECTION_DEBOUNCE_MS = 250; export const LIQUIDJAVA_SCOPES = [ "source.liquidjava keyword.other.liquidjava", "source.liquidjava entity.name.function.liquidjava", @@ -16,4 +17,14 @@ export const LIQUIDJAVA_SCOPES = [ "keyword.operator.liquidjava", "constant.language.boolean.liquidjava", "constant.numeric.liquidjava", -]; \ No newline at end of file +]; +export const LIQUIDJAVA_ANNOTATIONS = [ + "Refinement", + "RefinementAlias", + "RefinementPredicate", + "StateSet", + "Ghost", + "StateRefinement", + "ExternalRefinementsFor", +] +export const LIQUIDJAVA_ANNOTATION_START = new RegExp(`@(liquidjava\\.specification\\.)?(${LIQUIDJAVA_ANNOTATIONS.join("|")})\\s*\\(`, "g"); \ No newline at end of file diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts index 15a8c91..e0eb229 100644 --- a/client/src/utils/utils.ts +++ b/client/src/utils/utils.ts @@ -123,3 +123,8 @@ export async function killProcess(proc?: child_process.ChildProcess) { } }); } + +export function getSimpleName(qualifiedName: string): string { + const parts = qualifiedName.split("."); + return parts[parts.length - 1]; +} \ No newline at end of file diff --git a/client/syntaxes/liquidjava.json b/client/syntaxes/liquidjava.json index d850c83..aa7675d 100644 --- a/client/syntaxes/liquidjava.json +++ b/client/syntaxes/liquidjava.json @@ -91,6 +91,7 @@ "repository": { "keywords": { "patterns": [ + { "match": "\\bthis\\b", "name": "variable.language.this.liquidjava" }, { "match": "\\bghost\\b|\\balias\\b|\\btype\\b", "name": "keyword.other.liquidjava" } ] }, diff --git a/server/src/main/java/LJDiagnosticsHandler.java b/server/src/main/java/LJDiagnosticsHandler.java index 1346f62..ca6902c 100644 --- a/server/src/main/java/LJDiagnosticsHandler.java +++ b/server/src/main/java/LJDiagnosticsHandler.java @@ -46,7 +46,6 @@ public static LJDiagnostics getLJDiagnostics(String path) { } return new LJDiagnostics(errors, warnings); } catch (Exception e) { - System.err.println("LiquidJava verifier exception: " + e.getMessage()); e.printStackTrace(); errors.add(new CustomError("LiquidJava verification failed, check for Java errors")); return new LJDiagnostics(errors, warnings); diff --git a/server/src/main/java/LJDiagnosticsService.java b/server/src/main/java/LJDiagnosticsService.java index e44d314..a054965 100644 --- a/server/src/main/java/LJDiagnosticsService.java +++ b/server/src/main/java/LJDiagnosticsService.java @@ -15,6 +15,8 @@ import org.eclipse.lsp4j.services.WorkspaceService; import liquidjava.diagnostics.LJDiagnostic; +import liquidjava.processor.context.ContextHistory; +import utils.ContextHistoryConverter; import utils.DiagnosticConverter; import utils.PathUtils; @@ -58,6 +60,7 @@ public void generateDiagnostics(String uri) { }); List diagnostics = Stream.concat(ljDiagnostics.errors().stream(), ljDiagnostics.warnings().stream()).collect(Collectors.toList()); sendDiagnosticsNotification(diagnostics); + this.client.sendContext(ContextHistoryConverter.convertToDTO(ContextHistory.getInstance())); } /** diff --git a/server/src/main/java/LJLanguageClient.java b/server/src/main/java/LJLanguageClient.java index 61b1e99..3a19d4c 100644 --- a/server/src/main/java/LJLanguageClient.java +++ b/server/src/main/java/LJLanguageClient.java @@ -1,6 +1,9 @@ import java.util.List; import org.eclipse.lsp4j.services.LanguageClient; + +import dtos.context.ContextHistoryDTO; + import org.eclipse.lsp4j.jsonrpc.services.JsonNotification; /** @@ -10,8 +13,15 @@ public interface LJLanguageClient extends LanguageClient { /** * Sends custom diagnostics notification to the client - * @param diagnostics the LiquidJava diagnostics DTOs to send (can be any DTO type) + * @param diagnostics the LiquidJava diagnostics to send */ @JsonNotification("liquidjava/diagnostics") void sendDiagnostics(List diagnostics); + + /** + * Sends the context history to the client + * @param contextHistory the context history to send + */ + @JsonNotification("liquidjava/context") + void sendContext(ContextHistoryDTO contextHistory); } diff --git a/server/src/main/java/dtos/context/AliasDTO.java b/server/src/main/java/dtos/context/AliasDTO.java new file mode 100644 index 0000000..4a5b530 --- /dev/null +++ b/server/src/main/java/dtos/context/AliasDTO.java @@ -0,0 +1,25 @@ +package dtos.context; + +import java.util.List; +import java.util.stream.Collectors; + +import liquidjava.processor.context.AliasWrapper; + +/** + * DTO for serializing AliasWrapper instances to JSON. + */ +public record AliasDTO( + String name, + List parameters, + List types, + String predicate +) { + public static AliasDTO from(AliasWrapper aliasWrapper) { + return new AliasDTO( + aliasWrapper.getName(), + aliasWrapper.getVarNames(), + aliasWrapper.getTypes().stream().map(ContextHistoryDTO::stringifyType).collect(Collectors.toList()), + aliasWrapper.getClonedPredicate().toString() + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/dtos/context/ContextHistoryDTO.java b/server/src/main/java/dtos/context/ContextHistoryDTO.java new file mode 100644 index 0000000..aa279e2 --- /dev/null +++ b/server/src/main/java/dtos/context/ContextHistoryDTO.java @@ -0,0 +1,24 @@ +package dtos.context; + +import java.util.List; +import java.util.Map; + +import spoon.reflect.reference.CtTypeReference; + +/** + * DTO for serializing ContextHistory instances to JSON. + */ +public record ContextHistoryDTO( + Map>> vars, + List instanceVars, + List globalVars, + List ghosts, + List aliases +) { + public static String stringifyType(CtTypeReference typeReference) { + if (typeReference == null) + return ""; + String qualifiedName = typeReference.getQualifiedName(); + return qualifiedName == null || qualifiedName.isBlank() ? typeReference.toString() : qualifiedName; + } +} diff --git a/server/src/main/java/dtos/context/GhostDTO.java b/server/src/main/java/dtos/context/GhostDTO.java new file mode 100644 index 0000000..968852b --- /dev/null +++ b/server/src/main/java/dtos/context/GhostDTO.java @@ -0,0 +1,27 @@ +package dtos.context; + +import java.util.List; +import java.util.stream.Collectors; + +import liquidjava.processor.context.GhostState; + +/** + * DTO for serializing GhostState instances to JSON. + */ +public record GhostDTO( + String name, + String qualifiedName, + String returnType, + List parameterTypes, + String refinement +) { + public static GhostDTO from(GhostState ghostState) { + return new GhostDTO( + ghostState.getName(), + ghostState.getQualifiedName(), + ContextHistoryDTO.stringifyType(ghostState.getReturnType()), + ghostState.getParametersTypes().stream().map(ContextHistoryDTO::stringifyType).collect(Collectors.toList()), + ghostState.getRefinement() != null ? ghostState.getRefinement().toString() : null + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/dtos/context/VariableDTO.java b/server/src/main/java/dtos/context/VariableDTO.java new file mode 100644 index 0000000..d33664c --- /dev/null +++ b/server/src/main/java/dtos/context/VariableDTO.java @@ -0,0 +1,26 @@ +package dtos.context; + +import dtos.diagnostics.PlacementInCodeDTO; +import liquidjava.processor.context.RefinedVariable; +import spoon.reflect.reference.CtTypeReference; + +/** + * DTO for serializing RefinedVariable instances to JSON. + */ +public record VariableDTO( + String name, + String type, + String refinement, + String mainRefinement, + PlacementInCodeDTO placementInCode +) { + public static VariableDTO from(RefinedVariable refinedVariable) { + return new VariableDTO( + refinedVariable.getName(), + ContextHistoryDTO.stringifyType(refinedVariable.getType()), + refinedVariable.getRefinement().toString(), + refinedVariable.getMainRefinement().toString(), + PlacementInCodeDTO.from(refinedVariable.getPlacementInCode()) + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/dtos/diagnostics/PlacementInCodeDTO.java b/server/src/main/java/dtos/diagnostics/PlacementInCodeDTO.java index 934b51a..3ab1833 100644 --- a/server/src/main/java/dtos/diagnostics/PlacementInCodeDTO.java +++ b/server/src/main/java/dtos/diagnostics/PlacementInCodeDTO.java @@ -8,9 +8,8 @@ public record PlacementInCodeDTO(String text, PositionDTO position) { public static PlacementInCodeDTO from(PlacementInCode placement) { - return new PlacementInCodeDTO( - placement.getText(), - PositionDTO.from(placement.getPosition()) - ); + if (placement == null) + return null; + return new PlacementInCodeDTO(placement.getText(), PositionDTO.from(placement.getPosition())); } } diff --git a/server/src/main/java/utils/ContextHistoryConverter.java b/server/src/main/java/utils/ContextHistoryConverter.java new file mode 100644 index 0000000..1f0bf5d --- /dev/null +++ b/server/src/main/java/utils/ContextHistoryConverter.java @@ -0,0 +1,49 @@ +package utils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import dtos.context.AliasDTO; +import dtos.context.ContextHistoryDTO; +import dtos.context.GhostDTO; +import dtos.context.VariableDTO; +import liquidjava.processor.context.ContextHistory; +import liquidjava.processor.context.RefinedVariable; + +/** + * Utility class for converting LiquidJava context objects to DTOs. + */ +public class ContextHistoryConverter { + + /** + * Converts a ContextHistory to its DTO type. + * @param contextHistory the context history to convert + * @return the corresponding DTO + */ + public static ContextHistoryDTO convertToDTO(ContextHistory contextHistory) { + return new ContextHistoryDTO( + toVariablesMap(contextHistory.getVars()), + contextHistory.getInstanceVars().stream().map(VariableDTO::from).collect(Collectors.toList()), + contextHistory.getGlobalVars().stream().map(VariableDTO::from).collect(Collectors.toList()), + contextHistory.getGhosts().stream().map(GhostDTO::from).collect(Collectors.toList()), + contextHistory.getAliases().stream().map(AliasDTO::from).collect(Collectors.toList()) + ); + } + + private static Map>> toVariablesMap(Map>> vars) { + return vars.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + innerEntry -> innerEntry.getValue().stream().map(VariableDTO::from).collect(Collectors.toList()), + (left, right) -> left, + HashMap::new + )), + (left, right) -> left, + HashMap::new + )); + } +}