Skip to content

Commit 6b52fd2

Browse files
authored
State Machine Diagram for Typestates (#39)
1 parent b4077e4 commit 6b52fd2

File tree

18 files changed

+507
-27
lines changed

18 files changed

+507
-27
lines changed
6.15 KB
Binary file not shown.

client/src/extension.ts

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { connectToPort, findJavaExecutable, getAvailablePort, killProcess } from
99
import { SERVER_JAR, DEBUG_MODE, DEBUG_PORT } from "./constants";
1010
import { LiquidJavaWebviewProvider } from "./webview/provider";
1111
import { LJDiagnostic } from "./types";
12+
import { createMermaidDiagram } from "./webview/fsm";
13+
import { StateMachine } from "./types/fsm";
1214

1315
let serverProcess: child_process.ChildProcess;
1416
let client: LanguageClient;
@@ -19,6 +21,7 @@ let statusBarItem: vscode.StatusBarItem;
1921
let currentDiagnostics: LJDiagnostic[];
2022
let webviewProvider: LiquidJavaWebviewProvider;
2123
let currentFile: string | undefined;
24+
let currentStateMachine: StateMachine | undefined;
2225

2326
/**
2427
* Activates the LiquidJava extension
@@ -29,15 +32,11 @@ export async function activate(context: vscode.ExtensionContext) {
2932
initStatusBar(context);
3033
initCommandPalette(context);
3134
initWebview(context);
35+
initFileEvents(context);
3236
initHover();
3337

3438
logger.client.info("Activating LiquidJava extension...");
3539

36-
const activeEditor = vscode.window.activeTextEditor;
37-
if (activeEditor && activeEditor.document.languageId === "java") {
38-
currentFile = activeEditor.document.uri.fsPath;
39-
webviewProvider?.sendMessage({ type: "file", file: currentFile });
40-
}
4140
await applyItalicOverlay();
4241

4342
// find java executable path
@@ -135,15 +134,7 @@ function initWebview(context: vscode.ExtensionContext) {
135134
if (message.type === "ready") {
136135
webviewProvider.sendMessage({ type: "file", file: currentFile });
137136
webviewProvider.sendMessage({ type: "diagnostics", diagnostics: currentDiagnostics });
138-
}
139-
})
140-
);
141-
// listen for active text editor changes
142-
context.subscriptions.push(
143-
vscode.window.onDidChangeActiveTextEditor(editor => {
144-
if (editor && editor.document.languageId === "java") {
145-
currentFile = editor.document.uri.fsPath;
146-
webviewProvider?.sendMessage({ type: "file", file: currentFile });
137+
if (currentStateMachine) webviewProvider.sendMessage({ type: "fsm", sm: currentStateMachine });
147138
}
148139
})
149140
);
@@ -172,9 +163,39 @@ function initHover() {
172163
});
173164
}
174165

166+
167+
/**
168+
* Initializes file system event listeners
169+
* @param context The extension context
170+
*/
171+
function initFileEvents(context: vscode.ExtensionContext) {
172+
// listen for active text editor changes
173+
context.subscriptions.push(
174+
vscode.window.onDidChangeActiveTextEditor(editor => {
175+
if (!editor || editor.document.languageId !== "java") return;
176+
handleActiveFileChange(editor);
177+
178+
}),
179+
vscode.workspace.onDidSaveTextDocument(document => {
180+
if (document.uri.scheme !== 'file' || document.languageId !== "java") return;
181+
requestStateMachine(document)
182+
})
183+
);
184+
}
185+
186+
/**
187+
* Requests the state machine for the given document from the language server
188+
* @param document The text document
189+
*/
190+
async function requestStateMachine(document: vscode.TextDocument) {
191+
const sm: StateMachine = await client?.sendRequest("liquidjava/fsm", { uri: document.uri.toString() });
192+
webviewProvider?.sendMessage({ type: "fsm", sm });
193+
currentStateMachine = sm;
194+
}
195+
175196
/**
176197
* Updates the status bar with the current state
177-
* @param state The state of the status bar: "loading", "stopped", "passed" or "failed"
198+
* @param state The current state ("loading", "stopped", "passed", "failed")
178199
*/
179200
function updateStatusBar(state: "loading" | "stopped" | "passed" | "failed") {
180201
const icons = {
@@ -231,7 +252,7 @@ async function runLanguageServer(context: vscode.ExtensionContext, javaExecutabl
231252
/**
232253
* Starts the client and connects it to the language server
233254
* @param context The extension context
234-
* @param port The port the server is running on
255+
* @param port The port number the server is running on
235256
*/
236257
async function runClient(context: vscode.ExtensionContext, port: number) {
237258
const serverOptions: ServerOptions = () => {
@@ -270,6 +291,11 @@ async function runClient(context: vscode.ExtensionContext, port: number) {
270291
client.onNotification("liquidjava/diagnostics", (diagnostics: LJDiagnostic[]) => {
271292
handleLJDiagnostics(diagnostics);
272293
});
294+
295+
const editor = vscode.window.activeTextEditor;
296+
if (editor && editor.document.languageId === "java") {
297+
handleActiveFileChange(editor);
298+
}
273299
} catch (e) {
274300
vscode.window.showErrorMessage("LiquidJava failed to initialize: " + e.toString());
275301
logger.client.error("Failed to initialize: " + e.toString());
@@ -323,7 +349,7 @@ async function stopExtension(reason: string) {
323349

324350
/**
325351
* Handles LiquidJava diagnostics received from the language server
326-
* @param diagnostics The LiquidJava diagnostics
352+
* @param diagnostics The array of diagnostics received
327353
*/
328354
function handleLJDiagnostics(diagnostics: LJDiagnostic[]) {
329355
const containsError = diagnostics.some(d => d.category === "error");
@@ -335,3 +361,13 @@ function handleLJDiagnostics(diagnostics: LJDiagnostic[]) {
335361
webviewProvider?.sendMessage({ type: "diagnostics", diagnostics });
336362
currentDiagnostics = diagnostics;
337363
}
364+
365+
/**
366+
* Handles active file change events
367+
* @param editor The active text editor
368+
*/
369+
function handleActiveFileChange(editor: vscode.TextEditor) {
370+
currentFile = editor.document.uri.fsPath;
371+
webviewProvider?.sendMessage({ type: "file", file: currentFile });
372+
requestStateMachine(editor.document);
373+
}

client/src/types/fsm.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type StateMachine = {
2+
className: string;
3+
initial: string;
4+
states: string[];
5+
transitions: { from: string; to: string; label: string }[];
6+
};

client/src/webview/fsm.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { StateMachine } from "../types/fsm";
2+
3+
/**
4+
* Converts a StateMachine object to a Mermaid state diagram string
5+
* @param sm
6+
* @returns Mermaid diagram string
7+
*/
8+
export function createMermaidDiagram(sm: StateMachine): string {
9+
const lines: string[] = [];
10+
11+
// header
12+
lines.push('---');
13+
lines.push(`title: ${sm.className}`);
14+
lines.push('---');
15+
lines.push('stateDiagram-v2');
16+
17+
// initial state
18+
lines.push(` [*] --> ${sm.initial}`);
19+
20+
// transitions
21+
sm.transitions.forEach(transition => {
22+
lines.push(` ${transition.from} --> ${transition.to} : ${transition.label}`);
23+
});
24+
25+
return lines.join('\n');
26+
}

client/src/webview/html.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as vscode from "vscode";
22
import { getStyles } from "./styles";
33

4+
const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
5+
46
/**
57
* Generates the HTML content for the webview
68
* @param webview
@@ -18,12 +20,25 @@ export function getHtml(webview: vscode.Webview, extensionUri: vscode.Uri): stri
1820
<meta charset="utf-8">
1921
<meta
2022
http-equiv="Content-Security-Policy"
21-
content="default-src 'none'; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';"
23+
content="default-src 'none'; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}' https://cdn.jsdelivr.net; connect-src https://cdn.jsdelivr.net;"
2224
>
2325
<style>${getStyles()}</style>
2426
</head>
2527
<body>
2628
<div id="root"></div>
29+
<script nonce="${nonce}" type="module">
30+
import mermaid from '${MERMAID_CDN}';
31+
mermaid.initialize({
32+
startOnLoad: false,
33+
theme: document.body.classList.contains('vscode-light') ? 'default' : 'dark',
34+
securityLevel: 'loose',
35+
flowchart: {
36+
useMaxWidth: true,
37+
htmlLabels: true
38+
}
39+
});
40+
window.mermaid = mermaid;
41+
</script>
2742
<script nonce="${nonce}" src="${scriptUri}"></script>
2843
</body>
2944
</html>

client/src/webview/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getScript } from "./script";
22

3-
// Entry point for the webview
3+
// webview entry point
44

55
declare function acquireVsCodeApi(): any;
66
declare const document: any;

client/src/webview/renderers/diagnostics/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ export function renderTranslationTable(translationTable: TranslationTable): stri
3333

3434
return /*html*/`
3535
<div class="translation-table">
36-
<h3>Translation Table</h3>
36+
<h3>Context Variables</h3>
3737
<table>
3838
<thead>
3939
<tr>
4040
<th>Variable</th>
41-
<th>Code</th>
41+
<th>Source</th>
4242
<th>Location</th>
4343
</tr>
4444
</thead>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { StateMachine } from "../../types/fsm";
2+
3+
export function renderStateMachineView(sm: StateMachine, diagram: string): string {
4+
return /*html*/`
5+
<div class="diagram-section">
6+
<div class="diagram-container">
7+
<pre class="mermaid">${diagram}</pre>
8+
</div>
9+
<div>
10+
<p><strong>States:</strong> ${sm.states.join(', ')}</p>
11+
<p><strong>Initial state:</strong> ${sm.initial}</p>
12+
<p><strong>Number of states:</strong> ${sm.states.length}</p>
13+
<p><strong>Number of transitions:</strong> ${sm.transitions.length + 1}</p>
14+
</div>
15+
</div>
16+
`;
17+
}

client/src/webview/script.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { getCorrectView } from "./renderers/correct";
44
import { getLoadingView } from "./renderers/loading";
55
import { getErrorsView } from "./renderers/diagnostics/errors";
66
import { getWarningsView } from "./renderers/diagnostics/warnings";
7+
import { renderStateMachineView } from "./renderers/diagram";
8+
import { StateMachine } from "../types/fsm";
9+
import { createMermaidDiagram } from "./fsm";
710

811
/**
912
* Initializes the webview script
@@ -18,6 +21,7 @@ export function getScript(vscode: any, document: any, window: any) {
1821
let showAllDiagnostics = false;
1922
let currentFile: string | undefined;
2023
let expandedErrors = new Set<number>();
24+
let stateMachineView = '';
2125

2226
// initial state
2327
root.innerHTML = getLoadingView();
@@ -103,13 +107,40 @@ export function getScript(vscode: any, document: any, window: any) {
103107
} else if (msg.type === 'file') {
104108
currentFile = msg.file;
105109
if (!showAllDiagnostics) updateView();
110+
} else if (msg.type === 'fsm') {
111+
if (!msg.sm) {
112+
stateMachineView = '';
113+
updateView();
114+
return;
115+
}
116+
const sm = msg.sm as StateMachine;
117+
const diagram = createMermaidDiagram(sm);
118+
stateMachineView = renderStateMachineView(sm, diagram);
119+
updateView();
106120
}
107-
});
121+
});
122+
123+
async function renderMermaidDiagram() {
124+
const mermaid = (window as any).mermaid;
125+
if (!mermaid) return;
126+
127+
const mermaidElements = document.querySelectorAll('.mermaid');
128+
if (mermaidElements.length === 0) return;
129+
130+
try {
131+
await mermaid.run({ nodes: mermaidElements });
132+
} catch (e) {
133+
console.error('Failed to render Mermaid diagram:', e);
134+
}
135+
}
108136

109137
function updateView() {
110138
let mainView = fileErrors.length > 0 ? getErrorsView(fileErrors, showAllDiagnostics, currentFile, expandedErrors) : getCorrectView(showAllDiagnostics);
111139
let warningsView = fileWarnings.length > 0 ? getWarningsView(fileWarnings, showAllDiagnostics, currentFile) : '';
112-
root.innerHTML = mainView + warningsView;
140+
root.innerHTML = mainView + warningsView + stateMachineView;
141+
142+
// re-render mermaid diagram after DOM update
143+
if (stateMachineView) renderMermaidDiagram();
113144
}
114145
}
115146

client/src/webview/styles.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function getStyles(): string {
3030
overflow-x: auto;
3131
}
3232
strong {
33-
display: block;
33+
display: inline;
3434
margin-bottom: 0.5rem;
3535
}
3636
.container {
@@ -241,5 +241,30 @@ export function getStyles(): string {
241241
font-family: var(--vscode-editor-font-family);
242242
font-size: 0.9em;
243243
}
244+
.diagram-section {
245+
margin-bottom: 1.5rem;
246+
padding-bottom: 1rem;
247+
border-bottom: 1px solid var(--vscode-panel-border);
248+
}
249+
.diagram-section h2 {
250+
margin-bottom: 0.5rem;
251+
}
252+
.diagram-container {
253+
background-color: var(--vscode-editor-background);
254+
border-radius: 4px;
255+
padding: 1rem;
256+
overflow-x: auto;
257+
}
258+
.diagram-container .mermaid {
259+
display: flex;
260+
justify-content: center;
261+
}
262+
.diagram-container .mermaid svg {
263+
max-width: 100%;
264+
height: auto;
265+
}
266+
.mermaid .statediagramTitleText {
267+
font-size: 22px!important;
268+
}
244269
`;
245270
}

0 commit comments

Comments
 (0)